From 1f1a3666ee36fbb009e9afa9a39208a5c7008c80 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sun, 14 Jun 2026 10:58:55 +0100 Subject: [PATCH 1/2] fix(sdk): custom agent loop parity for continuations, steering, and subtasks (#3936) ## Summary Three fixes that bring custom agent loops (`chat.customAgent` hand-rolled loops and `chat.createSession`) up to the behavior `chat.agent` users already get, and that the docs already promise: - **Continuation runs no longer replay already-answered messages.** A chat continuing after a cancel, crash, or upgrade re-delivered every prior user message into the loop's first wait, so the model re-answered an old message while the real new one had to arrive via steering. The `.in` resume cursor is now seeded before any listener attaches, using the same boot logic as `chat.agent`. - **Mid-stream steering no longer wipes the in-flight response.** `chat.pipeAndCapture` (also backing `turn.complete()`) streamed without a server-generated message id, so a `prepareStep` injection regenerated the assistant id mid-stream and the frontend replaced the partial message, discarding everything streamed before the injection. - **Task-backed tools now work from custom agent loops.** A child task triggered via `ai.toolExecute` failed with "chat.agent session handle is not initialized" because the parent's chatId only threaded from the per-turn context that hand-rolled loops never set. It now falls back to the session handle the `chat.customAgent` wrapper binds at run boot, so children can stream progress into the chat with `chat.stream.writer({ target: "root" })` (the documented sub-agent pattern). ## Root cause on the replay fix Attaching any `.in` listener (`chat.createStopSignal`, `chat.messages.on`, the first wait) opens the SSE tail with `Last-Event-ID` taken from the seq cursor at attach time. Custom loops attached before any cursor existed, so S2 replayed from seq 0. The fix resolves the cursor from the latest turn-complete header and seeds both manager cursors (`setLastSeqNum` drives the SSE resume point, `setLastDispatchedSeqNum` gates waiter dispatch) before attach; `chat.createSession` now creates its stop signal lazily on the first iteration, after the seed. Seeding only the first cursor after attach does not work, which is why the earlier attempt at this was reverted. All three were reproduced red-green against the references ai-chat project: the replay repro showed the continuation wait consuming a stale message in 403ms with the real message arriving via steering injection; post-fix the wait consumes the real message directly with no injection. Steering now preserves the full in-flight response, and the deepResearch sub-agent streams its progress parts into a raw-loop parent. Existing behavior verified unchanged: full SDK unit suite, `chat.agent` steering, and stop-then-continue on `chat.createSession`. --- .changeset/custom-agent-loop-fixes.md | 9 +++ packages/trigger-sdk/src/v3/ai.ts | 92 +++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 .changeset/custom-agent-loop-fixes.md diff --git a/.changeset/custom-agent-loop-fixes.md b/.changeset/custom-agent-loop-fixes.md new file mode 100644 index 0000000000..4d37fff535 --- /dev/null +++ b/.changeset/custom-agent-loop-fixes.md @@ -0,0 +1,9 @@ +--- +"@trigger.dev/sdk": patch +--- + +Three fixes for custom agent loops (`chat.customAgent`, `chat.createSession`, and hand-rolled `MessageAccumulator` loops): + +- Continuation runs no longer replay already-answered user messages into the first turn. The `.in` resume cursor is now seeded before any listener attaches (the same boot logic `chat.agent` uses), so a chat that continues after a cancel, crash, or upgrade only sees genuinely new messages. +- Steering a hand-rolled loop mid-stream no longer wipes the in-flight assistant response. `chat.pipeAndCapture` now stamps a server-generated message id on the stream, so a `prepareStep` injection keeps the partial text instead of replacing the message. +- Task-backed tools (`ai.toolExecute`) now work from custom agent loops: the parent's session is threaded to the child run, so child tasks can stream progress into the chat with `chat.stream.writer({ target: "root" })` instead of failing with "session handle is not initialized". diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index e3b3e60549..0d0caf7c96 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -160,6 +160,10 @@ const chatTurnContextKey = locals.create("chat.turnContext"); * @internal */ const chatSessionHandleKey = locals.create("chat.sessionHandle"); +// The external `chatId` from the boot payload — the value `ToolCallExecutionOptions.chatId` +// is documented to carry. Custom-agent loops never set per-turn context, so subtask tool +// metadata reads this directly rather than the Session handle id. +const chatExternalIdKey = locals.create("chat.externalId"); /** * S2 seq_num of the most recent `turn-complete` control record written by @@ -221,6 +225,47 @@ export async function __findLatestSessionInCursorForTests( return findLatestSessionInCursor(chatId); } +/** + * Seed the `.in` resume cursor for custom-agent loops (`chat.customAgent` + * raw loops and `chat.createSession`) the way `chat.agent`'s boot does. + * + * MUST run before anything attaches a `.in` listener (`createStopSignal`, + * `chat.messages.on`, the first wait): attaching opens the SSE tail with + * `Last-Event-ID` from the seeded cursor, so attach-then-seed replays + * every record from seq 0 — already-answered user messages get delivered + * into the new run's first wait and the loop re-answers them. + * + * Seeds both cursors: `setLastSeqNum` controls the SSE `Last-Event-ID`, + * `setLastDispatchedSeqNum` gates waiter dispatch — seeding only the + * former still re-delivers records the manager buffered before the seed. + * + * No-ops on fresh boots and when a cursor is already seeded (e.g. the + * `chatCustomAgent` wrapper ran before a nested `createChatSession`). + * @internal + */ +async function seedSessionInResumeCursorForCustomLoop( + payload: Pick +): Promise { + if (sessionStreams.lastSeqNum(payload.chatId, "in") !== undefined) return; + // No continuation/attempt gate: the wire may omit `continuation` on a + // run that still has prior turns (chat.agent covers that case via its + // snapshot). The scan doubles as the prior-state probe — a fresh + // session has no turn-complete on `.out`, returns no cursor, and + // seeds nothing. Cost on fresh boots is one non-blocking records read. + try { + const cursor = await findLatestSessionInCursor(payload.chatId); + if (cursor !== undefined) { + sessionStreams.setLastSeqNum(payload.chatId, "in", cursor); + sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", cursor); + } + } catch (error) { + logger.warn( + "chat session: session.in resume cursor lookup failed; old messages may replay", + { error: error instanceof Error ? error.message : String(error) } + ); + } +} + /** * Versioned blob written to S3 after every turn completes (when no * `hydrateMessages` hook is registered). Read at run boot to seed the @@ -921,6 +966,15 @@ function createTaskToolExecuteHandler< toolMeta.turn = chatCtx.turn; toolMeta.continuation = chatCtx.continuation; toolMeta.clientData = chatCtx.clientData; + } else { + // Hand-rolled chat.customAgent loops never set per-turn context, but + // the wrapper records the boot payload's external chatId at run boot + // — thread it so subtask chat helpers (`chat.stream.writer` with + // target "root") can open the parent's session. + const chatExternalId = locals.get(chatExternalIdKey); + if (chatExternalId) { + toolMeta.chatId = chatExternalId; + } } const chatLocals: Record = {}; @@ -5104,6 +5158,7 @@ function chatCustomAgent< // `chat.createStartSessionAction`) before this run is triggered. // No client-side upsert needed. locals.set(chatSessionHandleKey, sessions.open(payload.chatId)); + locals.set(chatExternalIdKey, payload.chatId); locals.set(chatAgentRunContextKey, runOptions.ctx); // Initialize the turn-complete trim slot so `chat.writeTurnComplete` // trims `session.out` back to the previous turn boundary. Without @@ -5113,6 +5168,10 @@ function chatCustomAgent< markChatAgentRunForStreamsWarning(); taskContext.setConversationId(payload.chatId); stampConversationIdOnActiveSpan(payload.chatId); + // Seed the `.in` resume cursor before user code attaches any `.in` + // listener — otherwise a continuation boot replays already-answered + // messages into the loop's first wait. + await seedSessionInResumeCursorForCustomLoop(payload); return userRun(payload, runOptions); }, }); @@ -5213,6 +5272,7 @@ function chatAgent< // `chat.createStartSessionAction` or browser-direct) before this // run is triggered — no client-side upsert needed here. locals.set(chatSessionHandleKey, sessions.open(payload.chatId)); + locals.set(chatExternalIdKey, payload.chatId); // Mutable holder; advances in `writeTurnCompleteChunk` after each turn // and is the trim target for the NEXT turn's trim record. locals.set(lastTurnCompleteSeqNumKey, { value: undefined }); @@ -8613,8 +8673,15 @@ async function pipeChatAndCapture( resolveOnFinish = r; }); + const resolvedOptions = resolveUIMessageStreamOptions(); const uiStream = source.toUIMessageStream({ - ...resolveUIMessageStreamOptions(), + ...resolvedOptions, + // Stamp a server-generated id on the start chunk, same as chat.agent's + // pipe. Without it the AI SDK regenerates the assistant id when a + // prepareStep injection (steering) starts a new step mid-stream, and + // the frontend replaces the partial message — wiping the + // pre-injection text from the UI and the captured response. + generateMessageId: resolvedOptions.generateMessageId ?? generateMessageId, onFinish: ({ responseMessage }: { responseMessage: UIMessage }) => { captured = responseMessage; resolveOnFinish!(); @@ -8936,14 +9003,18 @@ export type ChatTurn = { * signaling, and idle/suspend between turns. You control: initialization, * model/tool selection, persistence, and any custom per-turn logic. * + * Call from inside a `chat.customAgent()` run — the wrapper binds the + * backing Session that the iterator's stop signal and message channels + * resolve to. (A plain `task()` does not bind it, so `createSession` + * would throw "session handle is not initialized".) + * * @example * ```ts - * import { task } from "@trigger.dev/sdk"; * import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai"; * import { streamText } from "ai"; * import { openai } from "@ai-sdk/openai"; * - * export const myChat = task({ + * export const myChat = chat.customAgent({ * id: "my-chat", * run: async (payload: ChatTaskWirePayload, { signal }) => { * const session = chat.createSession(payload, { signal }); @@ -8979,13 +9050,23 @@ function createChatSession( [Symbol.asyncIterator]() { let currentPayload = payload; let turn = -1; - const stop = createStopSignal(); + // Created on the first next() call, AFTER the resume-cursor seed — + // createStopSignal attaches the `.in` SSE tail, and attaching + // before the seed replays every record from seq 0 (the seed is a + // no-op when the chatCustomAgent wrapper already ran it). + let stop!: ReturnType; + let booted = false; const accumulator = new ChatMessageAccumulator(); let previousTurnUsage: LanguageModelUsage | undefined; let cumulativeUsage: LanguageModelUsage = emptyUsage(); return { async next(): Promise> { + if (!booted) { + booted = true; + await seedSessionInResumeCursorForCustomLoop(currentPayload); + stop = createStopSignal(); + } turn++; // First turn: wait when the boot payload carries no message. @@ -9328,7 +9409,8 @@ function createChatSession( }, async return() { - stop.cleanup(); + // `stop` only exists once next() has booted the iterator. + stop?.cleanup(); return { done: true, value: undefined }; }, }; From e092919c3f7931a5b4abd6530a16332a7bee9ff1 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sun, 14 Jun 2026 11:00:50 +0100 Subject: [PATCH 2/2] feat(sdk,cli): bundle agent skills + docs in the SDK for zero-drift (#3937) ## Summary `@trigger.dev/sdk` now ships the Trigger.dev agent skills and a curated snapshot of the docs those skills cite. The skills that `trigger skills` installs into your coding agent are thin pointers that read this bundled content from `node_modules`, so the guidance always matches the SDK version installed in your project. Previously the full skill text was copied into your repo at install time and went stale until you reinstalled after an upgrade. ## How it works The SDK's `files[]` now includes `skills/` (the full skill text) and `docs/` (a curated snapshot generated at build time). The docs manifest is derived from each skill's own `sources:` frontmatter, so a skill only ships the docs it references, and a skill that cites a missing doc fails the build. The CLI installs thin skills whose body points the agent at `node_modules/@trigger.dev/sdk/skills//SKILL.md` and `node_modules/@trigger.dev/sdk/docs/`. They keep the high-value "Common mistakes" anti-patterns inline so the trigger and the guardrails survive even if the agent does not follow the pointer. `getting-started` stays self-contained in the CLI because it runs before the SDK is installed. --- .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 | 294 ++++++++++++++ .../skills/authoring-tasks/SKILL.md | 254 ++++++++++++ .../skills/chat-agent-advanced/SKILL.md | 366 ++++++++++++++++++ .../skills/realtime-and-frontend/SKILL.md | 276 +++++++++++++ scripts/bundleSdkDocs.ts | 111 ++++++ 12 files changed, 1339 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 0000000000..2c3ae96f52 --- /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 257108d05d..0a2393a5b7 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:** the full, version-pinned docs ship bundled at `node_modules/@trigger.dev/sdk/docs/ai-chat/`; the skill above lists the exact pages it draws from in its `sources:` frontmatter. Grep for an API, e.g. `grep -rl "toStreamTextOptions" node_modules/@trigger.dev/sdk/docs/`. -## Setup - -Three pieces: the agent task, two server actions, and the frontend transport. - -### 1. Define the agent - -```ts trigger/chat.ts -import { chat } from "@trigger.dev/sdk/ai"; -import { streamText, stepCountIs } from "ai"; -import { anthropic } from "@ai-sdk/anthropic"; - -export const myChat = chat.agent({ - id: "my-chat", - run: async ({ messages, signal }) => - streamText({ - // Spread this FIRST. See "Common mistakes". - ...chat.toStreamTextOptions(), - model: anthropic("claude-sonnet-4-5"), - messages, - abortSignal: signal, - stopWhen: stepCountIs(15), - }), -}); -``` - -`run` receives `messages` already converted to `ModelMessage[]` (the SDK converts the frontend's -`UIMessage[]` for you) plus a `signal` that aborts on stop or cancel. Returning the -`StreamTextResult` auto-pipes it to the frontend. - -### 2. Add two server actions - -Both run on your server, so the browser never holds your environment secret key. This is also -where per-user / per-plan authorization and any paired DB writes live. - -```ts app/actions.ts -"use server"; -import { auth } from "@trigger.dev/sdk"; -import { chat } from "@trigger.dev/sdk/ai"; - -// Creates the Session + first run, returns a session PAT. Idempotent on (env, chatId). -export const startChatSession = chat.createStartSessionAction("my-chat"); - -// Pure mint. The transport calls this on 401/403 to refresh an expired token. -export async function mintChatAccessToken(chatId: string) { - return auth.createPublicToken({ - scopes: { read: { sessions: chatId }, write: { sessions: chatId } }, - expirationTime: "1h", - }); -} -``` - -### 3. Wire the frontend - -```tsx app/components/chat.tsx -"use client"; -import { useState } from "react"; -import { useChat } from "@ai-sdk/react"; -import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; -import type { myChat } from "@/trigger/chat"; -import { mintChatAccessToken, startChatSession } from "@/app/actions"; - -export function Chat() { - const transport = useTriggerChatTransport({ - 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 6ff10209dd..1c4e4b7f17 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:** the full, version-pinned docs ship bundled at `node_modules/@trigger.dev/sdk/docs/`; the skill above lists the exact pages it draws from in its `sources:` frontmatter. Grep for an API, e.g. `grep -rl "schemaTask" node_modules/@trigger.dev/sdk/docs/`. -```ts -// /trigger/hello-world.ts -import { task } from "@trigger.dev/sdk"; +If those paths don't exist, `@trigger.dev/sdk` isn't installed yet — install it first. In a non-hoisted layout, resolve the package with `node -p "require.resolve('@trigger.dev/sdk/package.json')"` and read `skills/` + `docs/` beside it. -export const helloWorld = task({ - id: "hello-world", // unique within the project - run: async (payload: { message: string }, { ctx }) => { - console.log(payload.message, "attempt", ctx.attempt.number); - return { ok: true }; // must be JSON serializable - }, -}); -``` - -The `run` function receives the payload and a second argument with `ctx` (run context), an abort `signal`, and a deprecated `init` output. The return value is the task output and must be JSON serializable. - -## Core patterns - -### 1. Validate the payload with `schemaTask` - -`schema` accepts a Zod / Yup / Superstruct / ArkType / valibot / typebox parser or a custom `(data: unknown) => T` function. A validation failure throws `TaskPayloadParsedError` and skips retrying. - -```ts -import { schemaTask } from "@trigger.dev/sdk"; -import { z } from "zod"; - -export const createUser = schemaTask({ - id: "create-user", - schema: z.object({ name: z.string(), age: z.number() }), - run: async (payload) => ({ greeting: `Hi ${payload.name}` }), -}); -``` - -### 2. Configure retries and abort early - -The default `maxAttempts` is 3. Throw `AbortTaskRunError` to stop retrying immediately. Task-level `retry` overrides the config-file defaults. - -```ts -import { task, AbortTaskRunError } from "@trigger.dev/sdk"; - -export const charge = task({ - id: "charge", - retry: { maxAttempts: 5, factor: 1.8, minTimeoutInMs: 500, maxTimeoutInMs: 30_000, randomize: true }, - run: async (payload: { amount: number }) => { - if (payload.amount <= 0) throw new AbortTaskRunError("Invalid amount"); // no retry - // work that may throw and retry - }, -}); -``` - -For finer control, `catchError: async ({ payload, error, ctx, retryAt }) => {...}` can return `{ skipRetrying: true }`, `{ retryAt: Date }`, or `undefined` (use normal logic). `retry.onThrow`, `retry.fetch`, also exist for in-task retrying. - -### 3. Trigger another task and handle the Result - -From inside a task use `yourTask.triggerAndWait(payload)`. The result is a Result object that you must check (`ok`), or `.unwrap()` to throw on failure. - -```ts -export const parentTask = task({ - id: "parent-task", - run: async () => { - const result = await childTask.triggerAndWait({ data: "x" }); - if (result.ok) return result.output; // typed child output - console.error("child failed", result.error); - // or: const output = await childTask.triggerAndWait({ data: "x" }).unwrap(); - }, -}); -``` - -`SubtaskUnwrapError` carries `runId`, `taskId`, and `cause`. For fan-out use `childTask.batchTriggerAndWait([{ payload: a }, { payload: b }])`; the result has a `.runs` array, each entry `{ ok, id, output?, error?, taskIdentifier }`. - -### 4. Trigger from backend code with a type-only import - -Outside a task, import the task type only and trigger by id. Do not import the task instance into backend bundles. - -```ts -import { tasks } from "@trigger.dev/sdk"; -import type { emailSequence } from "~/trigger/emails"; - -const handle = await tasks.trigger( - "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 68ce1c6b06..7d90464146 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:** the full, version-pinned docs ship bundled at `node_modules/@trigger.dev/sdk/docs/ai-chat/` (including `patterns/` for HITL, sub-agents, sessions); the skill above lists the exact pages it draws from in its `sources:` frontmatter. Grep for an API, e.g. `grep -rl "mockChatAgent" node_modules/@trigger.dev/sdk/docs/`. -## Setup - -Happy path: drive an agent from server-side code (task, webhook, or script) with `AgentChat`. - -```ts -import { AgentChat } from "@trigger.dev/sdk/chat"; -import type { myAgent } from "./trigger/my-agent"; - -const chat = new AgentChat({ 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 811fb7d1ff..a722bfb5e2 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:** the full, version-pinned docs ship bundled at `node_modules/@trigger.dev/sdk/docs/realtime/`; the skill above lists the exact pages it draws from in its `sources:` frontmatter. Grep for a hook, e.g. `grep -rl "useRealtimeRun" node_modules/@trigger.dev/sdk/docs/`. -```bash -npm add @trigger.dev/react-hooks # frontend hooks (React/Next.js/Remix) -# @trigger.dev/sdk is already installed for the backend -``` - -The flow is always: mint a scoped token in the backend, pass it to the -frontend, subscribe with a hook. - -```ts -// backend (API route / server action) -import { auth } from "@trigger.dev/sdk"; - -const publicAccessToken = await auth.createPublicToken({ - scopes: { read: { runs: ["run_1234"] } }, // a token with no scopes is useless -}); -``` - -```tsx -// frontend -"use client"; -import { useRealtimeRun } from "@trigger.dev/react-hooks"; - -export function RunStatus({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) { - const { run, error } = useRealtimeRun(runId, { accessToken: publicAccessToken }); - if (error) return
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 0000000000..8288471483 --- /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 7162bf023b..2bbd1519de 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 0000000000..34fb3d8d23 --- /dev/null +++ b/packages/trigger-sdk/skills/authoring-chat-agent/SKILL.md @@ -0,0 +1,294 @@ +--- +name: authoring-chat-agent +description: > + Author and run a durable AI chat agent with chat.agent from @trigger.dev/sdk/ai: the per-turn + run loop, why you MUST spread ...chat.toStreamTextOptions() first, returning a StreamTextResult + vs calling chat.pipe(), the two server actions (chat.createStartSessionAction + + auth.createPublicToken), and wiring useChat to useTriggerChatTransport. Load this when building, + modifying, or debugging a chat backend (the agent task or its lifecycle hooks) or its React + transport, when declaring typed tools or custom data parts, or when migrating a plain AI SDK + streamText route to chat.agent. +type: core +library: trigger.dev +sources: + - docs/ai-chat/overview.mdx + - docs/ai-chat/quick-start.mdx + - docs/ai-chat/how-it-works.mdx + - docs/ai-chat/backend.mdx + - docs/ai-chat/frontend.mdx + - docs/ai-chat/reference.mdx + - docs/ai-chat/types.mdx + - docs/ai-chat/tools.mdx + - docs/ai-chat/lifecycle-hooks.mdx + - docs/ai-chat/error-handling.mdx +--- + +# Authoring a chat agent + +A `chat.agent` runs an entire conversation as one long-lived Trigger.dev task. It wakes when a +message arrives, freezes when none do, and in-memory state survives page refreshes, deploys, idle +gaps, and crashes. Your code is the loop you would write anyway: messages in, `streamText` out. +There are no API routes. The frontend talks to the agent through a `TriggerChatTransport`, so +history accumulates server-side and the client ships only the new message each turn. + +Works with Vercel AI SDK v5, v6, or v7. On v7 also install `@ai-sdk/otel` so model calls are traced +(the SDK registers it for you). + +## Setup + +Three pieces: the agent task, two server actions, and the frontend transport. + +### 1. Define the agent + +```ts trigger/chat.ts +import { chat } from "@trigger.dev/sdk/ai"; +import { streamText, stepCountIs } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; + +export const myChat = chat.agent({ + id: "my-chat", + run: async ({ messages, signal }) => + streamText({ + // Spread this FIRST. See "Common mistakes". + ...chat.toStreamTextOptions(), + model: anthropic("claude-sonnet-4-5"), + messages, + abortSignal: signal, + stopWhen: stepCountIs(15), + }), +}); +``` + +`run` receives `messages` already converted to `ModelMessage[]` (the SDK converts the frontend's +`UIMessage[]` for you) plus a `signal` that aborts on stop or cancel. Returning the +`StreamTextResult` auto-pipes it to the frontend. + +### 2. Add two server actions + +Both run on your server, so the browser never holds your environment secret key. This is also +where per-user / per-plan authorization and any paired DB writes live. + +```ts app/actions.ts +"use server"; +import { auth } from "@trigger.dev/sdk"; +import { chat } from "@trigger.dev/sdk/ai"; + +// Creates the Session + first run, returns a session PAT. Idempotent on (env, chatId). +export const startChatSession = chat.createStartSessionAction("my-chat"); + +// Pure mint. The transport calls this on 401/403 to refresh an expired token. +export async function mintChatAccessToken(chatId: string) { + return auth.createPublicToken({ + scopes: { read: { sessions: chatId }, write: { sessions: chatId } }, + expirationTime: "1h", + }); +} +``` + +### 3. Wire the frontend + +```tsx app/components/chat.tsx +"use client"; +import { useState } from "react"; +import { useChat } from "@ai-sdk/react"; +import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; +import type { myChat } from "@/trigger/chat"; +import { mintChatAccessToken, startChatSession } from "@/app/actions"; + +export function Chat() { + const transport = useTriggerChatTransport({ + 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. + +Reference docs ship beside this skill in the same package, read them locally (no network), pinned to your installed version. The `sources:` frontmatter above lists every doc this skill draws from, all under `@trigger.dev/sdk/docs/ai-chat/`. Start with `quick-start.mdx`, `backend.mdx`, `tools.mdx`, `types.mdx`, `frontend.mdx`. + +## Version + +This skill is bundled inside `@trigger.dev/sdk` and read directly from `node_modules`, so it always matches your installed SDK version (see the adjacent `package.json`). The full documentation for these APIs ships alongside it under `@trigger.dev/sdk/docs/`. 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 0000000000..7e683a8337 --- /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. + +Reference docs ship beside this skill in the same package, read them locally (no network), pinned to your installed version. The `sources:` frontmatter above lists every doc this skill draws from, all under `@trigger.dev/sdk/docs/`. Start with: + +- `@trigger.dev/sdk/docs/tasks/overview.mdx` +- `@trigger.dev/sdk/docs/triggering.mdx` +- `@trigger.dev/sdk/docs/config/config-file.mdx` + +## Version + +This skill is bundled inside `@trigger.dev/sdk` and read directly from `node_modules`, so it always matches your installed SDK version (see the adjacent `package.json`). The full documentation for these APIs ships alongside it under `@trigger.dev/sdk/docs/`. 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 0000000000..59fda72125 --- /dev/null +++ b/packages/trigger-sdk/skills/chat-agent-advanced/SKILL.md @@ -0,0 +1,366 @@ +--- +name: chat-agent-advanced +description: > + Advanced and operational chat.agent capabilities for Trigger.dev, loaded on demand. Load this when + working on the raw Sessions primitive (sessions / SessionHandle), a custom chat transport or the + realtime wire protocol, durable sub-agents (AgentChat, chat.stream.writer), human-in-the-loop, + steering, actions, background injection (chat.defer / chat.inject), fast starts (preload, Head + Start via @trigger.dev/sdk/chat-server), context resilience (compaction, recovery boot, OOM, large + payloads), chat.local run-scoped state, offline testing with mockChatAgent, or prerelease/version + upgrades. For the everyday chat.agent({...}) definition and the useTriggerChatTransport happy path, + use the authoring-chat-agent skill instead. +type: core +library: trigger.dev +sources: + - docs/ai-chat/sessions.mdx + - docs/ai-chat/server-chat.mdx + - docs/ai-chat/client-protocol.mdx + - docs/ai-chat/pending-messages.mdx + - docs/ai-chat/actions.mdx + - docs/ai-chat/background-injection.mdx + - docs/ai-chat/compaction.mdx + - docs/ai-chat/fast-starts.mdx + - docs/ai-chat/chat-local.mdx + - docs/ai-chat/mcp.mdx + - docs/ai-chat/testing.mdx + - docs/ai-chat/upgrade-guide.mdx + - docs/ai-chat/patterns/sub-agents.mdx + - docs/ai-chat/patterns/human-in-the-loop.mdx + - docs/ai-chat/patterns/persistence-and-replay.mdx + - docs/ai-chat/patterns/recovery-boot.mdx + - docs/ai-chat/patterns/oom-resilience.mdx + - docs/ai-chat/patterns/large-payloads.mdx + - docs/ai-chat/patterns/version-upgrades.mdx + - docs/ai-chat/tools.mdx +--- + +# chat.agent: advanced and operational + +`chat.agent` is built on **Sessions**: a durable, task-bound, bi-directional I/O channel pair keyed +on a stable `externalId` (e.g. `chatId`) that outlives any single run. This skill covers the layers +beneath and around the everyday agent: the raw `sessions` API, server-side `AgentChat`, durable +sub-agents, actions / background injection, fast starts, compaction and recovery, and the wire +protocol for custom transports. + +Two `chat` namespaces are easy to confuse: the agent definition imports `chat` from +`@trigger.dev/sdk/ai`; Head Start / Node-listener server entries import `chat` from +`@trigger.dev/sdk/chat-server`. + +## Setup + +Happy path: drive an agent from server-side code (task, webhook, or script) with `AgentChat`. + +```ts +import { AgentChat } from "@trigger.dev/sdk/chat"; +import type { myAgent } from "./trigger/my-agent"; + +const chat = new AgentChat({ 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. + +Reference docs ship beside this skill in the same package, read them locally (no network), pinned to your installed version. The `sources:` frontmatter above lists every doc this skill draws from, all under `@trigger.dev/sdk/docs/ai-chat/` (including `patterns/`). For HITL, sessions, and sub-agents start with `sessions.mdx`, `server-chat.mdx`, `client-protocol.mdx`, `patterns/human-in-the-loop.mdx`, `patterns/sub-agents.mdx`. + +## Version + +This skill is bundled inside `@trigger.dev/sdk` and read directly from `node_modules`, so it always matches your installed SDK version (see the adjacent `package.json`). The full documentation for these APIs ships alongside it under `@trigger.dev/sdk/docs/`. 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 0000000000..f503250d94 --- /dev/null +++ b/packages/trigger-sdk/skills/realtime-and-frontend/SKILL.md @@ -0,0 +1,276 @@ +--- +name: realtime-and-frontend +description: > + Trigger.dev client/frontend surface: subscribe to runs in realtime + (runs.subscribeToRun and the @trigger.dev/react-hooks hook useRealtimeRun), + consume metadata and AI/text streams in React (useRealtimeStream), trigger + tasks from the browser (useTaskTrigger, useRealtimeTaskTrigger), and mint + scoped frontend credentials with auth.createPublicToken / + auth.createTriggerPublicToken. + Load when wiring a frontend (React/Next.js/Remix) or backend-for-frontend to + show live run progress, status badges, token streams, trigger buttons, or + wait-token approval UIs. NOT for writing the backend task itself (streams.define + / metadata.set is authoring-tasks territory); this is the consumer side. +type: core +library: trigger.dev +sources: + - docs/realtime/overview.mdx + - docs/realtime/how-it-works.mdx + - docs/realtime/auth.mdx + - docs/realtime/run-object.mdx + - docs/realtime/react-hooks/overview.mdx + - docs/realtime/react-hooks/subscribe.mdx + - docs/realtime/react-hooks/triggering.mdx + - docs/realtime/react-hooks/streams.mdx + - docs/realtime/react-hooks/swr.mdx + - docs/realtime/react-hooks/use-wait-token.mdx + - docs/realtime/backend/subscribe.mdx +--- + +# Realtime and Frontend + +The consumer side of Trigger.dev's run state and streams: read live run +updates, render AI/text streams, and trigger tasks from a browser. Hooks come +from `@trigger.dev/react-hooks`; token minting and backend subscription come +from `@trigger.dev/sdk`. + +## Setup + +```bash +npm add @trigger.dev/react-hooks # frontend hooks (React/Next.js/Remix) +# @trigger.dev/sdk is already installed for the backend +``` + +The flow is always: mint a scoped token in the backend, pass it to the +frontend, subscribe with a hook. + +```ts +// backend (API route / server action) +import { auth } from "@trigger.dev/sdk"; + +const publicAccessToken = await auth.createPublicToken({ + scopes: { read: { runs: ["run_1234"] } }, // a token with no scopes is useless +}); +``` + +```tsx +// frontend +"use client"; +import { useRealtimeRun } from "@trigger.dev/react-hooks"; + +export function RunStatus({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) { + const { run, error } = useRealtimeRun(runId, { accessToken: publicAccessToken }); + if (error) return
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({ triggerToken }: { triggerToken: string }) { + const { submit, handle, isLoading } = useTaskTrigger("my-task", { + accessToken: triggerToken, + }); + 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. + +Reference docs ship beside this skill in the same package, read them locally (no network), pinned to your installed version. The `sources:` frontmatter above lists every doc this skill draws from, all under `@trigger.dev/sdk/docs/`. Start with: +- `@trigger.dev/sdk/docs/realtime/react-hooks/subscribe.mdx` +- `@trigger.dev/sdk/docs/realtime/react-hooks/streams.mdx` +- `@trigger.dev/sdk/docs/realtime/auth.mdx` +- `@trigger.dev/sdk/docs/realtime/run-object.mdx` (the realtime run object differs from the management-API object returned by `useRun`) + +## Version + +This skill is bundled inside `@trigger.dev/sdk` and read directly from `node_modules`, so it always matches your installed SDK version (see the adjacent `package.json`). The full documentation for these APIs ships alongside it under `@trigger.dev/sdk/docs/`. diff --git a/scripts/bundleSdkDocs.ts b/scripts/bundleSdkDocs.ts new file mode 100644 index 0000000000..b27d104bc4 --- /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) { + // Fail the build rather than silently ship the SDK with stale or missing docs. + throw new Error("[bundleSdkDocs] no doc sources found in skills/*/SKILL.md"); + } + + // Rebuild from scratch so removed sources don't linger in the package. + await fs.rm(outDir, { recursive: true, force: true }); + + const missing: string[] = []; + let copied = 0; + + for (const rel of manifest) { + const src = path.join(repoRoot, rel); + try { + await fs.access(src); + } catch { + missing.push(rel); + continue; + } + // Strip the leading "docs/" so files land at /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); +});