---
title: "Types"
sidebarTitle: "Types"
description: "TypeScript types for AI Agents, UI messages, and the frontend transport."
---

TypeScript patterns for [AI Chat](/ai-chat/overview). This page covers how to pin a custom AI SDK [`UIMessage`](https://sdk.vercel.ai/docs/reference/ai-sdk-core/ui-message) subtype with `chat.withUIMessage`, fix a typed `clientData` schema with `chat.withClientData`, chain builder-level hooks, and align types on the client.

## Custom `UIMessage` with `chat.withUIMessage`

`chat.agent()` types the wire payload with the base AI SDK `UIMessage`. That is enough for many apps.

When you add **custom `data-*` parts** (via `chat.stream` / `writer`) or a **typed tool map** (e.g. `InferUITools<typeof tools>`), you want a **narrower** `UIMessage` generic so that:

- `onTurnStart`, `onTurnComplete`, and similar hooks expose correctly typed `uiMessages`
- Stream options like `sendReasoning` align with your message shape
- The frontend can treat `useChat` messages as the same subtype end-to-end

`chat.withUIMessage<YourUIMessage>(config?)` returns a [ChatBuilder](#chatbuilder) where `.agent(...)` accepts the **same options as** [`chat.agent()`](/ai-chat/backend#chat-agent) but fixes `YourUIMessage` as the UI message type for that chat agent.

### Defining a `UIMessage` subtype

Build the type from AI SDK helpers and your tools object:

```ts
import type { InferUITools, UIDataTypes, UIMessage } from "ai";
import { tool, stepCountIs } from "ai";
import { z } from "zod";

const myTools = {
  lookup: tool({
    description: "Look up a record",
    inputSchema: z.object({ id: z.string() }),
    execute: async ({ id }) => ({ id, label: "example" }),
  }),
};

type MyChatTools = InferUITools<typeof myTools>;

type MyChatDataTypes = UIDataTypes & {
  "turn-status": { status: "preparing" | "streaming" | "done" };
};

export type MyChatUIMessage = UIMessage<unknown, MyChatDataTypes, MyChatTools>;
```

<Tip>
  If you don't need custom `data-*` parts, [`InferChatUIMessageFromTools<typeof myTools>`](/ai-chat/tools#typing-messages-from-your-tools) from `@trigger.dev/sdk/ai` collapses the tools half into one line (it's shorthand for `UIMessage<unknown, UIDataTypes, InferUITools<typeof myTools>>`).
</Tip>

Task-backed tools should use AI SDK [`tool()`](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling) with `execute: ai.toolExecute(schemaTask)` where needed — see [Task-backed AI tools](/tasks/schemaTask#task-backed-ai-tools).

### Backend: `chat.withUIMessage(...).agent(...)`

Call `withUIMessage` **once**, then chain `.agent({ ... })` instead of `chat.agent({ ... })`. You can also chain `.withClientData()` and hook methods before `.agent()`:

```ts
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, tool } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
import type { MyChatUIMessage } from "./my-chat-types";

const myTools = {
  lookup: tool({
    description: "Look up a record",
    inputSchema: z.object({ id: z.string() }),
    execute: async ({ id }) => ({ id, label: "example" }),
  }),
};

export const myChat = chat
  .withUIMessage<MyChatUIMessage>({
    streamOptions: {
      sendReasoning: true,
      onError: (error) =>
        error instanceof Error ? error.message : "Something went wrong.",
    },
  })
  .withClientData({
    schema: z.object({ userId: z.string() }),
  })
  .agent({
    id: "my-chat",
    tools: myTools,
    onTurnStart: async ({ uiMessages, writer }) => {
      // uiMessages is MyChatUIMessage[] — custom data parts are typed
      writer.write({
        type: "data-turn-status",
        data: { status: "preparing" },
      });
    },
    run: async ({ messages, tools, signal }) => {
      // `tools` is myTools, typed, handed back on the payload
      return streamText({
        model: anthropic("claude-sonnet-4-5"),
        messages,
        tools,
        abortSignal: signal,
        stopWhen: stepCountIs(15),
      });
    },
  });
```

### Default stream options

The optional `streamOptions` object becomes the **default** [`uiMessageStreamOptions`](/ai-chat/reference#chatagentoptions) for `toUIMessageStream()`.

If you also set `uiMessageStreamOptions` on the inner `.agent({ ... })`, the two objects are **shallow-merged** — keys on the **agent** win on conflicts. Per-turn overrides via [`chat.setUIMessageStreamOptions()`](/ai-chat/backend#stream-options) still apply on top.

### Frontend: `InferChatUIMessage`

Import the helper type and pass it to `useChat` so `messages` and render logic match the backend:

```tsx
import { useChat } from "@ai-sdk/react";
import { useTriggerChatTransport, type InferChatUIMessage } from "@trigger.dev/sdk/chat/react";
import type { myChat } from "./myChat";

type Msg = InferChatUIMessage<typeof myChat>;

export function Chat() {
  const transport = useTriggerChatTransport<typeof myChat>({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  });

  const { messages } = useChat<Msg>({ transport });

  return messages.map((m) => (
    <div key={m.id}>{/* m.parts narrowed for your UIMessage subtype */}</div>
  ));
}
```

You can also import `InferChatUIMessage` from `@trigger.dev/sdk/ai` in non-React modules.

## Typed client data with `chat.withClientData`

`chat.withClientData({ schema })` returns a [ChatBuilder](#chatbuilder) that fixes the client data schema. All hooks and `run` receive typed `clientData` without needing `clientDataSchema` in `.agent()` options.

```ts
import { chat } from "@trigger.dev/sdk/ai";
import { z } from "zod";

export const myChat = chat
  .withClientData({
    schema: z.object({ userId: z.string(), model: z.string().optional() }),
  })
  .agent({
    id: "my-chat",
    onPreload: async ({ clientData }) => {
      // clientData is typed as { userId: string; model?: string }
      await initUser(clientData.userId);
    },
    run: async ({ messages, clientData, signal }) => {
      return streamText({
        model: getModel(clientData.model),
        messages,
        abortSignal: signal,
        stopWhen: stepCountIs(15),
      });
    },
  });
```

## ChatBuilder

Both `chat.withUIMessage()` and `chat.withClientData()` return a **ChatBuilder** — a chainable object that accumulates configuration before creating the agent with `.agent()`.

Builder methods can be chained in any order:

```ts
export const myChat = chat
  .withUIMessage<MyChatUIMessage>({
    streamOptions: { sendReasoning: true },
  })
  .withClientData({
    schema: z.object({ userId: z.string() }),
  })
  .onChatSuspend(async ({ ctx }) => {
    await disposeCodeSandbox(ctx.run.id);
  })
  .onChatResume(async ({ ctx }) => {
    warmCache(ctx.run.id);
  })
  .agent({
    id: "my-chat",
    run: async ({ messages, signal }) => {
      return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
    },
  });
```

### Builder-level hooks

All [lifecycle hooks](/ai-chat/lifecycle-hooks) can be set on the builder: `onPreload`, `onChatStart`, `onTurnStart`, `onBeforeTurnComplete`, `onTurnComplete`, `onCompacted`, `onChatSuspend`, `onChatResume`.

Builder hooks and task-level hooks **coexist**. When both are defined for the same event, the builder hook runs first, then the task hook:

```ts
chat
  .withUIMessage<MyChatUIMessage>()
  .onPreload(async (event) => {
    // Runs first — shared setup across tasks using this builder
    await initializeSharedState(event.chatId);
  })
  .agent({
    id: "my-chat",
    onPreload: async (event) => {
      // Runs second — task-specific logic
      await createChatRecord(event.chatId);
    },
    run: async ({ messages, signal }) => {
      return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
    },
  });
```

<Tip>
  Set types first (`.withUIMessage()`, `.withClientData()`), then hooks. Hook parameters are typed based on the builder's current generics — so hooks registered after `.withClientData()` get typed `clientData`.
</Tip>

### When plain `chat.agent()` is enough

If you do not rely on custom `UIMessage` generics (only default text, reasoning, and built-in tool UI types), **`chat.agent()` alone is fine** — no need for `withUIMessage`.

## See also

- [Backend — `chat.agent()`](/ai-chat/backend#chat-agent)
- [Lifecycle hooks](/ai-chat/lifecycle-hooks)
- [Frontend — transport & `useChat`](/ai-chat/frontend)
- [API reference — `chat.withUIMessage`](/ai-chat/reference#chat-withuimessage)
- [API reference — `chat.withClientData`](/ai-chat/reference#chat-withclientdata)
- [Task-backed AI tools — `ai.toolExecute`](/tasks/schemaTask#task-backed-ai-tools)
