chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,296 @@
---
name: trigger-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
- `trigger-chat-agent-advanced` skill - lifecycle hooks in depth, sessions, raw-task primitives
(`chat.createSession`, `chat.customAgent`, `chat.stream`), compaction, HITL approvals, recovery.
- `trigger-realtime-and-frontend` skill - Realtime hooks and frontend streaming beyond the chat transport.
- `trigger-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`.
A `chat.agent` is a Trigger.dev task, so it builds and deploys like any other. For `trigger.config.ts` and build extensions (Prisma, Playwright, Python, FFmpeg, etc. — e.g. when a tool needs them), read the bundled config docs under `@trigger.dev/sdk/docs/config/` (extensions are in `config/extensions/`, starting with `overview.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: trigger-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`, `playwright`, `ffmpeg`, `pythonExtension`, `aptGet`, `syncEnvVars`, etc.) come from `@trigger.dev/build`. `telemetry` configures instrumentations and exporters. Each extension has its own setup doc, all bundled under `@trigger.dev/sdk/docs/config/extensions/` (start with `overview.mdx`); read the one you need before wiring it up rather than guessing the API.
### 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:
- **trigger-realtime-and-frontend** for subscribing to runs and triggering from the frontend with React hooks.
- **trigger-authoring-chat-agent** and **trigger-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,368 @@
---
name: trigger-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 trigger-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
- `trigger-authoring-chat-agent` skill - the everyday `chat.agent({...})` definition, lifecycle hooks, and
the `useTriggerChatTransport` happy path. Start there before reaching for this skill.
- `trigger-realtime-and-frontend` skill - Realtime hooks and frontend streaming beyond the chat transport.
- `trigger-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`.
For `trigger.config.ts` and build extensions a chat-agent task may need (Prisma, Playwright, Python, etc.), read the bundled config docs under `@trigger.dev/sdk/docs/config/` (`config/extensions/` for the per-extension setup).
## 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,116 @@
---
name: trigger-cost-savings
description: >
Analyze Trigger.dev tasks, schedules, and runs for cost optimization opportunities. Use when
asked to reduce spend, optimize costs, audit usage, right-size machines, or review task
efficiency. Combines static source analysis with live run analysis via the Trigger.dev MCP
tools (list_runs, get_run_details, get_current_worker).
type: core
library: trigger.dev
sources:
- docs/how-to-reduce-your-spend.mdx
- docs/machines.mdx
- docs/runs/max-duration.mdx
- docs/queue-concurrency.mdx
- docs/idempotency.mdx
- docs/triggering.mdx
- docs/errors-retrying.mdx
- docs/limits.mdx
---
# Trigger.dev Cost Savings Analysis
Analyze task runs and configurations to find cost reduction opportunities. This skill pairs static source analysis with live run analysis via the Trigger.dev MCP server.
## Before you start: read the canonical guidance
The authoritative, version-pinned cost guidance ships beside this skill. Read it first so your recommendations match the installed SDK version:
- `@trigger.dev/sdk/docs/how-to-reduce-your-spend.mdx` — the canonical "reduce your spend" guide (machine sizing, idempotency de-dup, parallelism, retries, `maxDuration`, checkpointed waits, debounce).
- Supporting references: `@trigger.dev/sdk/docs/machines.mdx`, `runs/max-duration.mdx`, `queue-concurrency.mdx`, `idempotency.mdx`, `triggering.mdx` (debounce + batch), `errors-retrying.mdx` (`AbortTaskRunError`).
## Prerequisites: MCP tools
Live run analysis needs the **Trigger.dev MCP server**. Verify these tools are available:
- `list_runs` — list runs with filters (status, task, time period, machine size)
- `get_run_details` — get run logs, duration, and status
- `get_current_worker` — get registered tasks and their configurations
If they're not available, tell the user to install the MCP server:
```bash
npx trigger.dev@latest install-mcp
```
Without the MCP tools you can still do the static source analysis below; do not fabricate run data.
## Analysis workflow
### Step 1: Static analysis (source code)
Scan task files for:
1. **Oversized machines** — tasks on `large-1x`/`large-2x` without clear need.
2. **Missing `maxDuration`** — no execution-time limit (runaway-cost risk).
3. **Excessive retries**`maxAttempts` > 5 without `AbortTaskRunError` for known-permanent failures.
4. **Missing debounce** — high-frequency triggers without debounce.
5. **Missing idempotency** — payment/critical tasks without idempotency keys.
6. **Polling instead of waits**`setTimeout`/`setInterval`/sleep loops instead of `wait.for()`.
7. **Short waits**`wait.for()` under 5 seconds (not checkpointed, wastes compute).
8. **Sequential instead of batch** — multiple `triggerAndWait()` calls that could be `batchTriggerAndWait()`.
9. **Over-scheduled crons** — schedules firing more often than needed.
### Step 2: Run analysis (requires MCP tools)
- **2a. Expensive tasks** — `list_runs` over `period: "30d"`/`"7d"`; find high total compute (duration × count), high failure rates, and large machines with short durations (over-provisioned).
- **2b. Failure patterns** — `list_runs` with `status: "FAILED"`/`"CRASHED"`; separate transient (retryable) from permanent; suggest `AbortTaskRunError` for the latter; estimate wasted retry compute.
- **2c. Machine utilization** — `get_run_details` on sample runs; if a `large-2x` task consistently runs in under a second, or is I/O-bound (API/DB), it's over-provisioned.
- **2d. Schedule frequency** — `get_current_worker` to list cron patterns; flag schedules that are too frequent for their purpose.
### Step 3: Generate recommendations
Present a prioritized report with estimated impact:
```markdown
## Cost Optimization Report
### High impact
1. **Right-size `process-images`** — currently `large-2x`, average run 2s. `small-2x` could cut this task's cost by ~16x.
`machine: { preset: "small-2x" }` // was "large-2x"
### Medium impact
2. **Debounce `sync-user-data`** — 847 runs/day, often bursty.
`debounce: { key: \`user-${userId}\`, delay: "5s" }`
### Low impact / best practice
3. **Add `maxDuration` to `generate-report`** — no timeout configured.
`maxDuration: 300` // 5 minutes
```
## Machine preset costs (relative)
Larger machines cost proportionally more per second of compute:
| Preset | vCPU | RAM | Relative cost |
|--------|------|-----|---------------|
| micro | 0.25 | 0.25 GB | 0.25x |
| small-1x | 0.5 | 0.5 GB | 1x (baseline) |
| small-2x | 1 | 1 GB | 2x |
| medium-1x | 1 | 2 GB | 2x |
| medium-2x | 2 | 4 GB | 4x |
| large-1x | 4 | 8 GB | 8x |
| large-2x | 8 | 16 GB | 16x |
## Key principles
- **Waits > 5 seconds are free** — checkpointed, no compute charge.
- **Start small, scale up** — the default `small-1x` is right for most tasks.
- **I/O-bound tasks don't need big machines** — API calls and DB queries wait on the network.
- **Debounce saves the most on high-frequency tasks** — it consolidates bursts into single runs.
- **Idempotency prevents duplicate billed work** — especially for expensive operations.
- **`AbortTaskRunError` stops wasteful retries** — don't pay to retry permanent failures.
## 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 cost documentation ships alongside it under `@trigger.dev/sdk/docs/`.
@@ -0,0 +1,276 @@
---
name: trigger-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 trigger-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:
- `trigger-authoring-tasks` for the task side: `streams.define()`, `metadata.set()`, and `wait.createToken`.
- `trigger-authoring-chat-agent` and `trigger-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/`.