You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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
+
274
277
<Tip>
275
278
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.
|**Stays**| Tool definitions (`inputSchema`, `execute`, `toModelOutput`) | Same tools, also declared on the agent config |
17
17
|**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|
19
19
|**Goes**|`convertToModelMessages`, `toUIMessageStreamResponse`| The runtime does both |
20
20
|**Goes**|`resumable-stream` / Redis, the stream-resume `GET` route | The transport resumes from `lastEventId`|
21
21
|**New**| — | A `chat.agent` task in `trigger/chat.ts`|
22
22
|**New**| — | Two server actions: mint a token, start a session |
23
23
|**New**| — |`useTriggerChatTransport` in place of the `api` URL |
24
24
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
+
25
29
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.
26
30
27
31
## Hand it to a coding agent
@@ -81,6 +85,14 @@ Constraints:
81
85
- Do not change the model, prompt, tool schemas, or UI components beyond what the
82
86
transport swap requires.
83
87
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
When you're done, list what you deleted and show the diff for the agent task, the server
85
97
actions, and the client component.
86
98
```
@@ -391,6 +403,122 @@ The turn shows up in the dashboard as a run, with a span per model call and per
391
403
2.**Press Stop.** Generation halts server-side, not just in the UI. If it doesn't, `signal` isn't reaching `streamText`.
392
404
3.**Send a follow-up after a few minutes idle.** The conversation continues with full history.
393
405
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
+
<Steptitle="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
+
exportconst 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:
The agent task is unchanged — it still imports the full `tools`.
449
+
</Step>
450
+
<Steptitle="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.
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
+
<Steptitle="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
+
exportconst maxDuration =60;
486
+
487
+
exportasyncfunction POST(req:Request) {
488
+
const session =awaitauth();
489
+
if (!session) returnnewResponse("Unauthorized", { status: 401 });
490
+
491
+
returnchatHandler(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
+
<Steptitle="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<typeofmyChat>({
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
+
394
522
## What you get once you're moved over
395
523
396
524
-**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
407
535
-**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).
408
536
-**Non-React clients** implement the same wire protocol directly — see [Client protocol](/ai-chat/client-protocol).
409
537
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
-
414
538
## Gotchas
415
539
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
+
416
546
**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.
417
547
418
548
**`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