feat(sdk,cli): bundle agent skills + docs in the SDK for zero-drift (#3937)

## Summary

`@trigger.dev/sdk` now ships the Trigger.dev agent skills and a curated
snapshot of the docs those skills cite. The skills that `trigger skills`
installs into your coding agent are thin pointers that read this bundled
content from `node_modules`, so the guidance always matches the SDK
version installed in your project. Previously the full skill text was
copied into your repo at install time and went stale until you
reinstalled after an upgrade.

## How it works

The SDK's `files[]` now includes `skills/` (the full skill text) and
`docs/` (a curated snapshot generated at build time). The docs manifest
is derived from each skill's own `sources:` frontmatter, so a skill only
ships the docs it references, and a skill that cites a missing doc fails
the build.

The CLI installs thin skills whose body points the agent at
`node_modules/@trigger.dev/sdk/skills/<name>/SKILL.md` and
`node_modules/@trigger.dev/sdk/docs/`. They keep the high-value "Common
mistakes" anti-patterns inline so the trigger and the guardrails survive
even if the agent does not follow the pointer. `getting-started` stays
self-contained in the CLI because it runs before the SDK is installed.
This commit is contained in:
Eric Allam
2026-06-14 11:00:50 +01:00
committed by GitHub
parent 1f1a3666ee
commit e092919c3f
12 changed files with 1339 additions and 979 deletions
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"trigger.dev": patch
---
`@trigger.dev/sdk` now bundles the Trigger.dev agent skills and a curated snapshot of the docs those skills reference. The skills that `trigger skills` installs into your coding agent read this content from node_modules, so the guidance your AI assistant follows is pinned to the SDK version installed in your project and stays current across upgrades instead of going stale until the next reinstall.
@@ -10,242 +10,16 @@ description: >
streamText route to chat.agent.
type: core
library: trigger.dev
library_version: "{{TRIGGER_SDK_VERSION}}"
sources:
- docs/ai-chat/overview.mdx
- docs/ai-chat/quick-start.mdx
- docs/ai-chat/how-it-works.mdx
- docs/ai-chat/backend.mdx
- docs/ai-chat/frontend.mdx
- docs/ai-chat/reference.mdx
- docs/ai-chat/types.mdx
- docs/ai-chat/tools.mdx
- docs/ai-chat/lifecycle-hooks.mdx
- docs/ai-chat/error-handling.mdx
---
# Authoring a chat agent
# Authoring a chat.agent
A `chat.agent` runs an entire conversation as one long-lived Trigger.dev task. It wakes when a
message arrives, freezes when none do, and in-memory state survives page refreshes, deploys, idle
gaps, and crashes. Your code is the loop you would write anyway: messages in, `streamText` out.
There are no API routes. The frontend talks to the agent through a `TriggerChatTransport`, so
history accumulates server-side and the client ships only the new message each turn.
The full, version-pinned reference ships **inside your installed `@trigger.dev/sdk`**. Read it before writing code — it always matches the SDK version in this project, so it never drifts:
Works with Vercel AI SDK v5, v6, or v7. On v7 also install `@ai-sdk/otel` so model calls are traced
(the SDK registers it for you).
- **Skill:** `node_modules/@trigger.dev/sdk/skills/authoring-chat-agent/SKILL.md` — the per-turn run loop, `chat.toStreamTextOptions()`, the two server actions, typed tools/data parts, and the React transport.
- **Docs:** the full, version-pinned docs ship bundled at `node_modules/@trigger.dev/sdk/docs/ai-chat/`; the skill above lists the exact pages it draws from in its `sources:` frontmatter. Grep for an API, e.g. `grep -rl "toStreamTextOptions" node_modules/@trigger.dev/sdk/docs/`.
## Setup
Three pieces: the agent task, two server actions, and the frontend transport.
### 1. Define the agent
```ts trigger/chat.ts
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
export const myChat = chat.agent({
id: "my-chat",
run: async ({ messages, signal }) =>
streamText({
// Spread this FIRST. See "Common mistakes".
...chat.toStreamTextOptions(),
model: anthropic("claude-sonnet-4-5"),
messages,
abortSignal: signal,
stopWhen: stepCountIs(15),
}),
});
```
`run` receives `messages` already converted to `ModelMessage[]` (the SDK converts the frontend's
`UIMessage[]` for you) plus a `signal` that aborts on stop or cancel. Returning the
`StreamTextResult` auto-pipes it to the frontend.
### 2. Add two server actions
Both run on your server, so the browser never holds your environment secret key. This is also
where per-user / per-plan authorization and any paired DB writes live.
```ts app/actions.ts
"use server";
import { auth } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";
// Creates the Session + first run, returns a session PAT. Idempotent on (env, chatId).
export const startChatSession = chat.createStartSessionAction("my-chat");
// Pure mint. The transport calls this on 401/403 to refresh an expired token.
export async function mintChatAccessToken(chatId: string) {
return auth.createPublicToken({
scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
expirationTime: "1h",
});
}
```
### 3. Wire the frontend
```tsx app/components/chat.tsx
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import type { myChat } from "@/trigger/chat";
import { mintChatAccessToken, startChatSession } from "@/app/actions";
export function Chat() {
const transport = useTriggerChatTransport<typeof myChat>({
task: "my-chat", // typeof myChat gives compile-time task-id validation
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }),
});
const { messages, sendMessage, stop, status } = useChat({ transport });
const [input, setInput] = useState("");
// render messages, a form that calls sendMessage({ text: input }),
// and a Stop button (onClick={stop}) while status === "streaming".
}
```
The transport is memoized (created once, reused across renders). Passing `typeof myChat` flows the
agent's message type through `useChat`.
## Core patterns
### 1. Return vs pipe
Return the `streamText` result from `run` for the simple case. When `streamText` is called deep
inside nested helpers, call `await chat.pipe(result)` from anywhere in the task instead, and let
`run` resolve `void`.
```ts
export const agentChat = chat.agent({
id: "agent-chat",
run: async ({ messages }) => {
await runAgentLoop(messages); // don't return; pipe inside
},
});
async function runAgentLoop(messages: ModelMessage[]) {
const result = streamText({
...chat.toStreamTextOptions(),
model: anthropic("claude-sonnet-4-5"),
messages,
});
await chat.pipe(result); // works from anywhere in the task
}
```
### 2. Typed tools (declare on config AND spread back)
Declare tools on `chat.agent({ tools })`, read them back typed from the `run()` payload, and pass
that set to `chat.toStreamTextOptions({ tools })`. One declaration flows everywhere.
```ts
import { tool, stepCountIs } from "ai";
import { z } from "zod";
const tools = {
searchDocs: tool({
description: "Search the docs.",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => searchIndex(query),
}),
};
export const myChat = chat.agent({
id: "my-chat",
tools, // so toModelOutput survives across turns
run: async ({ messages, tools, signal }) =>
streamText({
...chat.toStreamTextOptions({ tools }), // same set, handed back typed
model: anthropic("claude-sonnet-4-5"),
messages,
abortSignal: signal,
stopWhen: stepCountIs(15),
}),
});
```
`tools` also accepts a function `(event) => ToolSet` resolved per turn, where `event` carries
`chatId`, `turn`, `continuation`, and `clientData`.
### 3. Custom data parts (persisted vs transient)
`data-*` parts written via `chat.response.write()` in `run()` (or `writer.write()` in hooks)
persist into `responseMessage.parts` and surface in `onTurnComplete`. Add `transient: true` to
stream them without persisting. Writes via `chat.stream` are always ephemeral.
```ts
// In run() - persists, surfaces in onTurnComplete's responseMessage
chat.response.write({ type: "data-context", data: { searchResults } });
// In a hook via writer - streams but does NOT persist
writer.write({ type: "data-progress", id: "search", data: { percent: 50 }, transient: true });
```
### 4. Custom UIMessage type, client data, and builder hooks
For typed `data-*` parts or a tool map, build the agent through `chat.withUIMessage<T>()` and
`chat.withClientData({ schema })`. Builder methods chain in any order; builder hooks run before the
matching task hook. `streamOptions` becomes the default `uiMessageStreamOptions` (shallow-merged,
agent wins).
```ts
export const myChat = chat
.withUIMessage<MyChatUIMessage>({ streamOptions: { sendReasoning: true } })
.withClientData({ schema: z.object({ userId: z.string() }) })
.agent({
id: "my-chat",
tools: myTools,
onTurnStart: async ({ uiMessages, writer }) => {
writer.write({ type: "data-turn-status", data: { status: "preparing" } });
},
run: async ({ messages, tools, signal }) =>
streamText({ ...chat.toStreamTextOptions({ tools }), model, messages, abortSignal: signal }),
});
```
Build `MyChatUIMessage` as `UIMessage<unknown, MyDataTypes, InferUITools<typeof tools>>` (or, for
tools only, `InferChatUIMessageFromTools<typeof tools>` from `@trigger.dev/sdk/ai`). On the
frontend, narrow `useChat` with `InferChatUIMessage<typeof myChat>` from `@trigger.dev/sdk/chat/react`.
### 5. Lifecycle hooks and stop
`chat.agent` accepts hooks that fire in a fixed per-turn order:
```text
onValidateMessages -> hydrateMessages -> onChatStart (chat's first message only)
-> onTurnStart -> run() -> onBeforeTurnComplete -> onTurnComplete
```
`onBoot` fires once per worker process (every fresh boot, including continuation runs) and is where
`chat.local`, DB connections, and per-process state belong. `onChatStart` fires only on the chat's
first message. Suspend/resume use `onChatSuspend` / `onChatResume`. Config options include
`tools`, `clientDataSchema`, `maxTurns` (100), `turnTimeout` ("1h"), `idleTimeoutInSeconds` (30),
`uiMessageStreamOptions`, and `exitAfterPreloadIdle`. There is no generic `retry`; `chat.agent`
runs with `maxAttempts: 1` internally.
Stop is load-bearing: the `signal` passed to `run` aborts on stop or cancel. Forward it as
`abortSignal` to `streamText`, or the Stop button updates the UI while the model keeps generating
server-side.
```ts
run: async ({ messages, signal }) =>
streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal, stopWhen: stepCountIs(15) });
```
### 6. Migrating from a plain AI SDK `streamText` route
There is no API route in this model. The transport replaces the route round-trip, so:
- Delete the route handler. Move per-request auth into the two server actions from Setup step 2.
- Move the `streamText` call into `run`. It already receives pre-converted `ModelMessage[]`.
- Return the `StreamTextResult` (it auto-pipes) and add `...chat.toStreamTextOptions()` first.
- On the client, swap the `api` URL for `useTriggerChatTransport`; `useChat` stays the same shape.
If those paths don't exist, `@trigger.dev/sdk` isn't installed yet — install it first. In a non-hoisted layout, resolve the package with `node -p "require.resolve('@trigger.dev/sdk/package.json')"` and read `skills/` + `docs/` beside it.
## Common mistakes
@@ -283,13 +57,4 @@ There is no API route in this model. The transport replaces the route round-trip
## References
- `chat-agent-advanced` skill - lifecycle hooks in depth, sessions, raw-task primitives
(`chat.createSession`, `chat.customAgent`, `chat.stream`), compaction, HITL approvals, recovery.
- `realtime-and-frontend` skill - Realtime hooks and frontend streaming beyond the chat transport.
- `authoring-tasks` skill - base `task()` semantics, `ctx`, and standard lifecycle hooks.
- Docs: /ai-chat/quick-start, /ai-chat/backend, /ai-chat/tools, /ai-chat/types, /ai-chat/frontend
## Version
Generated for `@trigger.dev/sdk` `{{TRIGGER_SDK_VERSION}}`. Re-run the trigger.dev skills installer
after upgrading.
Sibling skills: **chat-agent-advanced** (Sessions primitive, custom transports, sub-agents, HITL, fast starts, resilience, testing, upgrades), **authoring-tasks** and **realtime-and-frontend** (the task + frontend foundations chat builds on).
+6 -204
View File
@@ -9,203 +9,18 @@ description: >
code that triggers tasks. Realtime/React hooks and AI chat are covered by separate skills.
type: core
library: trigger.dev
library_version: "{{TRIGGER_SDK_VERSION}}"
sources:
- docs/tasks/overview.mdx
- docs/tasks/schemaTask.mdx
- docs/tasks/scheduled.mdx
- docs/triggering.mdx
- docs/queue-concurrency.mdx
- docs/idempotency.mdx
- docs/runs/metadata.mdx
- docs/logging.mdx
- docs/errors-retrying.mdx
- docs/wait.mdx
- docs/wait-for.mdx
- docs/wait-until.mdx
- docs/wait-for-token.mdx
- docs/context.mdx
- docs/config/config-file.mdx
---
# Authoring Trigger.dev Tasks
Tasks are functions that can run for a long time with strong resilience to failure. Define them in files under your `/trigger` directory. Always import from `@trigger.dev/sdk`. Never import from `@trigger.dev/sdk/v3` (deprecated alias) or `@trigger.dev/core`.
The full, version-pinned reference for authoring tasks ships **inside your installed `@trigger.dev/sdk`**. Read it before writing code — it always matches the SDK version in this project, so it never drifts:
## Setup
- **Skill:** `node_modules/@trigger.dev/sdk/skills/authoring-tasks/SKILL.md` — the complete guide (setup, `schemaTask`, retries, triggering + the Result shape, idempotency, waits, metadata, scheduled tasks, queues/concurrency, `trigger.config.ts`).
- **Docs:** the full, version-pinned docs ship bundled at `node_modules/@trigger.dev/sdk/docs/`; the skill above lists the exact pages it draws from in its `sources:` frontmatter. Grep for an API, e.g. `grep -rl "schemaTask" node_modules/@trigger.dev/sdk/docs/`.
```ts
// /trigger/hello-world.ts
import { task } from "@trigger.dev/sdk";
If those paths don't exist, `@trigger.dev/sdk` isn't installed yet — install it first. In a non-hoisted layout, resolve the package with `node -p "require.resolve('@trigger.dev/sdk/package.json')"` and read `skills/` + `docs/` beside it.
export const helloWorld = task({
id: "hello-world", // unique within the project
run: async (payload: { message: string }, { ctx }) => {
console.log(payload.message, "attempt", ctx.attempt.number);
return { ok: true }; // must be JSON serializable
},
});
```
The `run` function receives the payload and a second argument with `ctx` (run context), an abort `signal`, and a deprecated `init` output. The return value is the task output and must be JSON serializable.
## Core patterns
### 1. Validate the payload with `schemaTask`
`schema` accepts a Zod / Yup / Superstruct / ArkType / valibot / typebox parser or a custom `(data: unknown) => T` function. A validation failure throws `TaskPayloadParsedError` and skips retrying.
```ts
import { schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";
export const createUser = schemaTask({
id: "create-user",
schema: z.object({ name: z.string(), age: z.number() }),
run: async (payload) => ({ greeting: `Hi ${payload.name}` }),
});
```
### 2. Configure retries and abort early
The default `maxAttempts` is 3. Throw `AbortTaskRunError` to stop retrying immediately. Task-level `retry` overrides the config-file defaults.
```ts
import { task, AbortTaskRunError } from "@trigger.dev/sdk";
export const charge = task({
id: "charge",
retry: { maxAttempts: 5, factor: 1.8, minTimeoutInMs: 500, maxTimeoutInMs: 30_000, randomize: true },
run: async (payload: { amount: number }) => {
if (payload.amount <= 0) throw new AbortTaskRunError("Invalid amount"); // no retry
// work that may throw and retry
},
});
```
For finer control, `catchError: async ({ payload, error, ctx, retryAt }) => {...}` can return `{ skipRetrying: true }`, `{ retryAt: Date }`, or `undefined` (use normal logic). `retry.onThrow`, `retry.fetch`, also exist for in-task retrying.
### 3. Trigger another task and handle the Result
From inside a task use `yourTask.triggerAndWait(payload)`. The result is a Result object that you must check (`ok`), or `.unwrap()` to throw on failure.
```ts
export const parentTask = task({
id: "parent-task",
run: async () => {
const result = await childTask.triggerAndWait({ data: "x" });
if (result.ok) return result.output; // typed child output
console.error("child failed", result.error);
// or: const output = await childTask.triggerAndWait({ data: "x" }).unwrap();
},
});
```
`SubtaskUnwrapError` carries `runId`, `taskId`, and `cause`. For fan-out use `childTask.batchTriggerAndWait([{ payload: a }, { payload: b }])`; the result has a `.runs` array, each entry `{ ok, id, output?, error?, taskIdentifier }`.
### 4. Trigger from backend code with a type-only import
Outside a task, import the task type only and trigger by id. Do not import the task instance into backend bundles.
```ts
import { tasks } from "@trigger.dev/sdk";
import type { emailSequence } from "~/trigger/emails";
const handle = await tasks.trigger<typeof emailSequence>(
"email-sequence",
{ to: "a@b.com", name: "Ada" },
{ delay: "1h" }
);
```
`tasks.batchTrigger` and `batch.trigger([{ id, payload }])` cover batches. Trigger options include `delay`, `ttl`, `idempotencyKey`, `idempotencyKeyTTL`, `debounce`, `queue`, `concurrencyKey`, `maxAttempts`, `tags`, `metadata`, `priority`, `region`, and `machine`. Inspect runs with `runs.retrieve`, `runs.cancel`, and `runs.reschedule`.
### 5. Idempotency keys
`idempotencyKeys.create(key, { scope })` returns a 64-char hashed key. A raw string key defaults to `"run"` scope (v4.3.1+); for once-ever behavior use `scope: "global"`.
```ts
import { idempotencyKeys, task } from "@trigger.dev/sdk";
export const processOrder = task({
id: "process-order",
run: async (payload: { orderId: string; email: string }) => {
const key = await idempotencyKeys.create(`confirm-${payload.orderId}`);
await sendEmail.trigger({ to: payload.email }, { idempotencyKey: key });
},
});
```
### 6. Waits and run metadata
`wait.for({ seconds })` and `wait.until({ date })` durably pause the run. `metadata.*` is readable and writable only inside `run()`; updates are synchronous and chainable (`set`, `del`, `replace`, `append`, `remove`, `increment`, `decrement`).
```ts
import { task, metadata, wait } from "@trigger.dev/sdk";
export const importer = task({
id: "importer",
run: async (payload: { rows: unknown[] }) => {
metadata.set("status", "processing").set("total", payload.rows.length);
await wait.for({ seconds: 5 });
metadata.set("status", "complete");
},
});
```
For human-in-the-loop, `wait.createToken({ timeout, tags })` returns `{ id, url, publicAccessToken, ... }`; resume with `wait.forToken<T>(token: string | { id: string })` which returns `{ ok, output?, error? }` (or `.unwrap()`), and complete it elsewhere with `wait.completeToken(tokenId, output)`. Metadata max is 256KB and is not propagated to child tasks; push values to a parent with `metadata.parent.*` / `metadata.root.*`. (`metadata.stream` is deprecated since 4.1.0 in favor of `streams.pipe()`.)
### 7. Scheduled (cron) tasks
```ts
import { schedules } from "@trigger.dev/sdk";
export const dailyReport = schedules.task({
id: "daily-report",
cron: { pattern: "0 5 * * *", timezone: "Asia/Tokyo" },
run: async (payload) => {
console.log("scheduled at", payload.timestamp, "next", payload.upcoming);
},
});
```
The payload includes `timestamp`, `lastTimestamp`, `timezone`, `scheduleId`, `externalId`, and `upcoming`. Attach schedules dynamically with `schedules.create({ task, cron, timezone?, externalId?, deduplicationKey })` (the dedup key is required and per-project), plus `retrieve / list / update / activate / deactivate / del / timezones`.
### 8. Queues and concurrency
Set `queue: { concurrencyLimit }` on a task, or share a queue across tasks:
```ts
import { queue, task } from "@trigger.dev/sdk";
export const emails = queue({ name: "emails", concurrencyLimit: 5 });
export const sendEmail = task({ id: "send-email", queue: emails, run: async () => {} });
```
At trigger time override with `{ queue: "queue-name" }` and add `concurrencyKey` for per-tenant queues. Manage queues with `queues.list / retrieve / pause / resume / overrideConcurrencyLimit / resetConcurrencyLimit`.
### 9. `trigger.config.ts` essentials
```ts
import { defineConfig } from "@trigger.dev/sdk";
export default defineConfig({
project: "<project ref>",
dirs: ["./trigger"],
machine: "small-1x",
retries: {
enabledInDev: false,
default: { maxAttempts: 3, factor: 2, minTimeoutInMs: 1000, maxTimeoutInMs: 10000, randomize: true },
},
});
```
`build.external` controls which packages stay out of the bundle. Build extensions (`additionalFiles`, `prismaExtension`, `puppeteer`, `ffmpeg`, `aptGet`, etc.) come from `@trigger.dev/build`. `telemetry` configures instrumentations and exporters.
### Logging
`logger.debug / log / info / warn / error(message, dataRecord?)` write structured logs; `logger.trace(name, async (span) => {...})` adds a span. Module-level metrics use `otel.metrics.getMeter(name)`.
Always import from `@trigger.dev/sdk` — never `@trigger.dev/sdk/v3` (deprecated alias) or `@trigger.dev/core`.
## Common mistakes
@@ -239,17 +54,4 @@ export default defineConfig({
## References
Sibling skills:
- **realtime-and-frontend** for subscribing to runs and triggering from the frontend with React hooks.
- **authoring-chat-agent** and **chat-agent-advanced** for building AI chat agents.
Docs:
- [Tasks overview](https://trigger.dev/docs/tasks/overview)
- [Triggering](https://trigger.dev/docs/triggering)
- [Configuration file](https://trigger.dev/docs/config/config-file)
## Version
Generated for @trigger.dev/sdk {{TRIGGER_SDK_VERSION}}. Re-run the trigger.dev skills installer after upgrading.
Sibling skills: **realtime-and-frontend** (subscribe to runs, trigger from the frontend), **authoring-chat-agent** and **chat-agent-advanced** (AI chat agents).
@@ -11,304 +11,16 @@ description: >
use the authoring-chat-agent skill instead.
type: core
library: trigger.dev
library_version: "{{TRIGGER_SDK_VERSION}}"
sources:
- docs/ai-chat/sessions.mdx
- docs/ai-chat/server-chat.mdx
- docs/ai-chat/client-protocol.mdx
- docs/ai-chat/pending-messages.mdx
- docs/ai-chat/actions.mdx
- docs/ai-chat/background-injection.mdx
- docs/ai-chat/compaction.mdx
- docs/ai-chat/fast-starts.mdx
- docs/ai-chat/chat-local.mdx
- docs/ai-chat/mcp.mdx
- docs/ai-chat/testing.mdx
- docs/ai-chat/upgrade-guide.mdx
- docs/ai-chat/patterns/sub-agents.mdx
- docs/ai-chat/patterns/human-in-the-loop.mdx
- docs/ai-chat/patterns/persistence-and-replay.mdx
- docs/ai-chat/patterns/recovery-boot.mdx
- docs/ai-chat/patterns/oom-resilience.mdx
- docs/ai-chat/patterns/large-payloads.mdx
- docs/ai-chat/patterns/version-upgrades.mdx
- docs/ai-chat/tools.mdx
---
# chat.agent: advanced and operational
# chat.agent advanced & operational
`chat.agent` is built on **Sessions**: a durable, task-bound, bi-directional I/O channel pair keyed
on a stable `externalId` (e.g. `chatId`) that outlives any single run. This skill covers the layers
beneath and around the everyday agent: the raw `sessions` API, server-side `AgentChat`, durable
sub-agents, actions / background injection, fast starts, compaction and recovery, and the wire
protocol for custom transports.
The full, version-pinned reference ships **inside your installed `@trigger.dev/sdk`**. Read it before writing code — it always matches the SDK version in this project, so it never drifts:
Two `chat` namespaces are easy to confuse: the agent definition imports `chat` from
`@trigger.dev/sdk/ai`; Head Start / Node-listener server entries import `chat` from
`@trigger.dev/sdk/chat-server`.
- **Skill:** `node_modules/@trigger.dev/sdk/skills/chat-agent-advanced/SKILL.md` — Sessions primitive, custom transports/wire protocol, sub-agents, HITL, steering, actions, background injection, fast starts, resilience (compaction/recovery/OOM/large payloads), `chat.local`, testing, upgrades.
- **Docs:** the full, version-pinned docs ship bundled at `node_modules/@trigger.dev/sdk/docs/ai-chat/` (including `patterns/` for HITL, sub-agents, sessions); the skill above lists the exact pages it draws from in its `sources:` frontmatter. Grep for an API, e.g. `grep -rl "mockChatAgent" node_modules/@trigger.dev/sdk/docs/`.
## Setup
Happy path: drive an agent from server-side code (task, webhook, or script) with `AgentChat`.
```ts
import { AgentChat } from "@trigger.dev/sdk/chat";
import type { myAgent } from "./trigger/my-agent";
const chat = new AgentChat<typeof myAgent>({ agent: "my-chat", clientData: { userId: "user_123" } });
const stream = await chat.sendMessage("Review PR #42");
const text = await stream.text();
await chat.close();
```
`sendMessage()` triggers a run on the first call, then reuses it via input streams. `ChatStream`
exposes `text()`, `result()` (`{ text, toolCalls, toolResults }`), `messages()` (UIMessage
snapshots), and the raw `.stream`. Other methods: `steer(text)`, `stop()`, `sendRaw(uiMessages)`,
`sendAction(action)`, `preload()`, `reconnect()`.
## Core patterns
### 1. Raw Sessions for non-chat, bi-directional I/O
Reach for `sessions` directly when the chat abstraction does not fit: agent inboxes, approval flows,
server-to-server pipelines. `sessions.start` is idempotent on `(env, externalId)`; `externalId`
cannot start with `session_`.
```ts
import { sessions } from "@trigger.dev/sdk";
const { id, publicAccessToken } = await sessions.start({
type: "chat.agent",
externalId: chatId,
taskIdentifier: "my-chat",
triggerConfig: { tags: [`chat:${chatId}`], basePayload: { chatId, trigger: "preload" } },
});
const session = sessions.open(chatId); // no network call; methods are lazy
await session.out.append({ kind: "message", text: "hello" });
const next = await session.in.once<MyEvent>({ timeoutMs: 30_000 });
```
`sessions.open(id).in` also has `send`, `on(handler)`, `peek`, `wait` (suspends the run, only inside
`task.run()`), and `waitWithIdleTimeout`. `.out` has `append`, `pipe`, `writer`, `read`,
`writeControl`, and `trimTo`. List with `sessions.list({ type, tag, status, ... })` (`for await`),
mutate with `sessions.update`, end with `sessions.close` (terminal, idempotent).
### 2. Durable sub-agent as a streaming tool
`AgentChat` inside an AI SDK `tool()` delegates to a durable sub-agent; its response streams as
preliminary tool results. Give the tool a `toModelOutput` so the model sees a compact summary.
```ts
import { tool } from "ai";
import { AgentChat } from "@trigger.dev/sdk/chat";
import { z } from "zod";
const researchTool = tool({
description: "Delegate research to a specialist agent.",
inputSchema: z.object({ topic: z.string() }),
execute: async function* ({ topic }, { abortSignal }) {
const chat = new AgentChat({ agent: "research-agent" });
const stream = await chat.sendMessage(topic, { abortSignal });
yield* stream.messages(); // UIMessage snapshots become preliminary tool results
await chat.close();
},
toModelOutput: ({ output: message }) => {
const lastText = message?.parts?.findLast((p: { type: string }) => p.type === "text") as
| { text?: string }
| undefined;
return { type: "text", value: lastText?.text ?? "Done." };
},
});
```
For a subtask exposed via `execute: ai.toolExecute(task)`, stream progress to the agent's run with
`chat.stream.writer({ target: "root" })`. `target` accepts `"self" | "parent" | "root" | <runId>`.
Inside the subtask, read context with `ai.toolCallId()` and `ai.chatContextOrThrow<typeof myChat>()`
(`{ chatId, turn, continuation, clientData }`).
```ts
import { chat, ai } from "@trigger.dev/sdk/ai";
const { waitUntilComplete } = chat.stream.writer({
target: "root",
execute: ({ write }) =>
write({ type: "data-research-status", id: partId, data: { query, status: "in-progress" } }),
});
await waitUntilComplete();
```
### 3. Background injection: defer + inject
`chat.defer(promise)` runs work in parallel with streaming (all deferred promises are awaited, with a
5s timeout, before `onTurnComplete`). `chat.inject(messages)` queues `ModelMessage[]` that drain at
the next turn start or `prepareStep` boundary.
```ts
export const myChat = chat.agent({
id: "my-chat",
onTurnComplete: async ({ messages }) => {
chat.defer(
(async () => {
const analysis = await analyzeConversation(messages);
chat.inject([{ role: "system", content: `[Analysis]\n\n${analysis}` }]);
})()
);
},
run: async ({ messages, signal }) =>
streamText({ ...chat.toStreamTextOptions({ registry }), messages, abortSignal: signal, stopWhen: stepCountIs(15) }),
});
```
### 4. Compaction (threshold-based)
`compaction.shouldCompact` decides when, `summarize` produces the summary that replaces the model
messages. UI messages are preserved by default (customize via `compactUIMessages`). The `prepareStep`
that performs inner-loop compaction is auto-injected by `chat.toStreamTextOptions()`; a `prepareStep`
you pass after the spread wins.
```ts
compaction: {
shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000,
summarize: async ({ messages }) =>
(await generateText({
model: anthropic("claude-haiku-4-5"),
messages: [...messages, { role: "user", content: "Summarize concisely." }],
})).text,
},
```
### 5. Actions: mutate state without a turn
`actionSchema` validates; `onAction` mutates via `chat.history` (`slice`, `replace`, `rollbackTo`,
`remove`, `getPendingToolCalls`, `extractNewToolResults`). Actions fire `hydrateMessages` and
`onAction` only, never `run()` or the turn hooks. Return a `StreamTextResult`, string, or `UIMessage`
to also emit a model response.
```ts
export const myChat = chat.agent({
id: "my-chat",
actionSchema: z.discriminatedUnion("type", [
z.object({ type: z.literal("undo") }),
z.object({ type: z.literal("rollback"), targetMessageId: z.string() }),
]),
onAction: async ({ action }) => {
if (action.type === "undo") chat.history.slice(0, -2);
if (action.type === "rollback") chat.history.rollbackTo(action.targetMessageId);
},
run: async ({ messages, signal }) => streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal }),
});
```
Send from the browser with `transport.sendAction(chatId, { type: "undo" })`, or server-side with
`agentChat.sendAction({ type: "rollback", targetMessageId: "msg-3" })`.
### 6. Fast starts: Head Start
`chat.headStart` (from `@trigger.dev/sdk/chat-server`, NOT `/ai`) returns a Web Fetch handler that
serves turn 1 from your own warm process, then hands off to the agent on turn 2+. Tools passed here
must be **schema-only** (a module importing `ai` + `zod` only); heavy executes stay in the task.
```ts
import { chat } from "@trigger.dev/sdk/chat-server";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { headStartTools } from "@/lib/chat-tools/schemas";
export const chatHandler = chat.headStart({
agentId: "my-chat",
run: async ({ chat: helper }) =>
streamText({
...helper.toStreamTextOptions({ tools: headStartTools }),
model: anthropic("claude-sonnet-4-6"),
system: "You are helpful.",
stopWhen: stepCountIs(15),
}),
});
// Next.js: export const POST = chatHandler; Transport: headStart: "/api/chat"
```
Node-only frameworks wrap a Web Fetch handler with `chat.toNodeListener(handler)`. Use the **same
model** on both sides to avoid a tone shift between turn 1 and turn 2+.
### 7. chat.local: init in onBoot, not onChatStart
`chat.local<T>({ id })` is module-level, shallow-proxy, run-scoped state. Initialize it in `onBoot`
(fires on every fresh worker, including continuation runs), never `onChatStart`.
```ts
const userContext = chat.local<{ name: string; plan: "free" | "pro" }>({ id: "userContext" });
export const myChat = chat.agent({
id: "my-chat",
onBoot: async ({ clientData }) => userContext.init({ name: "Alice", plan: "pro" }),
run: async ({ messages, signal }) => streamText({ /* ... */ }),
});
```
### 8. Pending messages (mid-stream user input)
A message sent while a turn is streaming should NOT cancel the stream. Configure
`pendingMessages` (`shouldInject`, `prepare`, `onReceived`, `onInjected`) on the agent so the SDK's
auto-injected `prepareStep` folds them in at the next boundary. On the frontend, `usePendingMessages`
returns `pending`, `steer(text)`, `queue(text)`, and `promoteToSteering(id)`; send via
`transport.sendPendingMessage(chatId, uiMessage, metadata?)`.
### 9. Recovery and version upgrades
`onRecoveryBoot` fires only when a **partial assistant message exists on the tail** (interrupted
deploy, crash, OOM retry). It does NOT fire on `chat.requestUpgrade()`, which is a graceful exit with
no partial. `chat.requestUpgrade()` (called in `onTurnStart` / `onValidateMessages` to skip `run()`,
or in `run()` / `chat.defer()` to exit after the turn) rotates the Session's `currentRunId` to a run
on the latest deployment without a client reconnect. Pair it with a contract version on `clientData`.
```ts
const SUPPORTED_VERSIONS = new Set(["v2", "v3"]);
onTurnStart: async ({ clientData }) => {
if (clientData?.protocolVersion && !SUPPORTED_VERSIONS.has(clientData.protocolVersion)) {
chat.requestUpgrade();
}
},
```
For OOM resilience, set `oomMachine` (and `machine`) on the agent so retries land on a larger preset.
### 10. Offline testing with mockChatAgent
`@trigger.dev/sdk/ai/test` runs the real turn loop in-memory. Import it **before** the agent module
so the resource catalog is installed. Drive with `sendMessage`, `sendRegenerate`, `sendAction`,
`sendStop`, `sendHeadStart`, `sendHandover`; seed state with `seedSnapshot` / `seedSessionOutTail` /
`seedSessionOutPartial` / `seedSessionInTail`; assert against `turn.chunks` and `harness.allChunks`.
```ts
import { mockChatAgent } from "@trigger.dev/sdk/ai/test"; // BEFORE the agent module
import { myChatAgent } from "./my-chat.js";
const harness = mockChatAgent(myChatAgent, { chatId: "test-1", clientData: { model } });
try {
const turn = await harness.sendMessage({ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] });
// assert against turn.chunks
} finally {
await harness.close();
}
```
Options include `mode` (`"preload" | "submit-message" | "handover-prepare" | "continuation"`),
`preload`, `continuation`, `previousRunId`, `snapshot`, `taskContext`, and `setupLocals`. Set
`taskContext.ctx.attempt.number > 1` to simulate an OOM-retry attempt. `runInMockTaskContext` drives a
non-chat task offline.
### 11. Custom transport: the wire protocol
Endpoints: `POST /api/v1/sessions` (create), `GET /realtime/v1/sessions/{id}/out` (SSE),
`POST /realtime/v1/sessions/{id}/in/append`, `POST /api/v1/sessions/{id}/close`. `ChatInputChunk` is
`{ kind: "message"; payload: ChatTaskWirePayload } | { kind: "stop"; message? }`. The
`ChatTaskWirePayload` carries `chatId`, `trigger` (`submit-message | regenerate-message | preload |
close | action | handover-prepare`), `message?`, `metadata?`, `action?`, `continuation?`,
`previousRunId?`, and more. Control records are header-form: `trigger-control: turn-complete` (with
optional `public-access-token`, `session-in-event-id`) and `trigger-control: upgrade-required`. The
TS helpers `SSEStreamSubscription` and `controlSubtype(headers)` (documented in
`docs/ai-chat/client-protocol.mdx`) handle batch decoding and control-record filtering for you.
If those paths don't exist, `@trigger.dev/sdk` isn't installed yet — install it first. In a non-hoisted layout, resolve the package with `node -p "require.resolve('@trigger.dev/sdk/package.json')"` and read `skills/` + `docs/` beside it.
## Common mistakes
@@ -355,13 +67,4 @@ TS helpers `SSEStreamSubscription` and `controlSubtype(headers)` (documented in
## References
- `authoring-chat-agent` skill - the everyday `chat.agent({...})` definition, lifecycle hooks, and
the `useTriggerChatTransport` happy path. Start there before reaching for this skill.
- `realtime-and-frontend` skill - Realtime hooks and frontend streaming beyond the chat transport.
- `authoring-tasks` skill - base `task()` semantics, `ctx`, and standard lifecycle hooks.
- Docs: /ai-chat/sessions, /ai-chat/server-chat, /ai-chat/client-protocol
## Version
Generated for `@trigger.dev/sdk` `{{TRIGGER_SDK_VERSION}}`. Re-run the trigger.dev skills installer
after upgrading.
Sibling skills: **authoring-chat-agent** (the everyday `chat.agent({...})` happy path), **authoring-tasks** and **realtime-and-frontend** (task + frontend foundations).
@@ -13,223 +13,16 @@ description: >
/ metadata.set is authoring-tasks territory); this is the consumer side.
type: core
library: trigger.dev
library_version: "{{TRIGGER_SDK_VERSION}}"
sources:
- docs/realtime/overview.mdx
- docs/realtime/how-it-works.mdx
- docs/realtime/auth.mdx
- docs/realtime/run-object.mdx
- docs/realtime/react-hooks/overview.mdx
- docs/realtime/react-hooks/subscribe.mdx
- docs/realtime/react-hooks/triggering.mdx
- docs/realtime/react-hooks/streams.mdx
- docs/realtime/react-hooks/swr.mdx
- docs/realtime/react-hooks/use-wait-token.mdx
- docs/realtime/backend/subscribe.mdx
---
# Realtime and Frontend
The consumer side of Trigger.dev's run state and streams: read live run
updates, render AI/text streams, and trigger tasks from a browser. Hooks come
from `@trigger.dev/react-hooks`; token minting and backend subscription come
from `@trigger.dev/sdk`.
The full, version-pinned reference ships **inside your installed `@trigger.dev/sdk`**. Read it before writing code — it always matches the SDK version in this project, so it never drifts:
## Setup
- **Skill:** `node_modules/@trigger.dev/sdk/skills/realtime-and-frontend/SKILL.md` — run subscriptions, `@trigger.dev/react-hooks`, streams, frontend triggering, and scoped tokens.
- **Docs:** the full, version-pinned docs ship bundled at `node_modules/@trigger.dev/sdk/docs/realtime/`; the skill above lists the exact pages it draws from in its `sources:` frontmatter. Grep for a hook, e.g. `grep -rl "useRealtimeRun" node_modules/@trigger.dev/sdk/docs/`.
```bash
npm add @trigger.dev/react-hooks # frontend hooks (React/Next.js/Remix)
# @trigger.dev/sdk is already installed for the backend
```
The flow is always: mint a scoped token in the backend, pass it to the
frontend, subscribe with a hook.
```ts
// backend (API route / server action)
import { auth } from "@trigger.dev/sdk";
const publicAccessToken = await auth.createPublicToken({
scopes: { read: { runs: ["run_1234"] } }, // a token with no scopes is useless
});
```
```tsx
// frontend
"use client";
import { useRealtimeRun } from "@trigger.dev/react-hooks";
export function RunStatus({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) {
const { run, error } = useRealtimeRun(runId, { accessToken: publicAccessToken });
if (error) return <div>Error: {error.message}</div>;
if (!run) return <div>Loading...</div>;
return <div>Run: {run.status}</div>;
}
```
There are two token kinds: Public Access Tokens (read/subscribe, from
`auth.createPublicToken`) and Trigger Tokens (trigger-from-browser, single-use,
from `auth.createTriggerPublicToken`). Both default to a 15 minute expiry.
## Core patterns
### 1. Subscribe to a run and render metadata progress
`metadata` is `Record<string, DeserializedJson>`, so nested values need a cast.
```tsx
"use client";
import { useRealtimeRun } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";
export function Progress({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) {
const { run, error } = useRealtimeRun<typeof myTask>(runId, { accessToken: publicAccessToken });
if (error) return <div>Error: {error.message}</div>;
if (!run) return <div>Loading...</div>;
const progress = run.metadata?.progress as { percentage?: number } | undefined;
return <div>{run.status}: {progress?.percentage ?? 0}%</div>;
}
```
Pass `onComplete: (run, error) => {}` to react when the run finishes.
### 2. Status-only subscription with `skipColumns`
For a badge or progress bar you do not need `payload`/`output`. Skipping them
reduces wire size and avoids "Large HTTP Payload" warnings.
```tsx
const { run } = useRealtimeRun(runId, {
accessToken: publicAccessToken,
skipColumns: ["payload", "output"],
});
```
You can skip any of: `payload`, `output`, `metadata`, `startedAt`, `delayUntil`,
`queuedAt`, `expiredAt`, `completedAt`, `number`, `isTest`, `usageDurationMs`,
`costInCents`, `baseCostInCents`, `ttl`, `payloadType`, `outputType`, `runTags`,
`error`.
### 3. Trigger from the browser with a Trigger Token
`accessToken` here is a Trigger Token (`auth.createTriggerPublicToken`), not a
Public Access Token.
```tsx
"use client";
import { useTaskTrigger } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";
export function TriggerButton({ publicAccessToken }: { publicAccessToken: string }) {
const { submit, handle, isLoading } = useTaskTrigger<typeof myTask>("my-task", {
accessToken: publicAccessToken,
});
if (handle) return <div>Run ID: {handle.id}</div>;
return (
<button onClick={() => submit({ foo: "bar" }, { tags: ["user:123"] })} disabled={isLoading}>
{isLoading ? "Triggering..." : "Run"}
</button>
);
}
```
`submit(payload, options?)` takes the same options as a backend `trigger` call.
### 4. Trigger and subscribe in one hook
```tsx
"use client";
import { useRealtimeTaskTrigger } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";
export function Runner({ publicAccessToken }: { publicAccessToken: string }) {
const { submit, run, isLoading } = useRealtimeTaskTrigger<typeof myTask>("my-task", {
accessToken: publicAccessToken,
});
if (run) return <div>{run.status}</div>;
return <button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>Run</button>;
}
```
Use `useRealtimeTaskTriggerWithStreams<typeof myTask, STREAMS>` when you also
want the task's streams (it returns `{ submit, run, streams, error, isLoading }`).
### 5. Consume an AI/text stream (SDK 4.1.0+, recommended)
`useRealtimeStream` takes a defined stream for full type safety, or a `runId`
plus optional stream key. Returns `{ parts, error }`.
```tsx
"use client";
import { useRealtimeStream } from "@trigger.dev/react-hooks";
import { aiStream } from "@/trigger/streams"; // a defined stream -> typed parts
export function StreamView({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) {
const { parts, error } = useRealtimeStream(aiStream, runId, {
accessToken: publicAccessToken,
timeoutInSeconds: 300, // default 60
onData: (chunk) => console.log(chunk),
});
if (error) return <div>Error: {error.message}</div>;
if (!parts) return <div>Loading...</div>;
return <div>{parts.join("")}</div>;
}
```
Without a defined stream: `useRealtimeStream<string>(runId, "ai-output", { accessToken })`,
or omit the key to use the default stream. Other options: `baseURL`, `startIndex`,
`throttleInMs` (default 16). The legacy `useRealtimeRunWithStreams(runId, options)`
hook is still supported when you need both the run and all its streams at once.
### 6. Send input back into a running task
```tsx
"use client";
import { useInputStreamSend } from "@trigger.dev/react-hooks";
import { approval } from "@/trigger/streams";
export function ApprovalForm({ runId, accessToken }: { runId: string; accessToken: string }) {
const { send, isLoading, isReady } = useInputStreamSend(approval.id, runId, { accessToken });
return (
<button disabled={!isReady || isLoading} onClick={() => send({ approved: true })}>
Approve
</button>
);
}
```
### 7. Complete a wait token from React
```ts
// backend: create the token, return id + publicAccessToken to the frontend
import { wait } from "@trigger.dev/sdk";
const token = await wait.createToken({ timeout: "10m" });
return { tokenId: token.id, publicToken: token.publicAccessToken };
```
```tsx
"use client";
import { useWaitToken } from "@trigger.dev/react-hooks";
export function Approve({ tokenId, publicToken }: { tokenId: string; publicToken: string }) {
const { complete } = useWaitToken(tokenId, { accessToken: publicToken });
return <button onClick={() => complete({ approved: true })}>Approve</button>;
}
```
### 8. Subscribe from the backend (async iterators)
```ts
import { runs, tasks } from "@trigger.dev/sdk";
import type { myTask } from "./trigger/my-task";
const handle = await tasks.trigger("my-task", { some: "data" });
for await (const run of runs.subscribeToRun<typeof myTask>(handle.id)) {
console.log(run.payload.some, run.output?.some); // typed
}
```
`runs.subscribeToRun` completes when the run finishes, so the loop exits on its own.
If those paths don't exist, `@trigger.dev/sdk` isn't installed yet — install it first. In a non-hoisted layout, resolve the package with `node -p "require.resolve('@trigger.dev/sdk/package.json')"` and read `skills/` + `docs/` beside it.
## Common mistakes
@@ -262,20 +55,4 @@ for await (const run of runs.subscribeToRun<typeof myTask>(handle.id)) {
## References
Sibling skills:
- `authoring-tasks` for the task side: `streams.define()`, `metadata.set()`, and `wait.createToken`.
- `authoring-chat-agent` and `chat-agent-advanced` for chat agents, which build on these realtime streams.
Docs:
- [React hooks: run updates](/realtime/react-hooks/subscribe)
- [React hooks: streaming](/realtime/react-hooks/streams)
- [Realtime auth](/realtime/auth)
The realtime run object differs from the management-API run object returned by
`useRun`; see [run object reference](/realtime/run-object). For the task side
(`streams.define`, `metadata.set`), see [/tasks/streams](/tasks/streams) and
[/runs/metadata](/runs/metadata).
## Version
Generated for @trigger.dev/sdk {{TRIGGER_SDK_VERSION}}. Re-run the trigger.dev skills installer after upgrading.
Sibling skills: **authoring-tasks** (the task side: `streams.define()`, `metadata.set()`, `wait.createToken`), **authoring-chat-agent** and **chat-agent-advanced** (chat agents build on these realtime streams).
+3
View File
@@ -0,0 +1,3 @@
# Generated at build time by scripts/bundleSdkDocs.ts (snapshot of curated docs/ the
# bundled skills cite). Shipped via files[] but never committed. skills/ IS committed.
/docs
+6 -3
View File
@@ -14,7 +14,9 @@
"type": "module",
"sideEffects": false,
"files": [
"dist"
"dist",
"docs",
"skills"
],
"tshy": {
"selfLink": false,
@@ -62,8 +64,9 @@
}
},
"scripts": {
"clean": "rimraf dist .tshy .tshy-build .turbo",
"build": "tshy && pnpm run update-version",
"clean": "rimraf dist docs .tshy .tshy-build .turbo",
"build": "tshy && pnpm run update-version && pnpm run bundle-docs",
"bundle-docs": "tsx ../../scripts/bundleSdkDocs.ts",
"dev": "tshy --watch",
"typecheck": "tsc --noEmit",
"typecheck:ai-v7": "tsc --noEmit -p tsconfig.ai-v7.json",
@@ -0,0 +1,294 @@
---
name: authoring-chat-agent
description: >
Author and run a durable AI chat agent with chat.agent from @trigger.dev/sdk/ai: the per-turn
run loop, why you MUST spread ...chat.toStreamTextOptions() first, returning a StreamTextResult
vs calling chat.pipe(), the two server actions (chat.createStartSessionAction +
auth.createPublicToken), and wiring useChat to useTriggerChatTransport. Load this when building,
modifying, or debugging a chat backend (the agent task or its lifecycle hooks) or its React
transport, when declaring typed tools or custom data parts, or when migrating a plain AI SDK
streamText route to chat.agent.
type: core
library: trigger.dev
sources:
- docs/ai-chat/overview.mdx
- docs/ai-chat/quick-start.mdx
- docs/ai-chat/how-it-works.mdx
- docs/ai-chat/backend.mdx
- docs/ai-chat/frontend.mdx
- docs/ai-chat/reference.mdx
- docs/ai-chat/types.mdx
- docs/ai-chat/tools.mdx
- docs/ai-chat/lifecycle-hooks.mdx
- docs/ai-chat/error-handling.mdx
---
# Authoring a chat agent
A `chat.agent` runs an entire conversation as one long-lived Trigger.dev task. It wakes when a
message arrives, freezes when none do, and in-memory state survives page refreshes, deploys, idle
gaps, and crashes. Your code is the loop you would write anyway: messages in, `streamText` out.
There are no API routes. The frontend talks to the agent through a `TriggerChatTransport`, so
history accumulates server-side and the client ships only the new message each turn.
Works with Vercel AI SDK v5, v6, or v7. On v7 also install `@ai-sdk/otel` so model calls are traced
(the SDK registers it for you).
## Setup
Three pieces: the agent task, two server actions, and the frontend transport.
### 1. Define the agent
```ts trigger/chat.ts
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
export const myChat = chat.agent({
id: "my-chat",
run: async ({ messages, signal }) =>
streamText({
// Spread this FIRST. See "Common mistakes".
...chat.toStreamTextOptions(),
model: anthropic("claude-sonnet-4-5"),
messages,
abortSignal: signal,
stopWhen: stepCountIs(15),
}),
});
```
`run` receives `messages` already converted to `ModelMessage[]` (the SDK converts the frontend's
`UIMessage[]` for you) plus a `signal` that aborts on stop or cancel. Returning the
`StreamTextResult` auto-pipes it to the frontend.
### 2. Add two server actions
Both run on your server, so the browser never holds your environment secret key. This is also
where per-user / per-plan authorization and any paired DB writes live.
```ts app/actions.ts
"use server";
import { auth } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";
// Creates the Session + first run, returns a session PAT. Idempotent on (env, chatId).
export const startChatSession = chat.createStartSessionAction("my-chat");
// Pure mint. The transport calls this on 401/403 to refresh an expired token.
export async function mintChatAccessToken(chatId: string) {
return auth.createPublicToken({
scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
expirationTime: "1h",
});
}
```
### 3. Wire the frontend
```tsx app/components/chat.tsx
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import type { myChat } from "@/trigger/chat";
import { mintChatAccessToken, startChatSession } from "@/app/actions";
export function Chat() {
const transport = useTriggerChatTransport<typeof myChat>({
task: "my-chat", // typeof myChat gives compile-time task-id validation
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }),
});
const { messages, sendMessage, stop, status } = useChat({ transport });
const [input, setInput] = useState("");
// render messages, a form that calls sendMessage({ text: input }),
// and a Stop button (onClick={stop}) while status === "streaming".
}
```
The transport is memoized (created once, reused across renders). Passing `typeof myChat` flows the
agent's message type through `useChat`.
## Core patterns
### 1. Return vs pipe
Return the `streamText` result from `run` for the simple case. When `streamText` is called deep
inside nested helpers, call `await chat.pipe(result)` from anywhere in the task instead, and let
`run` resolve `void`.
```ts
export const agentChat = chat.agent({
id: "agent-chat",
run: async ({ messages }) => {
await runAgentLoop(messages); // don't return; pipe inside
},
});
async function runAgentLoop(messages: ModelMessage[]) {
const result = streamText({
...chat.toStreamTextOptions(),
model: anthropic("claude-sonnet-4-5"),
messages,
});
await chat.pipe(result); // works from anywhere in the task
}
```
### 2. Typed tools (declare on config AND spread back)
Declare tools on `chat.agent({ tools })`, read them back typed from the `run()` payload, and pass
that set to `chat.toStreamTextOptions({ tools })`. One declaration flows everywhere.
```ts
import { tool, stepCountIs } from "ai";
import { z } from "zod";
const tools = {
searchDocs: tool({
description: "Search the docs.",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => searchIndex(query),
}),
};
export const myChat = chat.agent({
id: "my-chat",
tools, // so toModelOutput survives across turns
run: async ({ messages, tools, signal }) =>
streamText({
...chat.toStreamTextOptions({ tools }), // same set, handed back typed
model: anthropic("claude-sonnet-4-5"),
messages,
abortSignal: signal,
stopWhen: stepCountIs(15),
}),
});
```
`tools` also accepts a function `(event) => ToolSet` resolved per turn, where `event` carries
`chatId`, `turn`, `continuation`, and `clientData`.
### 3. Custom data parts (persisted vs transient)
`data-*` parts written via `chat.response.write()` in `run()` (or `writer.write()` in hooks)
persist into `responseMessage.parts` and surface in `onTurnComplete`. Add `transient: true` to
stream them without persisting. Writes via `chat.stream` are always ephemeral.
```ts
// In run() - persists, surfaces in onTurnComplete's responseMessage
chat.response.write({ type: "data-context", data: { searchResults } });
// In a hook via writer - streams but does NOT persist
writer.write({ type: "data-progress", id: "search", data: { percent: 50 }, transient: true });
```
### 4. Custom UIMessage type, client data, and builder hooks
For typed `data-*` parts or a tool map, build the agent through `chat.withUIMessage<T>()` and
`chat.withClientData({ schema })`. Builder methods chain in any order; builder hooks run before the
matching task hook. `streamOptions` becomes the default `uiMessageStreamOptions` (shallow-merged,
agent wins).
```ts
export const myChat = chat
.withUIMessage<MyChatUIMessage>({ streamOptions: { sendReasoning: true } })
.withClientData({ schema: z.object({ userId: z.string() }) })
.agent({
id: "my-chat",
tools: myTools,
onTurnStart: async ({ uiMessages, writer }) => {
writer.write({ type: "data-turn-status", data: { status: "preparing" } });
},
run: async ({ messages, tools, signal }) =>
streamText({ ...chat.toStreamTextOptions({ tools }), model, messages, abortSignal: signal }),
});
```
Build `MyChatUIMessage` as `UIMessage<unknown, MyDataTypes, InferUITools<typeof tools>>` (or, for
tools only, `InferChatUIMessageFromTools<typeof tools>` from `@trigger.dev/sdk/ai`). On the
frontend, narrow `useChat` with `InferChatUIMessage<typeof myChat>` from `@trigger.dev/sdk/chat/react`.
### 5. Lifecycle hooks and stop
`chat.agent` accepts hooks that fire in a fixed per-turn order:
```text
onValidateMessages -> hydrateMessages -> onChatStart (chat's first message only)
-> onTurnStart -> run() -> onBeforeTurnComplete -> onTurnComplete
```
`onBoot` fires once per worker process (every fresh boot, including continuation runs) and is where
`chat.local`, DB connections, and per-process state belong. `onChatStart` fires only on the chat's
first message. Suspend/resume use `onChatSuspend` / `onChatResume`. Config options include
`tools`, `clientDataSchema`, `maxTurns` (100), `turnTimeout` ("1h"), `idleTimeoutInSeconds` (30),
`uiMessageStreamOptions`, and `exitAfterPreloadIdle`. There is no generic `retry`; `chat.agent`
runs with `maxAttempts: 1` internally.
Stop is load-bearing: the `signal` passed to `run` aborts on stop or cancel. Forward it as
`abortSignal` to `streamText`, or the Stop button updates the UI while the model keeps generating
server-side.
```ts
run: async ({ messages, signal }) =>
streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal, stopWhen: stepCountIs(15) });
```
### 6. Migrating from a plain AI SDK `streamText` route
There is no API route in this model. The transport replaces the route round-trip, so:
- Delete the route handler. Move per-request auth into the two server actions from Setup step 2.
- Move the `streamText` call into `run`. It already receives pre-converted `ModelMessage[]`.
- Return the `StreamTextResult` (it auto-pipes) and add `...chat.toStreamTextOptions()` first.
- On the client, swap the `api` URL for `useTriggerChatTransport`; `useChat` stays the same shape.
## Common mistakes
- **CRITICAL: forgetting `...chat.toStreamTextOptions()`.**
```ts
// Wrong - compaction / steering / background injection silently no-op
return streamText({ model, messages, abortSignal: signal });
// Correct - spread FIRST so explicit overrides win
return streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal });
```
It wires the `prepareStep` callback behind compaction, mid-turn steering, and background
injection, injects the system prompt from `chat.prompt()`, resolves the registry model, and adds
telemetry. Omitting it makes all of those silently no-op with no error.
- **Declaring tools only on `streamText`.** Also declare them on `chat.agent({ tools })`, read them
back from `run`, and pass `chat.toStreamTextOptions({ tools })`. Otherwise each tool's
`toModelOutput` runs on turn 1 but is dropped when history is re-converted on later turns.
- **Not forwarding `signal` for stop.** Without `abortSignal: signal`, Stop updates the UI but the
model keeps generating server-side.
- **Initializing `chat.local` in `onChatStart`.** Initialize it in `onBoot`. `onChatStart` fires
once per chat, so continuation runs skip it and crash with
`chat.local can only be modified after initialization`. `onBoot` fires on every fresh worker.
- **Minting tokens in the browser.** Never expose the environment secret key client-side. Mint via
the two server actions; the transport calls them.
- **Clearing `lastEventId` on `chat.endRun()`.** Keep the cursor for the Session lifetime; clear it
only when the Session itself closes. It is sessionId-keyed, so clearing forces a resubscribe from
`seq_num=0` that can hit the prior turn's stale `turn-complete` and close the stream empty.
- **Returning the raw error from `uiMessageStreamOptions.onError`.** It leaks internals (keys,
stack traces). Return a sanitized string instead.
## References
- `chat-agent-advanced` skill - lifecycle hooks in depth, sessions, raw-task primitives
(`chat.createSession`, `chat.customAgent`, `chat.stream`), compaction, HITL approvals, recovery.
- `realtime-and-frontend` skill - Realtime hooks and frontend streaming beyond the chat transport.
- `authoring-tasks` skill - base `task()` semantics, `ctx`, and standard lifecycle hooks.
Reference docs ship beside this skill in the same package, read them locally (no network), pinned to your installed version. The `sources:` frontmatter above lists every doc this skill draws from, all under `@trigger.dev/sdk/docs/ai-chat/`. Start with `quick-start.mdx`, `backend.mdx`, `tools.mdx`, `types.mdx`, `frontend.mdx`.
## Version
This skill is bundled inside `@trigger.dev/sdk` and read directly from `node_modules`, so it always matches your installed SDK version (see the adjacent `package.json`). The full documentation for these APIs ships alongside it under `@trigger.dev/sdk/docs/`.
@@ -0,0 +1,254 @@
---
name: authoring-tasks
description: >
Covers writing backend Trigger.dev tasks with @trigger.dev/sdk: defining task() and
schemaTask(), the run function and its ctx, retries, waits, queues and concurrency,
idempotency keys, run metadata, logging, triggering other tasks (and the Result shape),
scheduled/cron tasks, and the essentials of trigger.config.ts. Load this whenever you are
authoring or editing code inside a /trigger directory, defining a task, or writing backend
code that triggers tasks. Realtime/React hooks and AI chat are covered by separate skills.
type: core
library: trigger.dev
sources:
- docs/tasks/overview.mdx
- docs/tasks/schemaTask.mdx
- docs/tasks/scheduled.mdx
- docs/triggering.mdx
- docs/queue-concurrency.mdx
- docs/idempotency.mdx
- docs/runs/metadata.mdx
- docs/logging.mdx
- docs/errors-retrying.mdx
- docs/wait.mdx
- docs/wait-for.mdx
- docs/wait-until.mdx
- docs/wait-for-token.mdx
- docs/context.mdx
- docs/config/config-file.mdx
---
# Authoring Trigger.dev Tasks
Tasks are functions that can run for a long time with strong resilience to failure. Define them in files under your `/trigger` directory. Always import from `@trigger.dev/sdk`. Never import from `@trigger.dev/sdk/v3` (deprecated alias) or `@trigger.dev/core`.
## Setup
```ts
// /trigger/hello-world.ts
import { task } from "@trigger.dev/sdk";
export const helloWorld = task({
id: "hello-world", // unique within the project
run: async (payload: { message: string }, { ctx }) => {
console.log(payload.message, "attempt", ctx.attempt.number);
return { ok: true }; // must be JSON serializable
},
});
```
The `run` function receives the payload and a second argument with `ctx` (run context), an abort `signal`, and a deprecated `init` output. The return value is the task output and must be JSON serializable.
## Core patterns
### 1. Validate the payload with `schemaTask`
`schema` accepts a Zod / Yup / Superstruct / ArkType / valibot / typebox parser or a custom `(data: unknown) => T` function. A validation failure throws `TaskPayloadParsedError` and skips retrying.
```ts
import { schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";
export const createUser = schemaTask({
id: "create-user",
schema: z.object({ name: z.string(), age: z.number() }),
run: async (payload) => ({ greeting: `Hi ${payload.name}` }),
});
```
### 2. Configure retries and abort early
The default `maxAttempts` is 3. Throw `AbortTaskRunError` to stop retrying immediately. Task-level `retry` overrides the config-file defaults.
```ts
import { task, AbortTaskRunError } from "@trigger.dev/sdk";
export const charge = task({
id: "charge",
retry: { maxAttempts: 5, factor: 1.8, minTimeoutInMs: 500, maxTimeoutInMs: 30_000, randomize: true },
run: async (payload: { amount: number }) => {
if (payload.amount <= 0) throw new AbortTaskRunError("Invalid amount"); // no retry
// work that may throw and retry
},
});
```
For finer control, `catchError: async ({ payload, error, ctx, retryAt }) => {...}` can return `{ skipRetrying: true }`, `{ retryAt: Date }`, or `undefined` (use normal logic). `retry.onThrow`, `retry.fetch`, also exist for in-task retrying.
### 3. Trigger another task and handle the Result
From inside a task use `yourTask.triggerAndWait(payload)`. The result is a Result object that you must check (`ok`), or `.unwrap()` to throw on failure.
```ts
export const parentTask = task({
id: "parent-task",
run: async () => {
const result = await childTask.triggerAndWait({ data: "x" });
if (result.ok) return result.output; // typed child output
console.error("child failed", result.error);
// or: const output = await childTask.triggerAndWait({ data: "x" }).unwrap();
},
});
```
`SubtaskUnwrapError` carries `runId`, `taskId`, and `cause`. For fan-out use `childTask.batchTriggerAndWait([{ payload: a }, { payload: b }])`; the result has a `.runs` array, each entry `{ ok, id, output?, error?, taskIdentifier }`.
### 4. Trigger from backend code with a type-only import
Outside a task, import the task type only and trigger by id. Do not import the task instance into backend bundles.
```ts
import { tasks } from "@trigger.dev/sdk";
import type { emailSequence } from "~/trigger/emails";
const handle = await tasks.trigger<typeof emailSequence>(
"email-sequence",
{ to: "a@b.com", name: "Ada" },
{ delay: "1h" }
);
```
`tasks.batchTrigger` and `batch.trigger([{ id, payload }])` cover batches. Trigger options include `delay`, `ttl`, `idempotencyKey`, `idempotencyKeyTTL`, `debounce`, `queue`, `concurrencyKey`, `maxAttempts`, `tags`, `metadata`, `priority`, `region`, and `machine`. Inspect runs with `runs.retrieve`, `runs.cancel`, and `runs.reschedule`.
### 5. Idempotency keys
`idempotencyKeys.create(key, { scope })` returns a 64-char hashed key. A raw string key defaults to `"run"` scope (v4.3.1+); for once-ever behavior use `scope: "global"`.
```ts
import { idempotencyKeys, task } from "@trigger.dev/sdk";
export const processOrder = task({
id: "process-order",
run: async (payload: { orderId: string; email: string }) => {
const key = await idempotencyKeys.create(`confirm-${payload.orderId}`);
await sendEmail.trigger({ to: payload.email }, { idempotencyKey: key });
},
});
```
### 6. Waits and run metadata
`wait.for({ seconds })` and `wait.until({ date })` durably pause the run. `metadata.*` is readable and writable only inside `run()`; updates are synchronous and chainable (`set`, `del`, `replace`, `append`, `remove`, `increment`, `decrement`).
```ts
import { task, metadata, wait } from "@trigger.dev/sdk";
export const importer = task({
id: "importer",
run: async (payload: { rows: unknown[] }) => {
metadata.set("status", "processing").set("total", payload.rows.length);
await wait.for({ seconds: 5 });
metadata.set("status", "complete");
},
});
```
For human-in-the-loop, `wait.createToken({ timeout, tags })` returns `{ id, url, publicAccessToken, ... }`; resume with `wait.forToken<T>(token: string | { id: string })` which returns `{ ok, output?, error? }` (or `.unwrap()`), and complete it elsewhere with `wait.completeToken(tokenId, output)`. Metadata max is 256KB and is not propagated to child tasks; push values to a parent with `metadata.parent.*` / `metadata.root.*`. (`metadata.stream` is deprecated since 4.1.0 in favor of `streams.pipe()`.)
### 7. Scheduled (cron) tasks
```ts
import { schedules } from "@trigger.dev/sdk";
export const dailyReport = schedules.task({
id: "daily-report",
cron: { pattern: "0 5 * * *", timezone: "Asia/Tokyo" },
run: async (payload) => {
console.log("scheduled at", payload.timestamp, "next", payload.upcoming);
},
});
```
The payload includes `timestamp`, `lastTimestamp`, `timezone`, `scheduleId`, `externalId`, and `upcoming`. Attach schedules dynamically with `schedules.create({ task, cron, timezone?, externalId?, deduplicationKey })` (the dedup key is required and per-project), plus `retrieve / list / update / activate / deactivate / del / timezones`.
### 8. Queues and concurrency
Set `queue: { concurrencyLimit }` on a task, or share a queue across tasks:
```ts
import { queue, task } from "@trigger.dev/sdk";
export const emails = queue({ name: "emails", concurrencyLimit: 5 });
export const sendEmail = task({ id: "send-email", queue: emails, run: async () => {} });
```
At trigger time override with `{ queue: "queue-name" }` and add `concurrencyKey` for per-tenant queues. Manage queues with `queues.list / retrieve / pause / resume / overrideConcurrencyLimit / resetConcurrencyLimit`.
### 9. `trigger.config.ts` essentials
```ts
import { defineConfig } from "@trigger.dev/sdk";
export default defineConfig({
project: "<project ref>",
dirs: ["./trigger"],
machine: "small-1x",
retries: {
enabledInDev: false,
default: { maxAttempts: 3, factor: 2, minTimeoutInMs: 1000, maxTimeoutInMs: 10000, randomize: true },
},
});
```
`build.external` controls which packages stay out of the bundle. Build extensions (`additionalFiles`, `prismaExtension`, `puppeteer`, `ffmpeg`, `aptGet`, etc.) come from `@trigger.dev/build`. `telemetry` configures instrumentations and exporters.
### Logging
`logger.debug / log / info / warn / error(message, dataRecord?)` write structured logs; `logger.trace(name, async (span) => {...})` adds a span. Module-level metrics use `otel.metrics.getMeter(name)`.
## Common mistakes
1. **CRITICAL: Treating the wait result as the output.** `triggerAndWait` and `wait.forToken` return a Result object, not the raw output.
- Wrong: `const out = await childTask.triggerAndWait(p); use(out.foo);`
- Correct: `const r = await childTask.triggerAndWait(p); if (r.ok) use(r.output.foo);` (or `.unwrap()`).
2. **Wrapping `triggerAndWait` / `batchTriggerAndWait` / `wait` in `Promise.all`.**
- Wrong: `await Promise.all([childTask.triggerAndWait(a), childTask.triggerAndWait(b)]);`
- Correct: `await childTask.batchTriggerAndWait([{ payload: a }, { payload: b }]);` (or a sequential for-loop).
3. **Importing the task instance into backend code.**
- Wrong: `import { emailSequence } from "~/trigger/emails";` in a route handler.
- Correct: `import type { emailSequence }` plus `tasks.trigger<typeof emailSequence>("email-sequence", payload)`.
4. **Calling `metadata.set/get` outside `run()`.**
- Wrong: setting metadata at module scope or in unrelated backend code (a no-op; `get` returns `undefined`).
- Correct: call inside `run()` or a task lifecycle hook.
5. **Assuming child tasks inherit the parent's queue or metadata.**
- Wrong: expecting a subtask to share the parent's `concurrencyLimit` or see its metadata.
- Correct: subtasks run on their own queue; pass metadata explicitly via `{ metadata: metadata.current() }`, or push up with `metadata.parent.*`.
6. **Bundling native/WASM packages.**
- Wrong: leaving `sharp`, `re2`, `sqlite3`, or WASM packages in the default bundle.
- Correct: add them to `build.external` in `trigger.config.ts`.
7. **Relying on a raw string idempotency key being global.**
- Wrong: `trigger(p, { idempotencyKey: "welcome-email" })` expecting once-ever (true only in v4.3.0 and earlier).
- Correct: `await idempotencyKeys.create("welcome-email", { scope: "global" })`.
## References
Sibling skills:
- **realtime-and-frontend** for subscribing to runs and triggering from the frontend with React hooks.
- **authoring-chat-agent** and **chat-agent-advanced** for building AI chat agents.
Reference docs ship beside this skill in the same package, read them locally (no network), pinned to your installed version. The `sources:` frontmatter above lists every doc this skill draws from, all under `@trigger.dev/sdk/docs/`. Start with:
- `@trigger.dev/sdk/docs/tasks/overview.mdx`
- `@trigger.dev/sdk/docs/triggering.mdx`
- `@trigger.dev/sdk/docs/config/config-file.mdx`
## Version
This skill is bundled inside `@trigger.dev/sdk` and read directly from `node_modules`, so it always matches your installed SDK version (see the adjacent `package.json`). The full documentation for these APIs ships alongside it under `@trigger.dev/sdk/docs/`.
@@ -0,0 +1,366 @@
---
name: chat-agent-advanced
description: >
Advanced and operational chat.agent capabilities for Trigger.dev, loaded on demand. Load this when
working on the raw Sessions primitive (sessions / SessionHandle), a custom chat transport or the
realtime wire protocol, durable sub-agents (AgentChat, chat.stream.writer), human-in-the-loop,
steering, actions, background injection (chat.defer / chat.inject), fast starts (preload, Head
Start via @trigger.dev/sdk/chat-server), context resilience (compaction, recovery boot, OOM, large
payloads), chat.local run-scoped state, offline testing with mockChatAgent, or prerelease/version
upgrades. For the everyday chat.agent({...}) definition and the useTriggerChatTransport happy path,
use the authoring-chat-agent skill instead.
type: core
library: trigger.dev
sources:
- docs/ai-chat/sessions.mdx
- docs/ai-chat/server-chat.mdx
- docs/ai-chat/client-protocol.mdx
- docs/ai-chat/pending-messages.mdx
- docs/ai-chat/actions.mdx
- docs/ai-chat/background-injection.mdx
- docs/ai-chat/compaction.mdx
- docs/ai-chat/fast-starts.mdx
- docs/ai-chat/chat-local.mdx
- docs/ai-chat/mcp.mdx
- docs/ai-chat/testing.mdx
- docs/ai-chat/upgrade-guide.mdx
- docs/ai-chat/patterns/sub-agents.mdx
- docs/ai-chat/patterns/human-in-the-loop.mdx
- docs/ai-chat/patterns/persistence-and-replay.mdx
- docs/ai-chat/patterns/recovery-boot.mdx
- docs/ai-chat/patterns/oom-resilience.mdx
- docs/ai-chat/patterns/large-payloads.mdx
- docs/ai-chat/patterns/version-upgrades.mdx
- docs/ai-chat/tools.mdx
---
# chat.agent: advanced and operational
`chat.agent` is built on **Sessions**: a durable, task-bound, bi-directional I/O channel pair keyed
on a stable `externalId` (e.g. `chatId`) that outlives any single run. This skill covers the layers
beneath and around the everyday agent: the raw `sessions` API, server-side `AgentChat`, durable
sub-agents, actions / background injection, fast starts, compaction and recovery, and the wire
protocol for custom transports.
Two `chat` namespaces are easy to confuse: the agent definition imports `chat` from
`@trigger.dev/sdk/ai`; Head Start / Node-listener server entries import `chat` from
`@trigger.dev/sdk/chat-server`.
## Setup
Happy path: drive an agent from server-side code (task, webhook, or script) with `AgentChat`.
```ts
import { AgentChat } from "@trigger.dev/sdk/chat";
import type { myAgent } from "./trigger/my-agent";
const chat = new AgentChat<typeof myAgent>({ agent: "my-chat", clientData: { userId: "user_123" } });
const stream = await chat.sendMessage("Review PR #42");
const text = await stream.text();
await chat.close();
```
`sendMessage()` triggers a run on the first call, then reuses it via input streams. `ChatStream`
exposes `text()`, `result()` (`{ text, toolCalls, toolResults }`), `messages()` (UIMessage
snapshots), and the raw `.stream`. Other methods: `steer(text)`, `stop()`, `sendRaw(uiMessages)`,
`sendAction(action)`, `preload()`, `reconnect()`.
## Core patterns
### 1. Raw Sessions for non-chat, bi-directional I/O
Reach for `sessions` directly when the chat abstraction does not fit: agent inboxes, approval flows,
server-to-server pipelines. `sessions.start` is idempotent on `(env, externalId)`; `externalId`
cannot start with `session_`.
```ts
import { sessions } from "@trigger.dev/sdk";
const { id, publicAccessToken } = await sessions.start({
type: "chat.agent",
externalId: chatId,
taskIdentifier: "my-chat",
triggerConfig: { tags: [`chat:${chatId}`], basePayload: { chatId, trigger: "preload" } },
});
const session = sessions.open(chatId); // no network call; methods are lazy
await session.out.append({ kind: "message", text: "hello" });
const next = await session.in.once<MyEvent>({ timeoutMs: 30_000 });
```
`sessions.open(id).in` also has `send`, `on(handler)`, `peek`, `wait` (suspends the run, only inside
`task.run()`), and `waitWithIdleTimeout`. `.out` has `append`, `pipe`, `writer`, `read`,
`writeControl`, and `trimTo`. List with `sessions.list({ type, tag, status, ... })` (`for await`),
mutate with `sessions.update`, end with `sessions.close` (terminal, idempotent).
### 2. Durable sub-agent as a streaming tool
`AgentChat` inside an AI SDK `tool()` delegates to a durable sub-agent; its response streams as
preliminary tool results. Give the tool a `toModelOutput` so the model sees a compact summary.
```ts
import { tool } from "ai";
import { AgentChat } from "@trigger.dev/sdk/chat";
import { z } from "zod";
const researchTool = tool({
description: "Delegate research to a specialist agent.",
inputSchema: z.object({ topic: z.string() }),
execute: async function* ({ topic }, { abortSignal }) {
const chat = new AgentChat({ agent: "research-agent" });
const stream = await chat.sendMessage(topic, { abortSignal });
yield* stream.messages(); // UIMessage snapshots become preliminary tool results
await chat.close();
},
toModelOutput: ({ output: message }) => {
const lastText = message?.parts?.findLast((p: { type: string }) => p.type === "text") as
| { text?: string }
| undefined;
return { type: "text", value: lastText?.text ?? "Done." };
},
});
```
For a subtask exposed via `execute: ai.toolExecute(task)`, stream progress to the agent's run with
`chat.stream.writer({ target: "root" })`. `target` accepts `"self" | "parent" | "root" | <runId>`.
Inside the subtask, read context with `ai.toolCallId()` and `ai.chatContextOrThrow<typeof myChat>()`
(`{ chatId, turn, continuation, clientData }`).
```ts
import { chat, ai } from "@trigger.dev/sdk/ai";
const { waitUntilComplete } = chat.stream.writer({
target: "root",
execute: ({ write }) =>
write({ type: "data-research-status", id: partId, data: { query, status: "in-progress" } }),
});
await waitUntilComplete();
```
### 3. Background injection: defer + inject
`chat.defer(promise)` runs work in parallel with streaming (all deferred promises are awaited, with a
5s timeout, before `onTurnComplete`). `chat.inject(messages)` queues `ModelMessage[]` that drain at
the next turn start or `prepareStep` boundary.
```ts
export const myChat = chat.agent({
id: "my-chat",
onTurnComplete: async ({ messages }) => {
chat.defer(
(async () => {
const analysis = await analyzeConversation(messages);
chat.inject([{ role: "system", content: `[Analysis]\n\n${analysis}` }]);
})()
);
},
run: async ({ messages, signal }) =>
streamText({ ...chat.toStreamTextOptions({ registry }), messages, abortSignal: signal, stopWhen: stepCountIs(15) }),
});
```
### 4. Compaction (threshold-based)
`compaction.shouldCompact` decides when, `summarize` produces the summary that replaces the model
messages. UI messages are preserved by default (customize via `compactUIMessages`). The `prepareStep`
that performs inner-loop compaction is auto-injected by `chat.toStreamTextOptions()`; a `prepareStep`
you pass after the spread wins.
```ts
compaction: {
shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000,
summarize: async ({ messages }) =>
(await generateText({
model: anthropic("claude-haiku-4-5"),
messages: [...messages, { role: "user", content: "Summarize concisely." }],
})).text,
},
```
### 5. Actions: mutate state without a turn
`actionSchema` validates; `onAction` mutates via `chat.history` (`slice`, `replace`, `rollbackTo`,
`remove`, `getPendingToolCalls`, `extractNewToolResults`). Actions fire `hydrateMessages` and
`onAction` only, never `run()` or the turn hooks. Return a `StreamTextResult`, string, or `UIMessage`
to also emit a model response.
```ts
export const myChat = chat.agent({
id: "my-chat",
actionSchema: z.discriminatedUnion("type", [
z.object({ type: z.literal("undo") }),
z.object({ type: z.literal("rollback"), targetMessageId: z.string() }),
]),
onAction: async ({ action }) => {
if (action.type === "undo") chat.history.slice(0, -2);
if (action.type === "rollback") chat.history.rollbackTo(action.targetMessageId);
},
run: async ({ messages, signal }) => streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal }),
});
```
Send from the browser with `transport.sendAction(chatId, { type: "undo" })`, or server-side with
`agentChat.sendAction({ type: "rollback", targetMessageId: "msg-3" })`.
### 6. Fast starts: Head Start
`chat.headStart` (from `@trigger.dev/sdk/chat-server`, NOT `/ai`) returns a Web Fetch handler that
serves turn 1 from your own warm process, then hands off to the agent on turn 2+. Tools passed here
must be **schema-only** (a module importing `ai` + `zod` only); heavy executes stay in the task.
```ts
import { chat } from "@trigger.dev/sdk/chat-server";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { headStartTools } from "@/lib/chat-tools/schemas";
export const chatHandler = chat.headStart({
agentId: "my-chat",
run: async ({ chat: helper }) =>
streamText({
...helper.toStreamTextOptions({ tools: headStartTools }),
model: anthropic("claude-sonnet-4-6"),
system: "You are helpful.",
stopWhen: stepCountIs(15),
}),
});
// Next.js: export const POST = chatHandler; Transport: headStart: "/api/chat"
```
Node-only frameworks wrap a Web Fetch handler with `chat.toNodeListener(handler)`. Use the **same
model** on both sides to avoid a tone shift between turn 1 and turn 2+.
### 7. chat.local: init in onBoot, not onChatStart
`chat.local<T>({ id })` is module-level, shallow-proxy, run-scoped state. Initialize it in `onBoot`
(fires on every fresh worker, including continuation runs), never `onChatStart`.
```ts
const userContext = chat.local<{ name: string; plan: "free" | "pro" }>({ id: "userContext" });
export const myChat = chat.agent({
id: "my-chat",
onBoot: async ({ clientData }) => userContext.init({ name: "Alice", plan: "pro" }),
run: async ({ messages, signal }) => streamText({ /* ... */ }),
});
```
### 8. Pending messages (mid-stream user input)
A message sent while a turn is streaming should NOT cancel the stream. Configure
`pendingMessages` (`shouldInject`, `prepare`, `onReceived`, `onInjected`) on the agent so the SDK's
auto-injected `prepareStep` folds them in at the next boundary. On the frontend, `usePendingMessages`
returns `pending`, `steer(text)`, `queue(text)`, and `promoteToSteering(id)`; send via
`transport.sendPendingMessage(chatId, uiMessage, metadata?)`.
### 9. Recovery and version upgrades
`onRecoveryBoot` fires only when a **partial assistant message exists on the tail** (interrupted
deploy, crash, OOM retry). It does NOT fire on `chat.requestUpgrade()`, which is a graceful exit with
no partial. `chat.requestUpgrade()` (called in `onTurnStart` / `onValidateMessages` to skip `run()`,
or in `run()` / `chat.defer()` to exit after the turn) rotates the Session's `currentRunId` to a run
on the latest deployment without a client reconnect. Pair it with a contract version on `clientData`.
```ts
const SUPPORTED_VERSIONS = new Set(["v2", "v3"]);
onTurnStart: async ({ clientData }) => {
if (clientData?.protocolVersion && !SUPPORTED_VERSIONS.has(clientData.protocolVersion)) {
chat.requestUpgrade();
}
},
```
For OOM resilience, set `oomMachine` (and `machine`) on the agent so retries land on a larger preset.
### 10. Offline testing with mockChatAgent
`@trigger.dev/sdk/ai/test` runs the real turn loop in-memory. Import it **before** the agent module
so the resource catalog is installed. Drive with `sendMessage`, `sendRegenerate`, `sendAction`,
`sendStop`, `sendHeadStart`, `sendHandover`; seed state with `seedSnapshot` / `seedSessionOutTail` /
`seedSessionOutPartial` / `seedSessionInTail`; assert against `turn.chunks` and `harness.allChunks`.
```ts
import { mockChatAgent } from "@trigger.dev/sdk/ai/test"; // BEFORE the agent module
import { myChatAgent } from "./my-chat.js";
const harness = mockChatAgent(myChatAgent, { chatId: "test-1", clientData: { model } });
try {
const turn = await harness.sendMessage({ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] });
// assert against turn.chunks
} finally {
await harness.close();
}
```
Options include `mode` (`"preload" | "submit-message" | "handover-prepare" | "continuation"`),
`preload`, `continuation`, `previousRunId`, `snapshot`, `taskContext`, and `setupLocals`. Set
`taskContext.ctx.attempt.number > 1` to simulate an OOM-retry attempt. `runInMockTaskContext` drives a
non-chat task offline.
### 11. Custom transport: the wire protocol
Endpoints: `POST /api/v1/sessions` (create), `GET /realtime/v1/sessions/{id}/out` (SSE),
`POST /realtime/v1/sessions/{id}/in/append`, `POST /api/v1/sessions/{id}/close`. `ChatInputChunk` is
`{ kind: "message"; payload: ChatTaskWirePayload } | { kind: "stop"; message? }`. The
`ChatTaskWirePayload` carries `chatId`, `trigger` (`submit-message | regenerate-message | preload |
close | action | handover-prepare`), `message?`, `metadata?`, `action?`, `continuation?`,
`previousRunId?`, and more. Control records are header-form: `trigger-control: turn-complete` (with
optional `public-access-token`, `session-in-event-id`) and `trigger-control: upgrade-required`. The
TS helpers `SSEStreamSubscription` and `controlSubtype(headers)` (documented in
`docs/ai-chat/client-protocol.mdx`) handle batch decoding and control-record filtering for you.
## Common mistakes
- **CRITICAL: sending a follow-up by re-POSTing `POST /api/v1/sessions`.**
```ts
// Wrong - a cached re-POST silently drops basePayload.message; basePayload is trigger config, not a channel
await fetch("/api/v1/sessions", { method: "POST", body: JSON.stringify({ ...createBody }) });
// Correct - append to the session's input channel
await fetch(`/realtime/v1/sessions/${id}/in/append`, { method: "POST", body: JSON.stringify({ kind: "message", payload }) });
```
- **Using the wrong token for `.in` / `.out`.** Use `publicAccessToken` from the create response
body (session-scoped). The `x-trigger-jwt` response header is run-scoped and cannot subscribe.
- **Initializing `chat.local` in `onChatStart`.** It is skipped on continuation runs, so `run()`
crashes with `chat.local can only be modified after initialization`. Init in `onBoot`.
- **`chat.defer` for the message-history write.** A mid-stream refresh would read `[]`. `await` that
write inline before the model streams; reserve `chat.defer` for analytics, audit, cache warming.
- **Giving the HITL tool an `execute`.** `streamText` calls it immediately. Leave it execute-less;
the frontend supplies the answer via `addToolOutput` + `sendAutomaticallyWhen`.
- **Declaring sub-agent / heavy tools only on `streamText`.** Also declare them on
`chat.agent({ tools })` (or pass to `convertToModelMessages(uiMessages, { tools })` in a custom
agent) so `toModelOutput` re-applies on every turn.
- **Importing heavy-execute tools into the Head Start route module.** This is a build-time import
chain problem; runtime strip helpers do not fix it. Keep schemas in an `ai` + `zod`-only module.
- **Returning a megabyte tool output on the stream.** One `tool-output-available` record over ~1 MiB
throws `ChatChunkTooLargeError`. Persist to your store, write the row first, then emit only an id.
- **Setting `X-Peek-Settled: 1` on the active-send path.** It races the new turn's first chunk and
closes the stream early. Use it only on reconnect-on-reload paths.
> Note on docs vocabulary: agent-side examples in some docs still use the legacy
> `trigger:turn-complete` chunk type. That is the agent-emit vocabulary. A custom **reader** must
> filter on the `trigger-control` header, not on `chunk.type`.
>
> MCP-driven agent chats (`list_agents`, `start_agent_chat`, `send_agent_message`,
> `close_agent_chat`) are MCP server tools used from Claude Code / Cursor, not importable SDK
> functions. See `/mcp-tools#agent-chat-tools`.
## References
- `authoring-chat-agent` skill - the everyday `chat.agent({...})` definition, lifecycle hooks, and
the `useTriggerChatTransport` happy path. Start there before reaching for this skill.
- `realtime-and-frontend` skill - Realtime hooks and frontend streaming beyond the chat transport.
- `authoring-tasks` skill - base `task()` semantics, `ctx`, and standard lifecycle hooks.
Reference docs ship beside this skill in the same package, read them locally (no network), pinned to your installed version. The `sources:` frontmatter above lists every doc this skill draws from, all under `@trigger.dev/sdk/docs/ai-chat/` (including `patterns/`). For HITL, sessions, and sub-agents start with `sessions.mdx`, `server-chat.mdx`, `client-protocol.mdx`, `patterns/human-in-the-loop.mdx`, `patterns/sub-agents.mdx`.
## Version
This skill is bundled inside `@trigger.dev/sdk` and read directly from `node_modules`, so it always matches your installed SDK version (see the adjacent `package.json`). The full documentation for these APIs ships alongside it under `@trigger.dev/sdk/docs/`.
@@ -0,0 +1,276 @@
---
name: realtime-and-frontend
description: >
Trigger.dev client/frontend surface: subscribe to runs in realtime
(runs.subscribeToRun and the @trigger.dev/react-hooks hook useRealtimeRun),
consume metadata and AI/text streams in React (useRealtimeStream), trigger
tasks from the browser (useTaskTrigger, useRealtimeTaskTrigger), and mint
scoped frontend credentials with auth.createPublicToken /
auth.createTriggerPublicToken.
Load when wiring a frontend (React/Next.js/Remix) or backend-for-frontend to
show live run progress, status badges, token streams, trigger buttons, or
wait-token approval UIs. NOT for writing the backend task itself (streams.define
/ metadata.set is authoring-tasks territory); this is the consumer side.
type: core
library: trigger.dev
sources:
- docs/realtime/overview.mdx
- docs/realtime/how-it-works.mdx
- docs/realtime/auth.mdx
- docs/realtime/run-object.mdx
- docs/realtime/react-hooks/overview.mdx
- docs/realtime/react-hooks/subscribe.mdx
- docs/realtime/react-hooks/triggering.mdx
- docs/realtime/react-hooks/streams.mdx
- docs/realtime/react-hooks/swr.mdx
- docs/realtime/react-hooks/use-wait-token.mdx
- docs/realtime/backend/subscribe.mdx
---
# Realtime and Frontend
The consumer side of Trigger.dev's run state and streams: read live run
updates, render AI/text streams, and trigger tasks from a browser. Hooks come
from `@trigger.dev/react-hooks`; token minting and backend subscription come
from `@trigger.dev/sdk`.
## Setup
```bash
npm add @trigger.dev/react-hooks # frontend hooks (React/Next.js/Remix)
# @trigger.dev/sdk is already installed for the backend
```
The flow is always: mint a scoped token in the backend, pass it to the
frontend, subscribe with a hook.
```ts
// backend (API route / server action)
import { auth } from "@trigger.dev/sdk";
const publicAccessToken = await auth.createPublicToken({
scopes: { read: { runs: ["run_1234"] } }, // a token with no scopes is useless
});
```
```tsx
// frontend
"use client";
import { useRealtimeRun } from "@trigger.dev/react-hooks";
export function RunStatus({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) {
const { run, error } = useRealtimeRun(runId, { accessToken: publicAccessToken });
if (error) return <div>Error: {error.message}</div>;
if (!run) return <div>Loading...</div>;
return <div>Run: {run.status}</div>;
}
```
There are two token kinds: Public Access Tokens (read/subscribe, from
`auth.createPublicToken`) and Trigger Tokens (trigger-from-browser, single-use,
from `auth.createTriggerPublicToken`). Both default to a 15 minute expiry.
## Core patterns
### 1. Subscribe to a run and render metadata progress
`metadata` is `Record<string, DeserializedJson>`, so nested values need a cast.
```tsx
"use client";
import { useRealtimeRun } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";
export function Progress({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) {
const { run, error } = useRealtimeRun<typeof myTask>(runId, { accessToken: publicAccessToken });
if (error) return <div>Error: {error.message}</div>;
if (!run) return <div>Loading...</div>;
const progress = run.metadata?.progress as { percentage?: number } | undefined;
return <div>{run.status}: {progress?.percentage ?? 0}%</div>;
}
```
Pass `onComplete: (run, error) => {}` to react when the run finishes.
### 2. Status-only subscription with `skipColumns`
For a badge or progress bar you do not need `payload`/`output`. Skipping them
reduces wire size and avoids "Large HTTP Payload" warnings.
```tsx
const { run } = useRealtimeRun(runId, {
accessToken: publicAccessToken,
skipColumns: ["payload", "output"],
});
```
You can skip any of: `payload`, `output`, `metadata`, `startedAt`, `delayUntil`,
`queuedAt`, `expiredAt`, `completedAt`, `number`, `isTest`, `usageDurationMs`,
`costInCents`, `baseCostInCents`, `ttl`, `payloadType`, `outputType`, `runTags`,
`error`.
### 3. Trigger from the browser with a Trigger Token
`accessToken` here is a Trigger Token (`auth.createTriggerPublicToken`), not a
Public Access Token.
```tsx
"use client";
import { useTaskTrigger } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";
export function TriggerButton({ triggerToken }: { triggerToken: string }) {
const { submit, handle, isLoading } = useTaskTrigger<typeof myTask>("my-task", {
accessToken: triggerToken,
});
if (handle) return <div>Run ID: {handle.id}</div>;
return (
<button onClick={() => submit({ foo: "bar" }, { tags: ["user:123"] })} disabled={isLoading}>
{isLoading ? "Triggering..." : "Run"}
</button>
);
}
```
`submit(payload, options?)` takes the same options as a backend `trigger` call.
### 4. Trigger and subscribe in one hook
```tsx
"use client";
import { useRealtimeTaskTrigger } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";
export function Runner({ publicAccessToken }: { publicAccessToken: string }) {
const { submit, run, isLoading } = useRealtimeTaskTrigger<typeof myTask>("my-task", {
accessToken: publicAccessToken,
});
if (run) return <div>{run.status}</div>;
return <button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>Run</button>;
}
```
Use `useRealtimeTaskTriggerWithStreams<typeof myTask, STREAMS>` when you also
want the task's streams (it returns `{ submit, run, streams, error, isLoading }`).
### 5. Consume an AI/text stream (SDK 4.1.0+, recommended)
`useRealtimeStream` takes a defined stream for full type safety, or a `runId`
plus optional stream key. Returns `{ parts, error }`.
```tsx
"use client";
import { useRealtimeStream } from "@trigger.dev/react-hooks";
import { aiStream } from "@/trigger/streams"; // a defined stream -> typed parts
export function StreamView({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) {
const { parts, error } = useRealtimeStream(aiStream, runId, {
accessToken: publicAccessToken,
timeoutInSeconds: 300, // default 60
onData: (chunk) => console.log(chunk),
});
if (error) return <div>Error: {error.message}</div>;
if (!parts) return <div>Loading...</div>;
return <div>{parts.join("")}</div>;
}
```
Without a defined stream: `useRealtimeStream<string>(runId, "ai-output", { accessToken })`,
or omit the key to use the default stream. Other options: `baseURL`, `startIndex`,
`throttleInMs` (default 16). The legacy `useRealtimeRunWithStreams(runId, options)`
hook is still supported when you need both the run and all its streams at once.
### 6. Send input back into a running task
```tsx
"use client";
import { useInputStreamSend } from "@trigger.dev/react-hooks";
import { approval } from "@/trigger/streams";
export function ApprovalForm({ runId, accessToken }: { runId: string; accessToken: string }) {
const { send, isLoading, isReady } = useInputStreamSend(approval.id, runId, { accessToken });
return (
<button disabled={!isReady || isLoading} onClick={() => send({ approved: true })}>
Approve
</button>
);
}
```
### 7. Complete a wait token from React
```ts
// backend: create the token, return id + publicAccessToken to the frontend
import { wait } from "@trigger.dev/sdk";
const token = await wait.createToken({ timeout: "10m" });
return { tokenId: token.id, publicToken: token.publicAccessToken };
```
```tsx
"use client";
import { useWaitToken } from "@trigger.dev/react-hooks";
export function Approve({ tokenId, publicToken }: { tokenId: string; publicToken: string }) {
const { complete } = useWaitToken(tokenId, { accessToken: publicToken });
return <button onClick={() => complete({ approved: true })}>Approve</button>;
}
```
### 8. Subscribe from the backend (async iterators)
```ts
import { runs, tasks } from "@trigger.dev/sdk";
import type { myTask } from "./trigger/my-task";
const handle = await tasks.trigger("my-task", { some: "data" });
for await (const run of runs.subscribeToRun<typeof myTask>(handle.id)) {
console.log(run.payload.some, run.output?.some); // typed
}
```
`runs.subscribeToRun` completes when the run finishes, so the loop exits on its own.
## Common mistakes
1. **CRITICAL: Triggering from the browser with a Public Access Token.** The
read token from `createPublicToken` cannot trigger tasks.
- Wrong: `useTaskTrigger("my-task", { accessToken: publicAccessTokenFromCreatePublicToken })`
- Correct: mint a single-use Trigger Token with `auth.createTriggerPublicToken("my-task")` and pass that.
2. **Token with no scopes.** A scopeless token authorizes nothing, so every subscribe 403s.
- Wrong: `await auth.createPublicToken()`
- Correct: `await auth.createPublicToken({ scopes: { read: { runs: ["run_1234"] } } })`
3. **Polling with `useRun`/SWR for live updates.** `useRun` is the SWR-based
management-API hook (not recommended for live state); set `refreshInterval: 0`
to stop polling if you do use it.
- Wrong: `useRun(runId, { refreshInterval: 1000 })` to track progress
- Correct: `useRealtimeRun(runId, { accessToken })` (no polling, no WebSocket setup)
4. **Forgetting `"use client"`.** Realtime/trigger hooks cannot run in a server component.
- Wrong: a Next.js App Router server component using `useRealtimeRun`
- Correct: put `"use client";` at the top of any component using these hooks.
5. **Shipping `payload`/`output` you do not render.**
- Wrong: `useRealtimeRun(runId, { accessToken })` for a status badge (large payloads over the wire)
- Correct: `useRealtimeRun(runId, { accessToken, skipColumns: ["payload", "output"] })`
6. **Subscribing before the handle exists.**
- Wrong: `useRealtimeRun(handle, { accessToken: handle?.publicAccessToken })` with no guard
- Correct: add `enabled: !!handle` so it subscribes only once the trigger returns a handle.
## References
Sibling skills:
- `authoring-tasks` for the task side: `streams.define()`, `metadata.set()`, and `wait.createToken`.
- `authoring-chat-agent` and `chat-agent-advanced` for chat agents, which build on these realtime streams.
Reference docs ship beside this skill in the same package, read them locally (no network), pinned to your installed version. The `sources:` frontmatter above lists every doc this skill draws from, all under `@trigger.dev/sdk/docs/`. Start with:
- `@trigger.dev/sdk/docs/realtime/react-hooks/subscribe.mdx`
- `@trigger.dev/sdk/docs/realtime/react-hooks/streams.mdx`
- `@trigger.dev/sdk/docs/realtime/auth.mdx`
- `@trigger.dev/sdk/docs/realtime/run-object.mdx` (the realtime run object differs from the management-API object returned by `useRun`)
## Version
This skill is bundled inside `@trigger.dev/sdk` and read directly from `node_modules`, so it always matches your installed SDK version (see the adjacent `package.json`). The full documentation for these APIs ships alongside it under `@trigger.dev/sdk/docs/`.
+111
View File
@@ -0,0 +1,111 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
// Snapshots the curated docs that the bundled agent skills cite into the SDK package, so
// AI coding agents can read the version-pinned reference directly from node_modules
// (zero drift). Run as part of `@trigger.dev/sdk`'s build, from the package dir.
//
// The "manifest" is the union of every `sources:` entry across the SDK's bundled skills
// (skills/*/SKILL.md). The skill declares what it needs; the build copies exactly that.
// Add a `sources:` line to a skill and its doc ships automatically — nothing else to edit.
//
// Layout: a source `docs/tasks/overview.mdx` (relative to the repo root) is copied to
// `<sdk>/docs/tasks/overview.mdx`, so a skill at `<sdk>/skills/<name>/SKILL.md` reaches it
// at `../../docs/tasks/overview.mdx` and an agent reaches it at `@trigger.dev/sdk/docs/...`.
const packageDir = process.cwd(); // packages/trigger-sdk when run from the SDK build
const repoRoot = path.resolve(packageDir, "..", "..");
const skillsDir = path.join(packageDir, "skills");
const outDir = path.join(packageDir, "docs");
/** Pull the `sources:` list out of a SKILL.md YAML frontmatter block (simple line scan, no YAML dep). */
async function readSkillSources(skillMdPath: string): Promise<string[]> {
const txt = await fs.readFile(skillMdPath, "utf8");
const fm = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!fm) return [];
const lines = fm[1].split(/\r?\n/);
const sources: string[] = [];
let inSources = false;
for (const line of lines) {
if (/^sources:\s*$/.test(line)) {
inSources = true;
continue;
}
if (inSources) {
const item = line.match(/^\s*-\s*(.+?)\s*$/);
if (item) {
sources.push(item[1]);
continue;
}
// A non-list, non-blank line ends the block (next top-level key).
if (line.trim() !== "") break;
}
}
return sources;
}
async function collectManifest(): Promise<string[]> {
const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => []);
const all = new Set<string>();
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillMd = path.join(skillsDir, entry.name, "SKILL.md");
const sources = await readSkillSources(skillMd).catch(() => []);
for (const s of sources) {
// Only bundle docs paths; ignore anything that isn't a docs/*.mdx source.
if (s.startsWith("docs/") && s.endsWith(".mdx")) all.add(s);
}
}
return [...all].sort();
}
async function bundleSdkDocs() {
const manifest = await collectManifest();
if (manifest.length === 0) {
// Fail the build rather than silently ship the SDK with stale or missing docs.
throw new Error("[bundleSdkDocs] no doc sources found in skills/*/SKILL.md");
}
// Rebuild from scratch so removed sources don't linger in the package.
await fs.rm(outDir, { recursive: true, force: true });
const missing: string[] = [];
let copied = 0;
for (const rel of manifest) {
const src = path.join(repoRoot, rel);
try {
await fs.access(src);
} catch {
missing.push(rel);
continue;
}
// Strip the leading "docs/" so files land at <sdk>/docs/<subpath>.
const dest = path.join(outDir, rel.slice("docs/".length));
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.copyFile(src, dest);
copied++;
}
if (missing.length > 0) {
console.error(
`[bundleSdkDocs] ${missing.length} doc source(s) cited by a skill do not exist:\n` +
missing.map((m) => ` - ${m}`).join("\n") +
`\nFix the skill's sources: list or add the doc.`
);
process.exit(1);
}
console.log(`[bundleSdkDocs] bundled ${copied} docs into ${path.relative(repoRoot, outDir)}`);
}
bundleSdkDocs().catch((e) => {
console.error(e);
process.exit(1);
});