@trigger.dev/sdk 4.5.0-rc.6 → 4.5.0-rc.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commonjs/v3/ai.d.ts +171 -5
- package/dist/commonjs/v3/ai.js +309 -22
- package/dist/commonjs/v3/ai.js.map +1 -1
- package/dist/commonjs/v3/chat-server.d.ts +8 -0
- package/dist/commonjs/v3/chat-server.js +32 -10
- package/dist/commonjs/v3/chat-server.js.map +1 -1
- package/dist/commonjs/v3/chat-server.test.js +51 -0
- package/dist/commonjs/v3/chat-server.test.js.map +1 -1
- package/dist/commonjs/v3/createStartSessionAction.test.js +30 -0
- package/dist/commonjs/v3/createStartSessionAction.test.js.map +1 -1
- package/dist/commonjs/v3/sessions.d.ts +3 -2
- package/dist/commonjs/v3/sessions.js +3 -2
- package/dist/commonjs/v3/sessions.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/v3/ai.d.ts +171 -5
- package/dist/esm/v3/ai.js +309 -22
- package/dist/esm/v3/ai.js.map +1 -1
- package/dist/esm/v3/chat-server.d.ts +8 -0
- package/dist/esm/v3/chat-server.js +32 -10
- package/dist/esm/v3/chat-server.js.map +1 -1
- package/dist/esm/v3/chat-server.test.js +51 -0
- package/dist/esm/v3/chat-server.test.js.map +1 -1
- package/dist/esm/v3/createStartSessionAction.test.js +30 -0
- package/dist/esm/v3/createStartSessionAction.test.js.map +1 -1
- package/dist/esm/v3/sessions.d.ts +3 -2
- package/dist/esm/v3/sessions.js +3 -2
- package/dist/esm/v3/sessions.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/docs/ai/prompts.mdx +430 -0
- package/docs/ai-chat/actions.mdx +115 -0
- package/docs/ai-chat/anatomy.mdx +71 -0
- package/docs/ai-chat/backend.mdx +817 -0
- package/docs/ai-chat/background-injection.mdx +221 -0
- package/docs/ai-chat/changelog.mdx +850 -0
- package/docs/ai-chat/chat-local.mdx +174 -0
- package/docs/ai-chat/client-protocol.mdx +1081 -0
- package/docs/ai-chat/compaction.mdx +411 -0
- package/docs/ai-chat/custom-agents.mdx +364 -0
- package/docs/ai-chat/error-handling.mdx +415 -0
- package/docs/ai-chat/fast-starts.mdx +672 -0
- package/docs/ai-chat/frontend.mdx +580 -0
- package/docs/ai-chat/how-it-works.mdx +230 -0
- package/docs/ai-chat/lifecycle-hooks.mdx +530 -0
- package/docs/ai-chat/mcp.mdx +101 -0
- package/docs/ai-chat/overview.mdx +90 -0
- package/docs/ai-chat/patterns/branching-conversations.mdx +284 -0
- package/docs/ai-chat/patterns/code-sandbox.mdx +126 -0
- package/docs/ai-chat/patterns/database-persistence.mdx +414 -0
- package/docs/ai-chat/patterns/human-in-the-loop.mdx +275 -0
- package/docs/ai-chat/patterns/large-payloads.mdx +169 -0
- package/docs/ai-chat/patterns/oom-resilience.mdx +120 -0
- package/docs/ai-chat/patterns/persistence-and-replay.mdx +211 -0
- package/docs/ai-chat/patterns/recovery-boot.mdx +230 -0
- package/docs/ai-chat/patterns/skills.mdx +221 -0
- package/docs/ai-chat/patterns/sub-agents.mdx +383 -0
- package/docs/ai-chat/patterns/tool-result-auditing.mdx +148 -0
- package/docs/ai-chat/patterns/trusted-edge-signals.mdx +337 -0
- package/docs/ai-chat/patterns/version-upgrades.mdx +172 -0
- package/docs/ai-chat/pending-messages.mdx +343 -0
- package/docs/ai-chat/prompt-caching.mdx +206 -0
- package/docs/ai-chat/quick-start.mdx +161 -0
- package/docs/ai-chat/reference.mdx +909 -0
- package/docs/ai-chat/server-chat.mdx +263 -0
- package/docs/ai-chat/sessions.mdx +333 -0
- package/docs/ai-chat/testing.mdx +682 -0
- package/docs/ai-chat/tools.mdx +191 -0
- package/docs/ai-chat/types.mdx +242 -0
- package/docs/ai-chat/upgrade-guide.mdx +515 -0
- package/docs/apikeys.mdx +54 -0
- package/docs/building-with-ai.mdx +261 -0
- package/docs/bulk-actions.mdx +49 -0
- package/docs/changelog.mdx +6 -0
- package/docs/cli-deploy-commands.mdx +9 -0
- package/docs/cli-dev-commands.mdx +9 -0
- package/docs/cli-dev.mdx +8 -0
- package/docs/cli-init-commands.mdx +58 -0
- package/docs/cli-introduction.mdx +25 -0
- package/docs/cli-list-profiles-commands.mdx +42 -0
- package/docs/cli-login-commands.mdx +33 -0
- package/docs/cli-logout-commands.mdx +33 -0
- package/docs/cli-preview-archive.mdx +59 -0
- package/docs/cli-promote-commands.mdx +9 -0
- package/docs/cli-switch.mdx +43 -0
- package/docs/cli-update-commands.mdx +42 -0
- package/docs/cli-whoami-commands.mdx +33 -0
- package/docs/community.mdx +6 -0
- package/docs/config/config-file.mdx +602 -0
- package/docs/config/extensions/additionalFiles.mdx +38 -0
- package/docs/config/extensions/additionalPackages.mdx +40 -0
- package/docs/config/extensions/aptGet.mdx +34 -0
- package/docs/config/extensions/audioWaveform.mdx +20 -0
- package/docs/config/extensions/custom.mdx +380 -0
- package/docs/config/extensions/emitDecoratorMetadata.mdx +29 -0
- package/docs/config/extensions/esbuildPlugin.mdx +31 -0
- package/docs/config/extensions/ffmpeg.mdx +45 -0
- package/docs/config/extensions/lightpanda.mdx +56 -0
- package/docs/config/extensions/overview.mdx +67 -0
- package/docs/config/extensions/playwright.mdx +195 -0
- package/docs/config/extensions/prismaExtension.mdx +1014 -0
- package/docs/config/extensions/puppeteer.mdx +30 -0
- package/docs/config/extensions/pythonExtension.mdx +182 -0
- package/docs/config/extensions/syncEnvVars.mdx +291 -0
- package/docs/context.mdx +235 -0
- package/docs/database-connections.mdx +213 -0
- package/docs/deploy-environment-variables.mdx +435 -0
- package/docs/deployment/atomic-deployment.mdx +172 -0
- package/docs/deployment/overview.mdx +257 -0
- package/docs/deployment/preview-branches.mdx +224 -0
- package/docs/errors-retrying.mdx +379 -0
- package/docs/github-actions.mdx +222 -0
- package/docs/github-integration.mdx +136 -0
- package/docs/github-repo.mdx +8 -0
- package/docs/help-email.mdx +6 -0
- package/docs/help-slack.mdx +11 -0
- package/docs/hidden-tasks.mdx +56 -0
- package/docs/how-it-works.mdx +454 -0
- package/docs/how-to-reduce-your-spend.mdx +217 -0
- package/docs/idempotency.mdx +504 -0
- package/docs/introduction.mdx +223 -0
- package/docs/limits.mdx +241 -0
- package/docs/logging.mdx +195 -0
- package/docs/machines.mdx +952 -0
- package/docs/manual-setup.mdx +632 -0
- package/docs/mcp-agent-rules.mdx +41 -0
- package/docs/mcp-introduction.mdx +385 -0
- package/docs/mcp-tools.mdx +273 -0
- package/docs/migrating-from-v3.mdx +334 -0
- package/docs/observability/dashboards.mdx +102 -0
- package/docs/observability/query.mdx +585 -0
- package/docs/open-source-contributing.mdx +16 -0
- package/docs/open-source-self-hosting.mdx +541 -0
- package/docs/private-networking/aws-console-setup.mdx +304 -0
- package/docs/private-networking/overview.mdx +144 -0
- package/docs/private-networking/troubleshooting.mdx +78 -0
- package/docs/queue-concurrency.mdx +354 -0
- package/docs/quick-start.mdx +97 -0
- package/docs/realtime/auth.mdx +208 -0
- package/docs/realtime/backend/overview.mdx +45 -0
- package/docs/realtime/backend/streams.mdx +418 -0
- package/docs/realtime/backend/subscribe.mdx +225 -0
- package/docs/realtime/how-it-works.mdx +94 -0
- package/docs/realtime/overview.mdx +63 -0
- package/docs/realtime/react-hooks/overview.mdx +73 -0
- package/docs/realtime/react-hooks/streams.mdx +449 -0
- package/docs/realtime/react-hooks/subscribe.mdx +674 -0
- package/docs/realtime/react-hooks/swr.mdx +87 -0
- package/docs/realtime/react-hooks/triggering.mdx +194 -0
- package/docs/realtime/react-hooks/use-wait-token.mdx +34 -0
- package/docs/realtime/run-object.mdx +174 -0
- package/docs/replaying.mdx +72 -0
- package/docs/request-feature.mdx +6 -0
- package/docs/roadmap.mdx +6 -0
- package/docs/run-tests.mdx +20 -0
- package/docs/run-usage.mdx +113 -0
- package/docs/runs/heartbeats.mdx +38 -0
- package/docs/runs/max-duration.mdx +139 -0
- package/docs/runs/metadata.mdx +734 -0
- package/docs/runs/priority.mdx +31 -0
- package/docs/runs.mdx +396 -0
- package/docs/self-hosting/docker.mdx +458 -0
- package/docs/self-hosting/env/supervisor.mdx +74 -0
- package/docs/self-hosting/env/webapp.mdx +276 -0
- package/docs/self-hosting/kubernetes.mdx +601 -0
- package/docs/self-hosting/overview.mdx +108 -0
- package/docs/skills.mdx +85 -0
- package/docs/tags.mdx +120 -0
- package/docs/tasks/overview.mdx +697 -0
- package/docs/tasks/scheduled.mdx +382 -0
- package/docs/tasks/schemaTask.mdx +413 -0
- package/docs/tasks/streams.mdx +884 -0
- package/docs/triggering.mdx +1320 -0
- package/docs/troubleshooting-alerts.mdx +385 -0
- package/docs/troubleshooting-debugging-in-vscode.mdx +8 -0
- package/docs/troubleshooting-github-issues.mdx +6 -0
- package/docs/troubleshooting-uptime-status.mdx +6 -0
- package/docs/troubleshooting.mdx +398 -0
- package/docs/upgrading-packages.mdx +80 -0
- package/docs/vercel-integration.mdx +207 -0
- package/docs/versioning.mdx +56 -0
- package/docs/video-walkthrough.mdx +23 -0
- package/docs/wait-for-token.mdx +540 -0
- package/docs/wait-for.mdx +42 -0
- package/docs/wait-until.mdx +53 -0
- package/docs/wait.mdx +18 -0
- package/docs/writing-tasks-introduction.mdx +33 -0
- package/package.json +8 -5
- package/skills/trigger-authoring-chat-agent/SKILL.md +296 -0
- package/skills/trigger-authoring-tasks/SKILL.md +254 -0
- package/skills/trigger-chat-agent-advanced/SKILL.md +368 -0
- package/skills/trigger-cost-savings/SKILL.md +116 -0
- package/skills/trigger-realtime-and-frontend/SKILL.md +276 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Fast starts"
|
|
3
|
+
sidebarTitle: "Fast starts"
|
|
4
|
+
description: "Two ways to cut first-turn TTFC: Preload eagerly triggers the run before the first message; Head Start runs step 1 in your warm server while the agent boots in parallel."
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
import RcBanner from "/snippets/ai-chat-rc-banner.mdx";
|
|
8
|
+
|
|
9
|
+
<RcBanner />
|
|
10
|
+
|
|
11
|
+
The first turn of a brand-new conversation pays for the chat.agent run's cold start: dequeue, process boot, `onPreload` / `onChatStart` hooks, and only then the LLM call. Two features address this from different angles.
|
|
12
|
+
|
|
13
|
+
## Picking an approach
|
|
14
|
+
|
|
15
|
+
| | [Preload](#preload) | [Head Start](#head-start) |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| **What it does** | Eagerly triggers the run before the first message | Runs step 1's LLM call in your warm process while the agent boots in parallel |
|
|
18
|
+
| **First-turn TTFC win** | Hides agent boot if the user *does* send a message | ~50% reduction (LLM TTFB floor); boot fully overlaps with TTFB |
|
|
19
|
+
| **When to fire** | Page load / input focus — your call | First message arrival — automatic |
|
|
20
|
+
| **Cost when user never sends** | Idle compute until the preload window times out | Zero (no run was triggered) |
|
|
21
|
+
| **Requires a warm server process** | No — works for browser-only surfaces | Yes — your route handler runs step 1 |
|
|
22
|
+
| **Requires LLM keys client-side?** | No | No — keys stay in your warm server |
|
|
23
|
+
| **Bundle constraints** | None | Route handler must import schema-only tools (no heavy executes) |
|
|
24
|
+
|
|
25
|
+
**Pick one, not both.** Running both for the same chat is wasted work — Head Start gates on a real first message, so adding Preload on top eats the idle-compute cost Head Start was avoiding.
|
|
26
|
+
|
|
27
|
+
**Use Preload** when the chat surface is browser-only, when you don't have a warm Node/Bun/Edge process serving the page, or when you can confidently predict the user *will* send a message (the run never goes idle).
|
|
28
|
+
|
|
29
|
+
**Use Head Start** when the chat lives behind a warm server (Next.js App Router, Hono, SvelteKit, Workers, etc.) and you want first-turn TTFC down at the LLM TTFB floor without any speculative run.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Preload
|
|
34
|
+
|
|
35
|
+
Preload eagerly triggers a run for a chat before the first message is sent. Initialization (DB setup, context loading) happens while the user is still typing, reducing first-response latency.
|
|
36
|
+
|
|
37
|
+
### Frontend
|
|
38
|
+
|
|
39
|
+
Call `transport.preload(chatId)` to start a run early:
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import { useEffect } from "react";
|
|
43
|
+
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
|
|
44
|
+
import { useChat } from "@ai-sdk/react";
|
|
45
|
+
|
|
46
|
+
export function Chat({ chatId }) {
|
|
47
|
+
const transport = useTriggerChatTransport({
|
|
48
|
+
task: "my-chat",
|
|
49
|
+
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
|
|
50
|
+
startSession: ({ chatId, clientData }) =>
|
|
51
|
+
startChatSession({ chatId, clientData }),
|
|
52
|
+
clientData: { userId: currentUser.id },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Preload on mount: run starts before the user types anything.
|
|
56
|
+
// Trigger config (idleTimeoutInSeconds, machine, tags) lives in the
|
|
57
|
+
// server action that wraps `chat.createStartSessionAction`.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
transport.preload(chatId);
|
|
60
|
+
}, [chatId]);
|
|
61
|
+
|
|
62
|
+
const { messages, sendMessage } = useChat({ id: chatId, transport });
|
|
63
|
+
// ...
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Preload is a no-op if a session already exists for this chatId.
|
|
68
|
+
|
|
69
|
+
Your `accessToken` callback receives `{ chatId }` and is invoked the same way on preload as on any other refresh — no special branching by purpose. See [TriggerChatTransport options](/ai-chat/reference#triggerchattransport-options).
|
|
70
|
+
|
|
71
|
+
### Backend
|
|
72
|
+
|
|
73
|
+
The `onPreload` hook fires immediately. The run then waits for the first message. When the user sends a message, `onChatStart` fires with `preloaded: true` so you can skip work that already ran:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
export const myChat = chat.agent({
|
|
77
|
+
id: "my-chat",
|
|
78
|
+
onPreload: async ({ chatId, clientData }) => {
|
|
79
|
+
// Eagerly initialize: runs before the first message
|
|
80
|
+
userContext.init(await loadUser(clientData.userId));
|
|
81
|
+
await db.chat.create({ data: { id: chatId } });
|
|
82
|
+
},
|
|
83
|
+
onChatStart: async ({ preloaded }) => {
|
|
84
|
+
if (preloaded) return; // Already initialized in onPreload
|
|
85
|
+
// ... fallback initialization for non-preloaded runs
|
|
86
|
+
},
|
|
87
|
+
run: async ({ messages, signal }) => {
|
|
88
|
+
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
With `chat.createSession()` or raw tasks, check `payload.trigger === "preload"` and wait for the first message:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
if (payload.trigger === "preload") {
|
|
97
|
+
// Initialize early...
|
|
98
|
+
const result = await chat.messages.waitWithIdleTimeout({
|
|
99
|
+
idleTimeoutInSeconds: 60,
|
|
100
|
+
timeout: "1h",
|
|
101
|
+
});
|
|
102
|
+
if (!result.ok) return;
|
|
103
|
+
currentPayload = result.output;
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Head Start
|
|
110
|
+
|
|
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)).
|
|
112
|
+
|
|
113
|
+
`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.
|
|
114
|
+
|
|
115
|
+
**Verified runtimes:** Node 18+, Bun, Deno, Cloudflare Workers, Vercel (Node and Edge), Netlify (Functions and Edge). The handler uses only `fetch` and Web `ReadableStream` / `TransformStream` (no `node:*` imports), and the S2 streaming dependency picks the right transport for each runtime automatically (HTTP/2 on Node/Deno, HTTP/1.1 on Bun/Workers/browsers).
|
|
116
|
+
|
|
117
|
+
**Compatible frameworks (native Web Fetch):** Next.js App Router, Hono, SvelteKit, Remix, React Router v7, TanStack Start, Astro, Nitro/Nuxt, Elysia. Mount the handler directly.
|
|
118
|
+
|
|
119
|
+
**Node-only frameworks (Express, Fastify, Koa):** the handler still works, but the framework gives you a Node `IncomingMessage` instead of a Web `Request`. Use a small adapter — examples in [Mounting in your framework](#mounting-in-your-framework) below.
|
|
120
|
+
|
|
121
|
+
When the first turn is pure text (no tool calls), the agent run boots and exits without ever calling an LLM. You only pay for what the conversation actually needed.
|
|
122
|
+
|
|
123
|
+
### Measured TTFC
|
|
124
|
+
|
|
125
|
+
3 runs each, prompt `"say hi in five words"`, same model both sides (Anthropic Claude Sonnet 4):
|
|
126
|
+
|
|
127
|
+
| | Without Head Start | With Head Start | Δ |
|
|
128
|
+
| --- | --- | --- | --- |
|
|
129
|
+
| TTFT (avg) | 2801 ms | **1218 ms** | **−57%** |
|
|
130
|
+
| TTFT (range) | 2351–3101 ms | 1201–1252 ms | |
|
|
131
|
+
| Total turn | 4180 ms | 2345 ms | −44% |
|
|
132
|
+
|
|
133
|
+
With Head Start, time-to-first-text is essentially the LLM TTFB floor (50ms spread). Without it, agent boot + hooks stack before the LLM call, adding 750ms of variance.
|
|
134
|
+
|
|
135
|
+
### How it works
|
|
136
|
+
|
|
137
|
+
```mermaid
|
|
138
|
+
sequenceDiagram
|
|
139
|
+
autonumber
|
|
140
|
+
participant B as Browser
|
|
141
|
+
participant H as Route handler<br/>(your warm server)
|
|
142
|
+
participant T as chat.agent run<br/>(Trigger.dev)
|
|
143
|
+
|
|
144
|
+
B->>H: POST first message<br/>(headStart URL)
|
|
145
|
+
|
|
146
|
+
par Step 1 + agent boot in parallel
|
|
147
|
+
H->>H: streamText step 1<br/>(your model, schema-only tools)
|
|
148
|
+
H-->>B: SSE: step 1 chunks
|
|
149
|
+
and
|
|
150
|
+
H->>T: createSession + trigger run
|
|
151
|
+
T->>T: boot → wait on session.in
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
alt finishReason: tool-calls
|
|
155
|
+
H->>T: handover signal<br/>(partial assistant message)
|
|
156
|
+
T->>T: execute tools, run step 2 LLM
|
|
157
|
+
T-->>H: chunks via session.out
|
|
158
|
+
H-->>B: SSE: step 2 chunks
|
|
159
|
+
T-->>H: trigger:turn-complete
|
|
160
|
+
else finishReason: stop (pure text)
|
|
161
|
+
H->>T: handover-skip signal
|
|
162
|
+
T->>T: exit (no LLM call)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
H-->>B: SSE close
|
|
166
|
+
Note over B,T: Subsequent turns bypass the handler:<br/>browser writes directly to session.in
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
<Steps>
|
|
170
|
+
<Step title="Browser POSTs the first message to your route handler">
|
|
171
|
+
The transport sees `headStart: "/api/chat"` is set and there's no session yet for this chat. It POSTs the wire payload (messages, chatId, metadata) to your route handler.
|
|
172
|
+
</Step>
|
|
173
|
+
<Step title="Your handler creates the session and triggers the agent run">
|
|
174
|
+
A single `apiClient.createSession` round-trip both creates the chat session and triggers an agent run with `trigger: "handover-prepare"`. The agent run boots into a wait state on `session.in`.
|
|
175
|
+
</Step>
|
|
176
|
+
<Step title="Your handler runs streamText step 1">
|
|
177
|
+
`streamText` runs in your warm process with `stopWhen: stepCountIs(1)`. The output is streamed to the browser as SSE while the agent run boots in parallel. Boot time (~488ms) overlaps with LLM TTFB (~389ms), fully hidden.
|
|
178
|
+
</Step>
|
|
179
|
+
<Step title="Mid-turn handover">
|
|
180
|
+
On step 1's `tool-calls` finish, your handler signals the agent and the SDK splices the agent's step-2+ stream into the same SSE response. On pure-text finish, your handler signals `handover-skip` and the agent run exits clean — no LLM call from the trigger side.
|
|
181
|
+
</Step>
|
|
182
|
+
<Step title="Subsequent turns bypass the route handler">
|
|
183
|
+
After turn 1, the transport hydrates the session PAT from response headers and writes turn 2 onward directly to `session.in`. Same direct-trigger path as a regular `chat.agent` setup.
|
|
184
|
+
</Step>
|
|
185
|
+
</Steps>
|
|
186
|
+
|
|
187
|
+
### Setup
|
|
188
|
+
|
|
189
|
+
<Warning>
|
|
190
|
+
**Bundle isolation is the load-bearing constraint.** Head Start only saves time because your route-handler bundle stays lightweight. Anything you import in that handler — and anything those modules import transitively — lands in the bundle. If your tool catalog with heavy `execute` fns (E2B, Puppeteer, native bindings, the trigger SDK runtime, Turndown, image processing, `node:child_process`) ends up in the bundle, you've put cold-start back into a different process.
|
|
191
|
+
|
|
192
|
+
This is an **import-chain** problem, not a runtime one. A "we'll strip the executes at runtime" helper would not fix it — bundlers resolve imports at build time. The only correct shape is to keep schemas in their own module that imports `ai` and `zod` only.
|
|
193
|
+
</Warning>
|
|
194
|
+
|
|
195
|
+
<Steps>
|
|
196
|
+
<Step title="Split your tool definitions into schemas + executes">
|
|
197
|
+
Schemas in one module (light deps), executes in another (heavy deps). The agent task pulls in both; the route handler pulls in schemas only.
|
|
198
|
+
|
|
199
|
+
```ts lib/chat-tools/schemas.ts
|
|
200
|
+
// ⚠️ This file MUST NOT import anything heavier than `ai` and `zod`.
|
|
201
|
+
// Any import here lands in the route-handler bundle.
|
|
202
|
+
import { tool } from "ai";
|
|
203
|
+
import { z } from "zod";
|
|
204
|
+
|
|
205
|
+
export const fetchPage = tool({
|
|
206
|
+
description: "Fetch a URL and return text",
|
|
207
|
+
inputSchema: z.object({ url: z.string().url() }),
|
|
208
|
+
// No execute — agent task adds it elsewhere.
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
export const headStartTools = { fetchPage };
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```ts trigger/chat-tools.ts
|
|
215
|
+
// Heavy deps live here. Only the trigger task imports this module.
|
|
216
|
+
import { tool } from "ai";
|
|
217
|
+
import TurndownService from "turndown";
|
|
218
|
+
import { fetchPage as fetchPageSchema } from "@/lib/chat-tools/schemas";
|
|
219
|
+
|
|
220
|
+
const turndown = new TurndownService();
|
|
221
|
+
|
|
222
|
+
export const fetchPage = tool({
|
|
223
|
+
...fetchPageSchema,
|
|
224
|
+
execute: async ({ url }) => {
|
|
225
|
+
const res = await fetch(url);
|
|
226
|
+
return { body: turndown.turndown(await res.text()) };
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
export const chatTools = { fetchPage };
|
|
231
|
+
```
|
|
232
|
+
</Step>
|
|
233
|
+
<Step title="Define your chat.agent (heavy executes)">
|
|
234
|
+
The agent uses the full tool set — these are the executes that run when step 2+ needs them.
|
|
235
|
+
|
|
236
|
+
```ts trigger/chat.ts
|
|
237
|
+
import { chat } from "@trigger.dev/sdk/ai";
|
|
238
|
+
import { streamText, stepCountIs } from "ai";
|
|
239
|
+
import { anthropic } from "@ai-sdk/anthropic";
|
|
240
|
+
import { chatTools } from "./chat-tools";
|
|
241
|
+
|
|
242
|
+
export const myChat = chat.agent({
|
|
243
|
+
id: "my-chat",
|
|
244
|
+
run: async ({ messages, signal }) =>
|
|
245
|
+
streamText({
|
|
246
|
+
...chat.toStreamTextOptions({ tools: chatTools }),
|
|
247
|
+
model: anthropic("claude-sonnet-4-6"),
|
|
248
|
+
messages,
|
|
249
|
+
stopWhen: stepCountIs(10),
|
|
250
|
+
abortSignal: signal,
|
|
251
|
+
}),
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
</Step>
|
|
255
|
+
<Step title="Build the head-start handler">
|
|
256
|
+
Call `chat.headStart({ agentId, run })`. It returns a standard Web Fetch handler: `(req: Request) => Promise<Response>`. Inside the `run` callback you call `streamText` yourself and spread `chat.toStreamTextOptions({ tools })` to inherit the SDK-owned wiring (messages, schema-only tools, `stopWhen: stepCountIs(1)`, abort signal). Add your own `model` and `system` on top.
|
|
257
|
+
|
|
258
|
+
```ts lib/chat-handler.ts
|
|
259
|
+
import { chat } from "@trigger.dev/sdk/chat-server";
|
|
260
|
+
import { streamText } from "ai";
|
|
261
|
+
import { anthropic } from "@ai-sdk/anthropic";
|
|
262
|
+
import { headStartTools } from "@/lib/chat-tools/schemas";
|
|
263
|
+
|
|
264
|
+
export const chatHandler = chat.headStart({
|
|
265
|
+
agentId: "my-chat",
|
|
266
|
+
run: async ({ chat: helper }) =>
|
|
267
|
+
streamText({
|
|
268
|
+
...helper.toStreamTextOptions({ tools: headStartTools }),
|
|
269
|
+
model: anthropic("claude-sonnet-4-6"),
|
|
270
|
+
system: "You are a helpful assistant.",
|
|
271
|
+
stopWhen: stepCountIs(15),
|
|
272
|
+
}),
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
<Tip>
|
|
277
|
+
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.
|
|
278
|
+
</Tip>
|
|
279
|
+
|
|
280
|
+
Mount the handler in whatever framework you use — see [Mounting in your framework](#mounting-in-your-framework) below.
|
|
281
|
+
</Step>
|
|
282
|
+
<Step title="Opt in on the transport">
|
|
283
|
+
Add `headStart: "/api/chat"` to `useTriggerChatTransport`. Subsequent turns bypass this URL automatically — `accessToken` and (optionally) `startSession` still run for the direct-trigger path on turn 2 onward.
|
|
284
|
+
|
|
285
|
+
```tsx components/chat.tsx
|
|
286
|
+
const transport = useTriggerChatTransport<typeof myChat>({
|
|
287
|
+
task: "my-chat",
|
|
288
|
+
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
|
|
289
|
+
startSession: ({ chatId, clientData }) =>
|
|
290
|
+
startChatSession({ chatId, clientData }),
|
|
291
|
+
headStart: "/api/chat",
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
</Step>
|
|
295
|
+
</Steps>
|
|
296
|
+
|
|
297
|
+
### Mounting in your framework
|
|
298
|
+
|
|
299
|
+
`chat.headStart` returns a Web Fetch handler — `(req: Request) => Promise<Response>`. Frameworks that natively pass Web `Request` objects mount it as-is. Node-only frameworks (Express, Fastify, Koa) need a small adapter.
|
|
300
|
+
|
|
301
|
+
#### Web Fetch frameworks (recommended)
|
|
302
|
+
|
|
303
|
+
<CodeGroup>
|
|
304
|
+
|
|
305
|
+
```ts Next.js (App Router)
|
|
306
|
+
// app/api/chat/route.ts
|
|
307
|
+
import { chatHandler } from "@/lib/chat-handler";
|
|
308
|
+
|
|
309
|
+
export const POST = chatHandler;
|
|
310
|
+
// Default function timeout on Vercel is 10s. Bump if your turns
|
|
311
|
+
// run long (multi-step tool use, slow models):
|
|
312
|
+
// export const maxDuration = 60;
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
```ts Hono
|
|
316
|
+
// src/index.ts
|
|
317
|
+
import { Hono } from "hono";
|
|
318
|
+
import { chatHandler } from "./chat-handler";
|
|
319
|
+
|
|
320
|
+
const app = new Hono();
|
|
321
|
+
|
|
322
|
+
app.post("/api/chat", (c) => chatHandler(c.req.raw));
|
|
323
|
+
|
|
324
|
+
export default app;
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
```ts SvelteKit
|
|
328
|
+
// src/routes/api/chat/+server.ts
|
|
329
|
+
import type { RequestHandler } from "./$types";
|
|
330
|
+
import { chatHandler } from "$lib/chat-handler";
|
|
331
|
+
|
|
332
|
+
export const POST: RequestHandler = ({ request }) => chatHandler(request);
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
```ts Remix / React Router v7
|
|
336
|
+
// app/routes/api.chat.ts
|
|
337
|
+
import type { ActionFunctionArgs } from "@remix-run/node";
|
|
338
|
+
import { chatHandler } from "~/lib/chat-handler";
|
|
339
|
+
|
|
340
|
+
export async function action({ request }: ActionFunctionArgs) {
|
|
341
|
+
return chatHandler(request);
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
```ts TanStack Start
|
|
346
|
+
// app/routes/api/chat.ts
|
|
347
|
+
import { createAPIFileRoute } from "@tanstack/start/api";
|
|
348
|
+
import { chatHandler } from "~/lib/chat-handler";
|
|
349
|
+
|
|
350
|
+
export const Route = createAPIFileRoute("/api/chat")({
|
|
351
|
+
POST: ({ request }) => chatHandler(request),
|
|
352
|
+
});
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
```ts Astro
|
|
356
|
+
// src/pages/api/chat.ts
|
|
357
|
+
import type { APIRoute } from "astro";
|
|
358
|
+
import { chatHandler } from "../../lib/chat-handler";
|
|
359
|
+
|
|
360
|
+
export const POST: APIRoute = ({ request }) => chatHandler(request);
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
```ts Nitro / Nuxt
|
|
364
|
+
// server/api/chat.post.ts
|
|
365
|
+
import { chatHandler } from "~/lib/chat-handler";
|
|
366
|
+
|
|
367
|
+
export default defineEventHandler((event) => chatHandler(toWebRequest(event)));
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
```ts Elysia
|
|
371
|
+
// src/index.ts
|
|
372
|
+
import { Elysia } from "elysia";
|
|
373
|
+
import { chatHandler } from "./chat-handler";
|
|
374
|
+
|
|
375
|
+
new Elysia()
|
|
376
|
+
.post("/api/chat", ({ request }) => chatHandler(request))
|
|
377
|
+
.listen(3000);
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
</CodeGroup>
|
|
381
|
+
|
|
382
|
+
#### Edge / standalone runtimes
|
|
383
|
+
|
|
384
|
+
<CodeGroup>
|
|
385
|
+
|
|
386
|
+
```ts Cloudflare Workers
|
|
387
|
+
// src/index.ts
|
|
388
|
+
import { chatHandler } from "./chat-handler";
|
|
389
|
+
|
|
390
|
+
export default {
|
|
391
|
+
async fetch(req: Request): Promise<Response> {
|
|
392
|
+
const url = new URL(req.url);
|
|
393
|
+
if (req.method === "POST" && url.pathname === "/api/chat") {
|
|
394
|
+
return chatHandler(req);
|
|
395
|
+
}
|
|
396
|
+
return new Response("Not found", { status: 404 });
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
```ts Bun (native server)
|
|
402
|
+
// server.ts
|
|
403
|
+
import { chatHandler } from "./chat-handler";
|
|
404
|
+
|
|
405
|
+
Bun.serve({
|
|
406
|
+
port: 3000,
|
|
407
|
+
async fetch(req) {
|
|
408
|
+
const url = new URL(req.url);
|
|
409
|
+
if (req.method === "POST" && url.pathname === "/api/chat") {
|
|
410
|
+
return chatHandler(req);
|
|
411
|
+
}
|
|
412
|
+
return new Response("Not found", { status: 404 });
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
```ts Deno (Deno.serve)
|
|
418
|
+
// server.ts
|
|
419
|
+
import { chatHandler } from "./chat-handler.ts";
|
|
420
|
+
|
|
421
|
+
Deno.serve({ port: 3000 }, async (req) => {
|
|
422
|
+
const url = new URL(req.url);
|
|
423
|
+
if (req.method === "POST" && url.pathname === "/api/chat") {
|
|
424
|
+
return chatHandler(req);
|
|
425
|
+
}
|
|
426
|
+
return new Response("Not found", { status: 404 });
|
|
427
|
+
});
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
</CodeGroup>
|
|
431
|
+
|
|
432
|
+
#### Node-only frameworks
|
|
433
|
+
|
|
434
|
+
Express, Fastify, and Koa pass Node `IncomingMessage` / `ServerResponse` objects rather than Web `Request` / `Response`. The SDK ships `chat.toNodeListener` that wraps any Web Fetch handler as a Node `(req, res)` listener — body bytes are read upfront, headers translated, the response body streamed chunk-by-chunk, and client disconnect is propagated to the handler via `AbortSignal`.
|
|
435
|
+
|
|
436
|
+
<CodeGroup>
|
|
437
|
+
|
|
438
|
+
```ts Express
|
|
439
|
+
import express from "express";
|
|
440
|
+
import { chat } from "@trigger.dev/sdk/chat-server";
|
|
441
|
+
import { chatHandler } from "./chat-handler";
|
|
442
|
+
|
|
443
|
+
const app = express();
|
|
444
|
+
app.post("/api/chat", chat.toNodeListener(chatHandler));
|
|
445
|
+
app.listen(3000);
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
```ts Fastify
|
|
449
|
+
import Fastify from "fastify";
|
|
450
|
+
import { chat } from "@trigger.dev/sdk/chat-server";
|
|
451
|
+
import { chatHandler } from "./chat-handler";
|
|
452
|
+
|
|
453
|
+
const fastify = Fastify();
|
|
454
|
+
const listener = chat.toNodeListener(chatHandler);
|
|
455
|
+
|
|
456
|
+
fastify.post("/api/chat", (req, reply) => {
|
|
457
|
+
// Hand the raw Node request/response to the adapter and tell
|
|
458
|
+
// Fastify we'll handle the response ourselves (no auto-reply).
|
|
459
|
+
reply.hijack();
|
|
460
|
+
return listener(req.raw, reply.raw);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
fastify.listen({ port: 3000 });
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
```ts Koa
|
|
467
|
+
import Koa from "koa";
|
|
468
|
+
import Router from "@koa/router";
|
|
469
|
+
import { chat } from "@trigger.dev/sdk/chat-server";
|
|
470
|
+
import { chatHandler } from "./chat-handler";
|
|
471
|
+
|
|
472
|
+
const app = new Koa();
|
|
473
|
+
const router = new Router();
|
|
474
|
+
const listener = chat.toNodeListener(chatHandler);
|
|
475
|
+
|
|
476
|
+
router.post("/api/chat", async (ctx) => {
|
|
477
|
+
ctx.respond = false; // Tell Koa not to send the response itself.
|
|
478
|
+
await listener(ctx.req, ctx.res);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
app.use(router.routes()).listen(3000);
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
```ts Raw node:http
|
|
485
|
+
import http from "node:http";
|
|
486
|
+
import { chat } from "@trigger.dev/sdk/chat-server";
|
|
487
|
+
import { chatHandler } from "./chat-handler";
|
|
488
|
+
|
|
489
|
+
const listener = chat.toNodeListener(chatHandler);
|
|
490
|
+
|
|
491
|
+
http
|
|
492
|
+
.createServer((req, res) => {
|
|
493
|
+
if (req.method === "POST" && req.url === "/api/chat") {
|
|
494
|
+
return listener(req, res);
|
|
495
|
+
}
|
|
496
|
+
res.statusCode = 404;
|
|
497
|
+
res.end();
|
|
498
|
+
})
|
|
499
|
+
.listen(3000);
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
</CodeGroup>
|
|
503
|
+
|
|
504
|
+
<Warning>
|
|
505
|
+
Don't run `express.json()` (or any body-parsing middleware) before the head-start route — it consumes the request body before `chat.toNodeListener` can read the raw bytes. Either skip the parser for this route, or scope it to other routes.
|
|
506
|
+
</Warning>
|
|
507
|
+
|
|
508
|
+
#### Streaming response timeouts
|
|
509
|
+
|
|
510
|
+
The handler keeps the SSE response open until the agent run signals turn-complete (or skip, on a pure-text turn). Make sure your framework / serverless function timeout accommodates that:
|
|
511
|
+
|
|
512
|
+
- **Pure-text first turns**: ~LLM TTFB (1–3 s typically).
|
|
513
|
+
- **Tool-calling first turns**: LLM step 1 + agent boot + tool execution + step 2 LLM call. Usually 5–15 s; longer for multi-step tool use.
|
|
514
|
+
- **Vercel**: default function timeout is 10 s on Hobby, 60 s on Pro. Set `export const maxDuration = N;` on the route segment.
|
|
515
|
+
- **Cloudflare Workers**: default 30 s CPU time (paid plans up to 5 min). Streaming wall time is generally not the bottleneck.
|
|
516
|
+
- **AWS Lambda behind API Gateway**: 29 s API Gateway hard limit; Lambda Function URL allows up to 15 min.
|
|
517
|
+
|
|
518
|
+
### What gets routed where
|
|
519
|
+
|
|
520
|
+
| | First turn (handover) | Subsequent turns |
|
|
521
|
+
| --- | --- | --- |
|
|
522
|
+
| Browser sends message via | POST to `headStart` URL | Direct write to `session.in` |
|
|
523
|
+
| Step 1 LLM call runs in | Your warm process | Trigger.dev agent run |
|
|
524
|
+
| Tool execution runs in | Trigger.dev agent run | Trigger.dev agent run |
|
|
525
|
+
| Step 2+ LLM call runs in | Trigger.dev agent run | Trigger.dev agent run |
|
|
526
|
+
| `onChatStart` / `onTurnStart` fire | After handover signal arrives | Normally |
|
|
527
|
+
| `hydrateMessages` fires (if registered) | After handover, with the first-turn history as `incomingMessages` | Normally |
|
|
528
|
+
| `onTurnComplete` fires | After turn finishes (handover) or skipped (handover-skip) | Normally |
|
|
529
|
+
|
|
530
|
+
### Persistence and the handover contract
|
|
531
|
+
|
|
532
|
+
A head-start turn persists exactly like a normal turn — the handover machinery is invisible to your hooks. The guarantees:
|
|
533
|
+
|
|
534
|
+
- **One stable assistant `messageId` across the whole turn.** The route handler generates the id, the handover signal carries it to the agent, and the agent's step 2+ stream reuses it — so the browser merges step 1 and step 2+ into a single assistant message, and you can merge-by-id when persisting.
|
|
535
|
+
- **`onTurnComplete` is the canonical persistence point**, same as any turn. It carries the full assistant message under that one id: step-1 text, reasoning, and tool calls plus step-2+ tool results and text. The [database persistence](/ai-chat/patterns/database-persistence) patterns apply unchanged.
|
|
536
|
+
- **Reasoning parts survive the handover.** When step 1 runs on an extended-thinking model, the reasoning streamed by your route handler lands in the durable session history (and `onTurnComplete`) under the same `messageId`, with provider metadata intact — Anthropic thinking signatures survive a replay back to the model. Step-2 reasoning appends to the same message rather than replacing it.
|
|
537
|
+
|
|
538
|
+
#### With `hydrateMessages`
|
|
539
|
+
|
|
540
|
+
Head Start composes with [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages). On the first turn, the hook receives the route handler's first-turn history as `incomingMessages` — the canonical upsert-and-return pattern persists the user message exactly as it would on a direct-trigger turn. The runtime splices the warm handler's partial assistant onto your hydrated chain after the hook returns, deduplicated by the assistant `messageId`, so your hook never needs to include the in-flight partial.
|
|
541
|
+
|
|
542
|
+
<Warning>
|
|
543
|
+
**Hydrate hooks must upsert their conversation row, not update it.** Head-start turns skip preload entirely, so row-creating hooks (`onPreload`, or an `onChatStart` create) have not run when `hydrateMessages` first fires. A bare `UPDATE` against a missing row throws and errors the turn.
|
|
544
|
+
</Warning>
|
|
545
|
+
|
|
546
|
+
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.
|
|
547
|
+
|
|
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
|
+
|
|
606
|
+
### The `chat.headStart` API
|
|
607
|
+
|
|
608
|
+
```ts
|
|
609
|
+
chat.headStart<TTools>({
|
|
610
|
+
agentId: string, // The chat.agent / chat.customAgent id you're handing off to
|
|
611
|
+
run: (args: HeadStartRunArgs<TTools>) => Promise<StreamTextResult<any, any>>,
|
|
612
|
+
idleTimeoutInSeconds?: number, // How long the agent waits for the handover signal. Default: 60
|
|
613
|
+
triggerConfig?: Partial<SessionTriggerConfig>, // Run options for the handover-prepare run
|
|
614
|
+
}): (req: Request) => Promise<Response>
|
|
615
|
+
```
|
|
616
|
+
|
|
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
|
+
|
|
628
|
+
The `run` callback receives:
|
|
629
|
+
|
|
630
|
+
- `messages: UIMessage[]` — user messages parsed from the request body.
|
|
631
|
+
- `signal: AbortSignal` — fires when the request closes or the SDK times out the handover.
|
|
632
|
+
- `chat: HeadStartChatHelper<TTools>` — exposes `chat.toStreamTextOptions({ tools })` and a `chat.session` escape hatch for power users.
|
|
633
|
+
|
|
634
|
+
`chat.toStreamTextOptions({ tools })` returns options to spread into `streamText`. The SDK owns these keys — overriding them will break the protocol:
|
|
635
|
+
|
|
636
|
+
| Key | What the SDK sets | Why |
|
|
637
|
+
| --- | --- | --- |
|
|
638
|
+
| `messages` | `convertToModelMessages(uiMessages)` | First-turn user history |
|
|
639
|
+
| `tools` | What you pass | Schema-only tools for step 1 |
|
|
640
|
+
| `stopWhen` | `stepCountIs(1)` | Step 1 only — agent picks up step 2+ |
|
|
641
|
+
| `abortSignal` | Combined request + idle timeout | Safe cleanup on disconnect |
|
|
642
|
+
|
|
643
|
+
You bring `model`, `system`, `providerOptions`, `prepareStep`, anything else `streamText` accepts.
|
|
644
|
+
|
|
645
|
+
#### The transport option
|
|
646
|
+
|
|
647
|
+
```ts
|
|
648
|
+
useTriggerChatTransport({
|
|
649
|
+
// ... task, accessToken, startSession, ...
|
|
650
|
+
headStart?: string, // URL of your chat.headStart route handler
|
|
651
|
+
});
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
Optional. When set, the FIRST message of a brand-new chat (no existing session state) routes through this URL. Subsequent turns bypass it and use the direct-trigger path.
|
|
655
|
+
|
|
656
|
+
This is **not** a stock `useChat` `endpoint` — it's not the canonical request URL for every turn, just the first-turn shortcut.
|
|
657
|
+
|
|
658
|
+
### Limitations
|
|
659
|
+
|
|
660
|
+
- **First turn only.** Step 2+ and turn 2+ run on the trigger side. There's no per-turn "head start every turn" mode — the win comes from amortizing agent boot across the LLM call once.
|
|
661
|
+
- **Single step on the warm-server side.** The handler runs `stopWhen: stepCountIs(1)`. Multi-step handover (handler does step 1 + step 2 + ...) is out of scope.
|
|
662
|
+
- **Your server needs an LLM provider key.** The first-turn LLM call runs in your warm process, so that environment needs whatever keys the model requires. The agent's executes still run on the Trigger.dev side with whatever environment variables they need there.
|
|
663
|
+
- **Browser-only chat surfaces don't apply.** Without a warm server process, there's nowhere to run step 1 ahead of the agent run. Use [Preload](#preload) or eat the cold-start tax.
|
|
664
|
+
- **Streaming-capable runtime required.** Your framework / runtime has to support streaming HTTP responses (Web Fetch `Response` body or equivalent). Most modern hosts do — Next.js, Hono, SvelteKit, Workers, Bun, Deno, Vercel, etc. Some legacy platforms that buffer full responses won't deliver chunks until the turn is over, which negates the TTFC benefit (correctness still holds).
|
|
665
|
+
- **Non-`useChat` chat surfaces** (Slack bots, Discord bots, custom protocols) don't fit the `chat.headStart` shape — the API expects the AI SDK transport's wire payload on input. For those, trigger the chat.agent directly from your bot handler.
|
|
666
|
+
|
|
667
|
+
## Reference
|
|
668
|
+
|
|
669
|
+
- [`chat.headStart` factory and types](/ai-chat/reference) — full signatures for `HeadStartRunArgs`, `HeadStartChatHelper`, `HeadStartSession`, `HeadStartHandlerOptions`.
|
|
670
|
+
- [`headStart` transport option](/ai-chat/reference#triggerchattransport-options) — alongside `accessToken`, `startSession`, etc.
|
|
671
|
+
- [`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.
|