Skip to content

Commit 98f89a2

Browse files
committed
chore(references): demo chat.headStart in ai-chat reference
Adds a /api/chat route handler exporting chat.headStart, splits the tool definitions across two modules so heavy executes never reach the browser bundle, and exposes a sidebar toggle for paired TTFC tests. - src/lib/chat-tools-schemas.ts (new): schema-only tool definitions — imported by both the route handler and the agent task. No `execute`, no heavy deps. Bundle stays small. - src/trigger/chat-tools.ts (renamed): re-exports the schemas with agent-side `execute` fns added (E2B sandbox, turndown, deepResearch subtask, etc.). Only the trigger task imports this. - src/app/api/chat/route.ts (new): exports POST = chat.headStart, runs step 1 streamText with claude-sonnet-4-6 to match the agent's default. - ChatSettingsContext + sidebar gain a "Use handover (1st turn)" toggle; chat-view threads it into the transport's `headStart` URL. - Smoke result: ~53% TTFC reduction on first turn (1561ms vs 3358ms), with persistence + tool execution behaving identically.
1 parent 6797da9 commit 98f89a2

11 files changed

Lines changed: 557 additions & 8 deletions

File tree

references/ai-chat/src/app/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
aiChatSession,
1010
upgradeTestAgent,
1111
} from "@/trigger/chat";
12-
import type { ChatUiMessage } from "@/lib/chat-tools";
12+
import type { ChatUiMessage } from "@/lib/chat-tools-schemas";
1313
import { prisma } from "@/lib/prisma";
1414

1515
/** Short-lived PATs for local testing of expiry + renewal (not for production). */
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* chat.headStart first-turn endpoint.
3+
*
4+
* The browser transport POSTs first-turn messages here when the
5+
* `headStart` option is set on `useTriggerChatTransport`. This
6+
* handler:
7+
*
8+
* 1. Creates the chat.agent session and triggers a `handover-prepare`
9+
* run (atomic, one round-trip), so the agent boots in parallel.
10+
* 2. Runs `streamText` step 1 right here in the warm Next.js process
11+
* and returns the SSE stream directly to the browser — no waiting
12+
* on the agent's cold start.
13+
* 3. On step 1's tool-call boundary, hands ownership of the durable
14+
* session.out stream over to the agent run, which executes tools
15+
* and continues from step 2+ (or exits clean for pure-text turns).
16+
*
17+
* Subsequent turns bypass this endpoint — the transport hydrates the
18+
* session PAT from response headers and writes directly to
19+
* `session.in` for turn 2 onward.
20+
*
21+
* The TTFC win: cold-start agent boot (~488ms) + onTurnStart hooks
22+
* (~316ms) overlap with the LLM TTFB instead of stacking before it,
23+
* so the user-perceived first chunk arrives ~50% sooner. The agent
24+
* still owns tool execution and everything after — heavy deps stay
25+
* where they belong.
26+
*/
27+
import { chat } from "@trigger.dev/sdk/chat-server";
28+
import { streamText } from "ai";
29+
import { anthropic } from "@ai-sdk/anthropic";
30+
// ⚠️ Imports MUST come from `chat-tools-schemas` only — see the
31+
// header comment in that file for the bundle-isolation rationale.
32+
// Importing `src/trigger/chat-tools.ts` here would drag E2B,
33+
// turndown, the trigger SDK runtime, etc. into the Next.js bundle
34+
// and defeat the whole point of `chat.headStart`.
35+
import { headStartTools } from "@/lib/chat-tools-schemas";
36+
37+
export const POST = chat.headStart({
38+
agentId: "ai-chat",
39+
run: async ({ chat: chatHelper }) => {
40+
return streamText({
41+
// `toStreamTextOptions` wires `messages` (converted from
42+
// UIMessages), `tools`, `stopWhen: stepCountIs(1)`, and the
43+
// combined `abortSignal`. Customer adds model + system prompt on
44+
// top — anything else `streamText` accepts is fair game.
45+
...chatHelper.toStreamTextOptions({ tools: headStartTools }),
46+
// Match the agent's default (`DEFAULT_MODEL` in `lib/models.ts`)
47+
// so step 1 and step 2+ run on the same provider — no jarring
48+
// tone/style shift mid-turn, and TTFC comparisons stay honest.
49+
model: anthropic("claude-sonnet-4-6"),
50+
system:
51+
"You are a helpful AI assistant. Be concise and friendly. Use the available tools when relevant.",
52+
});
53+
},
54+
});

references/ai-chat/src/components/chat-app.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { generateId } from "ai";
44
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
5-
import type { ChatUiMessage } from "@/lib/chat-tools";
5+
import type { ChatUiMessage } from "@/lib/chat-tools-schemas";
66
import { useCallback, useEffect, useState } from "react";
77
import { Chat } from "@/components/chat";
88
import { ChatSidebar } from "@/components/chat-sidebar";
@@ -169,6 +169,8 @@ export function ChatApp({
169169
onIdleTimeoutChange={setIdleTimeoutInSeconds}
170170
taskMode={taskMode}
171171
onTaskModeChange={onTaskModeChange}
172+
useHandover={false}
173+
onUseHandoverChange={() => {}}
172174
/>
173175
<div className="flex-1">
174176
{activeChatId ? (

references/ai-chat/src/components/chat-settings-context.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,29 @@ type ChatSettings = {
77
setTaskMode: (mode: string) => void;
88
idleTimeoutInSeconds: number;
99
setIdleTimeoutInSeconds: (seconds: number) => void;
10+
/**
11+
* When true, first-turn messages are POSTed to `/api/chat`
12+
* (`chat.handover` route handler) instead of triggering the agent
13+
* directly. Subsequent turns bypass the endpoint regardless.
14+
*/
15+
useHandover: boolean;
16+
setUseHandover: (on: boolean) => void;
1017
};
1118

1219
const ChatSettingsContext = createContext<ChatSettings | null>(null);
1320

1421
export function ChatSettingsProvider({ children }: { children: ReactNode }) {
1522
const [taskMode, setTaskMode] = useState("ai-chat");
1623
const [idleTimeoutInSeconds, setIdleTimeoutInSeconds] = useState(60);
24+
const [useHandover, setUseHandover] = useState(false);
1725

1826
const value: ChatSettings = {
1927
taskMode,
2028
setTaskMode,
2129
idleTimeoutInSeconds,
2230
setIdleTimeoutInSeconds,
31+
useHandover,
32+
setUseHandover,
2333
};
2434

2535
// eslint-disable-next-line @typescript-eslint/no-explicit-any

references/ai-chat/src/components/chat-sidebar-wrapper.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export function ChatSidebarWrapper({
2828
setTaskMode,
2929
idleTimeoutInSeconds,
3030
setIdleTimeoutInSeconds,
31+
useHandover,
32+
setUseHandover,
3133
} = useChatSettings();
3234

3335
// Extract active chatId from URL
@@ -85,6 +87,8 @@ export function ChatSidebarWrapper({
8587
onIdleTimeoutChange={setIdleTimeoutInSeconds}
8688
taskMode={taskMode}
8789
onTaskModeChange={setTaskMode}
90+
useHandover={useHandover}
91+
onUseHandoverChange={setUseHandover}
8892
/>
8993
);
9094
}

references/ai-chat/src/components/chat-sidebar.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ type ChatSidebarProps = {
2929
onIdleTimeoutChange: (seconds: number) => void;
3030
taskMode: string;
3131
onTaskModeChange: (mode: string) => void;
32+
useHandover: boolean;
33+
onUseHandoverChange: (on: boolean) => void;
3234
};
3335

3436
export function ChatSidebar({
@@ -42,6 +44,8 @@ export function ChatSidebar({
4244
onIdleTimeoutChange,
4345
taskMode,
4446
onTaskModeChange,
47+
useHandover,
48+
onUseHandoverChange,
4549
}: ChatSidebarProps) {
4650
const sorted = [...chats].sort((a, b) => b.updatedAt - a.updatedAt);
4751

@@ -115,6 +119,18 @@ export function ChatSidebar({
115119
<option value="upgrade-test">upgrade-test (requestUpgrade after 3 turns)</option>
116120
</select>
117121
</div>
122+
<label
123+
className="flex items-center gap-2 text-xs text-gray-500"
124+
title="Route first-turn messages through /api/chat (chat.handover) so step 1 streams from the Next.js process while the agent run boots in parallel."
125+
>
126+
<input
127+
type="checkbox"
128+
checked={useHandover}
129+
onChange={(e) => onUseHandoverChange(e.target.checked)}
130+
className="h-3 w-3 rounded border-gray-300"
131+
/>
132+
<span>Use handover (1st turn)</span>
133+
</label>
118134
<button
119135
type="button"
120136
onClick={onWipeAll}

references/ai-chat/src/components/chat-view.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
4-
import type { ChatUiMessage } from "@/lib/chat-tools";
4+
import type { ChatUiMessage } from "@/lib/chat-tools-schemas";
55
import { Chat } from "@/components/chat";
66
import { useChatSettings } from "@/components/chat-settings-context";
77
import {
@@ -34,7 +34,7 @@ export function ChatView({
3434
model,
3535
}: ChatViewProps) {
3636
const router = useRouter();
37-
const { taskMode } = useChatSettings();
37+
const { taskMode, useHandover } = useChatSettings();
3838

3939
const [currentSession, setCurrentSession] = useState<SessionInfo | null>(initialSession);
4040

@@ -67,6 +67,15 @@ export function ChatView({
6767
onSessionChange: handleSessionChange,
6868
clientData: { userId: "user_123" },
6969
multiTab: true,
70+
// Head-start URL: opt-in fast-path for the first message of a
71+
// brand-new chat. The transport POSTs to `/api/chat` (which
72+
// exports `chat.handover({ agentId, run })`) so step 1's LLM
73+
// call runs in the warm Next.js process while the trigger agent
74+
// run boots in parallel. After turn 1 the transport hydrates
75+
// session state from response headers and writes directly to
76+
// `session.in` for turn 2 onward — same direct-trigger path as
77+
// when `headStart` is unset.
78+
headStart: useHandover ? "/api/chat" : undefined,
7079
});
7180

7281
const handleFirstMessage = useCallback(
@@ -101,6 +110,7 @@ export function ChatView({
101110
projectDashboardPath={process.env.NEXT_PUBLIC_TRIGGER_PROJECT_DASHBOARD_PATH}
102111
onFirstMessage={handleFirstMessage}
103112
onMessagesChange={handleMessagesChange}
113+
handoverEnabled={useHandover}
104114
/>
105115
);
106116
}

references/ai-chat/src/components/chat.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
lastAssistantMessageIsCompleteWithApprovalResponses,
66
lastAssistantMessageIsCompleteWithToolCalls,
77
} from "ai";
8-
import type { ChatUiMessage } from "@/lib/chat-tools";
8+
import type { ChatUiMessage } from "@/lib/chat-tools-schemas";
99
import type { TriggerChatTransport } from "@trigger.dev/sdk/chat";
1010

1111
// Structural type mirroring @trigger.dev/sdk/ai's CompactionChunkData.
@@ -323,6 +323,8 @@ type ChatProps = {
323323
projectDashboardPath?: string;
324324
onFirstMessage?: (chatId: string, text: string) => void;
325325
onMessagesChange?: (chatId: string, messages: ChatUiMessage[]) => void;
326+
/** Whether the transport is configured to route first-turn through `chat.handover`. */
327+
handoverEnabled?: boolean;
326328
};
327329

328330
export function Chat({
@@ -338,6 +340,7 @@ export function Chat({
338340
projectDashboardPath,
339341
onFirstMessage,
340342
onMessagesChange,
343+
handoverEnabled = false,
341344
}: ChatProps) {
342345
const [input, setInput] = useState("");
343346
const hasCalledFirstMessage = useRef(false);
@@ -552,6 +555,8 @@ export function Chat({
552555
return transport.getSession(chatId)?.lastEventId ?? null;
553556
},
554557
chatId,
558+
/** True when the transport is configured to route first-turn through `chat.handover`. */
559+
handoverEnabled,
555560

556561
// ── Actions ───────────────────────────────────────────────────
557562
steer: (text: string) => actionsRef.current.steer(text),
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* Schema-only tool definitions — shared between the chat.handover
3+
* route handler and the trigger.dev agent task.
4+
*
5+
* ⚠️ HARD CONSTRAINT — bundle isolation
6+
*
7+
* This file is imported by `app/api/chat/route.ts` (the chat.handover
8+
* POST handler) and runs in the Next.js process. Anything imported
9+
* here lands in the route-handler bundle.
10+
*
11+
* Allowed imports: `ai` (for `tool()`), `zod`, type-only AI SDK
12+
* imports. Nothing else.
13+
*
14+
* DO NOT import from this file:
15+
* - `@e2b/code-interpreter`, `puppeteer`, `playwright`, native bindings
16+
* - `node:child_process`, heavy filesystem ops
17+
* - `@trigger.dev/sdk` runtime (`task`, `schemaTask`,
18+
* `chat.stream.writer`, etc. — pulls in the whole task runtime)
19+
* - `turndown`, image processing libs, anything that pulls weight
20+
*
21+
* Heavy `execute` fns live in `src/trigger/chat-tools.ts` — that file
22+
* imports these schemas and adds executes on top. The agent task
23+
* picks up the executes when it runs; the route handler never sees
24+
* them and never imports their deps.
25+
*
26+
* If you need to add a new tool to the chat.agent's schema-only set,
27+
* declare its description + inputSchema here, then wire its execute
28+
* fn in `src/trigger/chat-tools.ts`.
29+
*/
30+
import { tool } from "ai";
31+
import type { InferUITools, UIDataTypes, UIMessage } from "ai";
32+
import { z } from "zod";
33+
34+
export const inspectEnvironment = tool({
35+
description:
36+
"Inspect the current execution environment. Returns runtime info (Node.js/Bun/Deno version), " +
37+
"OS details, CPU architecture, memory usage, environment variables, and platform metadata.",
38+
inputSchema: z.object({}),
39+
// execute → src/trigger/chat-tools.ts
40+
});
41+
42+
export const webFetch = tool({
43+
description:
44+
"Fetch a URL and return the response as text. " +
45+
"Use this to retrieve web pages, APIs, or any HTTP resource.",
46+
inputSchema: z.object({
47+
url: z.string().url().describe("The URL to fetch"),
48+
}),
49+
// execute → src/trigger/chat-tools.ts (uses turndown)
50+
});
51+
52+
export const deepResearch = tool({
53+
description:
54+
"Research a topic by fetching multiple URLs and synthesizing the results. " +
55+
"Streams progress updates to the chat as it works.",
56+
inputSchema: z.object({
57+
query: z.string().describe("The research query or topic"),
58+
urls: z.array(z.string().url()).describe("URLs to fetch and analyze"),
59+
}),
60+
// execute → src/trigger/chat-tools.ts (subtask via ai.toolExecute)
61+
});
62+
63+
export const posthogQuery = tool({
64+
description:
65+
"Query PostHog analytics using HogQL. Use this to answer questions about events, " +
66+
"pageviews, user activity, feature flag usage, or any product analytics question. " +
67+
"Write a HogQL query (SQL-like syntax over PostHog events).",
68+
inputSchema: z.object({
69+
query: z
70+
.string()
71+
.describe(
72+
"HogQL query, e.g. SELECT event, count() FROM events WHERE timestamp > now() - interval 1 day GROUP BY event ORDER BY count() DESC LIMIT 10"
73+
),
74+
}),
75+
// execute → src/trigger/chat-tools.ts (HTTP to PostHog)
76+
});
77+
78+
export const executeCode = tool({
79+
description:
80+
"Run code in an isolated E2B sandbox (Python by default; other languages supported by E2B). " +
81+
"Use for calculations, data analysis, or transforming tool outputs (e.g. PostHog query results). " +
82+
"The sandbox persists across turns in the same run until the chat idles and suspends.",
83+
inputSchema: z.object({
84+
code: z.string().describe("Source code to execute in the sandbox"),
85+
language: z
86+
.string()
87+
.optional()
88+
.describe("Language id (e.g. python, javascript). Defaults to python."),
89+
}),
90+
// execute → src/trigger/chat-tools.ts (E2B sandbox — heavy native dep)
91+
});
92+
93+
export const sendEmail = tool({
94+
description:
95+
"Send an email to a recipient. Requires human approval before sending. " +
96+
"Use when the user asks you to send, draft, or compose an email.",
97+
inputSchema: z.object({
98+
to: z.string().describe("Recipient email address"),
99+
subject: z.string().describe("Email subject line"),
100+
body: z.string().describe("Email body text"),
101+
}),
102+
needsApproval: true,
103+
// execute → src/trigger/chat-tools.ts
104+
});
105+
106+
export const askUser = tool({
107+
description:
108+
"Ask the user a question when you need clarification or input before proceeding. " +
109+
"Present 2-4 options for the user to choose from. Use when uncertain about the user's intent.",
110+
inputSchema: z.object({
111+
question: z.string().describe("The question to ask the user"),
112+
options: z
113+
.array(
114+
z.object({
115+
id: z.string().describe("Unique option identifier"),
116+
label: z.string().describe("Short option title"),
117+
description: z.string().optional().describe("Longer explanation"),
118+
})
119+
)
120+
.min(2)
121+
.max(4),
122+
}),
123+
// No execute by design — round-tripped through the frontend's addToolOutput.
124+
});
125+
126+
/**
127+
* The schema-only tool set passed to `chat.headStart`'s `streamText`
128+
* call. The agent task imports each schema individually and adds the
129+
* matching `execute` fn — see `src/trigger/chat-tools.ts`.
130+
*/
131+
export const headStartTools = {
132+
inspectEnvironment,
133+
webFetch,
134+
deepResearch,
135+
posthogQuery,
136+
executeCode,
137+
sendEmail,
138+
askUser,
139+
};
140+
141+
type ChatToolSet = typeof headStartTools;
142+
export type ChatUiTools = InferUITools<ChatToolSet>;
143+
export type ChatUiMessage = UIMessage<unknown, UIDataTypes, ChatUiTools>;

0 commit comments

Comments
 (0)