# Using AI SDK UI

[AI SDK UI](https://sdk.vercel.ai) is a library of React utilities and components for building AI-powered interfaces. In this guide, you'll learn how to use `@mastra/ai-sdk` to convert Mastra's output to AI SDK-compatible formats, enabling you to use its hooks and components in your frontend.

> **Note:** Migrating from AI SDK v4 to v5? See the [migration guide](https://mastra.ai/guides/migrations/ai-sdk-v4-to-v5).

> **Tip:** Want to see more examples? Visit Mastra's [**UI Dojo**](https://ui-dojo.mastra.ai/) or the [Next.js quickstart guide](https://mastra.ai/guides/getting-started/next-js).

## Getting started

Use Mastra and AI SDK UI together by installing the `@mastra/ai-sdk` package. `@mastra/ai-sdk` provides custom API routes and utilities for streaming Mastra agents in AI SDK-compatible formats. This includes chat, workflow, and network route handlers, along with utilities and exported types for UI integrations.

`@mastra/ai-sdk` integrates with AI SDK UI's three main hooks: [`useChat()`](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot), [`useCompletion()`](https://ai-sdk.dev/docs/ai-sdk-ui/completion), and [`useObject()`](https://ai-sdk.dev/docs/ai-sdk-ui/object-generation).

Install the required packages to get started:

**npm**:

```bash
npm install @mastra/ai-sdk@latest @ai-sdk/react ai
```

**pnpm**:

```bash
pnpm add @mastra/ai-sdk@latest @ai-sdk/react ai
```

**Yarn**:

```bash
yarn add @mastra/ai-sdk@latest @ai-sdk/react ai
```

**Bun**:

```bash
bun add @mastra/ai-sdk@latest @ai-sdk/react ai
```

You're now ready to follow the integration guides and recipes below!

## Integration guides

Typically, you'll set up API routes that stream Mastra content in AI SDK-compatible format, and then use those routes in AI SDK UI hooks like `useChat()`. Below you'll find two main approaches to achieve this:

- [Mastra's server](#mastras-server)
- [Framework-agnostic](#framework-agnostic)

Once you have your API routes set up, you can use them in the [`useChat()`](#usechat) hook.

### Mastra's server

Run Mastra as a standalone server and connect your frontend (e.g. using Vite + React) to its API endpoints. You'll be using Mastra's [custom API routes](https://mastra.ai/docs/server/custom-api-routes) feature for this.

> **Info:** Mastra's [**UI Dojo**](https://ui-dojo.mastra.ai/) is an example of this setup.

You can use [`chatRoute()`](https://mastra.ai/reference/ai-sdk/chat-route), [`workflowRoute()`](https://mastra.ai/reference/ai-sdk/workflow-route), and [`networkRoute()`](https://mastra.ai/reference/ai-sdk/network-route) to create API routes that stream Mastra content in AI SDK-compatible format. Once implemented, you can use these API routes in [`useChat()`](#usechat).

**chatRoute()**:

This example shows how to set up a chat route at the `/chat` endpoint that uses an agent with the ID `weatherAgent`.

```typescript
import { Mastra } from '@mastra/core'
import { chatRoute } from '@mastra/ai-sdk'

export const mastra = new Mastra({
  server: {
    apiRoutes: [
      chatRoute({
        path: '/chat',
        agent: 'weatherAgent',
      }),
    ],
  },
})
```

You can also use dynamic agent routing, see the [`chatRoute()` reference documentation](https://mastra.ai/reference/ai-sdk/chat-route) for more details.

**workflowRoute()**:

This example shows how to set up a workflow route at the `/workflow` endpoint that uses a workflow with the ID `weatherWorkflow`.

```typescript
import { Mastra } from '@mastra/core'
import { workflowRoute } from '@mastra/ai-sdk'

export const mastra = new Mastra({
  server: {
    apiRoutes: [
      workflowRoute({
        path: '/workflow',
        workflow: 'weatherWorkflow',
      }),
    ],
  },
})
```

You can also use dynamic workflow routing, see the [`workflowRoute()` reference documentation](https://mastra.ai/reference/ai-sdk/workflow-route) for more details.

> **Agent streaming in workflows:** When a workflow step pipes an agent's stream to the workflow writer (e.g., `await response.fullStream.pipeTo(writer)`), the agent's text chunks and tool calls are forwarded to the UI stream in real time, even when the agent runs inside workflow steps.
>
> See [Workflow Streaming](https://mastra.ai/docs/streaming/workflow-streaming) for more details.

**networkRoute()**:

This example shows how to set up a network route at the `/network` endpoint that uses an agent with the ID `weatherAgent`.

```typescript
import { Mastra } from '@mastra/core'
import { networkRoute } from '@mastra/ai-sdk'

export const mastra = new Mastra({
  server: {
    apiRoutes: [
      networkRoute({
        path: '/network',
        agent: 'weatherAgent',
      }),
    ],
  },
})
```

You can also use dynamic network routing, see the [`networkRoute()` reference documentation](https://mastra.ai/reference/ai-sdk/network-route) for more details.

### Framework-agnostic

If you don't want to run Mastra's server and instead use frameworks like Next.js or Express, you can use the [`handleChatStream()`](https://mastra.ai/reference/ai-sdk/handle-chat-stream), [`handleWorkflowStream()`](https://mastra.ai/reference/ai-sdk/handle-workflow-stream), and [`handleNetworkStream()`](https://mastra.ai/reference/ai-sdk/handle-network-stream) functions in your own API route handlers.

They return a `ReadableStream` that you can wrap with [`createUIMessageStreamResponse()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/create-ui-message-stream-response).

> **AI SDK v6 compatibility:** The framework-agnostic handlers keep the existing AI SDK v5/default behavior. If your app is typed against AI SDK v6, pass `version: 'v6'`. For best TypeScript inference with `handleChatStream()` and `handleNetworkStream()`, pass `messages` as `UIMessage[]` from your installed `ai` version.

The examples below show you how to use them with Next.js App Router.

**handleChatStream()**:

This example shows how to set up a chat route at the `/chat` endpoint that uses an agent with the ID `weatherAgent`.

```typescript
import { handleChatStream } from '@mastra/ai-sdk'
import { createUIMessageStreamResponse } from 'ai'
import { mastra } from '@/src/mastra'

export async function POST(req: Request) {
  const params = await req.json()
  const stream = await handleChatStream({
    mastra,
    agentId: 'weatherAgent',
    params,
  })
  return createUIMessageStreamResponse({ stream })
}
```

**handleWorkflowStream()**:

This example shows how to set up a workflow route at the `/workflow` endpoint that uses a workflow with the ID `weatherWorkflow`.

```typescript
import { handleWorkflowStream } from '@mastra/ai-sdk'
import { createUIMessageStreamResponse } from 'ai'
import { mastra } from '@/src/mastra'

export async function POST(req: Request) {
  const params = await req.json()
  const stream = await handleWorkflowStream({
    mastra,
    workflowId: 'weatherWorkflow',
    params,
  })
  return createUIMessageStreamResponse({ stream })
}
```

**handleNetworkStream()**:

This example shows how to set up a network route at the `/network` endpoint that uses an agent with the ID `routingAgent`.

```typescript
import { handleNetworkStream } from '@mastra/ai-sdk'
import { createUIMessageStreamResponse } from 'ai'
import { mastra } from '@/src/mastra'

export async function POST(req: Request) {
  const params = await req.json()
  const stream = await handleNetworkStream({
    mastra,
    agentId: 'routingAgent',
    params,
  })
  return createUIMessageStreamResponse({ stream })
}
```

### `useChat()`

Whether you created API routes through [Mastra's server](#mastras-server) or used a [framework of your choice](#framework-agnostic), you can now use the API endpoints in the `useChat()` hook.

Assuming you set up a route at `/chat` that uses a weather agent, you can ask it questions as seen below. It's important that you set the correct `api` URL.

```ts
import { useChat } from '@ai-sdk/react'
import { useState } from 'react'
import { DefaultChatTransport } from 'ai'

export default function Chat() {
  const [inputValue, setInputValue] = useState('')
  const { messages, sendMessage } = useChat({
    transport: new DefaultChatTransport({
      api: 'http://localhost:4111/chat',
    }),
  })

  const handleFormSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    sendMessage({ text: inputValue })
  }

  return (
    <div>
      <pre>{JSON.stringify(messages, null, 2)}</pre>
      <form onSubmit={handleFormSubmit}>
        <input
          value={inputValue}
          onChange={e => setInputValue(e.target.value)}
          placeholder="Name of the city"
        />
      </form>
    </div>
  )
}
```

Use [`prepareSendMessagesRequest`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat#transport.default-chat-transport.prepare-send-messages-request) to customize the request sent to the chat route, for example to pass additional configuration to the agent.

### `useCompletion()`

The `useCompletion()` hook handles single-turn completions between your frontend and a Mastra agent, allowing you to send a prompt and receive a streamed response over HTTP.

Your frontend could look like this:

```typescript
import { useCompletion } from '@ai-sdk/react'

export default function Page() {
  const { completion, input, handleInputChange, handleSubmit } = useCompletion({
    api: '/api/completion',
  })

  return (
    <form onSubmit={handleSubmit}>
      <input name="prompt" value={input} onChange={handleInputChange} id="input" />
      <button type="submit">Submit</button>
      <div>{completion}</div>
    </form>
  )
}
```

Below are two approaches to implementing the backend:

**Mastra Server**:

```ts
import { Mastra } from '@mastra/core/mastra'
import { registerApiRoute } from '@mastra/core/server'
import { handleChatStream } from '@mastra/ai-sdk'
import { createUIMessageStreamResponse } from 'ai'

export const mastra = new Mastra({
  server: {
    apiRoutes: [
      registerApiRoute('/completion', {
        method: 'POST',
        handler: async c => {
          const { prompt } = await c.req.json()
          const mastra = c.get('mastra')
          const stream = await handleChatStream({
            mastra,
            agentId: 'weatherAgent',
            params: {
              messages: [
                {
                  id: '1',
                  role: 'user',
                  parts: [
                    {
                      type: 'text',
                      text: prompt,
                    },
                  ],
                },
              ],
            },
          })

          return createUIMessageStreamResponse({ stream })
        },
      }),
    ],
  },
})
```

**Next.js**:

```ts
import { handleChatStream } from '@mastra/ai-sdk'
import { createUIMessageStreamResponse } from 'ai'
import { mastra } from '@/src/mastra'

// Allow streaming responses up to 30 seconds
export const maxDuration = 30

export async function POST(req: Request) {
  const { prompt }: { prompt: string } = await req.json()

  const stream = await handleChatStream({
    mastra,
    agentId: 'weatherAgent',
    params: {
      messages: [
        {
          id: '1',
          role: 'user',
          parts: [
            {
              type: 'text',
              text: prompt,
            },
          ],
        },
      ],
    },
  })
  return createUIMessageStreamResponse({ stream })
}
```

## Custom UI

Custom UI (also known as Generative UI) allows you to render custom React components based on data streamed from Mastra. Instead of displaying raw text or JSON, you can create visual components for tool outputs, workflow progress, agent network execution, and custom events.

Use Custom UI when you want to:

- Render tool outputs as visual components (e.g., a weather card instead of JSON)
- Display workflow step progress with status indicators
- Visualize agent network execution with step-by-step updates
- Show progress indicators or status updates during long-running operations

### Data part types

Mastra streams data to the frontend as "parts" within messages. Each part has a `type` that determines how to render it. The `@mastra/ai-sdk` package transforms Mastra streams into AI SDK-compatible [UI Message DataParts](https://ai-sdk.dev/docs/reference/ai-sdk-core/ui-message#datauipart).

| Data Part Type       | Source                  | Description                                                                        |
| -------------------- | ----------------------- | ---------------------------------------------------------------------------------- |
| `tool-{toolKey}`     | AI SDK built-in         | Tool invocation with states: `input-available`, `output-available`, `output-error` |
| `data-workflow`      | `workflowRoute()`       | Workflow execution with step inputs, outputs, and status                           |
| `data-network`       | `networkRoute()`        | Agent network execution with ordered steps and outputs                             |
| `data-tool-agent`    | Nested agent in tool    | Agent output streamed from within a tool's `execute()`                             |
| `data-tool-workflow` | Nested workflow in tool | Workflow output streamed from within a tool's `execute()`                          |
| `data-tool-network`  | Nested network in tool  | Network output streamed from within a tool's `execute()`                           |
| `data-{custom}`      | `writer.custom()`       | Custom events for progress indicators, status updates, etc.                        |

### Rendering tool outputs

AI SDK automatically creates `tool-{toolKey}` parts when an agent calls a tool. These parts include the tool's state and output, which you can use to render custom components.

The tool part cycles through states:

- `input-streaming`: Tool input is being streamed (when tool call streaming is enabled)
- `input-available`: Tool has been called with complete input, waiting for execution
- `output-available`: Tool execution completed with output
- `output-error`: Tool execution failed

Here's an example of rendering a weather tool's output as a custom `WeatherCard` component.

**Backend**:

Define a tool with an `outputSchema` so the frontend knows the shape of the data to render.

```typescript
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'

export const weatherTool = createTool({
  id: 'get-weather',
  description: 'Get current weather for a location',
  inputSchema: z.object({
    location: z.string().describe('The location to get the weather for'),
  }),
  outputSchema: z.object({
    temperature: z.number(),
    feelsLike: z.number(),
    humidity: z.number(),
    windSpeed: z.number(),
    conditions: z.string(),
    location: z.string(),
  }),
  execute: async inputData => {
    const response = await fetch(
      `https://api.weatherapi.com/v1/current.json?key=${process.env.WEATHER_API_KEY}&q=${inputData.location}`,
    )
    const data = await response.json()
    return {
      temperature: data.current.temp_c,
      feelsLike: data.current.feelslike_c,
      humidity: data.current.humidity,
      windSpeed: data.current.wind_kph,
      conditions: data.current.condition.text,
      location: data.location.name,
    }
  },
})
```

**Frontend**:

Check for `tool-{toolKey}` parts in the message and render a custom component based on the tool's state and output.

```typescript
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { WeatherCard } from './weather-card'
import { Loader } from './loader'

export function Chat() {
  const { messages, sendMessage } = useChat({
    transport: new DefaultChatTransport({
      api: 'http://localhost:4111/chat/weatherAgent',
    }),
  })

  return (
    <div>
      {messages.map(message => (
        <div key={message.id}>
          {message.parts.map((part, index) => {
            // Handle user text messages
            if (part.type === 'text' && message.role === 'user') {
              return <p key={index}>{part.text}</p>
            }

            // Handle weather tool output
            if (part.type === 'tool-weatherTool') {
              switch (part.state) {
                case 'input-available':
                  return <Loader key={index} />
                case 'output-available':
                  return <WeatherCard key={index} {...part.output} />
                case 'output-error':
                  return <div key={index}>Error: {part.errorText}</div>
                default:
                  return null
              }
            }

            return null
          })}
        </div>
      ))}
    </div>
  )
}
```

> **Tip:** The tool part type follows the pattern `tool-{toolKey}`, where `toolKey` is the key used when registering the tool with the agent. For example, if you register tools as `tools: { weatherTool }`, the part type will be `tool-weatherTool`.

### Rendering workflow data

When using `workflowRoute()` or `handleWorkflowStream()`, Mastra emits `data-workflow` parts that contain the workflow's execution state, including step statuses and outputs.

**Backend**:

Define a workflow with multiple steps that will emit `data-workflow` parts as it executes.

```typescript
import { createStep, createWorkflow } from '@mastra/core/workflows'
import { z } from 'zod'

const fetchWeather = createStep({
  id: 'fetch-weather',
  inputSchema: z.object({
    location: z.string(),
  }),
  outputSchema: z.object({
    temperature: z.number(),
    conditions: z.string(),
  }),
  execute: async ({ inputData }) => {
    // Fetch weather data...
    return { temperature: 22, conditions: 'Sunny' }
  },
})

const planActivities = createStep({
  id: 'plan-activities',
  inputSchema: z.object({
    temperature: z.number(),
    conditions: z.string(),
  }),
  outputSchema: z.object({
    activities: z.string(),
  }),
  execute: async ({ inputData, mastra }) => {
    const agent = mastra?.getAgent('activityAgent')
    const response = await agent?.generate(
      `Suggest activities for ${inputData.conditions} weather at ${inputData.temperature}°C`,
    )
    return { activities: response?.text || '' }
  },
})

export const activitiesWorkflow = createWorkflow({
  id: 'activities-workflow',
  inputSchema: z.object({
    location: z.string(),
  }),
  outputSchema: z.object({
    activities: z.string(),
  }),
})
  .then(fetchWeather)
  .then(planActivities)

activitiesWorkflow.commit()
```

Register the workflow with Mastra and expose it via `workflowRoute()` to stream workflow events to the frontend.

```typescript
import { Mastra } from '@mastra/core'
import { workflowRoute } from '@mastra/ai-sdk'

export const mastra = new Mastra({
  workflows: { activitiesWorkflow },
  server: {
    apiRoutes: [
      workflowRoute({
        path: '/workflow/activitiesWorkflow',
        workflow: 'activitiesWorkflow',
      }),
    ],
  },
})
```

**Frontend**:

Check for `data-workflow` parts and render each step's status and output using the `WorkflowDataPart` type for type safety.

```typescript
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import type { WorkflowDataPart } from '@mastra/ai-sdk'

type WorkflowData = WorkflowDataPart['data']
type StepStatus = 'running' | 'success' | 'failed' | 'suspended' | 'waiting'

function StepIndicator({
  name,
  status,
  output,
}: {
  name: string
  status: StepStatus
  output: unknown
}) {
  return (
    <div className="step">
      <div className="step-header">
        <span>{name}</span>
        <span className={`status status-${status}`}>{status}</span>
      </div>
      {status === 'success' && output && <pre>{JSON.stringify(output, null, 2)}</pre>}
    </div>
  )
}

export function WorkflowChat() {
  const { messages, sendMessage, status } = useChat({
    transport: new DefaultChatTransport({
      api: 'http://localhost:4111/workflow/activitiesWorkflow',
      prepareSendMessagesRequest: ({ messages }) => ({
        body: {
          inputData: {
            location: messages[messages.length - 1]?.parts[0]?.text,
          },
        },
      }),
    }),
  })

  return (
    <div>
      {messages.map(message => (
        <div key={message.id}>
          {message.parts.map((part, index) => {
            if (part.type === 'data-workflow') {
              const workflowData = part.data as WorkflowData
              const steps = Object.values(workflowData.steps)

              return (
                <div key={index} className="workflow-progress">
                  <h3>Workflow: {workflowData.name}</h3>
                  <p>Status: {workflowData.status}</p>
                  {steps.map(step => (
                    <StepIndicator
                      key={step.name}
                      name={step.name}
                      status={step.status}
                      output={step.output}
                    />
                  ))}
                </div>
              )
            }
            return null
          })}
        </div>
      ))}
    </div>
  )
}
```

For more details on workflow streaming, see [Workflow Streaming](https://mastra.ai/docs/streaming/workflow-streaming).

### Rendering network data

When using `networkRoute()` or `handleNetworkStream()`, Mastra emits `data-network` parts that contain the agent network's execution state, including which agents were called and their outputs.

**Backend**:

Register agents with Mastra and expose the routing agent via `networkRoute()` to stream network execution events to the frontend.

```typescript
import { Mastra } from '@mastra/core'
import { networkRoute } from '@mastra/ai-sdk'

export const mastra = new Mastra({
  agents: { routingAgent, researchAgent, weatherAgent },
  server: {
    apiRoutes: [
      networkRoute({
        path: '/network',
        agent: 'routingAgent',
      }),
    ],
  },
})
```

**Frontend**:

Check for `data-network` parts and render each agent's execution step using the `NetworkDataPart` type for type safety.

```typescript
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import type { NetworkDataPart } from '@mastra/ai-sdk'

type NetworkData = NetworkDataPart['data']

function AgentStep({ step }: { step: NetworkData['steps'][number] }) {
  return (
    <div className="agent-step">
      <div className="step-header">
        <span className="agent-name">{step.name}</span>
        <span className={`status status-${step.status}`}>{step.status}</span>
      </div>
      {step.input && (
        <div className="step-input">
          <strong>Input:</strong>
          <pre>{JSON.stringify(step.input, null, 2)}</pre>
        </div>
      )}
      {step.output && (
        <div className="step-output">
          <strong>Output:</strong>
          <pre>
            {typeof step.output === 'string' ? step.output : JSON.stringify(step.output, null, 2)}
          </pre>
        </div>
      )}
    </div>
  )
}

export function NetworkChat() {
  const { messages, sendMessage, status } = useChat({
    transport: new DefaultChatTransport({
      api: 'http://localhost:4111/network',
    }),
  })

  return (
    <div>
      {messages.map(message => (
        <div key={message.id}>
          {message.parts.map((part, index) => {
            if (part.type === 'data-network') {
              const networkData = part.data as NetworkData

              return (
                <div key={index} className="network-execution">
                  <div className="network-header">
                    <h3>Agent Network: {networkData.name}</h3>
                    <span className={`status status-${networkData.status}`}>
                      {networkData.status}
                    </span>
                  </div>
                  <div className="network-steps">
                    {networkData.steps.map((step, stepIndex) => (
                      <AgentStep key={stepIndex} step={step} />
                    ))}
                  </div>
                </div>
              )
            }
            return null
          })}
        </div>
      ))}
    </div>
  )
}
```

For more details on agent networks, see [Agent Networks](https://mastra.ai/docs/agents/networks).

### Custom events

Use `writer.custom()` within a tool's `execute()` function to emit custom data parts. This is useful for progress indicators, status updates, or any custom UI updates during tool execution.

Custom event types must start with `data-` to be recognized as data parts.

> **Warning:** You must `await` the `writer.custom()` call, otherwise you may encounter a `WritableStream is locked` error.

**Backend**:

Use `writer.custom()` inside the tool's `execute()` function to emit custom `data-` prefixed events at different stages of execution.

```typescript
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'

export const taskTool = createTool({
  id: 'process-task',
  description: 'Process a task with progress updates',
  inputSchema: z.object({
    task: z.string().describe('The task to process'),
  }),
  outputSchema: z.object({
    result: z.string(),
    status: z.string(),
  }),
  execute: async (inputData, context) => {
    const { task } = inputData

    // Emit "in progress" custom event
    await context?.writer?.custom({
      type: 'data-tool-progress',
      data: {
        status: 'in-progress',
        message: 'Gathering information...',
      },
    })

    // Simulate work
    await new Promise(resolve => setTimeout(resolve, 3000))

    // Emit "done" custom event
    await context?.writer?.custom({
      type: 'data-tool-progress',
      data: {
        status: 'done',
        message: `Successfully processed "${task}"`,
      },
    })

    return {
      result: `Task "${task}" has been completed successfully!`,
      status: 'completed',
    }
  },
})
```

**Frontend**:

Filter message parts for your custom event type and render a progress indicator that updates as new events arrive.

```typescript
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { useMemo } from 'react'

type ProgressData = {
  status: 'in-progress' | 'done'
  message: string
}

function ProgressIndicator({ progress }: { progress: ProgressData }) {
  return (
    <div className="progress-indicator">
      {progress.status === 'in-progress' ? (
        <span className="spinner" />
      ) : (
        <span className="check-icon" />
      )}
      <span className={`status-${progress.status}`}>{progress.message}</span>
    </div>
  )
}

export function TaskChat() {
  const { messages, sendMessage } = useChat({
    transport: new DefaultChatTransport({
      api: 'http://localhost:4111/chat/taskAgent',
    }),
  })

  // Extract the latest progress event from messages
  const latestProgress = useMemo(() => {
    const allProgressParts: ProgressData[] = []
    messages.forEach(message => {
      message.parts.forEach(part => {
        if (part.type === 'data-tool-progress') {
          allProgressParts.push(part.data as ProgressData)
        }
      })
    })
    return allProgressParts[allProgressParts.length - 1]
  }, [messages])

  return (
    <div>
      {latestProgress && <ProgressIndicator progress={latestProgress} />}
      {messages.map(message => (
        <div key={message.id}>
          {message.parts.map((part, index) => {
            if (part.type === 'text') {
              return <p key={index}>{part.text}</p>
            }
            return null
          })}
        </div>
      ))}
    </div>
  )
}
```

### Tool streaming

Tools can also stream data using `context.writer.write()` for lower-level control, or pipe an agent's stream directly to the tool's writer. For more details, see [Tool Streaming](https://mastra.ai/docs/streaming/tool-streaming).

### Examples

For live examples of Custom UI patterns, visit [Mastra's UI Dojo](https://ui-dojo.mastra.ai/). The repository includes implementations for:

- [Generative UIs](https://github.com/mastra-ai/ui-dojo/blob/main/src/pages/ai-sdk/generative-user-interfaces.tsx) - Custom components for tool outputs
- [Workflows](https://github.com/mastra-ai/ui-dojo/blob/main/src/pages/ai-sdk/workflow.tsx) - Workflow step visualization
- [Agent Networks](https://github.com/mastra-ai/ui-dojo/blob/main/src/pages/ai-sdk/network.tsx) - Network execution display
- [Custom Events](https://github.com/mastra-ai/ui-dojo/blob/main/src/pages/ai-sdk/generative-user-interfaces-with-custom-events.tsx) - Progress indicators with custom events

## Recipes

### Stream transformations

To manually transform Mastra's streams to AI SDK-compatible format, use the [`toAISdkStream()`](https://mastra.ai/reference/ai-sdk/to-ai-sdk-stream) utility. See the [examples](https://mastra.ai/reference/ai-sdk/to-ai-sdk-stream) for concrete usage patterns.

`toAISdkStream()` keeps the existing AI SDK v5/default behavior. If your app is typed against AI SDK v6, pass `version: 'v6'`.

```typescript
import { toAISdkStream } from '@mastra/ai-sdk'

const v5Stream = toAISdkStream(mastraStream, { from: 'agent' })
const v6Stream = toAISdkStream(mastraStream, { from: 'agent', version: 'v6' })
```

### Loading historical messages

When loading messages from Mastra's memory to display in a chat UI, use [`toAISdkV5Messages()`](https://mastra.ai/reference/ai-sdk/to-ai-sdk-v5-messages) or [`toAISdkV4Messages()`](https://mastra.ai/reference/ai-sdk/to-ai-sdk-v4-messages) to convert them to the appropriate AI SDK format for `useChat()`'s `initialMessages`.

### Passing additional data

[`sendMessage()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat#send-message) allows you to pass additional data from the frontend to Mastra. This data can then be used on the server as [`RequestContext`](https://mastra.ai/docs/server/request-context).

Here's an example of the frontend code:

```typescript
import { useChat } from '@ai-sdk/react'
import { useState } from 'react'
import { DefaultChatTransport } from 'ai'

export function ChatAdditional() {
  const [inputValue, setInputValue] = useState('')
  const { messages, sendMessage } = useChat({
    transport: new DefaultChatTransport({
      api: 'http://localhost:4111/chat-extra',
    }),
  })

  const handleFormSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    sendMessage(
      { text: inputValue },
      {
        body: {
          data: {
            userId: 'user123',
            preferences: {
              language: 'en',
              temperature: 'celsius',
            },
          },
        },
      },
    )
  }

  return (
    <div>
      <pre>{JSON.stringify(messages, null, 2)}</pre>
      <form onSubmit={handleFormSubmit}>
        <input
          value={inputValue}
          onChange={e => setInputValue(e.target.value)}
          placeholder="Name of the city"
        />
      </form>
    </div>
  )
}
```

Two examples on how to implement the backend portion of it.

**Mastra Server**:

Add a `chatRoute()` to your Mastra configuration like shown above. Then, add a server-level middleware:

```typescript
import { Mastra } from '@mastra/core'

export const mastra = new Mastra({
  server: {
    middleware: [
      async (c, next) => {
        const requestContext = c.get('requestContext')

        if (c.req.method === 'POST') {
          const clonedReq = c.req.raw.clone()
          const body = await clonedReq.json()

          if (body?.data) {
            for (const [key, value] of Object.entries(body.data)) {
              requestContext.set(key, value)
            }
          }
        }
        await next()
      },
    ],
  },
})
```

> **Info:** You can access this data in your tools via the `requestContext` parameter. See the [Request Context documentation](https://mastra.ai/docs/server/request-context) for more details.

**Next.js**:

```typescript
import { handleChatStream } from '@mastra/ai-sdk'
import { RequestContext } from '@mastra/core/request-context'
import { createUIMessageStreamResponse } from 'ai'
import { mastra } from '@/src/mastra'

export async function POST(req: Request) {
  const { messages, data } = await req.json()

  const requestContext = new RequestContext()

  if (data) {
    for (const [key, value] of Object.entries(data)) {
      requestContext.set(key, value)
    }
  }

  const stream = await handleChatStream({
    mastra,
    agentId: 'weatherAgent',
    params: {
      messages,
      requestContext,
    },
  })
  return createUIMessageStreamResponse({ stream })
}
```

### Workflow suspend/resume with user approval

Workflows can suspend execution and wait for user input before continuing. This is useful for approval flows, confirmations, or any human-in-the-loop scenario.

The workflow uses:

- `suspendSchema` / `resumeSchema` - Define the data structure for suspend payload and resume input
- `suspend()` - Pauses the workflow and sends the suspend payload to the UI
- `resumeData` - Contains the user's response when the workflow resumes
- `bail()` - Exits the workflow early (e.g., when user rejects)

**Backend**:

Create a workflow step that suspends for approval. The step checks `resumeData` to determine if it's resuming, and calls `suspend()` on first execution.

```typescript
import { createStep, createWorkflow } from '@mastra/core/workflows'
import { z } from 'zod'

const requestApproval = createStep({
  id: 'request-approval',
  inputSchema: z.object({ requestId: z.string(), summary: z.string() }),
  outputSchema: z.object({
    approved: z.boolean(),
    requestId: z.string(),
    approvedBy: z.string().optional(),
  }),
  resumeSchema: z.object({
    approved: z.boolean(),
    approverName: z.string().optional(),
  }),
  suspendSchema: z.object({
    message: z.string(),
    requestId: z.string(),
  }),
  execute: async ({ inputData, resumeData, suspend, bail }) => {
    // User rejected - bail out
    if (resumeData?.approved === false) {
      return bail({ message: 'Request rejected' })
    }
    // User approved - continue
    if (resumeData?.approved) {
      return {
        approved: true,
        requestId: inputData.requestId,
        approvedBy: resumeData.approverName || 'User',
      }
    }
    // First execution - suspend and wait
    return await suspend({
      message: `Please approve: ${inputData.summary}`,
      requestId: inputData.requestId,
    })
  },
})

export const approvalWorkflow = createWorkflow({
  id: 'approval-workflow',
  inputSchema: z.object({ requestId: z.string(), summary: z.string() }),
  outputSchema: z.object({
    approved: z.boolean(),
    requestId: z.string(),
    approvedBy: z.string().optional(),
  }),
}).then(requestApproval)

approvalWorkflow.commit()
```

Register the workflow. Storage is required for suspend/resume to persist state.

```typescript
import { Mastra } from '@mastra/core'
import { workflowRoute } from '@mastra/ai-sdk'
import { LibSQLStore } from '@mastra/libsql'

export const mastra = new Mastra({
  workflows: { approvalWorkflow },
  storage: new LibSQLStore({
    url: 'file:../mastra.db',
  }),
  server: {
    apiRoutes: [
      workflowRoute({ path: '/workflow/approvalWorkflow', workflow: 'approvalWorkflow' }),
    ],
  },
})
```

**Frontend**:

Detect when the workflow is suspended and send resume data with `runId`, `step`, and `resumeData`.

```typescript
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { useMemo, useState } from 'react'
import type { WorkflowDataPart } from '@mastra/ai-sdk'

type WorkflowData = WorkflowDataPart['data']

export function ApprovalWorkflow() {
  const [requestId, setRequestId] = useState('')
  const [summary, setSummary] = useState('')

  const { messages, sendMessage, setMessages, status } = useChat({
    transport: new DefaultChatTransport({
      api: 'http://localhost:4111/workflow/approvalWorkflow',
      prepareSendMessagesRequest: ({ messages }) => {
        const lastMessage = messages[messages.length - 1]
        const text = lastMessage.parts.find(p => p.type === 'text')?.text
        const metadata = lastMessage.metadata as Record<string, string>

        // Resuming: send runId, step, and resumeData
        if (text === 'Approve' || text === 'Reject') {
          return {
            body: {
              runId: metadata.runId,
              step: 'request-approval',
              resumeData: { approved: text === 'Approve' },
            },
          }
        }
        // Starting: send inputData
        return {
          body: { inputData: { requestId: metadata.requestId, summary: metadata.summary } },
        }
      },
    }),
  })

  // Find suspended workflow
  const suspended = useMemo(() => {
    for (const m of messages) {
      for (const p of m.parts) {
        if (p.type === 'data-workflow' && (p.data as WorkflowData).status === 'suspended') {
          return { data: p.data as WorkflowData, runId: p.id }
        }
      }
    }
    return null
  }, [messages])

  const handleApprove = () => {
    setMessages([])
    sendMessage({ text: 'Approve', metadata: { runId: suspended?.runId } })
  }

  const handleReject = () => {
    setMessages([])
    sendMessage({ text: 'Reject', metadata: { runId: suspended?.runId } })
  }

  return (
    <div>
      {!suspended ? (
        <form
          onSubmit={e => {
            e.preventDefault()
            setMessages([])
            sendMessage({ text: 'Start', metadata: { requestId, summary } })
          }}
        >
          <input
            value={requestId}
            onChange={e => setRequestId(e.target.value)}
            placeholder="Request ID"
          />
          <input value={summary} onChange={e => setSummary(e.target.value)} placeholder="Summary" />
          <button type="submit" disabled={status !== 'ready'}>
            Submit
          </button>
        </form>
      ) : (
        <div>
          <p>
            {
              (suspended.data.steps['request-approval']?.suspendPayload as { message: string })
                ?.message
            }
          </p>
          <button onClick={handleApprove}>Approve</button>
          <button onClick={handleReject}>Reject</button>
        </div>
      )}
    </div>
  )
}
```

Key points:

- The suspend payload is accessible via `step.suspendPayload`
- To resume, send `runId`, `step` (the step ID), and `resumeData` in the request body
- Storage must be configured for suspend/resume to persist workflow state

For a complete implementation, see the [workflow-suspend-resume example](https://github.com/mastra-ai/ui-dojo/blob/main/src/pages/ai-sdk/workflow-suspend-resume.tsx) in UI Dojo.

### Nested agent streams in tools

Tools can call agents internally and stream the agent's output back to the UI. This creates `data-tool-agent` parts that can be rendered alongside the tool's final output.

The pattern uses:

- `context.mastra.getAgent()` - Get an agent instance from within a tool
- `agent.stream()` - Stream the agent's response
- `stream.fullStream.pipeTo(context.writer)` - Pipe the agent's stream to the tool's writer

**Backend**:

Create a tool that calls an agent and pipes its stream to the tool's writer.

```typescript
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'

export const nestedAgentTool = createTool({
  id: 'nested-agent-stream',
  description: 'Analyze weather using a nested agent',
  inputSchema: z.object({
    city: z.string().describe('The city to analyze'),
  }),
  outputSchema: z.object({
    summary: z.string(),
  }),
  execute: async (inputData, context) => {
    const agent = context?.mastra?.getAgent('weatherAgent')
    if (!agent) {
      return { summary: 'Weather agent not available' }
    }

    const stream = await agent.stream(
      `Analyze the weather in ${inputData.city} and provide a summary.`,
    )

    // Pipe the agent's stream to emit data-tool-agent parts
    await stream.fullStream.pipeTo(context!.writer!)

    return { summary: (await stream.text) ?? 'No summary available' }
  },
})
```

Create an agent that uses this tool.

```typescript
import { Agent } from '@mastra/core/agent'
import { nestedAgentTool } from '../tools/nested-agent-tool'

export const forecastAgent = new Agent({
  id: 'forecast-agent',
  instructions: 'Use the nested-agent-stream tool when asked about weather.',
  model: 'openai/gpt-5.4',
  tools: { nestedAgentTool },
})
```

**Frontend**:

Handle `data-tool-agent` parts to display the nested agent's streamed output.

```typescript
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { useState } from 'react'
import type { AgentDataPart } from '@mastra/ai-sdk'

export function NestedAgentChat() {
  const [input, setInput] = useState('')
  const { messages, sendMessage, status } = useChat({
    transport: new DefaultChatTransport({
      api: 'http://localhost:4111/chat/forecastAgent',
    }),
  })

  return (
    <div>
      <form
        onSubmit={e => {
          e.preventDefault()
          sendMessage({ text: input })
          setInput('')
        }}
      >
        <input value={input} onChange={e => setInput(e.target.value)} placeholder="Enter a city" />
        <button type="submit" disabled={status !== 'ready'}>
          Get Forecast
        </button>
      </form>

      {messages.map(message => (
        <div key={message.id}>
          {message.parts.map((part, index) => {
            if (part.type === 'text') {
              return <p key={index}>{part.text}</p>
            }
            if (part.type === 'data-tool-agent') {
              const { id, data } = part as AgentDataPart
              return (
                <div key={index} className="nested-agent">
                  <strong>Nested Agent: {id}</strong>
                  {data.text && <p>{data.text}</p>}
                </div>
              )
            }
            return null
          })}
        </div>
      ))}
    </div>
  )
}
```

Key points:

- Piping `fullStream` to `context.writer` creates `data-tool-agent` parts
- The `AgentDataPart` has `id` (on the part) and `data.text` (the agent's streamed text)
- The tool still returns its own output after the stream completes

For a complete implementation, see the [tool-nested-streams example](https://github.com/mastra-ai/ui-dojo/blob/main/src/pages/ai-sdk/tool-nested-streams.tsx) in UI Dojo.

### Streaming agent text from workflow steps

Workflow steps can stream an agent's text output in real-time by piping the agent's stream to the step's `writer`. This lets users see the agent "thinking" while the workflow executes, rather than waiting for the step to complete.

The pattern uses:

- `writer` in workflow step - Pipe the agent's `fullStream` to the step's writer
- `text` and `data-workflow` parts - The frontend receives streaming text alongside step progress

**Backend**:

Create a workflow step that streams an agent's response by piping to the step's `writer`.

```typescript
import { createStep, createWorkflow } from '@mastra/core/workflows'
import { z } from 'zod'
import { weatherAgent } from '../agents/weather-agent'

const analyzeWeather = createStep({
  id: 'analyze-weather',
  inputSchema: z.object({ location: z.string() }),
  outputSchema: z.object({ analysis: z.string(), location: z.string() }),
  execute: async ({ inputData, writer }) => {
    const response = await weatherAgent.stream(
      `Analyze the weather in ${inputData.location} and provide insights.`,
    )

    // Pipe agent stream to step writer for real-time text streaming
    await response.fullStream.pipeTo(writer)

    return {
      analysis: await response.text,
      location: inputData.location,
    }
  },
})

const calculateScore = createStep({
  id: 'calculate-score',
  inputSchema: z.object({ analysis: z.string(), location: z.string() }),
  outputSchema: z.object({ score: z.number(), summary: z.string() }),
  execute: async ({ inputData }) => {
    const score = inputData.analysis.includes('sunny') ? 85 : 50
    return { score, summary: `Comfort score for ${inputData.location}: ${score}/100` }
  },
})

export const weatherWorkflow = createWorkflow({
  id: 'weather-workflow',
  inputSchema: z.object({ location: z.string() }),
  outputSchema: z.object({ score: z.number(), summary: z.string() }),
})
  .then(analyzeWeather)
  .then(calculateScore)

weatherWorkflow.commit()
```

Register the workflow with a `workflowRoute()`. Text streaming is enabled by default.

```typescript
import { Mastra } from '@mastra/core'
import { workflowRoute } from '@mastra/ai-sdk'

export const mastra = new Mastra({
  agents: { weatherAgent },
  workflows: { weatherWorkflow },
  server: {
    apiRoutes: [workflowRoute({ path: '/workflow/weather', workflow: 'weatherWorkflow' })],
  },
})
```

**Frontend**:

Render both `text` parts (streaming agent output) and `data-workflow` parts (step progress).

```typescript
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { useState } from 'react'
import type { WorkflowDataPart } from '@mastra/ai-sdk'

type WorkflowData = WorkflowDataPart['data']

export function WeatherWorkflow() {
  const [location, setLocation] = useState('')
  const { messages, sendMessage, status } = useChat({
    transport: new DefaultChatTransport({
      api: 'http://localhost:4111/workflow/weather',
      prepareSendMessagesRequest: ({ messages }) => ({
        body: {
          inputData: {
            location: messages[messages.length - 1].parts.find(p => p.type === 'text')?.text,
          },
        },
      }),
    }),
  })

  return (
    <div>
      <form
        onSubmit={e => {
          e.preventDefault()
          sendMessage({ text: location })
          setLocation('')
        }}
      >
        <input
          value={location}
          onChange={e => setLocation(e.target.value)}
          placeholder="Enter city"
        />
        <button type="submit" disabled={status !== 'ready'}>
          Analyze
        </button>
      </form>

      {messages.map(message => (
        <div key={message.id}>
          {message.parts.map((part, index) => {
            // Streaming agent text
            if (part.type === 'text' && message.role === 'assistant') {
              return (
                <div key={index}>
                  {status === 'streaming' && (
                    <p>
                      <em>Agent analyzing...</em>
                    </p>
                  )}
                  <p>{part.text}</p>
                </div>
              )
            }
            // Workflow step progress
            if (part.type === 'data-workflow') {
              const workflow = part.data as WorkflowData
              return (
                <div key={index}>
                  {Object.entries(workflow.steps).map(([stepId, step]) => (
                    <div key={stepId}>
                      <strong>{stepId}</strong>: {step.status}
                    </div>
                  ))}
                </div>
              )
            }
            return null
          })}
        </div>
      ))}
    </div>
  )
}
```

Key points:

- The step's `writer` is available in the `execute` function (not via `context`)
- `includeTextStreamParts` defaults to `true` on `workflowRoute()`, so text streams by default
- Text parts stream in real-time while `data-workflow` parts update with step status

For a complete implementation, see the [workflow-agent-text-stream example](https://github.com/mastra-ai/ui-dojo/blob/main/src/pages/ai-sdk/workflow-agent-text-stream.tsx) in UI Dojo.

### Multi-stage progress with branching workflows

For workflows with conditional branching (e.g., express vs standard shipping), you can track progress across different branches by including a identifier in your custom events.

The UI Dojo example uses a `stage` field in the event data to identify which branch is executing (e.g., `"validation"`, `"standard-processing"`, `"express-processing"`). The frontend groups events by this field to show a pipeline-style progress UI.

See the [branching-workflow.ts](https://github.com/mastra-ai/ui-dojo/blob/main/src/mastra/workflows/branching-workflow.ts) (backend) and [workflow-custom-events.tsx](https://github.com/mastra-ai/ui-dojo/blob/main/src/pages/ai-sdk/workflow-custom-events.tsx) (frontend) in UI Dojo.

### Progress indicators in agent networks

When using agent networks, you can emit custom progress events from tools used by subagents to show which agent is currently active.

The UI Dojo example includes a `stage` field in the event data to identify which subagent is running (e.g., `"report-generation"`, `"report-review"`). The frontend groups events by this field and displays the latest status for each.

See the [report-generation-tool.ts](https://github.com/mastra-ai/ui-dojo/blob/main/src/mastra/tools/report-generation-tool.ts) (backend) and [agent-network-custom-events.tsx](https://github.com/mastra-ai/ui-dojo/blob/main/src/pages/ai-sdk/agent-network-custom-events.tsx) (frontend) in UI Dojo.