Skip to content

Commit 63d6432

Browse files
authored
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](#3963).
1 parent 2936382 commit 63d6432

3 files changed

Lines changed: 91 additions & 4 deletions

File tree

docs/ai-chat/custom-agents.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,11 @@ Each turn yielded by the iterator provides:
114114
| `continuation` | `boolean` | Whether this is a continuation run |
115115
| `previousTurnUsage` | `LanguageModelUsage \| undefined` | Token usage from the previous turn (undefined on turn 0) |
116116
| `totalUsage` | `LanguageModelUsage` | Cumulative token usage across all completed turns |
117+
| `handover` | `{ isFinal: boolean } \| null` | The [`chat.headStart`](/ai-chat/fast-starts#handover-with-custom-agents) handover for this turn (turn 0 only); `null` otherwise |
117118

118119
| Method | Description |
119120
| ----------------------------- | ---------------------------------------------------------------------------------------------------------- |
120-
| `turn.complete(source)` | Pipe stream, capture response, accumulate, and signal turn-complete |
121+
| `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 |
121122
| `turn.done()` | Signal turn-complete only (when you have piped manually) |
122123
| `turn.addResponse(response)` | Add a response to the accumulator manually |
123124
| `turn.setMessages(uiMessages)`| Replace the accumulated messages — continuation seeding and on-demand compaction |

docs/ai-chat/fast-starts.mdx

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ if (payload.trigger === "preload") {
108108

109109
## Head Start
110110

111-
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.
111+
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)).
112112

113113
`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.
114114

@@ -545,16 +545,86 @@ Head Start composes with [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemes
545545

546546
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.
547547

548+
### Handover with custom agents
549+
550+
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.
551+
552+
#### chat.createSession
553+
554+
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.
555+
556+
```ts trigger/chat.ts
557+
for await (const turn of session) {
558+
// Pure-text handover (isFinal): step 1 already IS the response.
559+
const result = turn.handover?.isFinal
560+
? undefined
561+
: streamText({
562+
model: anthropic("claude-sonnet-4-6"),
563+
messages: turn.messages,
564+
abortSignal: turn.signal,
565+
stopWhen: stepCountIs(10),
566+
});
567+
568+
await turn.complete(result); // no source on a final handover
569+
}
570+
```
571+
572+
#### chat.customAgent
573+
574+
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 }`.
575+
576+
```ts trigger/chat.ts
577+
// Turn 0, gated on a head-start run:
578+
if (turn === 0 && payload.trigger === "handover-prepare") {
579+
const { isFinal, skipped } = await conversation.consumeHandover({ payload });
580+
if (skipped) return; // not a head-start run, or the warm handler aborted — exit
581+
if (!isFinal) {
582+
// The partial carries a pending tool call. Run step 2 to execute it,
583+
// passing originalMessages so the tool output merges into the
584+
// handed-over assistant instead of starting a new message.
585+
const result = streamText({
586+
model: anthropic("claude-sonnet-4-6"),
587+
messages: conversation.modelMessages,
588+
stopWhen: stepCountIs(10),
589+
});
590+
const response = await chat.pipeAndCapture(result, {
591+
originalMessages: conversation.uiMessages,
592+
});
593+
if (response) await conversation.addResponse(response);
594+
}
595+
await chat.writeTurnComplete(); // on isFinal the warm partial is already the response
596+
return;
597+
}
598+
```
599+
600+
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.
601+
602+
<Note>
603+
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.
604+
</Note>
605+
548606
### The `chat.headStart` API
549607

550608
```ts
551609
chat.headStart<TTools>({
552-
agentId: string, // The chat.agent({ id }) you're handing off to
610+
agentId: string, // The chat.agent / chat.customAgent id you're handing off to
553611
run: (args: HeadStartRunArgs<TTools>) => Promise<StreamTextResult<any, any>>,
554612
idleTimeoutInSeconds?: number, // How long the agent waits for the handover signal. Default: 60
613+
triggerConfig?: Partial<SessionTriggerConfig>, // Run options for the handover-prepare run
555614
}): (req: Request) => Promise<Response>
556615
```
557616

617+
`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.
618+
619+
```ts lib/chat-handler.ts
620+
export const chatHandler = chat.headStart({
621+
agentId: "my-chat",
622+
triggerConfig: { tags: ["org:acme"], queue: "chat", machine: "small-2x" },
623+
run: async ({ chat: helper }) =>
624+
streamText({ ...helper.toStreamTextOptions({ tools: headStartTools }), model, system }),
625+
});
626+
```
627+
558628
The `run` callback receives:
559629

560630
- `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
599669
- [`chat.headStart` factory and types](/ai-chat/reference) — full signatures for `HeadStartRunArgs`, `HeadStartChatHelper`, `HeadStartSession`, `HeadStartHandlerOptions`.
600670
- [`headStart` transport option](/ai-chat/reference#triggerchattransport-options) — alongside `accessToken`, `startSession`, etc.
601671
- [`onPreload` hook](/ai-chat/lifecycle-hooks#onpreload) — the backend hook that fires when a run is preloaded.
672+
- [Custom agents](/ai-chat/custom-agents) — the `chat.customAgent` and `chat.createSession` loops that `consumeHandover` / `turn.handover` plug into.

docs/ai-chat/reference.mdx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -475,15 +475,29 @@ Each turn yielded by `chat.createSession()`.
475475
| `continuation` | `boolean` | Whether this is a continuation run |
476476
| `previousTurnUsage` | `LanguageModelUsage \| undefined` | Token usage from the previous turn (undefined on turn 0) |
477477
| `totalUsage` | `LanguageModelUsage` | Cumulative token usage across all completed turns |
478+
| `handover` | `{ isFinal: boolean } \| null` | The [`chat.headStart`](/ai-chat/fast-starts#handover-with-custom-agents) handover for this turn (turn 0 only); `null` otherwise |
478479

479480
| Method | Returns | Description |
480481
| ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------ |
481-
| `complete(source)` | `Promise<UIMessage \| undefined>` | Pipe, capture, accumulate, cleanup, and signal turn-complete |
482+
| `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 |
482483
| `done()` | `Promise<void>` | Signal turn-complete (when you've piped manually) |
483484
| `addResponse(response)` | `Promise<void>` | Add response to accumulator manually |
484485
| `setMessages(uiMessages)`| `Promise<void>` | Replace the accumulated messages (continuation seeding, compaction) |
485486
| `prepareStep()` | `function \| undefined` | `prepareStep` callback wiring compaction + injection — pass to `streamText` when not using `chat.toStreamTextOptions()` |
486487

488+
## HeadStartHandlerOptions
489+
490+
Options for [`chat.headStart()`](/ai-chat/fast-starts#head-start), the warm-server first-turn handler (`@trigger.dev/sdk/chat-server`).
491+
492+
| Option | Type | Default | Description |
493+
| ---------------------- | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------- |
494+
| `agentId` | `string` | required | The `chat.agent` / `chat.customAgent` id to hand off to |
495+
| `run` | `(args: HeadStartRunArgs) => Promise<StreamTextResult>` | required | First-turn callback. Call `streamText` and spread `chat.toStreamTextOptions({ tools })` |
496+
| `idleTimeoutInSeconds` | `number` | `60` | How long the agent waits for the handover signal |
497+
| `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 |
498+
499+
`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.
500+
487501
## chat namespace
488502

489503
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`.
499513
| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` |
500514
| `chat.local<T>({ id })` | Create a per-run typed local (see [`chat.local`](/ai-chat/chat-local)) |
501515
| `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)`. |
516+
| `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()` |
502517
| `chat.requestUpgrade()` | End the current run after this turn so the next message starts on the latest agent version. Server-orchestrated handoff. |
503518
| `chat.setTurnTimeout(duration)` | Override turn timeout at runtime (e.g. `"2h"`) |
504519
| `chat.setTurnTimeoutInSeconds(seconds)` | Override turn timeout at runtime (in seconds) |

0 commit comments

Comments
 (0)