docs(ai-chat): headStart handover for custom agents + triggerConfig (#3964)

## Summary

`chat.headStart` now works with the `chat.customAgent` and
`chat.createSession` backends (not just `chat.agent`), and takes a
`triggerConfig` option. These docs cover both.

The Fast starts guide gets a "Handover with custom agents" section
showing how each backend consumes the handover (`consumeHandover`
returning `{ isFinal, skipped }` for custom agents, `turn.handover` for
createSession), including threading `originalMessages` so a resumed tool
round merges into the handed-over assistant. The `chat.headStart` API
section documents `triggerConfig` (tags, queue, machine, and the rest)
on the auto-triggered run.

The reference picks up `ChatTurn.handover`, `turn.complete()` with no
source, `chat.waitForHandover`, and a new `HeadStartHandlerOptions`
table.

Docs for the SDK changes in
[#3963](https://github.com/triggerdotdev/trigger.dev/pull/3963).
This commit is contained in:
Eric Allam
2026-06-16 15:39:51 +01:00
committed by GitHub
parent 2936382e3e
commit 63d6432603
3 changed files with 91 additions and 4 deletions
+2 -1
View File
@@ -114,10 +114,11 @@ Each turn yielded by the iterator provides:
| `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 |
| `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 |
+73 -2
View File
@@ -108,7 +108,7 @@ if (payload.trigger === "preload") {
## Head Start
Head Start runs step 1's LLM call in your warm server process while the chat.agent run boots in parallel. The user sees one continuous turn: text first from your server, then a clean handover to the agent for tool execution and any further steps.
Head Start runs step 1's LLM call in your warm server process while the agent run boots in parallel. The user sees one continuous turn: text first from your server, then a clean handover to the agent for tool execution and any further steps. The agent you hand off to can be a `chat.agent`, a `chat.customAgent`, or a `chat.createSession` loop (see [Handover with custom agents](#handover-with-custom-agents)).
`chat.headStart` returns a standard [Web Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) handler — `(req: Request) => Promise<Response>` — so it slots into any runtime that speaks Web Fetch.
@@ -545,16 +545,86 @@ Head Start composes with [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemes
Your hydrate hook shapes **model context**, not the transcript — dropping reasoning-only entries or unresolved tool rows from the returned chain is fine and does not affect what `onTurnComplete` persists or what the UI renders.
### Handover with custom agents
The route handler is backend-agnostic: `agentId` can point at a `chat.agent`, a [`chat.customAgent`](/ai-chat/custom-agents), or a [`chat.createSession`](/ai-chat/custom-agents#managed-loop-chatcreatesession) loop. With `chat.agent` the handover is consumed for you (the steps above). The two hand-rolled backends consume it explicitly on turn 0.
#### chat.createSession
The turn iterator surfaces the handover as `turn.handover`. On a final (pure-text) handover, call `turn.complete()` with no source to finalize the warm partial without streaming; otherwise stream as usual. The iterator threads the spliced partial as `originalMessages` for you, so a resumed tool round merges into the handed-over assistant.
```ts trigger/chat.ts
for await (const turn of session) {
// Pure-text handover (isFinal): step 1 already IS the response.
const result = turn.handover?.isFinal
? undefined
: streamText({
model: anthropic("claude-sonnet-4-6"),
messages: turn.messages,
abortSignal: turn.signal,
stopWhen: stepCountIs(10),
});
await turn.complete(result); // no source on a final handover
}
```
#### chat.customAgent
In a hand-rolled loop, call `conversation.consumeHandover({ payload })` at the top of turn 0. It waits for the handover signal, seeds prior history from `payload.headStartMessages`, splices the warm step-1 partial into the accumulator, and returns `{ isFinal, skipped }`.
```ts trigger/chat.ts
// Turn 0, gated on a head-start run:
if (turn === 0 && payload.trigger === "handover-prepare") {
const { isFinal, skipped } = await conversation.consumeHandover({ payload });
if (skipped) return; // not a head-start run, or the warm handler aborted — exit
if (!isFinal) {
// The partial carries a pending tool call. Run step 2 to execute it,
// passing originalMessages so the tool output merges into the
// handed-over assistant instead of starting a new message.
const result = streamText({
model: anthropic("claude-sonnet-4-6"),
messages: conversation.modelMessages,
stopWhen: stepCountIs(10),
});
const response = await chat.pipeAndCapture(result, {
originalMessages: conversation.uiMessages,
});
if (response) await conversation.addResponse(response);
}
await chat.writeTurnComplete(); // on isFinal the warm partial is already the response
return;
}
```
Gate the call on `trigger === "handover-prepare"` — `consumeHandover` consumes the warm handover, not a normal first message. See [Custom agents](/ai-chat/custom-agents) for the full loop (continuation seeding, stop handling, persistence). The lower-level `chat.waitForHandover({ payload })` and `accumulator.applyHandover(signal)` are exported if you need to wait and splice in separate steps.
<Note>
Always pass `originalMessages: conversation.uiMessages` to `pipeAndCapture` in a custom loop. It keeps assistant message IDs stable across turns and lets a tool-approval or handover resume merge into the trailing assistant — the same threading `chat.agent` does internally.
</Note>
### The `chat.headStart` API
```ts
chat.headStart<TTools>({
agentId: string, // The chat.agent({ id }) you're handing off to
agentId: string, // The chat.agent / chat.customAgent id you're handing off to
run: (args: HeadStartRunArgs<TTools>) => Promise<StreamTextResult<any, any>>,
idleTimeoutInSeconds?: number, // How long the agent waits for the handover signal. Default: 60
triggerConfig?: Partial<SessionTriggerConfig>, // Run options for the handover-prepare run
}): (req: Request) => Promise<Response>
```
`triggerConfig` sets run options on the auto-triggered handover-prepare run: `tags`, `queue`, `machine`, `maxAttempts`, `maxDuration`, `region`, and `lockToVersion`. The `chat:{chatId}` tag is prepended automatically. Because the session is created once on the first head-start turn (idempotent on the chat id), this is the only place to set those options for a head-start chat's lifetime, mirroring what [`chat.createStartSessionAction`](/ai-chat/sessions) sets for the direct-trigger path.
```ts lib/chat-handler.ts
export const chatHandler = chat.headStart({
agentId: "my-chat",
triggerConfig: { tags: ["org:acme"], queue: "chat", machine: "small-2x" },
run: async ({ chat: helper }) =>
streamText({ ...helper.toStreamTextOptions({ tools: headStartTools }), model, system }),
});
```
The `run` callback receives:
- `messages: UIMessage[]` — user messages parsed from the request body.
@@ -599,3 +669,4 @@ This is **not** a stock `useChat` `endpoint` — it's not the canonical request
- [`chat.headStart` factory and types](/ai-chat/reference) — full signatures for `HeadStartRunArgs`, `HeadStartChatHelper`, `HeadStartSession`, `HeadStartHandlerOptions`.
- [`headStart` transport option](/ai-chat/reference#triggerchattransport-options) — alongside `accessToken`, `startSession`, etc.
- [`onPreload` hook](/ai-chat/lifecycle-hooks#onpreload) — the backend hook that fires when a run is preloaded.
- [Custom agents](/ai-chat/custom-agents) — the `chat.customAgent` and `chat.createSession` loops that `consumeHandover` / `turn.handover` plug into.
+16 -1
View File
@@ -475,15 +475,29 @@ Each turn yielded by `chat.createSession()`.
| `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 | Returns | Description |
| ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `complete(source)` | `Promise<UIMessage \| undefined>` | Pipe, capture, accumulate, cleanup, and signal turn-complete |
| `complete(source?)` | `Promise<UIMessage \| undefined>` | Pipe, capture, accumulate, cleanup, and signal turn-complete. Call with no source on a final head-start handover (`handover.isFinal`), where the warm partial is already the response |
| `done()` | `Promise<void>` | Signal turn-complete (when you've piped manually) |
| `addResponse(response)` | `Promise<void>` | Add response to accumulator manually |
| `setMessages(uiMessages)`| `Promise<void>` | Replace the accumulated messages (continuation seeding, compaction) |
| `prepareStep()` | `function \| undefined` | `prepareStep` callback wiring compaction + injection — pass to `streamText` when not using `chat.toStreamTextOptions()` |
## HeadStartHandlerOptions
Options for [`chat.headStart()`](/ai-chat/fast-starts#head-start), the warm-server first-turn handler (`@trigger.dev/sdk/chat-server`).
| Option | Type | Default | Description |
| ---------------------- | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------- |
| `agentId` | `string` | required | The `chat.agent` / `chat.customAgent` id to hand off to |
| `run` | `(args: HeadStartRunArgs) => Promise<StreamTextResult>` | required | First-turn callback. Call `streamText` and spread `chat.toStreamTextOptions({ tools })` |
| `idleTimeoutInSeconds` | `number` | `60` | How long the agent waits for the handover signal |
| `triggerConfig` | `Partial<SessionTriggerConfig>` | `undefined` | Run options (tags, queue, machine, maxAttempts, maxDuration, region, lockToVersion) for the auto-triggered handover-prepare run. The `chat:{chatId}` tag is prepended automatically |
`chat.headStart(options)` returns the handler `(req: Request) => Promise<Response>`. The `run` callback receives `HeadStartRunArgs`: `{ messages: UIMessage[], signal: AbortSignal, chat: HeadStartChatHelper }`, where the helper exposes `chat.toStreamTextOptions({ tools })` and a `chat.session` escape hatch. See [Head Start](/ai-chat/fast-starts#head-start) for the full guide.
## chat namespace
All methods available on the `chat` object from `@trigger.dev/sdk/ai`.
@@ -499,6 +513,7 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`.
| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` |
| `chat.local<T>({ id })` | Create a per-run typed local (see [`chat.local`](/ai-chat/chat-local)) |
| `chat.createStartSessionAction(taskId, options?)` | Returns a server action that creates a chat Session + triggers the first run + returns a session-scoped PAT. Idempotent on `(env, externalId)`. |
| `chat.waitForHandover(options)` | Wait for a [`chat.headStart`](/ai-chat/fast-starts#handover-with-custom-agents) handover signal in a custom loop. Returns the signal or `null`. `chat.MessageAccumulator` wraps this as `consumeHandover()` / `applyHandover()` |
| `chat.requestUpgrade()` | End the current run after this turn so the next message starts on the latest agent version. Server-orchestrated handoff. |
| `chat.setTurnTimeout(duration)` | Override turn timeout at runtime (e.g. `"2h"`) |
| `chat.setTurnTimeoutInSeconds(seconds)` | Override turn timeout at runtime (in seconds) |