> ## Documentation Index
> Fetch the complete documentation index at: https://trigger.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Backend

> Three approaches to building your chat backend — chat.agent(), session iterator, or raw task primitives.

There are three abstraction levels for a chat backend. All three speak the same wire protocol, so the [frontend transport](https://trigger.dev/docs/ai-chat/frontend.md) works unchanged whichever you pick.

| Capability | `chat.agent()` | `chat.createSession()` | Raw primitives |
| - | - | - | - |
| Turn loop, stop signals, accumulation | Managed | Managed | You write it |
| Lifecycle hooks | Yes | No — inline code per turn | No |
| Continuation recovery on new runs | Automatic | [Manual seeding](https://trigger.dev/docs/ai-chat/custom-agents.md#continuation-runs-and-history-seeding) | Manual seeding |
| Compaction / steering | Built-in | Built-in | Manual |
| Head Start, actions, tool approvals | Yes | No | No |
| Custom stream conversion | No | Limited | Full control |
| Agent dashboard visibility | Yes | Yes (via `customAgent`) | Yes |

The raw-primitives column assumes [`chat.customAgent()`](https://trigger.dev/docs/ai-chat/custom-agents) as the wrapper, which is what makes the task visible to the agent dashboard.

Start with `chat.agent()`. Drop to `chat.createSession()` when you want to own the per-turn code (model routing, persistence, custom telemetry) without rebuilding the turn loop. Drop to raw primitives only when you need full control over stream conversion or a custom protocol.

## chat.agent()

The highest-level approach. Handles message accumulation, stop signals, turn lifecycle, and auto-piping automatically.

### Simple: return a StreamTextResult

Return the `streamText` result from `run` and it's automatically piped to the frontend:

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

export const simpleChat = chat.agent({
  id: "simple-chat",
  run: async ({ messages, signal, streamText }) => {
    return streamText({
      model: anthropic("claude-sonnet-4-5"),
      system: "You are a helpful assistant.",
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

> [!NOTE]
>
> The `streamText` destructured from `run`'s argument is the SDK's, not the one
> imported from `ai`. It carries the agent's managed options, so nothing has to be
> spread in. [The managed streamText](#the-managed-streamtext) covers what those
> options are and what happens when yours collide with them.

### The managed streamText

`run()` is handed a `streamText` that already carries everything the spread provides, so the managed state cannot be lost by leaving the spread out:

```ts theme={"theme":"css-variables"}
export const simpleChat = chat.agent({
  id: "simple-chat",
  run: async ({ messages, signal, streamText }) =>
    streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    }),
});
```

Note the destructured `streamText`: it shadows the one imported from `ai` inside `run`, so the managed options apply without a spread. Spreading `chat.toStreamTextOptions()` into the imported `streamText` is still supported and equivalent.

It differs from the spread in three ways, all of them about what happens when your options collide with the managed ones:

| Option | Spread | Managed `streamText` |
| - | - | - |
| `tools` | Passing `tools` after the spread replaces the skill tools | Merged, so skill tools survive |
| `prepareStep` | Passing your own after the spread replaces the managed one, silently disabling steering, compaction and injection | Composed, yours runs after the managed one |
| `system` | Yours replaces the managed prompt and any injected instructions | Throws |

`system` throws rather than merging because there is no shape that combines two system values on every supported AI SDK version: v5 rejects an array of blocks, and a structured block carries the provider options that make [prompt caching](https://trigger.dev/docs/ai-chat/prompt-caching.md) work, so concatenating discards the cache entry. Set a static prompt with [`chat.prompt.set()`](#using-prompts) and add per-turn context with [`chat.inject()`](https://trigger.dev/docs/ai-chat/background-injection).

If the managed prompt names a model, pass a registry on the agent so the runtime can resolve it: `chat.agent({ registry, run })`.

### Using chat.pipe() for complex flows

For complex agent flows where `streamText` is called deep inside your code, use `chat.pipe()`. It works from **anywhere inside a task** — even nested function calls.

```ts trigger/agent-chat.ts theme={"theme":"css-variables"}
import { chat, type ChatStreamText } from "@trigger.dev/sdk/ai";
import { anthropic } from "@ai-sdk/anthropic";
import { stepCountIs, type ModelMessage } from "ai";

export const agentChat = chat.agent({
  id: "agent-chat",
  run: async ({ messages, streamText }) => {
    // Don't return anything, chat.pipe is called inside
    await runAgentLoop(messages, streamText);
  },
});

// A loop factored out of `run` takes `streamText` as an argument so it keeps the
// managed options. `ChatStreamText` types the parameter.
async function runAgentLoop(messages: ModelMessage[], streamText: ChatStreamText) {
  // ... agent logic, tool calls, etc.

  const result = streamText({
    model: anthropic("claude-sonnet-4-5"),
    messages,
    stopWhen: stepCountIs(15),
  });

  // Pipe from anywhere — no need to return it
  await chat.pipe(result);
}
```

### Custom data parts

Add custom `data-*` parts to the assistant's response message via `chat.response.write()` (from `run()`) or the `writer` parameter in lifecycle hooks. Non-transient `data-*` chunks are automatically added to `responseMessage.parts` and surface in `onTurnComplete` for persistence:

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  onBeforeTurnComplete: async ({ writer, turn }) => {
    // This data part will be in responseMessage.parts in onTurnComplete
    writer.write({
      type: "data-metadata",
      data: { turn, model: "gpt-4o", timestamp: Date.now() },
    });
  },
  onTurnComplete: async ({ responseMessage }) => {
    // responseMessage.parts includes the data-metadata part
    await db.messages.save(responseMessage);
  },
  run: async ({ messages, signal, streamText }) => {
    // Also works from run() via chat.response
    chat.response.write({
      type: "data-context",
      data: { searchResults: results },
    });

    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

Add `transient: true` to data chunks that should stream to the frontend but NOT persist in the response message. Use this for progress indicators, loading states, and other temporary UI:

```ts theme={"theme":"css-variables"}
// Transient — frontend sees it, but NOT in onTurnComplete's responseMessage
writer.write({
  type: "data-progress",
  id: "search",
  data: { percent: 50 },
  transient: true,
});
```

> [!NOTE]
>
> This matches the AI SDK's semantics: `data-*` chunks persist to `message.parts` by default. Only `transient: true` chunks are ephemeral. Non-data chunks (`text-delta`, `tool-*`, etc.) are handled by `streamText` and captured via `onFinish` — they don't need `chat.response`.

> [!NOTE]
>
> `chat.response` and the `writer` accumulation behavior work with `chat.agent` and `chat.createSession`. If you're using [`chat.customAgent`](https://trigger.dev/docs/ai-chat/custom-agents), you own the accumulator — see the raw-task example for the manual pattern.

### Raw streaming with `chat.stream`

For low-level stream access (piping from subtasks, reading streams by run ID), use `chat.stream`. Chunks written via `chat.stream` go directly to the realtime output — they are **NOT** accumulated into the response message regardless of the `transient` flag.

```ts theme={"theme":"css-variables"}
// Raw stream — always ephemeral, never in responseMessage
const { waitUntilComplete } = chat.stream.writer({
  execute: ({ write }) => {
    write({ type: "data-status", data: { message: "Processing..." } });
  },
});
await waitUntilComplete();
```

> [!TIP]
>
> Use `data-*` chunk types (e.g. `data-status`, `data-progress`) for custom data. The AI SDK processes these into `DataUIPart` objects in `message.parts` on the frontend. Writing the same `type` + `id` again updates the existing part instead of creating a new one — useful for live progress.

`chat.stream` exposes the full stream API:

| Method | Description |
| - | - |
| `chat.stream.writer(options)` | Write individual chunks via a callback |
| `chat.stream.pipe(stream, options?)` | Pipe a `ReadableStream` or `AsyncIterable` |
| `chat.stream.append(value, options?)` | Append raw data |
| `chat.stream.read(runId, options?)` | Read the stream by run ID |

For piping streams from subtasks to the parent chat (via `target: "root"`), see the [Sub-agents pattern](https://trigger.dev/docs/ai-chat/patterns/sub-agents.md).

### Backed by a Session

Every `chat.agent` conversation is backed by a durable [Session](https://trigger.dev/docs/ai-chat/sessions.md): `externalId` is your `chatId`, `type` is `"chat.agent"`, and `taskIdentifier` is the agent's task ID. The session is the run manager. It owns the chat's runs, persists across run lifecycles, and orchestrates handoffs (idle continuation, `chat.requestUpgrade`). You rarely touch it directly, since `chat.stream`, `chat.messages`, and `chat.stopSignal` wrap everything, but `payload.sessionId` is there when you need to reach in, e.g. `sessions.open(payload.sessionId)` to write from a sub-agent or from outside the turn loop.

### Tools

Declare your tools on the agent config, then read them back (typed) from the `run()` payload. Declaring them on the config, not just on `streamText`, is what lets the SDK re-apply each tool's `toModelOutput` when it re-converts history on later turns.

```ts theme={"theme":"css-variables"}
const tools = { searchDocs };

export const myChat = chat.agent({
  id: "my-chat",
  tools,
  run: async ({ messages, tools, signal, streamText }) =>
    streamText({
      tools,
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    }),
});
```

See [Tools](https://trigger.dev/docs/ai-chat/tools.md) for `toModelOutput` across turns, per-turn dynamic tools, the typed run payload, and how config tools relate to skills.

### Lifecycle hooks

`chat.agent({ ... })` accepts hooks that fire in a fixed order around each turn, plus dedicated suspend/resume hooks. The full reference lives on its own page:

* [Lifecycle hooks](https://trigger.dev/docs/ai-chat/lifecycle-hooks.md) — `onPreload`, `onChatStart`, `onValidateMessages`, `hydrateMessages`, `onTurnStart`, `onBeforeTurnComplete`, `onTurnComplete`, `onChatSuspend` / `onChatResume`, `exitAfterPreloadIdle`, plus how `ctx` plumbs through every callback.

**Per-turn order:** `onValidateMessages` → `hydrateMessages` → `onChatStart` (chat's first message only) → `onTurnStart` → `run()` → `onBeforeTurnComplete` → `onTurnComplete`.

### Using prompts

Use [AI Prompts](https://trigger.dev/docs/ai/prompts.md) to manage your system prompt as versioned, overridable config. Store the resolved prompt in a lifecycle hook with `chat.prompt.set()`. The `streamText` from `run`'s argument picks it up: system prompt, model, config and telemetry.

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { prompts } from "@trigger.dev/sdk";
import { createProviderRegistry } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

const registry = createProviderRegistry({ anthropic });

const systemPrompt = prompts.define({
  id: "my-chat-system",
  model: "anthropic:claude-sonnet-4-5",
  config: { temperature: 0.7 },
  variables: z.object({ name: z.string() }),
  content: `You are a helpful assistant for {{name}}.`,
});

export const myChat = chat.agent({
  id: "my-chat",
  registry,
  clientDataSchema: z.object({ userId: z.string() }),
  onChatStart: async ({ clientData }) => {
    const user = await db.user.findUnique({ where: { id: clientData.userId } });
    const resolved = await systemPrompt.resolve({ name: user.name });
    chat.prompt.set(resolved);
  },
  run: async ({ messages, signal, streamText }) => {
    return streamText({
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

The managed `streamText` carries the stored prompt's `system`, `model` (resolved through the agent's `registry`), sampling config, and `experimental_telemetry`. AI SDK telemetry is on even without a stored prompt, so model steps and tool calls show in the run trace. Options you pass at the call site win, apart from `system`, which throws when the prompt already set one.

`chat.toStreamTextOptions()` remains available for the same job, and is the only option in a [custom agent](#custom-agents), which has no `run` argument to take a bound `streamText` from. A `chat.headStart` route does get one, and there it also owns `messages`, `stopWhen` and `abortSignal`, since the handover depends on them. Pass `{ registry }` when a prompt names a provider-prefixed model, and `{ tools }` when you want HITL tool approvals, so the SDK knows which calls pause on `needsApproval`.

> [!TIP]
>
> See [Prompts](https://trigger.dev/docs/ai/prompts.md) for the full guide — defining templates, variable schemas, dashboard
> overrides, and the management SDK.

### Stop generation

#### How stop works

Calling `stop()` from `useChat` sends a stop signal to the running task via input streams. The task's `streamText` call aborts (if you passed `signal` or `stopSignal`), but the **run stays alive** and waits for the next message. The partial response is captured and accumulated normally.

#### Abort signals

The `run` function receives three abort signals:

| Signal | Fires when | Use for |
| - | - | - |
| `signal` | Stop **or** cancel | Pass to `streamText` — handles both cases. **Use this in most cases.** |
| `stopSignal` | Stop only (per-turn, reset each turn) | Custom logic that should only run on user stop, not cancellation |
| `cancelSignal` | Run cancel, expire, or maxDuration exceeded | Cleanup that should only happen on full cancellation |

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  run: async ({ messages, signal, stopSignal, cancelSignal, streamText }) => {
    return streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal, // Handles both stop and cancel
      stopWhen: stepCountIs(15),
    });
  },
});
```

> [!TIP]
>
> Use `signal` (the combined signal) in most cases. The separate `stopSignal` and `cancelSignal` are
> only needed if you want different behavior for stop vs cancel.

#### Detecting stop in callbacks

The `onTurnComplete` event includes a `stopped` boolean that indicates whether the user stopped generation during that turn:

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  onTurnComplete: async ({ chatId, uiMessages, stopped }) => {
    await db.chat.update({
      where: { id: chatId },
      data: { messages: uiMessages, lastStoppedAt: stopped ? new Date() : undefined },
    });
  },
  run: async ({ messages, signal, streamText }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

You can also check stop status from **anywhere** during a turn using `chat.isStopped()`. This is useful inside `streamText`'s `onFinish` callback where the AI SDK's `isAborted` flag can be unreliable (e.g. when using `createUIMessageStream` + `writer.merge()`):

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";

export const myChat = chat.agent({
  id: "my-chat",
  run: async ({ messages, signal, streamText }) => {
    return streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      onFinish: ({ isAborted }) => {
        // isAborted may be false even after stop when using createUIMessageStream
        const wasStopped = isAborted || chat.isStopped();
        if (wasStopped) {
          // handle stop — e.g. log analytics
        }
      },
      stopWhen: stepCountIs(15),
    });
  },
});
```

#### Cleaning up aborted messages

When stop happens mid-stream, the captured response message can contain parts in an incomplete state — tool calls stuck in `partial-call`, reasoning blocks still marked as `streaming`, etc. These can cause UI issues like permanent spinners.

`chat.agent` automatically cleans up the `responseMessage` when stop is detected before passing it to `onTurnComplete`. If you use `chat.pipe()` manually and capture response messages yourself, use `chat.cleanupAbortedParts()`:

```ts theme={"theme":"css-variables"}
const cleaned = chat.cleanupAbortedParts(rawResponseMessage);
```

This removes tool invocation parts stuck in `partial-call` state and marks any `streaming` text or reasoning parts as `done`.

> [!NOTE]
>
> Stop signal delivery is best-effort. There is a small race window where the model may finish
> before the stop signal arrives, in which case the turn completes normally with `stopped: false`.
> This is expected and does not require special handling.

### Tool approvals

Tools with `needsApproval: true` pause execution until the user approves or denies via the frontend. Define the tool as normal and pass it to `streamText` — `chat.agent` handles the rest:

```ts theme={"theme":"css-variables"}
const sendEmail = tool({
  description: "Send an email. Requires human approval.",
  inputSchema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
  needsApproval: true,
  execute: async ({ to, subject, body }) => {
    await emailService.send({ to, subject, body });
    return { sent: true };
  },
});

export const myChat = chat.agent({
  id: "my-chat",
  run: async ({ messages, signal, streamText }) => {
    return streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      tools: { sendEmail },
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

When the model calls an approval-required tool, the turn completes with the tool in `approval-requested` state. After the user approves on the frontend, the updated message is sent back and `chat.agent` replaces it in the conversation accumulator by matching the message ID. `streamText` then executes the approved tool and continues.

See [Tool approvals](https://trigger.dev/docs/ai-chat/frontend.md#tool-approvals) in the frontend docs for the UI setup.

### Persistence

To build a chat app that survives page refreshes you persist two things, both server-side from inside the agent:

1. **Conversation state.** Full `UIMessage[]` keyed by `chatId`. Written from `onTurnStart` (so the user message is durable before streaming begins) and `onTurnComplete` (so the assistant reply lands).
2. **Session state.** The transport's reconnect metadata: `publicAccessToken` and `lastEventId`. Written alongside the messages from the same hooks.

> [!NOTE]
>
> Sessions let the transport reconnect to an existing run after a page refresh. Without them, every page load would start a new run, losing the conversation context that was accumulated in the previous run.

For the full per-hook breakdown, race-condition warnings (atomic `lastEventId` writes, why not to use `chat.defer` in `onTurnStart`), token renewal via the `accessToken` callback, and an end-to-end three-file example, see [Database persistence](https://trigger.dev/docs/ai-chat/patterns/database-persistence.md).

### Pending messages (steering)

Users can send messages while the agent is executing tool calls. With `pendingMessages`, these messages are injected between tool-call steps, steering the agent mid-execution:

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  registry,
  pendingMessages: {
    shouldInject: ({ steps }) => steps.length > 0,
  },
  run: async ({ messages, signal, streamText }) => {
    return streamText({
      messages,
      tools: {
        /* ... */
      },
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

On the frontend, the `usePendingMessages` hook handles sending, tracking, and rendering injection points.

> [!TIP]
>
> See [Pending Messages](https://trigger.dev/docs/ai-chat/pending-messages.md) for the full guide — backend configuration,
> frontend hook, queuing vs steering, and how injection works with all three chat variants.

### Background injection

Inject context from background work into the conversation using `chat.inject()`. Combine with `chat.defer()` to run analysis between turns and inject results before the next response — self-review, RAG augmentation, safety checks, etc.

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  registry,
  onTurnComplete: async ({ messages }) => {
    chat.defer(
      (async () => {
        const review = await generateObject({
          /* ... */
        });
        if (review.object.needsImprovement) {
          chat.inject([
            {
              role: "system",
              content: `[Self-review]\n${review.object.suggestions.join("\n")}`,
            },
          ]);
        }
      })()
    );
  },
  run: async ({ messages, signal, streamText }) => {
    return streamText({ messages, abortSignal: signal });
  },
});
```

> [!TIP]
>
> See [Background Injection](https://trigger.dev/docs/ai-chat/background-injection.md) for the full guide — timing, self-review
> example, and how it differs from pending messages.

### Actions

Custom actions let the frontend send structured commands (undo, rollback, edit, regenerate) that modify the conversation state. **Actions are not turns**: they fire `hydrateMessages` (if set) and `onAction` only. The full surface (defining `actionSchema`, returning a model response from `onAction`, gating against pending HITL tool calls, and sending actions from the frontend) lives on its own page.

See [Actions](https://trigger.dev/docs/ai-chat/actions.md).

### Webhook events and channels

Two `chat.agent()` options wire an agent to verified inbound webhooks. `events` claims [`chat.event(...)`](https://trigger.dev/docs/webhooks/session-routing) descriptors: each verified delivery is routed to this agent's session and arrives at `onAction` as an action (not a turn), so [session routing](https://trigger.dev/docs/webhooks/session-routing.md) decides which conversation it lands on. `channels` claims channel connectors that turn an external chat surface into a frontend for the agent: an inbound message runs as a turn through `run()` and the reply is posted back. `slack()` ships in `@trigger.dev/slack`, and `chat.channels.custom(...)` builds a connector for any endpoint without a preset.

```ts theme={"theme":"css-variables"}
import { webhooks } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";
import { appMentions, slack, webhookSource } from "@trigger.dev/slack";

const slackEvents = webhooks.endpoint.define({ id: "slack", source: webhookSource() });
const stripe = webhooks.endpoint.define({ id: "stripe", source: webhooks.stripe() });

export const orderEvents = chat.event({
  id: "order-events",
  endpoint: stripe,
  key: "{event.data.object.customer}",
  // invoices always carry a customer; events without one would have no session to go to
  filter: "event.type startsWith 'invoice.'",
  type: "order.event",
});

export const myChat = chat.agent({
  id: "my-chat",
  events: [orderEvents],
  channels: [
    slack({
      id: "support-slack",
      endpoint: slackEvents,
      token: process.env.SLACK_BOT_TOKEN!,
      startOn: appMentions(),
    }),
  ],
  onAction: async ({ action }) => {
    // A verified order-events delivery arrives here as an action.
  },
  run: async (payload) => {
    // Inbound Slack messages run here as normal turns.
  },
});
```

See [session routing](https://trigger.dev/docs/webhooks/session-routing.md) and [channels](https://trigger.dev/docs/webhooks/channels.md). For the interactive approvals layer, where a turn pauses on a human decision (buttons in the thread) and resumes on the click, see [human-in-the-loop](https://trigger.dev/docs/webhooks/human-in-the-loop.md).

### Chat history

Imperative API for reading and modifying the accumulated message history. Works from any hook (`onAction`, `onTurnStart`, `onBeforeTurnComplete`, `onTurnComplete`, `hydrateMessages`) or from `run()` and AI SDK tools.

> [!NOTE]
>
> The agent's accumulator — not `session.out` — is the source of truth for the full conversation. The `.out` stream is a bounded sliding window (roughly one turn at steady state, see [Records on `session.out`](https://trigger.dev/docs/ai-chat/client-protocol#records-on-session-out)); the durable history lives in the agent's accumulator and is persisted to S3 between turns for fast next-run boots. `chat.history` reads and mutates that accumulator directly.

**Reads.** Synchronous against the current accumulator state.

| Method | Description |
| - | - |
| `chat.history.all()` | Returns a copy of the current accumulated UI messages. |
| `chat.history.getChain()` | Same as `all()`. Use whichever name reads better in context. |
| `chat.history.findMessage(messageId)` | Returns the message with that id, or `undefined`. |
| `chat.history.getPendingToolCalls()` | Tool calls on the most recent assistant message that are still in `input-available` state (waiting on `addToolOutput`). |
| `chat.history.getResolvedToolCalls()` | All tool calls in the chain in `output-available` or `output-error` state. |
| `chat.history.extractNewToolResults(message)` | Tool results in `message` whose `toolCallId` is not already resolved in the chain. Most useful in `hydrateMessages` against an incoming wire message, before the runtime merges it. |

Each pending and resolved entry is shaped `{ toolCallId, toolName, messageId }`. Each new-result entry is `{ toolCallId, toolName, output, errorText? }`, where `errorText` is set only for `output-error` parts.

**Mutations.** Applied at lifecycle checkpoints (after hooks return). Multiple mutations in the same hook compose correctly.

| Method | Description |
| - | - |
| `chat.history.set(messages)` | Replace all messages. Same as `chat.setMessages()`. |
| `chat.history.remove(messageId)` | Remove a specific message by ID. |
| `chat.history.rollbackTo(messageId)` | Keep messages up to and including the given ID (undo). |
| `chat.history.replace(messageId, message)` | Replace a specific message by ID (edit). |
| `chat.history.slice(start, end?)` | Keep only messages in the given range. |

```ts theme={"theme":"css-variables"}
// Undo the last exchange in onAction
onAction: async ({ action }) => {
  if (action.type === "undo") {
    chat.history.slice(0, -2);
  }
},

// Trim history in onTurnComplete
onTurnComplete: async ({ uiMessages }) => {
  if (uiMessages.length > 50) {
    chat.history.slice(-20);
  }
},
```

The HITL reads let an action or hook decide what to do without walking the accumulator manually:

```ts theme={"theme":"css-variables"}
// Refuse a regenerate while a tool call is still awaiting an answer
onAction: async ({ action }) => {
  if (action.type === "regenerate") {
    if (chat.history.getPendingToolCalls().length > 0) return;
    chat.history.slice(0, -1);
  }
},

// Side-effect once per net-new tool result when wire messages come in
hydrateMessages: async ({ incomingMessages }) => {
  for (const msg of incomingMessages) {
    for (const r of chat.history.extractNewToolResults(msg)) {
      await onToolResolved({ id: r.toolCallId, output: r.output, errorText: r.errorText });
    }
  }
  return incomingMessages;
},
```

`extractNewToolResults` compares against the *current* chain. Inside `onTurnComplete`, the chain already contains the just-finished `responseMessage`, so it returns `[]`. Use it where the message is from outside the accumulator: `hydrateMessages` (incoming wire), `onAction` if the action carries a message, or any custom pre-merge code path.

### prepareMessages

Transform model messages before they're used anywhere — in `run()`, in compaction rebuilds, and in compaction results. Define once, applied everywhere.

Use this for Anthropic cache breaks, injecting system context, stripping PII, etc.

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  prepareMessages: ({ messages, reason }) => {
    // Add Anthropic cache breaks to the last message
    if (messages.length === 0) return messages;
    const last = messages[messages.length - 1];
    return [
      ...messages.slice(0, -1),
      {
        ...last,
        providerOptions: {
          ...last.providerOptions,
          anthropic: { cacheControl: { type: "ephemeral" } },
        },
      },
    ];
  },
  run: async ({ messages, signal, streamText }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

The `reason` field tells you why messages are being prepared:

| Reason | Description |
| - | - |
| `"run"` | Messages being passed to `run()` for `streamText` |
| `"compaction-rebuild"` | Rebuilding from a previous compaction summary |
| `"compaction-result"` | Fresh compaction just produced these messages |

### Version upgrades

Chat agent runs are pinned to the worker version they started on. When you deploy a new version, suspended runs resume on the old code. Call `chat.requestUpgrade()` in `onTurnStart` to skip `run()` and exit immediately — the transport re-triggers the same message on the latest version. See the [Version Upgrades pattern](https://trigger.dev/docs/ai-chat/patterns/version-upgrades.md) for the full guide.

### Ending a run on your terms

By default, a chat agent stays idle after each turn waiting for the next user message. Call `chat.endRun()` from `run()`, `chat.defer()`, `onBeforeTurnComplete`, or `onTurnComplete` to exit the loop once the current turn finishes — no upgrade signal, no idle wait.

```ts theme={"theme":"css-variables"}
chat.agent({
  id: "one-shot",
  run: async ({ messages, signal, streamText }) => {
    // Single-response agent — exit after this turn.
    chat.endRun();
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

The current turn streams through normally, `onBeforeTurnComplete` / `onTurnComplete` fire, the turn-complete chunk is written, and the run exits instead of suspending. The next user message on the same `chatId` starts a fresh run via the standard continuation flow.

Use this when the agent knows its work is done (budget exhausted, goal achieved, one-shot response) rather than relying on the idle timeout. Unlike `chat.requestUpgrade()`, no `upgrade-required` signal is sent to the client, so there's no version-migration semantics.

> [!WARNING]
>
> If you persist `lastEventId` to your own storage for cross-page-load resume, **don't clear it on `chat.endRun()`**. The cursor is sessionId-keyed and stays valid across Run boundaries — clearing it forces the next `sendMessages` to subscribe from `seq_num=0`, where it may hit the prior turn's stale `turn-complete` record and close the stream empty before the new Run's chunks arrive.

### Ending the conversation

`chat.close()` ends the whole conversation. The session row is marked closed, further appends are refused with HTTP 409, and no continuation run is scheduled. Call it from `run()`, `prepareStep`, or `onBeforeTurnComplete`.

```ts theme={"theme":"css-variables"}
chat.agent({
  id: "budgeted-agent",
  run: async ({ messages, signal }) =>
    streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal }),
  // onBeforeTurnComplete carries the same fields as onTurnComplete, including
  // `usage`, and still runs while the response stream is open.
  onBeforeTurnComplete: async ({ chatId }) => {
    if (await overBudget(chatId)) {
      chat.close({ reason: "Monthly budget reached" });
    }
  },
});
```

The current turn finishes and streams through normally. Called mid-step, `chat.close()` aborts the in-flight `streamText` the same way the stop signal does, so the partial response is still captured and delivered. The transport then flips to a closed state, carrying `reason` so you can render why the conversation ended.

> [!WARNING]
>
> **Decide before the turn ends, not in `onTurnComplete`.** The closed state reaches the browser on the turn's final `turn-complete` record. `onTurnComplete` runs after that record is written and after the response stream has closed, so a close decided there does not reach a reader that is already done with the turn: the user sees a normal answer and learns the conversation ended only when their next message is refused. Use `onBeforeTurnComplete` for the same information one step earlier, and the user sees the closed state as soon as the answer finishes.

Closing is one-way. A closed session cannot be reopened, and its transcript stays readable. Start a new conversation under a different `chatId` to continue.

On the client, read the closed state off the transport:

```tsx app/components/Chat.tsx theme={"theme":"css-variables"}
if (transport.sessionStatus(chatId) === "closed") {
  return <p>This conversation has ended: {transport.sessionClosedReason(chatId)}</p>;
}
```

### Three levels of stopping

`chat.agent` stops at three levels, each with a different "what happens next".

| Level | Primitive | What stops | What happens next |
| - | - | - | - |
| Turn | `stopWhen`, or the [stop signal](https://trigger.dev/docs/ai-chat/backend.md#stop-generation) aborting `signal` | The current `streamText` | The turn completes, the agent idles for the next message |
| Run | `chat.endRun()` | The current worker | The next message on the same `chatId` starts a fresh continuation run |
| Session | `chat.close()` | The conversation, permanently | Nothing. Appends are refused with 409 and no run is triggered |

Reach for the narrowest level that does the job: a turn budget is a `stopWhen`, a finished one-shot answer is `chat.endRun()`, and an exhausted account or a signed-out user is `chat.close()`.

`chat.close()` is available to [custom agents](https://trigger.dev/docs/ai-chat/custom-agents.md#ending-the-conversation) too, where the close is performed when your `run()` returns.

A session can also be closed from outside the run: `sessions.close(chatId)` from your backend, or the Close action in the dashboard. A live run is told either way: the close lands on the session's input channel, so an idle or suspended agent leaves its loop on the next wake instead of waiting out its idle timeout.

### Runtime configuration

#### chat.setTurnTimeout()

Override how long the run stays suspended waiting for the next message. Call from inside `run()`:

```ts theme={"theme":"css-variables"}
run: async ({ messages, signal, streamText }) => {
  chat.setTurnTimeout("2h"); // Wait longer for this conversation
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
```

#### chat.setIdleTimeoutInSeconds()

Override how long the run stays idle (active, using compute) after each turn:

```ts theme={"theme":"css-variables"}
run: async ({ messages, signal, streamText }) => {
  chat.setIdleTimeoutInSeconds(60); // Stay idle for 1 minute
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
```

> [!NOTE]
>
> Longer idle timeout means faster responses but more compute usage. Set to `0` to suspend
> immediately after each turn (minimum latency cost, slight delay on next message).

#### Stream options

Control how `streamText` results are converted to the frontend stream via `toUIMessageStream()`. Set static defaults on the task, or override per-turn.

##### Error handling with onError

When `streamText` encounters an error mid-stream (rate limits, API failures, network errors), the `onError` callback converts it to a string that's sent to the frontend as an `{ type: "error", errorText }` chunk. The AI SDK's `useChat` receives this via its `onError` callback.

By default, the raw error message is sent to the frontend. Use `onError` to sanitize errors and avoid leaking internal details:

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  uiMessageStreamOptions: {
    onError: (error) => {
      // Log the full error server-side for debugging
      console.error("Stream error:", error);
      // Return a sanitized message — this is what the frontend sees
      if (error instanceof Error && error.message.includes("rate limit")) {
        return "Rate limited — please wait a moment and try again.";
      }
      return "Something went wrong. Please try again.";
    },
  },
  run: async ({ messages, signal, streamText }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

`onError` is also called for tool execution errors, so a single handler covers both LLM errors and tool failures.

On the frontend, handle the error in `useChat`:

```tsx theme={"theme":"css-variables"}
const { messages, sendMessage } = useChat({
  transport,
  onError: (error) => {
    // error.message contains the string returned by your onError handler
    toast.error(error.message);
  },
});
```

##### Reasoning and sources

Control which AI SDK features are forwarded to the frontend:

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  uiMessageStreamOptions: {
    sendReasoning: true, // Forward model reasoning (default: true)
    sendSources: true, // Forward source citations (default: false)
  },
  run: async ({ messages, signal, streamText }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

##### Custom message IDs

By default, response message IDs are generated using the AI SDK's built-in `generateId`. Pass a custom `generateMessageId` function to use your own ID format (e.g. UUID-v7):

```ts theme={"theme":"css-variables"}
import { v7 as uuidv7 } from "uuid";

export const myChat = chat.agent({
  id: "my-chat",
  uiMessageStreamOptions: {
    generateMessageId: () => uuidv7(),
  },
  run: async ({ messages, signal, streamText }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

With the `.withUIMessage()` builder, set it under `streamOptions`:

```ts theme={"theme":"css-variables"}
import { v7 as uuidv7 } from "uuid";

export const myChat = chat
  .withUIMessage<MyChatUIMessage>({
    streamOptions: {
      generateMessageId: () => uuidv7(),
      sendReasoning: true,
    },
  })
  .agent({
    id: "my-chat",
    run: async ({ messages, signal, streamText }) => {
      return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
    },
  });
```

> [!NOTE]
>
> The generated ID is sent to the frontend in the stream's `start` chunk, so frontend and backend
> always reference the same ID for each message. This is important for features like tool
> approvals, where the frontend resends an assistant message and the backend needs to match it
> by ID in the conversation accumulator.

##### Per-turn overrides

Override per-turn with `chat.setUIMessageStreamOptions()` — per-turn values merge with the static config (per-turn wins on conflicts). The override is cleared automatically after each turn.

```ts theme={"theme":"css-variables"}
run: async ({ messages, clientData, signal, streamText }) => {
  // Enable reasoning only for certain models
  if (clientData.model?.includes("claude")) {
    chat.setUIMessageStreamOptions({ sendReasoning: true });
  }
  return streamText({ model: openai(clientData.model ?? "gpt-4o"), messages, abortSignal: signal });
},
```

`chat.setUIMessageStreamOptions()` works across all abstraction levels — `chat.agent()`, `chat.createSession()` / `turn.complete()`, and `chat.pipeAndCapture()`.

See [ChatUIMessageStreamOptions](https://trigger.dev/docs/ai-chat/reference.md#chatuimessagestreamoptions) for the full reference.

> [!NOTE]
>
> `onFinish` is managed internally for response capture and cannot be overridden here. Use
> `streamText`'s `onFinish` callback for custom finish handling, or use [raw task
> mode](https://trigger.dev/docs/ai-chat/custom-agents.md) for full control over `toUIMessageStream()`.

### Manual mode with task()

If you need full control over task options, use the standard `task()` with `ChatTaskPayload` and `chat.pipe()`:

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { chat, type ChatTaskPayload } from "@trigger.dev/sdk/ai";
import { anthropic } from "@ai-sdk/anthropic";

export const manualChat = task({
  id: "manual-chat",
  retry: { maxAttempts: 3 },
  queue: { concurrencyLimit: 10 },
  run: async (payload: ChatTaskPayload) => {
    const result = streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages: payload.messages,
      stopWhen: stepCountIs(15),
    });

    await chat.pipe(result);
  },
});
```

> [!WARNING]
>
> Manual mode does not get automatic message accumulation or the `onTurnComplete`/`onChatStart`
> lifecycle hooks. The `responseMessage` field in `onTurnComplete` will be `undefined` when using
> `chat.pipe()` directly. Use `chat.agent()` for the full multi-turn experience.

***

## Custom agents

Both lower levels — `chat.createSession()` (managed turn iterator, your turn body) and `chat.customAgent()` with raw primitives (hand-rolled loop, full stream-conversion control) — are covered together on the Custom agents page, including the `ChatTurn` surface, the continuation-seeding pattern, and the hand-rolled-loop checklist:

> [card] [Custom agents](https://trigger.dev/docs/ai-chat/custom-agents.md)
>
> Build agents without the managed lifecycle — createSession or raw primitives.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.