| title | Quick Start |
|---|---|
| sidebarTitle | Quick Start |
| description | Get a working AI agent in 3 steps — define an agent, generate a token, and wire up the frontend. |
import RcBanner from "/snippets/ai-chat-rc-banner.mdx";
These steps assume you already have a Trigger.dev project with the SDK installed and the CLI authenticated — if you don't, follow Manual setup (or npx trigger.dev@latest init in an existing project) first. You should be able to run pnpm exec trigger dev from your project root before continuing.
The chat surface works with Vercel AI SDK v5, v6, or v7; install whichever major you want. On v7, also install @ai-sdk/otel so your model calls are traced (the SDK registers it for you). See compatibility for the full matrix.
If you return a `StreamTextResult`, it's **automatically piped** to the frontend.
```ts trigger/chat.ts
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
export const myChat = chat.agent({
id: "my-chat",
run: async ({ messages, signal }) => {
return streamText({
// Spread chat.toStreamTextOptions() FIRST — it wires up
// prepareStep (compaction, steering, background injection),
// the system prompt set via chat.prompt(), and telemetry.
// Skipping this is the single most common cause of subtle
// bugs (silent broken compaction, missing steering, etc.).
...chat.toStreamTextOptions(),
model: anthropic("claude-sonnet-4-5"),
messages,
abortSignal: signal,
stopWhen: stepCountIs(15),
});
},
});
```
<Warning>
**Always spread `chat.toStreamTextOptions()` into your `streamText` call.** It wires up the `prepareStep` callback that drives compaction, mid-turn steering, and background injection — features that silently no-op if the spread is missing. Spread it **first** so any explicit overrides (e.g. a custom `prepareStep`) win.
</Warning>
<Tip>
For a **custom** [`UIMessage`](https://sdk.vercel.ai/docs/reference/ai-sdk-core/ui-message) subtype (typed `data-*` parts, tool map, etc.), define the agent with [`chat.withUIMessage<...>().agent({...})`](/ai-chat/types) instead of `chat.agent`.
</Tip>
```ts app/actions.ts
"use server";
import { auth } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";
// Creates the Session row + triggers the first run, returns the
// session PAT. Idempotent on (env, chatId) so concurrent calls
// converge to the same session.
export const startChatSession = chat.createStartSessionAction("my-chat");
// Pure mint — fresh session-scoped PAT for an existing session.
// The transport calls this on 401/403 to refresh.
export async function mintChatAccessToken(chatId: string) {
return auth.createPublicToken({
scopes: {
read: { sessions: chatId },
write: { sessions: chatId },
},
expirationTime: "1h",
});
}
```
The browser never holds your environment's secret key — both helpers run on your server, where customer-side authorization (per-user, per-plan, etc.) lives alongside any DB writes you want to pair with session creation.
The example below uses the Next.js `@/*` path alias for imports from `@/trigger/chat` and `@/app/actions`. If you're not using Next.js (or haven't configured the alias), swap them for relative imports.
```tsx app/components/chat.tsx
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import type { myChat } from "@/trigger/chat";
import { mintChatAccessToken, startChatSession } from "@/app/actions";
export function Chat() {
const transport = useTriggerChatTransport<typeof myChat>({
task: "my-chat",
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
startSession: ({ chatId, clientData }) =>
startChatSession({ chatId, clientData }),
});
const { messages, sendMessage, stop, status } = useChat({ transport });
const [input, setInput] = useState("");
return (
<div>
{messages.map((m) => (
<div key={m.id}>
<strong>{m.role}:</strong>
{m.parts.map((part, i) =>
part.type === "text" ? <span key={i}>{part.text}</span> : null
)}
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault();
if (input.trim()) {
sendMessage({ text: input });
setInput("");
}
}}
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
/>
<button type="submit" disabled={status === "streaming"}>
Send
</button>
{status === "streaming" && (
<button type="button" onClick={stop}>
Stop
</button>
)}
</form>
</div>
);
}
```
- Backend — Lifecycle hooks, persistence, session iterator, raw task primitives
- Tools: Declare tools so
toModelOutputsurvives across turns, typed inrun() - Frontend — Session management, client data, reconnection
- Types —
chat.withUIMessage,InferChatUIMessage, and related typing chat.local— Per-run typed state across hooks, run, tools, subtasks- Sub-agents pattern — Subtask-as-tool,
target: "root"streaming,ai.toolExecutehelpers - Background injection —
chat.inject()andchat.defer()for between-turn work