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.
This commit is contained in:
Eric Allam
2026-05-03 15:33:12 +01:00
parent f063a2ffe0
commit 64ede82847
11 changed files with 557 additions and 8 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ import type {
aiChatSession,
upgradeTestAgent,
} from "@/trigger/chat";
import type { ChatUiMessage } from "@/lib/chat-tools";
import type { ChatUiMessage } from "@/lib/chat-tools-schemas";
import { prisma } from "@/lib/prisma";
/** Short-lived PATs for local testing of expiry + renewal (not for production). */
@@ -0,0 +1,54 @@
/**
* chat.headStart first-turn endpoint.
*
* The browser transport POSTs first-turn messages here when the
* `headStart` option is set on `useTriggerChatTransport`. This
* handler:
*
* 1. Creates the chat.agent session and triggers a `handover-prepare`
* run (atomic, one round-trip), so the agent boots in parallel.
* 2. Runs `streamText` step 1 right here in the warm Next.js process
* and returns the SSE stream directly to the browser — no waiting
* on the agent's cold start.
* 3. On step 1's tool-call boundary, hands ownership of the durable
* session.out stream over to the agent run, which executes tools
* and continues from step 2+ (or exits clean for pure-text turns).
*
* Subsequent turns bypass this endpoint — the transport hydrates the
* session PAT from response headers and writes directly to
* `session.in` for turn 2 onward.
*
* The TTFC win: cold-start agent boot (~488ms) + onTurnStart hooks
* (~316ms) overlap with the LLM TTFB instead of stacking before it,
* so the user-perceived first chunk arrives ~50% sooner. The agent
* still owns tool execution and everything after — heavy deps stay
* where they belong.
*/
import { chat } from "@trigger.dev/sdk/chat-server";
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
// ⚠️ Imports MUST come from `chat-tools-schemas` only — see the
// header comment in that file for the bundle-isolation rationale.
// Importing `src/trigger/chat-tools.ts` here would drag E2B,
// turndown, the trigger SDK runtime, etc. into the Next.js bundle
// and defeat the whole point of `chat.headStart`.
import { headStartTools } from "@/lib/chat-tools-schemas";
export const POST = chat.headStart({
agentId: "ai-chat",
run: async ({ chat: chatHelper }) => {
return streamText({
// `toStreamTextOptions` wires `messages` (converted from
// UIMessages), `tools`, `stopWhen: stepCountIs(1)`, and the
// combined `abortSignal`. Customer adds model + system prompt on
// top — anything else `streamText` accepts is fair game.
...chatHelper.toStreamTextOptions({ tools: headStartTools }),
// Match the agent's default (`DEFAULT_MODEL` in `lib/models.ts`)
// so step 1 and step 2+ run on the same provider — no jarring
// tone/style shift mid-turn, and TTFC comparisons stay honest.
model: anthropic("claude-sonnet-4-6"),
system:
"You are a helpful AI assistant. Be concise and friendly. Use the available tools when relevant.",
});
},
});
@@ -2,7 +2,7 @@
import { generateId } from "ai";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import type { ChatUiMessage } from "@/lib/chat-tools";
import type { ChatUiMessage } from "@/lib/chat-tools-schemas";
import { useCallback, useEffect, useState } from "react";
import { Chat } from "@/components/chat";
import { ChatSidebar } from "@/components/chat-sidebar";
@@ -169,6 +169,8 @@ export function ChatApp({
onIdleTimeoutChange={setIdleTimeoutInSeconds}
taskMode={taskMode}
onTaskModeChange={onTaskModeChange}
useHandover={false}
onUseHandoverChange={() => {}}
/>
<div className="flex-1">
{activeChatId ? (
@@ -7,6 +7,13 @@ type ChatSettings = {
setTaskMode: (mode: string) => void;
idleTimeoutInSeconds: number;
setIdleTimeoutInSeconds: (seconds: number) => void;
/**
* When true, first-turn messages are POSTed to `/api/chat`
* (`chat.handover` route handler) instead of triggering the agent
* directly. Subsequent turns bypass the endpoint regardless.
*/
useHandover: boolean;
setUseHandover: (on: boolean) => void;
};
const ChatSettingsContext = createContext<ChatSettings | null>(null);
@@ -14,12 +21,15 @@ const ChatSettingsContext = createContext<ChatSettings | null>(null);
export function ChatSettingsProvider({ children }: { children: ReactNode }) {
const [taskMode, setTaskMode] = useState("ai-chat");
const [idleTimeoutInSeconds, setIdleTimeoutInSeconds] = useState(60);
const [useHandover, setUseHandover] = useState(false);
const value: ChatSettings = {
taskMode,
setTaskMode,
idleTimeoutInSeconds,
setIdleTimeoutInSeconds,
useHandover,
setUseHandover,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -28,6 +28,8 @@ export function ChatSidebarWrapper({
setTaskMode,
idleTimeoutInSeconds,
setIdleTimeoutInSeconds,
useHandover,
setUseHandover,
} = useChatSettings();
// Extract active chatId from URL
@@ -85,6 +87,8 @@ export function ChatSidebarWrapper({
onIdleTimeoutChange={setIdleTimeoutInSeconds}
taskMode={taskMode}
onTaskModeChange={setTaskMode}
useHandover={useHandover}
onUseHandoverChange={setUseHandover}
/>
);
}
@@ -29,6 +29,8 @@ type ChatSidebarProps = {
onIdleTimeoutChange: (seconds: number) => void;
taskMode: string;
onTaskModeChange: (mode: string) => void;
useHandover: boolean;
onUseHandoverChange: (on: boolean) => void;
};
export function ChatSidebar({
@@ -42,6 +44,8 @@ export function ChatSidebar({
onIdleTimeoutChange,
taskMode,
onTaskModeChange,
useHandover,
onUseHandoverChange,
}: ChatSidebarProps) {
const sorted = [...chats].sort((a, b) => b.updatedAt - a.updatedAt);
@@ -115,6 +119,18 @@ export function ChatSidebar({
<option value="upgrade-test">upgrade-test (requestUpgrade after 3 turns)</option>
</select>
</div>
<label
className="flex items-center gap-2 text-xs text-gray-500"
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."
>
<input
type="checkbox"
checked={useHandover}
onChange={(e) => onUseHandoverChange(e.target.checked)}
className="h-3 w-3 rounded border-gray-300"
/>
<span>Use handover (1st turn)</span>
</label>
<button
type="button"
onClick={onWipeAll}
@@ -1,7 +1,7 @@
"use client";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import type { ChatUiMessage } from "@/lib/chat-tools";
import type { ChatUiMessage } from "@/lib/chat-tools-schemas";
import { Chat } from "@/components/chat";
import { useChatSettings } from "@/components/chat-settings-context";
import {
@@ -34,7 +34,7 @@ export function ChatView({
model,
}: ChatViewProps) {
const router = useRouter();
const { taskMode } = useChatSettings();
const { taskMode, useHandover } = useChatSettings();
const [currentSession, setCurrentSession] = useState<SessionInfo | null>(initialSession);
@@ -67,6 +67,15 @@ export function ChatView({
onSessionChange: handleSessionChange,
clientData: { userId: "user_123" },
multiTab: true,
// Head-start URL: opt-in fast-path for the first message of a
// brand-new chat. The transport POSTs to `/api/chat` (which
// exports `chat.handover({ agentId, run })`) so step 1's LLM
// call runs in the warm Next.js process while the trigger agent
// run boots in parallel. After turn 1 the transport hydrates
// session state from response headers and writes directly to
// `session.in` for turn 2 onward — same direct-trigger path as
// when `headStart` is unset.
headStart: useHandover ? "/api/chat" : undefined,
});
const handleFirstMessage = useCallback(
@@ -101,6 +110,7 @@ export function ChatView({
projectDashboardPath={process.env.NEXT_PUBLIC_TRIGGER_PROJECT_DASHBOARD_PATH}
onFirstMessage={handleFirstMessage}
onMessagesChange={handleMessagesChange}
handoverEnabled={useHandover}
/>
);
}
+6 -1
View File
@@ -5,7 +5,7 @@ import {
lastAssistantMessageIsCompleteWithApprovalResponses,
lastAssistantMessageIsCompleteWithToolCalls,
} from "ai";
import type { ChatUiMessage } from "@/lib/chat-tools";
import type { ChatUiMessage } from "@/lib/chat-tools-schemas";
import type { TriggerChatTransport } from "@trigger.dev/sdk/chat";
// Structural type mirroring @trigger.dev/sdk/ai's CompactionChunkData.
@@ -323,6 +323,8 @@ type ChatProps = {
projectDashboardPath?: string;
onFirstMessage?: (chatId: string, text: string) => void;
onMessagesChange?: (chatId: string, messages: ChatUiMessage[]) => void;
/** Whether the transport is configured to route first-turn through `chat.handover`. */
handoverEnabled?: boolean;
};
export function Chat({
@@ -338,6 +340,7 @@ export function Chat({
projectDashboardPath,
onFirstMessage,
onMessagesChange,
handoverEnabled = false,
}: ChatProps) {
const [input, setInput] = useState("");
const hasCalledFirstMessage = useRef(false);
@@ -552,6 +555,8 @@ export function Chat({
return transport.getSession(chatId)?.lastEventId ?? null;
},
chatId,
/** True when the transport is configured to route first-turn through `chat.handover`. */
handoverEnabled,
// ── Actions ───────────────────────────────────────────────────
steer: (text: string) => actionsRef.current.steer(text),
@@ -0,0 +1,143 @@
/**
* Schema-only tool definitions — shared between the chat.handover
* route handler and the trigger.dev agent task.
*
* ⚠️ HARD CONSTRAINT — bundle isolation
*
* This file is imported by `app/api/chat/route.ts` (the chat.handover
* POST handler) and runs in the Next.js process. Anything imported
* here lands in the route-handler bundle.
*
* Allowed imports: `ai` (for `tool()`), `zod`, type-only AI SDK
* imports. Nothing else.
*
* DO NOT import from this file:
* - `@e2b/code-interpreter`, `puppeteer`, `playwright`, native bindings
* - `node:child_process`, heavy filesystem ops
* - `@trigger.dev/sdk` runtime (`task`, `schemaTask`,
* `chat.stream.writer`, etc. — pulls in the whole task runtime)
* - `turndown`, image processing libs, anything that pulls weight
*
* Heavy `execute` fns live in `src/trigger/chat-tools.ts` — that file
* imports these schemas and adds executes on top. The agent task
* picks up the executes when it runs; the route handler never sees
* them and never imports their deps.
*
* If you need to add a new tool to the chat.agent's schema-only set,
* declare its description + inputSchema here, then wire its execute
* fn in `src/trigger/chat-tools.ts`.
*/
import { tool } from "ai";
import type { InferUITools, UIDataTypes, UIMessage } from "ai";
import { z } from "zod";
export const inspectEnvironment = tool({
description:
"Inspect the current execution environment. Returns runtime info (Node.js/Bun/Deno version), " +
"OS details, CPU architecture, memory usage, environment variables, and platform metadata.",
inputSchema: z.object({}),
// execute → src/trigger/chat-tools.ts
});
export const webFetch = tool({
description:
"Fetch a URL and return the response as text. " +
"Use this to retrieve web pages, APIs, or any HTTP resource.",
inputSchema: z.object({
url: z.string().url().describe("The URL to fetch"),
}),
// execute → src/trigger/chat-tools.ts (uses turndown)
});
export const deepResearch = tool({
description:
"Research a topic by fetching multiple URLs and synthesizing the results. " +
"Streams progress updates to the chat as it works.",
inputSchema: z.object({
query: z.string().describe("The research query or topic"),
urls: z.array(z.string().url()).describe("URLs to fetch and analyze"),
}),
// execute → src/trigger/chat-tools.ts (subtask via ai.toolExecute)
});
export const posthogQuery = tool({
description:
"Query PostHog analytics using HogQL. Use this to answer questions about events, " +
"pageviews, user activity, feature flag usage, or any product analytics question. " +
"Write a HogQL query (SQL-like syntax over PostHog events).",
inputSchema: z.object({
query: z
.string()
.describe(
"HogQL query, e.g. SELECT event, count() FROM events WHERE timestamp > now() - interval 1 day GROUP BY event ORDER BY count() DESC LIMIT 10"
),
}),
// execute → src/trigger/chat-tools.ts (HTTP to PostHog)
});
export const executeCode = tool({
description:
"Run code in an isolated E2B sandbox (Python by default; other languages supported by E2B). " +
"Use for calculations, data analysis, or transforming tool outputs (e.g. PostHog query results). " +
"The sandbox persists across turns in the same run until the chat idles and suspends.",
inputSchema: z.object({
code: z.string().describe("Source code to execute in the sandbox"),
language: z
.string()
.optional()
.describe("Language id (e.g. python, javascript). Defaults to python."),
}),
// execute → src/trigger/chat-tools.ts (E2B sandbox — heavy native dep)
});
export const sendEmail = tool({
description:
"Send an email to a recipient. Requires human approval before sending. " +
"Use when the user asks you to send, draft, or compose an email.",
inputSchema: z.object({
to: z.string().describe("Recipient email address"),
subject: z.string().describe("Email subject line"),
body: z.string().describe("Email body text"),
}),
needsApproval: true,
// execute → src/trigger/chat-tools.ts
});
export const askUser = tool({
description:
"Ask the user a question when you need clarification or input before proceeding. " +
"Present 2-4 options for the user to choose from. Use when uncertain about the user's intent.",
inputSchema: z.object({
question: z.string().describe("The question to ask the user"),
options: z
.array(
z.object({
id: z.string().describe("Unique option identifier"),
label: z.string().describe("Short option title"),
description: z.string().optional().describe("Longer explanation"),
})
)
.min(2)
.max(4),
}),
// No execute by design — round-tripped through the frontend's addToolOutput.
});
/**
* The schema-only tool set passed to `chat.headStart`'s `streamText`
* call. The agent task imports each schema individually and adds the
* matching `execute` fn — see `src/trigger/chat-tools.ts`.
*/
export const headStartTools = {
inspectEnvironment,
webFetch,
deepResearch,
posthogQuery,
executeCode,
sendEmail,
askUser,
};
type ChatToolSet = typeof headStartTools;
export type ChatUiTools = InferUITools<ChatToolSet>;
export type ChatUiMessage = UIMessage<unknown, UIDataTypes, ChatUiTools>;
@@ -0,0 +1,297 @@
/**
* Tool executes for the trigger.dev agent task.
*
* These tools wrap the schema-only definitions from
* `@/lib/chat-tools-schemas` with their heavy `execute` fns. This
* file is ONLY imported from inside the trigger task module
* (`src/trigger/chat.ts`); it must NOT be imported from anything that
* runs in the Next.js process (route handlers, components, server
* actions, etc.).
*
* See `src/lib/chat-tools-schemas.ts` for why this split matters —
* the bundle-isolation constraint is what makes `chat.handover`'s
* cold-start win possible.
*/
import { ai, chat } from "@trigger.dev/sdk/ai";
import { schemaTask } from "@trigger.dev/sdk";
import { tool, generateId } from "ai";
import { z } from "zod";
import os from "node:os";
import TurndownService from "turndown";
import { codeSandboxRun, runWithCodeSandbox } from "@/lib/code-sandbox";
import {
inspectEnvironment as inspectEnvironmentSchema,
webFetch as webFetchSchema,
deepResearch as deepResearchSchema,
posthogQuery as posthogQuerySchema,
executeCode as executeCodeSchema,
sendEmail as sendEmailSchema,
askUser as askUserSchema,
} from "@/lib/chat-tools-schemas";
const turndown = new TurndownService();
declare const Bun: unknown;
declare const Deno: unknown;
export const inspectEnvironment = tool({
...inspectEnvironmentSchema,
execute: async () => {
const memUsage = process.memoryUsage();
return {
runtime: {
name: typeof Bun !== "undefined" ? "bun" : typeof Deno !== "undefined" ? "deno" : "node",
version: process.version,
versions: {
v8: process.versions.v8,
openssl: process.versions.openssl,
modules: process.versions.modules,
},
},
os: {
platform: process.platform,
arch: process.arch,
release: os.release(),
type: os.type(),
hostname: os.hostname(),
uptime: `${Math.floor(os.uptime())}s`,
},
cpus: {
count: os.cpus().length,
model: os.cpus()[0]?.model,
},
memory: {
total: `${Math.round(os.totalmem() / 1024 / 1024)}MB`,
free: `${Math.round(os.freemem() / 1024 / 1024)}MB`,
process: {
rss: `${Math.round(memUsage.rss / 1024 / 1024)}MB`,
heapUsed: `${Math.round(memUsage.heapUsed / 1024 / 1024)}MB`,
heapTotal: `${Math.round(memUsage.heapTotal / 1024 / 1024)}MB`,
},
},
env: {
NODE_ENV: process.env.NODE_ENV,
TZ: process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
LANG: process.env.LANG,
},
process: {
pid: process.pid,
cwd: process.cwd(),
execPath: process.execPath,
argv: process.argv.slice(0, 3),
},
};
},
});
export const webFetch = tool({
...webFetchSchema,
execute: async ({ url }) => {
const latency = Number(process.env.WEBFETCH_LATENCY_MS);
if (latency > 0) {
await new Promise((r) => setTimeout(r, latency));
}
const response = await fetch(url);
let text = await response.text();
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("html")) {
text = turndown.turndown(text);
}
return {
status: response.status,
contentType,
body: text.slice(0, 2000),
truncated: text.length > 2000,
};
},
});
const deepResearchTask = schemaTask({
id: "deep-research",
description:
"Research a topic by fetching multiple URLs and synthesizing the results. " +
"Streams progress updates to the chat as it works.",
schema: z.object({
query: z.string().describe("The research query or topic"),
urls: z.array(z.string().url()).describe("URLs to fetch and analyze"),
}),
run: async ({ query, urls }) => {
const partId = generateId();
const results: { url: string; status: number; snippet: string }[] = [];
for (let i = 0; i < urls.length; i++) {
const url = urls[i]!;
const { waitUntilComplete } = chat.stream.writer({
target: "root",
execute: ({ write }) => {
write({
type: "data-research-progress",
id: partId,
data: {
status: "fetching" as const,
query,
current: i + 1,
total: urls.length,
currentUrl: url,
completedUrls: results.map((r) => r.url),
},
});
},
});
await waitUntilComplete();
try {
const response = await fetch(url);
let text = await response.text();
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("html")) {
text = turndown.turndown(text);
}
results.push({
url,
status: response.status,
snippet: text.slice(0, 500),
});
} catch (err) {
results.push({
url,
status: 0,
snippet: `Error: ${err instanceof Error ? err.message : String(err)}`,
});
}
}
const { waitUntilComplete: waitForDone } = chat.stream.writer({
target: "root",
execute: ({ write }) => {
write({
type: "data-research-progress",
id: partId,
data: {
status: "done" as const,
query,
current: urls.length,
total: urls.length,
completedUrls: results.map((r) => r.url),
},
});
},
});
await waitForDone();
return { query, results };
},
});
/** Task-backed tool: AI SDK `tool()` for shape/types; `ai.toolExecute` for Trigger subtask + metadata. */
export const deepResearch = tool({
...deepResearchSchema,
execute: ai.toolExecute(deepResearchTask),
});
const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY;
const POSTHOG_PROJECT_ID = process.env.POSTHOG_PROJECT_ID;
const POSTHOG_HOST = process.env.POSTHOG_HOST ?? "https://eu.posthog.com";
export const posthogQuery = tool({
...posthogQuerySchema,
execute: async ({ query }) => {
if (!POSTHOG_API_KEY || !POSTHOG_PROJECT_ID) {
return { error: "PostHog not configured. Set POSTHOG_API_KEY and POSTHOG_PROJECT_ID." };
}
const response = await fetch(`${POSTHOG_HOST}/api/projects/${POSTHOG_PROJECT_ID}/query/`, {
method: "POST",
headers: {
Authorization: `Bearer ${POSTHOG_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query: { kind: "HogQLQuery", query } }),
});
if (!response.ok) {
const text = await response.text();
return { error: `PostHog API error ${response.status}: ${text.slice(0, 500)}` };
}
const data = await response.json();
return {
columns: data.columns,
results: data.results?.slice(0, 50),
rowCount: data.results?.length ?? 0,
};
},
});
export const executeCode = tool({
...executeCodeSchema,
execute: async function executeCodeExecute({ code, language }) {
const runId = codeSandboxRun.runId;
if (!runId?.trim()) {
return {
error:
"Code sandbox run id is not set yet (call from the chat task after onTurnStart), or this tool is not wired to that task.",
};
}
const out = await runWithCodeSandbox(runId, async function runInSandbox(sandbox) {
const execution = await sandbox.runCode(code, {
...(language?.trim() ? { language: language.trim() } : {}),
timeoutMs: 60_000,
});
if (execution.error) {
return {
error: `${execution.error.name}: ${execution.error.value}`,
traceback: execution.error.traceback,
stdout: execution.logs.stdout.join("\n"),
stderr: execution.logs.stderr.join("\n"),
};
}
const mainText = execution.text;
const resultSnippets = execution.results
.map(function mapResult(r) {
return r.text ?? r.markdown ?? r.json;
})
.filter(Boolean)
.slice(0, 5);
return {
text: mainText,
results: resultSnippets,
stdout: execution.logs.stdout.join("\n"),
stderr: execution.logs.stderr.join("\n"),
};
});
return out;
},
});
export const sendEmail = tool({
...sendEmailSchema,
execute: async ({ to, subject, body }) => {
// Simulated — in a real app this would call an email API
return { sent: true, to, subject, preview: body.slice(0, 100) };
},
});
// askUser has no execute by design — round-tripped via addToolOutput.
export const askUser = askUserSchema;
/** Tool set passed to `streamText` for the main `chat.agent` run. */
export const chatTools = {
inspectEnvironment,
webFetch,
deepResearch,
posthogQuery,
executeCode,
sendEmail,
askUser,
};
+11 -3
View File
@@ -21,8 +21,8 @@ import {
deepResearch,
inspectEnvironment,
webFetch,
type ChatUiMessage,
} from "@/lib/chat-tools";
} from "./chat-tools";
import type { ChatUiMessage } from "@/lib/chat-tools-schemas";
import { disposeCodeSandboxForRun, warmCodeSandbox } from "@/lib/code-sandbox";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
@@ -232,7 +232,15 @@ export const aiChat = chat
turn,
count: messages.length,
});
return validateUIMessages({ messages, tools: chatTools });
// Cast: `chatTools` has executes (output types are real), but
// `ChatUiMessage` is derived from the schema-only set in
// `chat-tools-schemas.ts` so its tools have `output: never`.
// `validateUIMessages` only reads `inputSchema` at runtime, so
// the type narrowing is safely sidestepped.
return validateUIMessages({
messages,
tools: chatTools as unknown as Parameters<typeof validateUIMessages>[0]["tools"],
});
},
// #endregion