Skip to content

Commit e797790

Browse files
committed
docs(ai-chat): cover Head Start in the route handler migration guide
The migration trades a warm route handler for an agent run that has to boot, so the opening response of a new chat gets slower and that is the first thing a reader will notice. Head Start was only a closing aside. It is now a full section: splitting tool schemas from executes, building and mounting the handler with the original auth check intact, the transport option, and the function-timeout and bundle-isolation gotchas. Also drops a stopWhen override from the fast starts handler example. The spread pins stopWhen to stepCountIs(1), and re-setting it makes the warm handler run steps the agent is supposed to own.
1 parent 261a06b commit e797790

2 files changed

Lines changed: 141 additions & 7 deletions

File tree

docs/ai-chat/fast-starts.mdx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,11 +266,14 @@ This is an **import-chain** problem, not a runtime one. A "we'll strip the execu
266266
...helper.toStreamTextOptions({ tools: headStartTools }),
267267
model: anthropic("claude-sonnet-4-6"),
268268
system: "You are a helpful assistant.",
269-
stopWhen: stepCountIs(15),
270269
}),
271270
});
272271
```
273272

273+
<Warning>
274+
Don't set `stopWhen` here. The spread pins it to `stepCountIs(1)`, and overriding it makes the handler run steps the agent is supposed to own — the handover then splices a stream that has already moved past step 1.
275+
</Warning>
276+
274277
<Tip>
275278
Use the **same model** on both sides (route handler and `chat.agent`) to avoid a tone or style shift between step 1 and step 2+. Your LLM provider keys stay server-side in your warm process — Trigger.dev never holds them in this design.
276279
</Tip>

docs/ai-chat/migrating-from-a-route-handler.mdx

Lines changed: 137 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,17 @@ This guide assumes a Next.js App Router app with `useChat` on the client and an
1515
| **Stays** | `streamText` call, model, `system`, `stopWhen`, provider options | Same call, inside `run()` |
1616
| **Stays** | Tool definitions (`inputSchema`, `execute`, `toModelOutput`) | Same tools, also declared on the agent config |
1717
| **Stays** | `useChat`, `messages`, `message.parts`, your UI | Unchanged |
18-
| **Goes** | `app/api/chat/route.ts` | Deleted |
18+
| **Goes** | `app/api/chat/route.ts` | Deleted, or kept as a [Head Start](#keep-the-first-turn-fast-with-head-start) handler |
1919
| **Goes** | `convertToModelMessages`, `toUIMessageStreamResponse` | The runtime does both |
2020
| **Goes** | `resumable-stream` / Redis, the stream-resume `GET` route | The transport resumes from `lastEventId` |
2121
| **New** || A `chat.agent` task in `trigger/chat.ts` |
2222
| **New** || Two server actions: mint a token, start a session |
2323
| **New** || `useTriggerChatTransport` in place of the `api` URL |
2424

25+
<Note>
26+
One thing gets slower, and it's the thing you'll notice first: the opening response of a brand-new chat. Your route handler answered out of an already-warm process; the agent run has to boot before it reaches the model. [Head Start](#keep-the-first-turn-fast-with-head-start) gives that back — get the migration working first, then add it.
27+
</Note>
28+
2529
Before you start, make sure the project has the SDK installed and the CLI authenticated — [Manual setup](/manual-setup), or `npx trigger.dev@latest init` in an existing project.
2630

2731
## Hand it to a coding agent
@@ -81,6 +85,14 @@ Constraints:
8185
- Do not change the model, prompt, tool schemas, or UI components beyond what the
8286
transport swap requires.
8387
88+
Do NOT attempt this unless I ask for it separately:
89+
90+
- Head Start (`chat.headStart`), which keeps a route handler around to run the first
91+
turn's opening model call in the warm server process. It's a follow-on change with its
92+
own constraint — tool schemas have to be split away from tool executes so the route
93+
handler's bundle stays light. Read https://trigger.dev/docs/ai-chat/fast-starts.md
94+
before touching it.
95+
8496
When you're done, list what you deleted and show the diff for the agent task, the server
8597
actions, and the client component.
8698
```
@@ -166,6 +178,7 @@ Your tool definitions don't change. Declare the same set in two places: on `chat
166178
```ts lib/tools.ts
167179
import { tool } from "ai";
168180
import { z } from "zod";
181+
import { renderToPng } from "@/lib/charts";
169182

170183
export const tools = {
171184
renderChart: tool({
@@ -286,7 +299,7 @@ Three things to note in the new version:
286299

287300
- **`import type`, not a value import.** The agent module pulls in your tools' `execute` dependencies; `typeof myChat` gives you compile-time validation of the task id without any of that reaching the browser bundle.
288301
- **`sessions`** hydrates the transport from what you persisted (the session token and `lastEventId`), so a fresh tab reconnects without a round-trip to create a session.
289-
- **`resume: true`** reconnects to an in-flight stream on mount. Only enable it when there are existing messages — a brand-new chat has nothing to reconnect to.
302+
- **`resume`** reconnects to an in-flight stream on mount. Gate it on there being existing messages, as the snippet does — a brand-new chat has nothing to reconnect to.
290303

291304
<Note>
292305
After a resume, `useChat`'s built-in `stop()` doesn't reach the backend, because the AI SDK doesn't thread its abort signal through `reconnectToStream`. Call `transport.stopGeneration(chatId)` instead — see [Stop generation](/ai-chat/frontend#stop-generation).
@@ -391,6 +404,122 @@ The turn shows up in the dashboard as a run, with a span per model call and per
391404
2. **Press Stop.** Generation halts server-side, not just in the UI. If it doesn't, `signal` isn't reaching `streamText`.
392405
3. **Send a follow-up after a few minutes idle.** The conversation continues with full history.
393406

407+
## Keep the first turn fast with Head Start
408+
409+
Do this once the migration above works, because it's the regression you're about to notice. Opening a brand-new chat now waits on the agent run being dequeued and booted before anything reaches the model, where your route handler started streaming out of a process that was already warm. [Measured on a trivial prompt](/ai-chat/fast-starts#measured-ttfc), that's 2.8s to the first chunk against 1.2s once a warm first-turn call is back in front of it. Only the opening turn pays it — the run stays alive between messages, and a suspended run resumes without booting again.
410+
411+
Head Start brings the route handler back for exactly that first turn. It runs step 1 in your warm process while the agent boots alongside it, so boot time hides inside the model's own time-to-first-byte instead of stacking in front of it. When step 1 finishes as plain text the agent exits without ever calling a model; when it ends in tool calls the agent executes them and step 2 streams into the same assistant message. The user sees one continuous response.
412+
413+
<Steps>
414+
<Step title="Split your tools into schemas and executes">
415+
This is the constraint the whole feature rests on. Everything your route handler imports, and everything those modules import, ends up in its bundle — so a tool catalog with Puppeteer or native bindings behind its `execute` puts the cold start straight back, just in a different process. Bundlers resolve this at build time, so stripping executes at runtime doesn't help. Schemas need their own module that imports nothing heavier than `ai` and `zod`.
416+
417+
```ts lib/chat-tools/schemas.ts
418+
import { tool } from "ai";
419+
import { z } from "zod";
420+
421+
export const headStartTools = {
422+
renderChart: tool({
423+
description: "Render a chart and return it as an image.",
424+
inputSchema: z.object({ spec: z.string() }),
425+
// No execute — the agent's copy carries it.
426+
}),
427+
};
428+
```
429+
430+
Your existing `lib/tools.ts` then builds the real tools on top of those schemas, so the two can't drift apart:
431+
432+
```ts lib/tools.ts
433+
import { tool } from "ai";
434+
import { headStartTools } from "@/lib/chat-tools/schemas";
435+
import { renderToPng } from "@/lib/charts";
436+
437+
export const tools = {
438+
renderChart: tool({
439+
...headStartTools.renderChart,
440+
execute: async ({ spec }) => renderToPng(spec),
441+
toModelOutput: ({ output }) => ({
442+
type: "content",
443+
value: [{ type: "media", mediaType: "image/png", data: output.base64 }],
444+
}),
445+
}),
446+
};
447+
```
448+
449+
The agent task is unchanged — it still imports the full `tools`.
450+
</Step>
451+
<Step title="Build the head-start handler">
452+
`chat.headStart` returns a plain Web Fetch handler, `(req: Request) => Promise<Response>`. You call `streamText` inside it much as you did in the original route handler, with the same model and the same system prompt as the agent so there's no tone shift when step 2 takes over.
453+
454+
```ts lib/chat-handler.ts
455+
import { chat } from "@trigger.dev/sdk/chat-server";
456+
import { anthropic } from "@ai-sdk/anthropic";
457+
import { streamText } from "ai";
458+
import { headStartTools } from "@/lib/chat-tools/schemas";
459+
460+
export const chatHandler = chat.headStart({
461+
agentId: "my-chat",
462+
run: async ({ chat: helper }) =>
463+
streamText({
464+
...helper.toStreamTextOptions({ tools: headStartTools }),
465+
model: anthropic("claude-sonnet-4-5"),
466+
system: "You are a helpful assistant.",
467+
}),
468+
});
469+
```
470+
471+
<Warning>
472+
Spread `toStreamTextOptions()` first and add only your own keys after it. It owns `messages`, `tools`, `abortSignal`, and `stopWhen` — and unlike the agent-side spread, re-setting any of those breaks the handover rather than degrading it. `stopWhen` in particular is pinned to `stepCountIs(1)`: the agent, not the handler, runs step 2 onward.
473+
</Warning>
474+
475+
Your provider keys never leave your server — the first-turn model call runs in your process, so that environment needs whatever the model requires.
476+
</Step>
477+
<Step title="Mount it where the old handler was, auth check and all">
478+
The authorization check you moved into the server actions belongs here too, in the same place it always was. Wrap the handler rather than exporting it directly:
479+
480+
```ts app/api/chat/route.ts
481+
import { auth } from "@/lib/auth";
482+
import { chatHandler } from "@/lib/chat-handler";
483+
484+
// The handler holds the SSE response open until the agent signals
485+
// turn-complete, so this covers the whole first turn, not just step 1.
486+
export const maxDuration = 60;
487+
488+
export async function POST(req: Request) {
489+
const session = await auth();
490+
if (!session) return new Response("Unauthorized", { status: 401 });
491+
492+
return chatHandler(req);
493+
}
494+
```
495+
496+
Any framework that hands you a Web `Request` mounts it the same way — Hono, SvelteKit, Remix, TanStack Start, Astro, Nitro, Elysia, Workers, Bun, Deno. Express, Fastify, and Koa need the `chat.toNodeListener` adapter. [Mounting in your framework](/ai-chat/fast-starts#mounting-in-your-framework) has one for each.
497+
</Step>
498+
<Step title="Point the transport at it">
499+
One option on the transport you already wired up. Keep both server actions: Head Start only covers the first turn of a chat that has no session yet, and turns 2 onward go down the direct path that needs `accessToken`.
500+
501+
```tsx app/components/chat.tsx
502+
const transport = useTriggerChatTransport<typeof myChat>({
503+
task: "my-chat",
504+
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
505+
startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }),
506+
headStart: "/api/chat",
507+
sessions: initialSessions,
508+
});
509+
```
510+
511+
This isn't a `useChat` `api` URL under a different name. It's the first-turn shortcut only; the transport stops POSTing to it as soon as a session exists.
512+
</Step>
513+
</Steps>
514+
515+
Persistence doesn't change. The handover carries one stable assistant message id across both halves of the turn, so `onTurnComplete` still fires once with the whole message, and `hydrateMessages` still receives the first-turn history as `incomingMessages` — with one caveat: a head-start turn skips preload entirely, so a hydrate hook that assumes its conversation row already exists has to upsert rather than update.
516+
517+
If the first message gets captured somewhere other than the chat page — a "new chat" prompt box that navigates to `/chats/{id}` — there's no open connection to stream step 1 into. Use [`chat.startHeadStart`](/ai-chat/fast-starts#detached-head-start) instead: it drains step 1 into the durable session stream and the destination page resumes it.
518+
519+
<Note>
520+
Head Start and [Preload](/ai-chat/fast-starts#preload) solve the same problem from opposite ends, and running both for one chat is wasted work. Preload is the answer when there's no warm server to run step 1 in — a browser-only chat surface, say. [Picking an approach](/ai-chat/fast-starts#picking-an-approach) compares them.
521+
</Note>
522+
394523
## What you get once you're moved over
395524

396525
- **Turns aren't bounded by a function timeout.** A tool-heavy turn can run for minutes without a platform deadline to work around.
@@ -407,12 +536,14 @@ The shape is identical outside Next.js. The agent task and the React component d
407536
- **Hono, SvelteKit, Express, Remix** — expose the token mint and the session start as two small POST endpoints instead of server actions, and point the transport's `accessToken` and `startSession` callbacks at them with `fetch`. Type the handlers with `AccessTokenParams` and `StartSessionParams` from `@trigger.dev/sdk/chat`. See [calling a fetch endpoint instead of a server action](/ai-chat/frontend#calling-a-fetch-endpoint-instead-of-a-server-action).
408537
- **Non-React clients** implement the same wire protocol directly — see [Client protocol](/ai-chat/client-protocol).
409538

410-
<Tip>
411-
You can bring a route handler back later for a different reason. [Head Start](/ai-chat/fast-starts#head-start) runs the first model call in your already-warm server process while the agent boots in parallel, roughly halving time-to-first-chunk. It's opt-in and mounts in Next.js, Hono, SvelteKit, Remix, and others.
412-
</Tip>
413-
414539
## Gotchas
415540

541+
**The first response of a new chat is slower than the old route handler.** That's agent boot, and only the opening turn pays it. [Head Start](#keep-the-first-turn-fast-with-head-start) overlaps boot with the first model call and puts you back at the model's own TTFB.
542+
543+
**Head Start is on, and nothing got faster.** The route-handler bundle is pulling in the heavy side of your tools. Check what `lib/chat-tools/schemas.ts` imports transitively — `ai` and `zod` and nothing else.
544+
545+
**The head-start route dies mid-turn on Vercel.** The handler holds the SSE response open until the agent signals turn-complete, so the function timeout has to cover the whole turn, not just step 1. Set `maxDuration` on that route segment.
546+
416547
**Compaction and steering do nothing.** The `...chat.toStreamTextOptions()` spread is missing, or something before it in the object is overwriting `prepareStep`. Spread it as the first property.
417548

418549
**`toModelOutput` works on the first turn, then stops.** Tools are declared only on `streamText`. Declare the same set on `chat.agent({ tools })` too, and read it back off the `run` payload.

0 commit comments

Comments
 (0)