Skip to content

Commit 6d4e177

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 6d4e177

2 files changed

Lines changed: 139 additions & 6 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: 135 additions & 5 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
```
@@ -391,6 +403,122 @@ The turn shows up in the dashboard as a run, with a span per model call and per
391403
2. **Press Stop.** Generation halts server-side, not just in the UI. If it doesn't, `signal` isn't reaching `streamText`.
392404
3. **Send a follow-up after a few minutes idle.** The conversation continues with full history.
393405

406+
## Keep the first turn fast with Head Start
407+
408+
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. On a trivial prompt that's roughly [2.8s to first chunk instead of 1.2s](/ai-chat/fast-starts#measured-ttfc). Only the first turn pays it — the run stays alive between messages, and a suspended run resumes without booting again.
409+
410+
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.
411+
412+
<Steps>
413+
<Step title="Split your tools into schemas and executes">
414+
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`.
415+
416+
```ts lib/chat-tools/schemas.ts
417+
import { tool } from "ai";
418+
import { z } from "zod";
419+
420+
export const headStartTools = {
421+
renderChart: tool({
422+
description: "Render a chart and return it as an image.",
423+
inputSchema: z.object({ spec: z.string() }),
424+
// No execute — the agent's copy carries it.
425+
}),
426+
};
427+
```
428+
429+
Your existing `lib/tools.ts` then builds the real tools on top of those schemas, so the two can't drift apart:
430+
431+
```ts lib/tools.ts
432+
import { tool } from "ai";
433+
import { headStartTools } from "@/lib/chat-tools/schemas";
434+
import { renderToPng } from "@/lib/charts";
435+
436+
export const tools = {
437+
renderChart: tool({
438+
...headStartTools.renderChart,
439+
execute: async ({ spec }) => renderToPng(spec),
440+
toModelOutput: ({ output }) => ({
441+
type: "content",
442+
value: [{ type: "media", mediaType: "image/png", data: output.base64 }],
443+
}),
444+
}),
445+
};
446+
```
447+
448+
The agent task is unchanged — it still imports the full `tools`.
449+
</Step>
450+
<Step title="Build the head-start handler">
451+
`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.
452+
453+
```ts lib/chat-handler.ts
454+
import { chat } from "@trigger.dev/sdk/chat-server";
455+
import { anthropic } from "@ai-sdk/anthropic";
456+
import { streamText } from "ai";
457+
import { headStartTools } from "@/lib/chat-tools/schemas";
458+
459+
export const chatHandler = chat.headStart({
460+
agentId: "my-chat",
461+
run: async ({ chat: helper }) =>
462+
streamText({
463+
...helper.toStreamTextOptions({ tools: headStartTools }),
464+
model: anthropic("claude-sonnet-4-5"),
465+
system: "You are a helpful assistant.",
466+
}),
467+
});
468+
```
469+
470+
<Warning>
471+
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.
472+
</Warning>
473+
474+
Your provider keys never leave your server — the first-turn model call runs in your process, so that environment needs whatever the model requires.
475+
</Step>
476+
<Step title="Mount it where the old handler was, auth check and all">
477+
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:
478+
479+
```ts app/api/chat/route.ts
480+
import { auth } from "@/lib/auth";
481+
import { chatHandler } from "@/lib/chat-handler";
482+
483+
// The handler holds the SSE response open until the agent signals
484+
// turn-complete, so this covers the whole first turn, not just step 1.
485+
export const maxDuration = 60;
486+
487+
export async function POST(req: Request) {
488+
const session = await auth();
489+
if (!session) return new Response("Unauthorized", { status: 401 });
490+
491+
return chatHandler(req);
492+
}
493+
```
494+
495+
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.
496+
</Step>
497+
<Step title="Point the transport at it">
498+
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`.
499+
500+
```tsx app/components/chat.tsx
501+
const transport = useTriggerChatTransport<typeof myChat>({
502+
task: "my-chat",
503+
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
504+
startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }),
505+
headStart: "/api/chat",
506+
sessions: initialSessions,
507+
});
508+
```
509+
510+
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.
511+
</Step>
512+
</Steps>
513+
514+
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 your `hydrateMessages` hook still sees the user message 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.
515+
516+
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.
517+
518+
<Note>
519+
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.
520+
</Note>
521+
394522
## What you get once you're moved over
395523

396524
- **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 +535,14 @@ The shape is identical outside Next.js. The agent task and the React component d
407535
- **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).
408536
- **Non-React clients** implement the same wire protocol directly — see [Client protocol](/ai-chat/client-protocol).
409537

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-
414538
## Gotchas
415539

540+
**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.
541+
542+
**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.
543+
544+
**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.
545+
416546
**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.
417547

418548
**`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)