Files
2026-07-13 13:32:57 +08:00

393 lines
21 KiB
Plaintext

---
title: "Custom agents"
sidebarTitle: "Custom agents"
description: "Build chat agents without chat.agent()'s managed lifecycle: register with chat.customAgent(), then drive turns with the createSession iterator or a hand-rolled loop."
---
**A custom agent is a task you register with `chat.customAgent()` and drive yourself — either with the managed turn iterator from `chat.createSession()`, or with a fully hand-rolled loop over the raw chat primitives.** You give up `chat.agent()`'s lifecycle hooks and automatic continuation recovery; you gain inline control over every turn, and (at the lowest level) full control over the stream conversion.
See the [comparison table](/ai-chat/backend) before dropping down. The frontend is unchanged either way: all levels speak the same wire protocol, so [`useTriggerChatTransport`](/ai-chat/frontend) points at a custom agent exactly like a `chat.agent()`.
## chat.customAgent()
`chat.customAgent()` is a thin wrapper around `task()` that does two things: it registers the task as an agent (so it appears in the agent dashboard, the playground, and the MCP server's `list_agents`), and it binds the run to its backing [Session](/ai-chat/sessions) so the `chat.*` primitives resolve to the right `.in`/`.out` channels. There is no managed lifecycle — no turn loop, no hooks, no preload handling.
A plain `task()` works with the same primitives but stays invisible to the agent surfaces, so prefer `customAgent` unless you specifically don't want the task listed as an agent.
Inside the wrapper, pick one of two loop styles:
- **[Managed loop](#managed-loop-chatcreatesession)** — `chat.createSession()` yields turns; the SDK handles stop signals, accumulation, idle suspend/resume, and turn-complete signaling. You write the turn body.
- **[Hand-rolled loop](#hand-rolled-loop-with-primitives)** — you write the loop itself with `chat.messages`, `MessageAccumulator`, `pipeAndCapture`, and `writeTurnComplete`. The right choice when you need complete control over `.toUIMessageStream()` (e.g. `onFinish`, `originalMessages`) beyond what `chat.setUIMessageStreamOptions()` provides, or you're implementing a custom protocol.
## Managed loop: chat.createSession()
`chat.createSession()` gives you an async iterator of `ChatTurn` objects. Each turn arrives with the accumulated history, a combined stop+cancel signal, and helpers to finish the turn:
```ts trigger/my-chat.ts
import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
export const myChat = chat.customAgent({
id: "my-chat",
run: async (payload: ChatTaskWirePayload, { signal }) => {
// One-time initialization — plain code, no hooks. Upsert, not create:
// continuation runs boot with the row already in place.
const clientData = payload.metadata as { userId: string };
await db.chat.upsert({
where: { id: payload.chatId },
create: { id: payload.chatId, userId: clientData.userId },
update: {},
});
const session = chat.createSession(payload, {
signal,
idleTimeoutInSeconds: 60,
timeout: "1h",
});
for await (const turn of session) {
// Persist the incoming user message BEFORE streaming — this is your
// onTurnStart equivalent. Without it, a page reload mid-stream
// restores the assistant text (replayed from the session) but loses
// the user message that prompted it.
await db.chat.update({
where: { id: turn.chatId },
data: { messages: turn.uiMessages },
});
const result = streamText({
model: anthropic("claude-sonnet-4-5"),
messages: turn.messages,
abortSignal: turn.signal,
stopWhen: stepCountIs(15),
});
// Pipe, capture, accumulate, and signal turn-complete — all in one call
await turn.complete(result);
// Persist the full exchange after the turn — your onTurnComplete equivalent
await db.chat.update({
where: { id: turn.chatId },
data: { messages: turn.uiMessages },
});
}
},
});
```
<Warning>
If you pass `compaction` or `pendingMessages` to `chat.createSession()`, you must also pass `prepareStep: turn.prepareStep()` to `streamText` (or spread `chat.toStreamTextOptions()`, which wires it automatically). Without it, both features silently no-op.
</Warning>
### ChatSessionOptions
| Option | Type | Default | Description |
| ---------------------- | ---------------------------- | ----------- | -------------------------------------------------------------------------------------------------- |
| `signal` | `AbortSignal` | required | Run-level cancel signal (from task context) |
| `idleTimeoutInSeconds` | `number` | `30` | Seconds to stay idle between turns before suspending |
| `timeout` | `string` | `"1h"` | Duration string for suspend timeout |
| `maxTurns` | `number` | `100` | Max turns before ending |
| `compaction` | `ChatAgentCompactionOptions` | `undefined` | Automatic context [compaction](/ai-chat/compaction) — same options as on `chat.agent()` |
| `pendingMessages` | `PendingMessagesOptions` | `undefined` | Mid-execution [message injection](/ai-chat/pending-messages) — same options as on `chat.agent()` |
Between turns the run idles on `waitWithIdleTimeout`: after `idleTimeoutInSeconds` with no message it suspends (compute is freed), and the next message restores it on the same run — the same warm/suspended pipeline `chat.agent()` uses.
### ChatTurn
Each turn yielded by the iterator provides:
| Field | Type | Description |
| ------------------- | --------------------------------- | -------------------------------------------------------- |
| `number` | `number` | Turn number (0-indexed) |
| `chatId` | `string` | Chat session ID |
| `trigger` | `string` | What triggered this turn |
| `clientData` | `unknown` | Client data from the transport |
| `messages` | `ModelMessage[]` | Full accumulated model messages — pass to `streamText` |
| `uiMessages` | `UIMessage[]` | Full accumulated UI messages — use for persistence |
| `signal` | `AbortSignal` | Combined stop+cancel signal (fresh each turn) |
| `stopped` | `boolean` | Whether the user stopped generation this turn |
| `continuation` | `boolean` | Whether this is a continuation run |
| `previousTurnUsage` | `LanguageModelUsage \| undefined` | Token usage from the previous turn (undefined on turn 0) |
| `totalUsage` | `LanguageModelUsage` | Cumulative token usage across all completed turns |
| `handover` | `{ isFinal: boolean } \| null` | The [`chat.headStart`](/ai-chat/fast-starts#handover-with-custom-agents) handover for this turn (turn 0 only); `null` otherwise |
| Method | Description |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `turn.complete(source?)` | Pipe stream, capture response, accumulate, and signal turn-complete. Call with no source on a final head-start handover (`turn.handover.isFinal`), where the warm step-1 partial is already the response |
| `turn.done()` | Signal turn-complete only (when you have piped manually) |
| `turn.addResponse(response)` | Add a response to the accumulator manually |
| `turn.setMessages(uiMessages)`| Replace the accumulated messages — continuation seeding and on-demand compaction |
| `turn.prepareStep()` | `prepareStep` callback wiring compaction + injection — pass to `streamText` when not spreading `chat.toStreamTextOptions()` |
### Continuation runs and history seeding
`chat.agent()` rebuilds conversation history automatically when a chat continues on a fresh run (after a cancel, crash, version upgrade, or TTL expiry) — via its snapshot/replay boot or your `hydrateMessages` hook. Custom agents do none of that: a continuation run starts with an **empty accumulator**, and history restoration is your job.
With `createSession`, check `turn.continuation` on the first turn and seed from your store with `turn.setMessages()`:
```ts
for await (const turn of session) {
if (turn.continuation && turn.number === 0) {
const row = await db.chat.findUnique({ where: { id: turn.chatId } });
const stored = (row?.messages ?? []) as UIMessage[];
if (stored.length > 0) {
// Keep any incoming message that isn't already persisted
const incoming = turn.uiMessages.filter((m) => !stored.some((s) => s.id === m.id));
await turn.setMessages([...stored, ...incoming]);
}
}
// ... streamText + turn.complete as usual
}
```
Without this, a resumed chat silently loses its history: the model sees only the message that triggered the continuation. In a hand-rolled loop, seed by passing the stored history into the turn-0 `addIncoming` call — shown in the example below.
### turn.complete() vs manual control
`turn.complete(result)` is the one-call path — it handles piping, capturing the response, accumulating messages, cleaning up aborted parts on a stop, and writing the turn-complete chunk.
For more control, you can do each step manually:
```ts
for await (const turn of session) {
const result = streamText({
model: anthropic("claude-sonnet-4-5"),
messages: turn.messages,
abortSignal: turn.signal,
stopWhen: stepCountIs(15),
});
// Manual: pipe and capture separately
const response = await chat.pipeAndCapture(result, { signal: turn.signal });
if (response) {
// Custom processing before accumulating
await turn.addResponse(response);
}
// Custom persistence, analytics, etc.
await db.chat.update({ ... });
// Must call done() when not using complete()
await turn.done();
}
```
## Stopping generation
The frontend stops a turn with [`transport.stopGeneration(chatId)`](/ai-chat/frontend#stop-generation), which writes a stop signal to the session's input stream. It aborts the current turn's generation but keeps the run alive, so the next message continues on the same session.
`turn.signal` is a combined stop-and-cancel `AbortSignal`, fresh each turn. Pass it to `streamText` so the stop reaches the model, then let `turn.complete()` finish the turn:
```ts trigger/my-chat.ts
for await (const turn of session) {
const result = streamText({
model: anthropic("claude-sonnet-4-5"),
messages: turn.messages,
abortSignal: turn.signal, // fires on a user stop OR a run cancel
stopWhen: stepCountIs(15),
});
await turn.complete(result);
if (turn.stopped) {
// user stopped this turn — the partial response is already accumulated
}
}
```
On a stop, `turn.complete()` cleans up the aborted parts of the partial response, accumulates it as its own assistant message, and writes turn-complete. The run does not end — the loop continues to the next turn.
Read `turn.stopped` to tell a user stop from a full run cancel:
- **User stop** (`transport.stopGeneration`): `turn.signal` aborts, `turn.stopped` is `true`, the partial response is accumulated, and the run stays alive for the next message.
- **Run cancel** (cancelled, expired, or `maxDuration` exceeded): `turn.signal` aborts, `turn.stopped` is `false`, and `turn.complete()` returns without accumulating because the run is ending.
A hand-rolled loop wires this itself with `chat.createStopSignal()` and `chat.cleanupAbortedParts()`. Two things `createSession` handles for you are easy to get wrong there — see the [hand-rolled loop checklist](#hand-rolled-loop-checklist).
## Hand-rolled loop with primitives
For full control, skip `createSession` and compose the primitives directly:
| Primitive | Description |
| ------------------------------- | -------------------------------------------------------------------------------------------- |
| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` to wait for the next turn |
| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream |
| `chat.pipeAndCapture(result)` | Pipe a `StreamTextResult` to the chat stream and capture the response |
| `chat.writeTurnComplete()` | Signal the frontend that the current turn is complete |
| `chat.MessageAccumulator` | Accumulates conversation messages across turns |
| `chat.pipe(stream)` | Pipe a stream to the frontend (no response capture) |
| `chat.cleanupAbortedParts(msg)` | Clean up incomplete parts from a stopped response |
A complete loop:
```ts trigger/my-chat-raw.ts
import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
export const myChat = chat.customAgent({
id: "my-chat-raw",
run: async (payload: ChatTaskWirePayload, { signal: runSignal }) => {
let currentPayload = payload;
// Handle preload — wait for the first real message
if (currentPayload.trigger === "preload") {
const result = await chat.messages.waitWithIdleTimeout({
idleTimeoutInSeconds: 60,
timeout: "1h",
spanName: "waiting for first message",
});
if (!result.ok) return;
currentPayload = result.output;
}
const stop = chat.createStopSignal();
const conversation = new chat.MessageAccumulator();
// Continuation runs (cancel, crash, upgrade) start with an empty
// accumulator — fetch stored history so turn 0 can seed it.
let continuationSeed: UIMessage[] = [];
if (currentPayload.continuation) {
const row = await db.chat.findUnique({ where: { id: currentPayload.chatId } });
continuationSeed = (row?.messages ?? []) as UIMessage[];
}
for (let turn = 0; turn < 100; turn++) {
stop.reset();
// The wire payload carries at most one new message per turn. Turn 0
// REPLACES the accumulator, so seed stored history through
// addIncoming together with the incoming message — a setMessages
// call before the loop would be wiped here.
const incoming = currentPayload.message ? [currentPayload.message] : [];
const turnInput =
turn === 0 && continuationSeed.length > 0
? [...continuationSeed.filter((s) => !incoming.some((m) => m.id === s.id)), ...incoming]
: incoming;
const messages = await conversation.addIncoming(turnInput, currentPayload.trigger, turn);
// Persist the incoming user message before streaming so a
// mid-stream reload doesn't lose it.
await db.chat.update({
where: { id: currentPayload.chatId },
data: { messages: conversation.uiMessages },
});
const combinedSignal = AbortSignal.any([runSignal, stop.signal]);
const result = streamText({
model: anthropic("claude-sonnet-4-5"),
messages,
abortSignal: combinedSignal,
stopWhen: stepCountIs(15),
});
let response;
try {
response = await chat.pipeAndCapture(result, { signal: combinedSignal });
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
if (runSignal.aborted) break;
// Stop — fall through to accumulate partial
} else {
throw error;
}
}
if (response) {
const cleaned =
stop.signal.aborted && !runSignal.aborted ? chat.cleanupAbortedParts(response) : response;
await conversation.addResponse(cleaned);
}
if (runSignal.aborted) break;
// Persist, analytics, etc.
await db.chat.update({
where: { id: currentPayload.chatId },
data: { messages: conversation.uiMessages },
});
await chat.writeTurnComplete();
// Wait for the next message
const next = await chat.messages.waitWithIdleTimeout({
idleTimeoutInSeconds: 60,
timeout: "1h",
spanName: "waiting for next message",
});
if (!next.ok) break;
currentPayload = next.output;
}
stop.cleanup();
},
});
```
### MessageAccumulator
`addIncoming(messages, trigger, turn)` has two modes:
- **Turn 0 or `trigger === "regenerate-message"`: replaces** the accumulator with exactly what you pass. This is why continuation seeding goes through `addIncoming` (above), and why a regenerate needs you to slice your own history — the wire omits the message on regenerate, so pass the stored history minus the last assistant message.
- **Every other turn: appends** what you pass (the wire carries at most the one new user message).
```ts
const conversation = new chat.MessageAccumulator();
// Returns full accumulated ModelMessage[] for streamText
const messages = await conversation.addIncoming(
payload.message ? [payload.message] : [],
payload.trigger,
turn
);
// After piping, add the response
const response = await chat.pipeAndCapture(result);
if (response) await conversation.addResponse(response);
// Access accumulated messages for persistence
conversation.uiMessages; // UIMessage[]
conversation.modelMessages; // ModelMessage[]
```
The constructor also accepts `compaction` and `pendingMessages` options (same shapes as on `chat.agent()`); pass `prepareStep: conversation.prepareStep()` to `streamText` to activate them. See [pending messages](/ai-chat/pending-messages#backend-messageaccumulator-raw-task) for the manual steering wiring.
### Hand-rolled loop checklist
Things the managed levels do for you that a raw loop has to get right:
- **Don't bare-await `result.totalUsage`.** On a stop-abort the AI SDK's `totalUsage` promise never settles, which wedges the loop forever. Race it with a timeout:
```ts
const turnUsage = await Promise.race([
result.totalUsage,
new Promise((resolve) => setTimeout(() => resolve(undefined), 2000)),
]);
```
- **Persist the user message before streaming** (shown in the example above). The session replay restores the assistant's streamed text after a page reload, but nothing restores a user message you haven't written down.
- **Seed history on continuation runs through the turn-0 `addIncoming`** (shown above). `payload.continuation` is `true` when this run picked up an existing chat; the accumulator starts empty — and because turn 0 replaces the accumulator, a `setMessages` call before the loop gets wiped.
- **Clean up aborted parts on a stop** with `chat.cleanupAbortedParts()` before accumulating, or the partial response carries half-open tool calls into the next turn's prompt.
- **Read `payload.message` (singular).** The wire payload carries at most one new message per turn; there is no `messages` array on the payload.
## Next steps
<CardGroup cols={2}>
<Card title="Backend overview" icon="layer-group" href="/ai-chat/backend">
The three abstraction levels compared, and everything chat.agent() adds on top.
</Card>
<Card title="Sessions" icon="wave-pulse" href="/ai-chat/sessions">
The durable stream pair every agent — managed or custom — is built on.
</Card>
<Card title="Compaction" icon="compress" href="/ai-chat/compaction">
Automatic context compression — works with createSession and MessageAccumulator.
</Card>
<Card title="Client protocol" icon="plug" href="/ai-chat/client-protocol">
The wire format your loop is speaking, chunk by chunk.
</Card>
</CardGroup>