From 87f8df6e98901772846f3f58af9dd2580f72c6ad Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 13 Jun 2026 07:34:48 +0100 Subject: [PATCH 1/2] feat(sdk,cli): bundle agent skills + docs in the SDK for zero-drift @trigger.dev/sdk now ships the agent skills and a curated snapshot of the docs the skills cite. The skills the CLI installs into your coding agent (.claude/skills and friends) are thin pointers that read this content directly from node_modules, so the guidance always matches the SDK version in your project instead of going stale until the next reinstall. --- .changeset/agent-skills-bundled-in-sdk.md | 6 + .../skills/authoring-chat-agent/SKILL.md | 247 +----------- .../cli-v3/skills/authoring-tasks/SKILL.md | 210 +--------- .../skills/chat-agent-advanced/SKILL.md | 309 +-------------- .../skills/realtime-and-frontend/SKILL.md | 233 +---------- packages/trigger-sdk/.gitignore | 3 + packages/trigger-sdk/package.json | 9 +- .../skills/authoring-chat-agent/SKILL.md | 293 ++++++++++++++ .../skills/authoring-tasks/SKILL.md | 254 ++++++++++++ .../skills/chat-agent-advanced/SKILL.md | 365 ++++++++++++++++++ .../skills/realtime-and-frontend/SKILL.md | 280 ++++++++++++++ scripts/bundleSdkDocs.ts | 111 ++++++ 12 files changed, 1341 insertions(+), 979 deletions(-) create mode 100644 .changeset/agent-skills-bundled-in-sdk.md create mode 100644 packages/trigger-sdk/.gitignore create mode 100644 packages/trigger-sdk/skills/authoring-chat-agent/SKILL.md create mode 100644 packages/trigger-sdk/skills/authoring-tasks/SKILL.md create mode 100644 packages/trigger-sdk/skills/chat-agent-advanced/SKILL.md create mode 100644 packages/trigger-sdk/skills/realtime-and-frontend/SKILL.md create mode 100644 scripts/bundleSdkDocs.ts diff --git a/.changeset/agent-skills-bundled-in-sdk.md b/.changeset/agent-skills-bundled-in-sdk.md new file mode 100644 index 00000000000..2c3ae96f521 --- /dev/null +++ b/.changeset/agent-skills-bundled-in-sdk.md @@ -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. diff --git a/packages/cli-v3/skills/authoring-chat-agent/SKILL.md b/packages/cli-v3/skills/authoring-chat-agent/SKILL.md index 257108d05de..1a84612e028 100644 --- a/packages/cli-v3/skills/authoring-chat-agent/SKILL.md +++ b/packages/cli-v3/skills/authoring-chat-agent/SKILL.md @@ -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:** `node_modules/@trigger.dev/sdk/docs/ai-chat/` — exhaustive detail. 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({ - 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()` 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({ 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>` (or, for -tools only, `InferChatUIMessageFromTools` from `@trigger.dev/sdk/ai`). On the -frontend, narrow `useChat` with `InferChatUIMessage` 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). diff --git a/packages/cli-v3/skills/authoring-tasks/SKILL.md b/packages/cli-v3/skills/authoring-tasks/SKILL.md index 6ff10209dda..eeb64f7eb3b 100644 --- a/packages/cli-v3/skills/authoring-tasks/SKILL.md +++ b/packages/cli-v3/skills/authoring-tasks/SKILL.md @@ -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:** `node_modules/@trigger.dev/sdk/docs/` — exhaustive detail. 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( - "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(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: "", - 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). diff --git a/packages/cli-v3/skills/chat-agent-advanced/SKILL.md b/packages/cli-v3/skills/chat-agent-advanced/SKILL.md index 68ce1c6b066..cde66be8e3f 100644 --- a/packages/cli-v3/skills/chat-agent-advanced/SKILL.md +++ b/packages/cli-v3/skills/chat-agent-advanced/SKILL.md @@ -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:** `node_modules/@trigger.dev/sdk/docs/ai-chat/` (incl. `patterns/`) — exhaustive detail. 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({ 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({ 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" | `. -Inside the subtask, read context with `ai.toolCallId()` and `ai.chatContextOrThrow()` -(`{ 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({ 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). diff --git a/packages/cli-v3/skills/realtime-and-frontend/SKILL.md b/packages/cli-v3/skills/realtime-and-frontend/SKILL.md index 811fb7d1ff1..309a0970be9 100644 --- a/packages/cli-v3/skills/realtime-and-frontend/SKILL.md +++ b/packages/cli-v3/skills/realtime-and-frontend/SKILL.md @@ -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:** `node_modules/@trigger.dev/sdk/docs/realtime/` — exhaustive detail. 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
Error: {error.message}
; - if (!run) return
Loading...
; - return
Run: {run.status}
; -} -``` - -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`, 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(runId, { accessToken: publicAccessToken }); - if (error) return
Error: {error.message}
; - if (!run) return
Loading...
; - const progress = run.metadata?.progress as { percentage?: number } | undefined; - return
{run.status}: {progress?.percentage ?? 0}%
; -} -``` - -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("my-task", { - accessToken: publicAccessToken, - }); - if (handle) return
Run ID: {handle.id}
; - return ( - - ); -} -``` - -`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("my-task", { - accessToken: publicAccessToken, - }); - if (run) return
{run.status}
; - return ; -} -``` - -Use `useRealtimeTaskTriggerWithStreams` 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
Error: {error.message}
; - if (!parts) return
Loading...
; - return
{parts.join("")}
; -} -``` - -Without a defined stream: `useRealtimeStream(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 ( - - ); -} -``` - -### 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 ; -} -``` - -### 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(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(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). diff --git a/packages/trigger-sdk/.gitignore b/packages/trigger-sdk/.gitignore new file mode 100644 index 00000000000..82884714837 --- /dev/null +++ b/packages/trigger-sdk/.gitignore @@ -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 diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index 7162bf023b2..2bbd1519de2 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -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", diff --git a/packages/trigger-sdk/skills/authoring-chat-agent/SKILL.md b/packages/trigger-sdk/skills/authoring-chat-agent/SKILL.md new file mode 100644 index 00000000000..e83f32b43d7 --- /dev/null +++ b/packages/trigger-sdk/skills/authoring-chat-agent/SKILL.md @@ -0,0 +1,293 @@ +--- +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({ + 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()` 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({ 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>` (or, for +tools only, `InferChatUIMessageFromTools` from `@trigger.dev/sdk/ai`). On the +frontend, narrow `useChat` with `InferChatUIMessage` 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. +- Docs: /ai-chat/quick-start, /ai-chat/backend, /ai-chat/tools, /ai-chat/types, /ai-chat/frontend + +## 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/`. diff --git a/packages/trigger-sdk/skills/authoring-tasks/SKILL.md b/packages/trigger-sdk/skills/authoring-tasks/SKILL.md new file mode 100644 index 00000000000..5c6f8b68d3f --- /dev/null +++ b/packages/trigger-sdk/skills/authoring-tasks/SKILL.md @@ -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( + "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(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: "", + 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("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. + +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 + +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/`. diff --git a/packages/trigger-sdk/skills/chat-agent-advanced/SKILL.md b/packages/trigger-sdk/skills/chat-agent-advanced/SKILL.md new file mode 100644 index 00000000000..59639eb0959 --- /dev/null +++ b/packages/trigger-sdk/skills/chat-agent-advanced/SKILL.md @@ -0,0 +1,365 @@ +--- +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({ 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({ 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" | `. +Inside the subtask, read context with `ai.toolCallId()` and `ai.chatContextOrThrow()` +(`{ 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({ 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. +- Docs: /ai-chat/sessions, /ai-chat/server-chat, /ai-chat/client-protocol + +## 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/`. diff --git a/packages/trigger-sdk/skills/realtime-and-frontend/SKILL.md b/packages/trigger-sdk/skills/realtime-and-frontend/SKILL.md new file mode 100644 index 00000000000..2427c681a9a --- /dev/null +++ b/packages/trigger-sdk/skills/realtime-and-frontend/SKILL.md @@ -0,0 +1,280 @@ +--- +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
Error: {error.message}
; + if (!run) return
Loading...
; + return
Run: {run.status}
; +} +``` + +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`, 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(runId, { accessToken: publicAccessToken }); + if (error) return
Error: {error.message}
; + if (!run) return
Loading...
; + const progress = run.metadata?.progress as { percentage?: number } | undefined; + return
{run.status}: {progress?.percentage ?? 0}%
; +} +``` + +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("my-task", { + accessToken: publicAccessToken, + }); + if (handle) return
Run ID: {handle.id}
; + return ( + + ); +} +``` + +`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("my-task", { + accessToken: publicAccessToken, + }); + if (run) return
{run.status}
; + return ; +} +``` + +Use `useRealtimeTaskTriggerWithStreams` 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
Error: {error.message}
; + if (!parts) return
Loading...
; + return
{parts.join("")}
; +} +``` + +Without a defined stream: `useRealtimeStream(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 ( + + ); +} +``` + +### 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 ; +} +``` + +### 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(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. + +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 + +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/`. diff --git a/scripts/bundleSdkDocs.ts b/scripts/bundleSdkDocs.ts new file mode 100644 index 00000000000..a20c1ea6020 --- /dev/null +++ b/scripts/bundleSdkDocs.ts @@ -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 +// `/docs/tasks/overview.mdx`, so a skill at `/skills//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 { + 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 { + const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => []); + const all = new Set(); + + 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) { + console.warn("[bundleSdkDocs] no doc sources found in skills/*/SKILL.md; skipping"); + return; + } + + // 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 /docs/. + 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); +}); From 4ed7c1ee85cf7a7be2a62c09543ef0538b3594b1 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 13 Jun 2026 09:53:35 +0100 Subject: [PATCH 2/2] feat(sdk,cli): point skills at the bundled SDK docs, not the docs site The bundled skills now reference the docs shipped alongside them in @trigger.dev/sdk (read from node_modules, pinned to your installed version) instead of the docs website, and the CLI pointer skills name the bundled doc set and its sources explicitly. An assistant reading a skill is sent to the local pinned copy. --- .../cli-v3/skills/authoring-chat-agent/SKILL.md | 2 +- packages/cli-v3/skills/authoring-tasks/SKILL.md | 2 +- .../cli-v3/skills/chat-agent-advanced/SKILL.md | 2 +- .../cli-v3/skills/realtime-and-frontend/SKILL.md | 2 +- .../skills/authoring-chat-agent/SKILL.md | 3 ++- .../trigger-sdk/skills/authoring-tasks/SKILL.md | 8 ++++---- .../skills/chat-agent-advanced/SKILL.md | 3 ++- .../skills/realtime-and-frontend/SKILL.md | 14 +++++--------- 8 files changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/cli-v3/skills/authoring-chat-agent/SKILL.md b/packages/cli-v3/skills/authoring-chat-agent/SKILL.md index 1a84612e028..0a2393a5b72 100644 --- a/packages/cli-v3/skills/authoring-chat-agent/SKILL.md +++ b/packages/cli-v3/skills/authoring-chat-agent/SKILL.md @@ -17,7 +17,7 @@ library: trigger.dev 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: - **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:** `node_modules/@trigger.dev/sdk/docs/ai-chat/` — exhaustive detail. Grep for an API, e.g. `grep -rl "toStreamTextOptions" node_modules/@trigger.dev/sdk/docs/`. +- **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/`. 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. diff --git a/packages/cli-v3/skills/authoring-tasks/SKILL.md b/packages/cli-v3/skills/authoring-tasks/SKILL.md index eeb64f7eb3b..1c4e4b7f172 100644 --- a/packages/cli-v3/skills/authoring-tasks/SKILL.md +++ b/packages/cli-v3/skills/authoring-tasks/SKILL.md @@ -16,7 +16,7 @@ library: trigger.dev 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: - **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:** `node_modules/@trigger.dev/sdk/docs/` — exhaustive detail. Grep for an API, e.g. `grep -rl "schemaTask" node_modules/@trigger.dev/sdk/docs/`. +- **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/`. 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. diff --git a/packages/cli-v3/skills/chat-agent-advanced/SKILL.md b/packages/cli-v3/skills/chat-agent-advanced/SKILL.md index cde66be8e3f..7d90464146c 100644 --- a/packages/cli-v3/skills/chat-agent-advanced/SKILL.md +++ b/packages/cli-v3/skills/chat-agent-advanced/SKILL.md @@ -18,7 +18,7 @@ library: trigger.dev 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: - **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:** `node_modules/@trigger.dev/sdk/docs/ai-chat/` (incl. `patterns/`) — exhaustive detail. Grep for an API, e.g. `grep -rl "mockChatAgent" node_modules/@trigger.dev/sdk/docs/`. +- **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/`. 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. diff --git a/packages/cli-v3/skills/realtime-and-frontend/SKILL.md b/packages/cli-v3/skills/realtime-and-frontend/SKILL.md index 309a0970be9..a722bfb5e21 100644 --- a/packages/cli-v3/skills/realtime-and-frontend/SKILL.md +++ b/packages/cli-v3/skills/realtime-and-frontend/SKILL.md @@ -20,7 +20,7 @@ library: trigger.dev 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: - **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:** `node_modules/@trigger.dev/sdk/docs/realtime/` — exhaustive detail. Grep for a hook, e.g. `grep -rl "useRealtimeRun" node_modules/@trigger.dev/sdk/docs/`. +- **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/`. 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. diff --git a/packages/trigger-sdk/skills/authoring-chat-agent/SKILL.md b/packages/trigger-sdk/skills/authoring-chat-agent/SKILL.md index e83f32b43d7..34fb3d8d239 100644 --- a/packages/trigger-sdk/skills/authoring-chat-agent/SKILL.md +++ b/packages/trigger-sdk/skills/authoring-chat-agent/SKILL.md @@ -286,7 +286,8 @@ There is no API route in this model. The transport replaces the route round-trip (`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 + +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 diff --git a/packages/trigger-sdk/skills/authoring-tasks/SKILL.md b/packages/trigger-sdk/skills/authoring-tasks/SKILL.md index 5c6f8b68d3f..7e683a8337f 100644 --- a/packages/trigger-sdk/skills/authoring-tasks/SKILL.md +++ b/packages/trigger-sdk/skills/authoring-tasks/SKILL.md @@ -243,11 +243,11 @@ 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: +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: -- [Tasks overview](https://trigger.dev/docs/tasks/overview) -- [Triggering](https://trigger.dev/docs/triggering) -- [Configuration file](https://trigger.dev/docs/config/config-file) +- `@trigger.dev/sdk/docs/tasks/overview.mdx` +- `@trigger.dev/sdk/docs/triggering.mdx` +- `@trigger.dev/sdk/docs/config/config-file.mdx` ## Version diff --git a/packages/trigger-sdk/skills/chat-agent-advanced/SKILL.md b/packages/trigger-sdk/skills/chat-agent-advanced/SKILL.md index 59639eb0959..59fda721250 100644 --- a/packages/trigger-sdk/skills/chat-agent-advanced/SKILL.md +++ b/packages/trigger-sdk/skills/chat-agent-advanced/SKILL.md @@ -358,7 +358,8 @@ TS helpers `SSEStreamSubscription` and `controlSubtype(headers)` (documented in 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 + +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 diff --git a/packages/trigger-sdk/skills/realtime-and-frontend/SKILL.md b/packages/trigger-sdk/skills/realtime-and-frontend/SKILL.md index 2427c681a9a..2c15edd03f5 100644 --- a/packages/trigger-sdk/skills/realtime-and-frontend/SKILL.md +++ b/packages/trigger-sdk/skills/realtime-and-frontend/SKILL.md @@ -265,15 +265,11 @@ 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). +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