Skip to main content
TypeScript patterns for AI Chat. This page covers how to pin a custom AI SDK UIMessage 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 where .agent(...) accepts the same options as 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:
If you don’t need custom data-* parts, InferChatUIMessageFromTools<typeof myTools> from @trigger.dev/sdk/ai collapses the tools half into one line (it’s shorthand for UIMessage<unknown, UIDataTypes, InferUITools<typeof myTools>>).
Task-backed tools should use AI SDK tool() with execute: ai.toolExecute(schemaTask) where needed — see 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():

Default stream options

The optional streamOptions object becomes the default uiMessageStreamOptions 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() still apply on top.

Frontend: InferChatUIMessage

Import the helper type and pass it to useChat so messages and render logic match the backend:
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 that fixes the client data schema. All hooks and run receive typed clientData without needing clientDataSchema in .agent() options.

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:

Builder-level hooks

All 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:
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.

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