diff --git a/.changeset/trigger-skills-installer.md b/.changeset/trigger-skills-installer.md new file mode 100644 index 00000000000..4e05b125919 --- /dev/null +++ b/.changeset/trigger-skills-installer.md @@ -0,0 +1,11 @@ +--- +"trigger.dev": patch +--- + +`trigger skills` installs Trigger.dev agent skills into your coding agent so it knows how to write tasks, schedules, realtime, and chat.agent code. The skills ship with the CLI and are copied into each tool's native skills directory (Claude Code, Cursor, GitHub Copilot, and Codex / AGENTS.md), and `trigger dev` offers to install them on first run. + +```bash +trigger skills --target claude-code +``` + +Replaces the previous `install-rules` command, which stays as an alias. diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index 2cf51c778b7..a170baaf11a 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -24,10 +24,12 @@ "apis", "jobs", "background jobs", - "nextjs" + "nextjs", + "tanstack-intent" ], "files": [ - "dist" + "dist", + "skills" ], "bin": { "trigger": "./dist/esm/index.js" diff --git a/packages/cli-v3/skills/authoring-chat-agent/SKILL.md b/packages/cli-v3/skills/authoring-chat-agent/SKILL.md new file mode 100644 index 00000000000..257108d05de --- /dev/null +++ b/packages/cli-v3/skills/authoring-chat-agent/SKILL.md @@ -0,0 +1,295 @@ +--- +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 +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 + +A `chat.agent` runs an entire conversation as one long-lived Trigger.dev task. It wakes when a +message arrives, freezes when none do, and in-memory state survives page refreshes, deploys, idle +gaps, and crashes. Your code is the loop you would write anyway: messages in, `streamText` out. +There are no API routes. The frontend talks to the agent through a `TriggerChatTransport`, so +history accumulates server-side and the client ships only the new message each turn. + +Works with Vercel AI SDK v5, v6, or v7. On v7 also install `@ai-sdk/otel` so model calls are traced +(the SDK registers it for you). + +## Setup + +Three pieces: the agent task, two server actions, and the frontend transport. + +### 1. Define the agent + +```ts trigger/chat.ts +import { chat } from "@trigger.dev/sdk/ai"; +import { streamText, stepCountIs } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; + +export const myChat = chat.agent({ + id: "my-chat", + run: async ({ messages, signal }) => + streamText({ + // Spread this FIRST. See "Common mistakes". + ...chat.toStreamTextOptions(), + model: anthropic("claude-sonnet-4-5"), + messages, + abortSignal: signal, + stopWhen: stepCountIs(15), + }), +}); +``` + +`run` receives `messages` already converted to `ModelMessage[]` (the SDK converts the frontend's +`UIMessage[]` for you) plus a `signal` that aborts on stop or cancel. Returning the +`StreamTextResult` auto-pipes it to the frontend. + +### 2. Add two server actions + +Both run on your server, so the browser never holds your environment secret key. This is also +where per-user / per-plan authorization and any paired DB writes live. + +```ts app/actions.ts +"use server"; +import { auth } from "@trigger.dev/sdk"; +import { chat } from "@trigger.dev/sdk/ai"; + +// Creates the Session + first run, returns a session PAT. Idempotent on (env, chatId). +export const startChatSession = chat.createStartSessionAction("my-chat"); + +// Pure mint. The transport calls this on 401/403 to refresh an expired token. +export async function mintChatAccessToken(chatId: string) { + return auth.createPublicToken({ + scopes: { read: { sessions: chatId }, write: { sessions: chatId } }, + expirationTime: "1h", + }); +} +``` + +### 3. Wire the frontend + +```tsx app/components/chat.tsx +"use client"; +import { useState } from "react"; +import { useChat } from "@ai-sdk/react"; +import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; +import type { myChat } from "@/trigger/chat"; +import { mintChatAccessToken, startChatSession } from "@/app/actions"; + +export function Chat() { + const transport = useTriggerChatTransport({ + task: "my-chat", // typeof myChat gives compile-time task-id validation + accessToken: ({ chatId }) => mintChatAccessToken(chatId), + startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }), + }); + + const { messages, sendMessage, stop, status } = useChat({ transport }); + const [input, setInput] = useState(""); + // render messages, a form that calls sendMessage({ text: input }), + // and a Stop button (onClick={stop}) while status === "streaming". +} +``` + +The transport is memoized (created once, reused across renders). Passing `typeof myChat` flows the +agent's message type through `useChat`. + +## Core patterns + +### 1. Return vs pipe + +Return the `streamText` result from `run` for the simple case. When `streamText` is called deep +inside nested helpers, call `await chat.pipe(result)` from anywhere in the task instead, and let +`run` resolve `void`. + +```ts +export const agentChat = chat.agent({ + id: "agent-chat", + run: async ({ messages }) => { + await runAgentLoop(messages); // don't return; pipe inside + }, +}); + +async function runAgentLoop(messages: ModelMessage[]) { + const result = streamText({ + ...chat.toStreamTextOptions(), + model: anthropic("claude-sonnet-4-5"), + messages, + }); + await chat.pipe(result); // works from anywhere in the task +} +``` + +### 2. Typed tools (declare on config AND spread back) + +Declare tools on `chat.agent({ tools })`, read them back typed from the `run()` payload, and pass +that set to `chat.toStreamTextOptions({ tools })`. One declaration flows everywhere. + +```ts +import { tool, stepCountIs } from "ai"; +import { z } from "zod"; + +const tools = { + searchDocs: tool({ + description: "Search the docs.", + inputSchema: z.object({ query: z.string() }), + execute: async ({ query }) => searchIndex(query), + }), +}; + +export const myChat = chat.agent({ + id: "my-chat", + tools, // so toModelOutput survives across turns + run: async ({ messages, tools, signal }) => + streamText({ + ...chat.toStreamTextOptions({ tools }), // same set, handed back typed + model: anthropic("claude-sonnet-4-5"), + messages, + abortSignal: signal, + stopWhen: stepCountIs(15), + }), +}); +``` + +`tools` also accepts a function `(event) => ToolSet` resolved per turn, where `event` carries +`chatId`, `turn`, `continuation`, and `clientData`. + +### 3. Custom data parts (persisted vs transient) + +`data-*` parts written via `chat.response.write()` in `run()` (or `writer.write()` in hooks) +persist into `responseMessage.parts` and surface in `onTurnComplete`. Add `transient: true` to +stream them without persisting. Writes via `chat.stream` are always ephemeral. + +```ts +// In run() - persists, surfaces in onTurnComplete's responseMessage +chat.response.write({ type: "data-context", data: { searchResults } }); + +// In a hook via writer - streams but does NOT persist +writer.write({ type: "data-progress", id: "search", data: { percent: 50 }, transient: true }); +``` + +### 4. Custom UIMessage type, client data, and builder hooks + +For typed `data-*` parts or a tool map, build the agent through `chat.withUIMessage()` and +`chat.withClientData({ schema })`. Builder methods chain in any order; builder hooks run before the +matching task hook. `streamOptions` becomes the default `uiMessageStreamOptions` (shallow-merged, +agent wins). + +```ts +export const myChat = chat + .withUIMessage({ streamOptions: { sendReasoning: true } }) + .withClientData({ schema: z.object({ userId: z.string() }) }) + .agent({ + id: "my-chat", + tools: myTools, + onTurnStart: async ({ uiMessages, writer }) => { + writer.write({ type: "data-turn-status", data: { status: "preparing" } }); + }, + run: async ({ messages, tools, signal }) => + streamText({ ...chat.toStreamTextOptions({ tools }), model, messages, abortSignal: signal }), + }); +``` + +Build `MyChatUIMessage` as `UIMessage>` (or, for +tools only, `InferChatUIMessageFromTools` from `@trigger.dev/sdk/ai`). On the +frontend, narrow `useChat` with `InferChatUIMessage` from `@trigger.dev/sdk/chat/react`. + +### 5. Lifecycle hooks and stop + +`chat.agent` accepts hooks that fire in a fixed per-turn order: + +```text +onValidateMessages -> hydrateMessages -> onChatStart (chat's first message only) + -> onTurnStart -> run() -> onBeforeTurnComplete -> onTurnComplete +``` + +`onBoot` fires once per worker process (every fresh boot, including continuation runs) and is where +`chat.local`, DB connections, and per-process state belong. `onChatStart` fires only on the chat's +first message. Suspend/resume use `onChatSuspend` / `onChatResume`. Config options include +`tools`, `clientDataSchema`, `maxTurns` (100), `turnTimeout` ("1h"), `idleTimeoutInSeconds` (30), +`uiMessageStreamOptions`, and `exitAfterPreloadIdle`. There is no generic `retry`; `chat.agent` +runs with `maxAttempts: 1` internally. + +Stop is load-bearing: the `signal` passed to `run` aborts on stop or cancel. Forward it as +`abortSignal` to `streamText`, or the Stop button updates the UI while the model keeps generating +server-side. + +```ts +run: async ({ messages, signal }) => + streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal, stopWhen: stepCountIs(15) }); +``` + +### 6. Migrating from a plain AI SDK `streamText` route + +There is no API route in this model. The transport replaces the route round-trip, so: + +- Delete the route handler. Move per-request auth into the two server actions from Setup step 2. +- Move the `streamText` call into `run`. It already receives pre-converted `ModelMessage[]`. +- Return the `StreamTextResult` (it auto-pipes) and add `...chat.toStreamTextOptions()` first. +- On the client, swap the `api` URL for `useTriggerChatTransport`; `useChat` stays the same shape. + +## Common mistakes + +- **CRITICAL: forgetting `...chat.toStreamTextOptions()`.** + ```ts + // Wrong - compaction / steering / background injection silently no-op + return streamText({ model, messages, abortSignal: signal }); + // Correct - spread FIRST so explicit overrides win + return streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal }); + ``` + It wires the `prepareStep` callback behind compaction, mid-turn steering, and background + injection, injects the system prompt from `chat.prompt()`, resolves the registry model, and adds + telemetry. Omitting it makes all of those silently no-op with no error. + +- **Declaring tools only on `streamText`.** Also declare them on `chat.agent({ tools })`, read them + back from `run`, and pass `chat.toStreamTextOptions({ tools })`. Otherwise each tool's + `toModelOutput` runs on turn 1 but is dropped when history is re-converted on later turns. + +- **Not forwarding `signal` for stop.** Without `abortSignal: signal`, Stop updates the UI but the + model keeps generating server-side. + +- **Initializing `chat.local` in `onChatStart`.** Initialize it in `onBoot`. `onChatStart` fires + once per chat, so continuation runs skip it and crash with + `chat.local can only be modified after initialization`. `onBoot` fires on every fresh worker. + +- **Minting tokens in the browser.** Never expose the environment secret key client-side. Mint via + the two server actions; the transport calls them. + +- **Clearing `lastEventId` on `chat.endRun()`.** Keep the cursor for the Session lifetime; clear it + only when the Session itself closes. It is sessionId-keyed, so clearing forces a resubscribe from + `seq_num=0` that can hit the prior turn's stale `turn-complete` and close the stream empty. + +- **Returning the raw error from `uiMessageStreamOptions.onError`.** It leaks internals (keys, + stack traces). Return a sanitized string instead. + +## References + +- `chat-agent-advanced` skill - lifecycle hooks in depth, sessions, raw-task primitives + (`chat.createSession`, `chat.customAgent`, `chat.stream`), compaction, HITL approvals, recovery. +- `realtime-and-frontend` skill - Realtime hooks and frontend streaming beyond the chat transport. +- `authoring-tasks` skill - base `task()` semantics, `ctx`, and standard lifecycle hooks. +- Docs: /ai-chat/quick-start, /ai-chat/backend, /ai-chat/tools, /ai-chat/types, /ai-chat/frontend + +## Version + +Generated for `@trigger.dev/sdk` `{{TRIGGER_SDK_VERSION}}`. Re-run the trigger.dev skills installer +after upgrading. diff --git a/packages/cli-v3/skills/authoring-tasks/SKILL.md b/packages/cli-v3/skills/authoring-tasks/SKILL.md new file mode 100644 index 00000000000..6ff10209dda --- /dev/null +++ b/packages/cli-v3/skills/authoring-tasks/SKILL.md @@ -0,0 +1,255 @@ +--- +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 +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`. + +## Setup + +```ts +// /trigger/hello-world.ts +import { task } from "@trigger.dev/sdk"; + +export const helloWorld = task({ + id: "hello-world", // unique within the project + run: async (payload: { message: string }, { ctx }) => { + console.log(payload.message, "attempt", ctx.attempt.number); + return { ok: true }; // must be JSON serializable + }, +}); +``` + +The `run` function receives the payload and a second argument with `ctx` (run context), an abort `signal`, and a deprecated `init` output. The return value is the task output and must be JSON serializable. + +## Core patterns + +### 1. Validate the payload with `schemaTask` + +`schema` accepts a Zod / Yup / Superstruct / ArkType / valibot / typebox parser or a custom `(data: unknown) => T` function. A validation failure throws `TaskPayloadParsedError` and skips retrying. + +```ts +import { schemaTask } from "@trigger.dev/sdk"; +import { z } from "zod"; + +export const createUser = schemaTask({ + id: "create-user", + schema: z.object({ name: z.string(), age: z.number() }), + run: async (payload) => ({ greeting: `Hi ${payload.name}` }), +}); +``` + +### 2. Configure retries and abort early + +The default `maxAttempts` is 3. Throw `AbortTaskRunError` to stop retrying immediately. Task-level `retry` overrides the config-file defaults. + +```ts +import { task, AbortTaskRunError } from "@trigger.dev/sdk"; + +export const charge = task({ + id: "charge", + retry: { maxAttempts: 5, factor: 1.8, minTimeoutInMs: 500, maxTimeoutInMs: 30_000, randomize: true }, + run: async (payload: { amount: number }) => { + if (payload.amount <= 0) throw new AbortTaskRunError("Invalid amount"); // no retry + // work that may throw and retry + }, +}); +``` + +For finer control, `catchError: async ({ payload, error, ctx, retryAt }) => {...}` can return `{ skipRetrying: true }`, `{ retryAt: Date }`, or `undefined` (use normal logic). `retry.onThrow`, `retry.fetch`, also exist for in-task retrying. + +### 3. Trigger another task and handle the Result + +From inside a task use `yourTask.triggerAndWait(payload)`. The result is a Result object that you must check (`ok`), or `.unwrap()` to throw on failure. + +```ts +export const parentTask = task({ + id: "parent-task", + run: async () => { + const result = await childTask.triggerAndWait({ data: "x" }); + if (result.ok) return result.output; // typed child output + console.error("child failed", result.error); + // or: const output = await childTask.triggerAndWait({ data: "x" }).unwrap(); + }, +}); +``` + +`SubtaskUnwrapError` carries `runId`, `taskId`, and `cause`. For fan-out use `childTask.batchTriggerAndWait([{ payload: a }, { payload: b }])`; the result has a `.runs` array, each entry `{ ok, id, output?, error?, taskIdentifier }`. + +### 4. Trigger from backend code with a type-only import + +Outside a task, import the task type only and trigger by id. Do not import the task instance into backend bundles. + +```ts +import { tasks } from "@trigger.dev/sdk"; +import type { emailSequence } from "~/trigger/emails"; + +const handle = await tasks.trigger( + "email-sequence", + { to: "a@b.com", name: "Ada" }, + { delay: "1h" } +); +``` + +`tasks.batchTrigger` and `batch.trigger([{ id, payload }])` cover batches. Trigger options include `delay`, `ttl`, `idempotencyKey`, `idempotencyKeyTTL`, `debounce`, `queue`, `concurrencyKey`, `maxAttempts`, `tags`, `metadata`, `priority`, `region`, and `machine`. Inspect runs with `runs.retrieve`, `runs.cancel`, and `runs.reschedule`. + +### 5. Idempotency keys + +`idempotencyKeys.create(key, { scope })` returns a 64-char hashed key. A raw string key defaults to `"run"` scope (v4.3.1+); for once-ever behavior use `scope: "global"`. + +```ts +import { idempotencyKeys, task } from "@trigger.dev/sdk"; + +export const processOrder = task({ + id: "process-order", + run: async (payload: { orderId: string; email: string }) => { + const key = await idempotencyKeys.create(`confirm-${payload.orderId}`); + await sendEmail.trigger({ to: payload.email }, { idempotencyKey: key }); + }, +}); +``` + +### 6. Waits and run metadata + +`wait.for({ seconds })` and `wait.until({ date })` durably pause the run. `metadata.*` is readable and writable only inside `run()`; updates are synchronous and chainable (`set`, `del`, `replace`, `append`, `remove`, `increment`, `decrement`). + +```ts +import { task, metadata, wait } from "@trigger.dev/sdk"; + +export const importer = task({ + id: "importer", + run: async (payload: { rows: unknown[] }) => { + metadata.set("status", "processing").set("total", payload.rows.length); + await wait.for({ seconds: 5 }); + metadata.set("status", "complete"); + }, +}); +``` + +For human-in-the-loop, `wait.createToken({ timeout, tags })` returns `{ id, url, publicAccessToken, ... }`; resume with `wait.forToken(token: string | { id: string })` which returns `{ ok, output?, error? }` (or `.unwrap()`), and complete it elsewhere with `wait.completeToken(tokenId, output)`. Metadata max is 256KB and is not propagated to child tasks; push values to a parent with `metadata.parent.*` / `metadata.root.*`. (`metadata.stream` is deprecated since 4.1.0 in favor of `streams.pipe()`.) + +### 7. Scheduled (cron) tasks + +```ts +import { schedules } from "@trigger.dev/sdk"; + +export const dailyReport = schedules.task({ + id: "daily-report", + cron: { pattern: "0 5 * * *", timezone: "Asia/Tokyo" }, + run: async (payload) => { + console.log("scheduled at", payload.timestamp, "next", payload.upcoming); + }, +}); +``` + +The payload includes `timestamp`, `lastTimestamp`, `timezone`, `scheduleId`, `externalId`, and `upcoming`. Attach schedules dynamically with `schedules.create({ task, cron, timezone?, externalId?, deduplicationKey })` (the dedup key is required and per-project), plus `retrieve / list / update / activate / deactivate / del / timezones`. + +### 8. Queues and concurrency + +Set `queue: { concurrencyLimit }` on a task, or share a queue across tasks: + +```ts +import { queue, task } from "@trigger.dev/sdk"; + +export const emails = queue({ name: "emails", concurrencyLimit: 5 }); + +export const sendEmail = task({ id: "send-email", queue: emails, run: async () => {} }); +``` + +At trigger time override with `{ queue: "queue-name" }` and add `concurrencyKey` for per-tenant queues. Manage queues with `queues.list / retrieve / pause / resume / overrideConcurrencyLimit / resetConcurrencyLimit`. + +### 9. `trigger.config.ts` essentials + +```ts +import { defineConfig } from "@trigger.dev/sdk"; + +export default defineConfig({ + project: "", + dirs: ["./trigger"], + machine: "small-1x", + retries: { + enabledInDev: false, + default: { maxAttempts: 3, factor: 2, minTimeoutInMs: 1000, maxTimeoutInMs: 10000, randomize: true }, + }, +}); +``` + +`build.external` controls which packages stay out of the bundle. Build extensions (`additionalFiles`, `prismaExtension`, `puppeteer`, `ffmpeg`, `aptGet`, etc.) come from `@trigger.dev/build`. `telemetry` configures instrumentations and exporters. + +### Logging + +`logger.debug / log / info / warn / error(message, dataRecord?)` write structured logs; `logger.trace(name, async (span) => {...})` adds a span. Module-level metrics use `otel.metrics.getMeter(name)`. + +## Common mistakes + +1. **CRITICAL: Treating the wait result as the output.** `triggerAndWait` and `wait.forToken` return a Result object, not the raw output. + - Wrong: `const out = await childTask.triggerAndWait(p); use(out.foo);` + - Correct: `const r = await childTask.triggerAndWait(p); if (r.ok) use(r.output.foo);` (or `.unwrap()`). + +2. **Wrapping `triggerAndWait` / `batchTriggerAndWait` / `wait` in `Promise.all`.** + - Wrong: `await Promise.all([childTask.triggerAndWait(a), childTask.triggerAndWait(b)]);` + - Correct: `await childTask.batchTriggerAndWait([{ payload: a }, { payload: b }]);` (or a sequential for-loop). + +3. **Importing the task instance into backend code.** + - Wrong: `import { emailSequence } from "~/trigger/emails";` in a route handler. + - Correct: `import type { emailSequence }` plus `tasks.trigger("email-sequence", payload)`. + +4. **Calling `metadata.set/get` outside `run()`.** + - Wrong: setting metadata at module scope or in unrelated backend code (a no-op; `get` returns `undefined`). + - Correct: call inside `run()` or a task lifecycle hook. + +5. **Assuming child tasks inherit the parent's queue or metadata.** + - Wrong: expecting a subtask to share the parent's `concurrencyLimit` or see its metadata. + - Correct: subtasks run on their own queue; pass metadata explicitly via `{ metadata: metadata.current() }`, or push up with `metadata.parent.*`. + +6. **Bundling native/WASM packages.** + - Wrong: leaving `sharp`, `re2`, `sqlite3`, or WASM packages in the default bundle. + - Correct: add them to `build.external` in `trigger.config.ts`. + +7. **Relying on a raw string idempotency key being global.** + - Wrong: `trigger(p, { idempotencyKey: "welcome-email" })` expecting once-ever (true only in v4.3.0 and earlier). + - Correct: `await idempotencyKeys.create("welcome-email", { scope: "global" })`. + +## References + +Sibling skills: + +- **realtime-and-frontend** for subscribing to runs and triggering from the frontend with React hooks. +- **authoring-chat-agent** and **chat-agent-advanced** for building AI chat agents. + +Docs: + +- [Tasks overview](https://trigger.dev/docs/tasks/overview) +- [Triggering](https://trigger.dev/docs/triggering) +- [Configuration file](https://trigger.dev/docs/config/config-file) + +## Version + +Generated for @trigger.dev/sdk {{TRIGGER_SDK_VERSION}}. Re-run the trigger.dev skills installer after upgrading. diff --git a/packages/cli-v3/skills/chat-agent-advanced/SKILL.md b/packages/cli-v3/skills/chat-agent-advanced/SKILL.md new file mode 100644 index 00000000000..68ce1c6b066 --- /dev/null +++ b/packages/cli-v3/skills/chat-agent-advanced/SKILL.md @@ -0,0 +1,367 @@ +--- +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 +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` is built on **Sessions**: a durable, task-bound, bi-directional I/O channel pair keyed +on a stable `externalId` (e.g. `chatId`) that outlives any single run. This skill covers the layers +beneath and around the everyday agent: the raw `sessions` API, server-side `AgentChat`, durable +sub-agents, actions / background injection, fast starts, compaction and recovery, and the wire +protocol for custom transports. + +Two `chat` namespaces are easy to confuse: the agent definition imports `chat` from +`@trigger.dev/sdk/ai`; Head Start / Node-listener server entries import `chat` from +`@trigger.dev/sdk/chat-server`. + +## Setup + +Happy path: drive an agent from server-side code (task, webhook, or script) with `AgentChat`. + +```ts +import { AgentChat } from "@trigger.dev/sdk/chat"; +import type { myAgent } from "./trigger/my-agent"; + +const chat = new AgentChat({ agent: "my-chat", clientData: { userId: "user_123" } }); +const stream = await chat.sendMessage("Review PR #42"); +const text = await stream.text(); +await chat.close(); +``` + +`sendMessage()` triggers a run on the first call, then reuses it via input streams. `ChatStream` +exposes `text()`, `result()` (`{ text, toolCalls, toolResults }`), `messages()` (UIMessage +snapshots), and the raw `.stream`. Other methods: `steer(text)`, `stop()`, `sendRaw(uiMessages)`, +`sendAction(action)`, `preload()`, `reconnect()`. + +## Core patterns + +### 1. Raw Sessions for non-chat, bi-directional I/O + +Reach for `sessions` directly when the chat abstraction does not fit: agent inboxes, approval flows, +server-to-server pipelines. `sessions.start` is idempotent on `(env, externalId)`; `externalId` +cannot start with `session_`. + +```ts +import { sessions } from "@trigger.dev/sdk"; + +const { id, publicAccessToken } = await sessions.start({ + type: "chat.agent", + externalId: chatId, + taskIdentifier: "my-chat", + triggerConfig: { tags: [`chat:${chatId}`], basePayload: { chatId, trigger: "preload" } }, +}); + +const session = sessions.open(chatId); // no network call; methods are lazy +await session.out.append({ kind: "message", text: "hello" }); +const next = await session.in.once({ timeoutMs: 30_000 }); +``` + +`sessions.open(id).in` also has `send`, `on(handler)`, `peek`, `wait` (suspends the run, only inside +`task.run()`), and `waitWithIdleTimeout`. `.out` has `append`, `pipe`, `writer`, `read`, +`writeControl`, and `trimTo`. List with `sessions.list({ type, tag, status, ... })` (`for await`), +mutate with `sessions.update`, end with `sessions.close` (terminal, idempotent). + +### 2. Durable sub-agent as a streaming tool + +`AgentChat` inside an AI SDK `tool()` delegates to a durable sub-agent; its response streams as +preliminary tool results. Give the tool a `toModelOutput` so the model sees a compact summary. + +```ts +import { tool } from "ai"; +import { AgentChat } from "@trigger.dev/sdk/chat"; +import { z } from "zod"; + +const researchTool = tool({ + description: "Delegate research to a specialist agent.", + inputSchema: z.object({ topic: z.string() }), + execute: async function* ({ topic }, { abortSignal }) { + const chat = new AgentChat({ agent: "research-agent" }); + const stream = await chat.sendMessage(topic, { abortSignal }); + yield* stream.messages(); // UIMessage snapshots become preliminary tool results + await chat.close(); + }, + toModelOutput: ({ output: message }) => { + const lastText = message?.parts?.findLast((p: { type: string }) => p.type === "text") as + | { text?: string } + | undefined; + return { type: "text", value: lastText?.text ?? "Done." }; + }, +}); +``` + +For a subtask exposed via `execute: ai.toolExecute(task)`, stream progress to the agent's run with +`chat.stream.writer({ target: "root" })`. `target` accepts `"self" | "parent" | "root" | `. +Inside the subtask, read context with `ai.toolCallId()` and `ai.chatContextOrThrow()` +(`{ chatId, turn, continuation, clientData }`). + +```ts +import { chat, ai } from "@trigger.dev/sdk/ai"; + +const { waitUntilComplete } = chat.stream.writer({ + target: "root", + execute: ({ write }) => + write({ type: "data-research-status", id: partId, data: { query, status: "in-progress" } }), +}); +await waitUntilComplete(); +``` + +### 3. Background injection: defer + inject + +`chat.defer(promise)` runs work in parallel with streaming (all deferred promises are awaited, with a +5s timeout, before `onTurnComplete`). `chat.inject(messages)` queues `ModelMessage[]` that drain at +the next turn start or `prepareStep` boundary. + +```ts +export const myChat = chat.agent({ + id: "my-chat", + onTurnComplete: async ({ messages }) => { + chat.defer( + (async () => { + const analysis = await analyzeConversation(messages); + chat.inject([{ role: "system", content: `[Analysis]\n\n${analysis}` }]); + })() + ); + }, + run: async ({ messages, signal }) => + streamText({ ...chat.toStreamTextOptions({ registry }), messages, abortSignal: signal, stopWhen: stepCountIs(15) }), +}); +``` + +### 4. Compaction (threshold-based) + +`compaction.shouldCompact` decides when, `summarize` produces the summary that replaces the model +messages. UI messages are preserved by default (customize via `compactUIMessages`). The `prepareStep` +that performs inner-loop compaction is auto-injected by `chat.toStreamTextOptions()`; a `prepareStep` +you pass after the spread wins. + +```ts +compaction: { + shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000, + summarize: async ({ messages }) => + (await generateText({ + model: anthropic("claude-haiku-4-5"), + messages: [...messages, { role: "user", content: "Summarize concisely." }], + })).text, +}, +``` + +### 5. Actions: mutate state without a turn + +`actionSchema` validates; `onAction` mutates via `chat.history` (`slice`, `replace`, `rollbackTo`, +`remove`, `getPendingToolCalls`, `extractNewToolResults`). Actions fire `hydrateMessages` and +`onAction` only, never `run()` or the turn hooks. Return a `StreamTextResult`, string, or `UIMessage` +to also emit a model response. + +```ts +export const myChat = chat.agent({ + id: "my-chat", + actionSchema: z.discriminatedUnion("type", [ + z.object({ type: z.literal("undo") }), + z.object({ type: z.literal("rollback"), targetMessageId: z.string() }), + ]), + onAction: async ({ action }) => { + if (action.type === "undo") chat.history.slice(0, -2); + if (action.type === "rollback") chat.history.rollbackTo(action.targetMessageId); + }, + run: async ({ messages, signal }) => streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal }), +}); +``` + +Send from the browser with `transport.sendAction(chatId, { type: "undo" })`, or server-side with +`agentChat.sendAction({ type: "rollback", targetMessageId: "msg-3" })`. + +### 6. Fast starts: Head Start + +`chat.headStart` (from `@trigger.dev/sdk/chat-server`, NOT `/ai`) returns a Web Fetch handler that +serves turn 1 from your own warm process, then hands off to the agent on turn 2+. Tools passed here +must be **schema-only** (a module importing `ai` + `zod` only); heavy executes stay in the task. + +```ts +import { chat } from "@trigger.dev/sdk/chat-server"; +import { streamText, stepCountIs } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { headStartTools } from "@/lib/chat-tools/schemas"; + +export const chatHandler = chat.headStart({ + agentId: "my-chat", + run: async ({ chat: helper }) => + streamText({ + ...helper.toStreamTextOptions({ tools: headStartTools }), + model: anthropic("claude-sonnet-4-6"), + system: "You are helpful.", + stopWhen: stepCountIs(15), + }), +}); +// Next.js: export const POST = chatHandler; Transport: headStart: "/api/chat" +``` + +Node-only frameworks wrap a Web Fetch handler with `chat.toNodeListener(handler)`. Use the **same +model** on both sides to avoid a tone shift between turn 1 and turn 2+. + +### 7. chat.local: init in onBoot, not onChatStart + +`chat.local({ id })` is module-level, shallow-proxy, run-scoped state. Initialize it in `onBoot` +(fires on every fresh worker, including continuation runs), never `onChatStart`. + +```ts +const userContext = chat.local<{ name: string; plan: "free" | "pro" }>({ id: "userContext" }); + +export const myChat = chat.agent({ + id: "my-chat", + onBoot: async ({ clientData }) => userContext.init({ name: "Alice", plan: "pro" }), + run: async ({ messages, signal }) => streamText({ /* ... */ }), +}); +``` + +### 8. Pending messages (mid-stream user input) + +A message sent while a turn is streaming should NOT cancel the stream. Configure +`pendingMessages` (`shouldInject`, `prepare`, `onReceived`, `onInjected`) on the agent so the SDK's +auto-injected `prepareStep` folds them in at the next boundary. On the frontend, `usePendingMessages` +returns `pending`, `steer(text)`, `queue(text)`, and `promoteToSteering(id)`; send via +`transport.sendPendingMessage(chatId, uiMessage, metadata?)`. + +### 9. Recovery and version upgrades + +`onRecoveryBoot` fires only when a **partial assistant message exists on the tail** (interrupted +deploy, crash, OOM retry). It does NOT fire on `chat.requestUpgrade()`, which is a graceful exit with +no partial. `chat.requestUpgrade()` (called in `onTurnStart` / `onValidateMessages` to skip `run()`, +or in `run()` / `chat.defer()` to exit after the turn) rotates the Session's `currentRunId` to a run +on the latest deployment without a client reconnect. Pair it with a contract version on `clientData`. + +```ts +const SUPPORTED_VERSIONS = new Set(["v2", "v3"]); +onTurnStart: async ({ clientData }) => { + if (clientData?.protocolVersion && !SUPPORTED_VERSIONS.has(clientData.protocolVersion)) { + chat.requestUpgrade(); + } +}, +``` + +For OOM resilience, set `oomMachine` (and `machine`) on the agent so retries land on a larger preset. + +### 10. Offline testing with mockChatAgent + +`@trigger.dev/sdk/ai/test` runs the real turn loop in-memory. Import it **before** the agent module +so the resource catalog is installed. Drive with `sendMessage`, `sendRegenerate`, `sendAction`, +`sendStop`, `sendHeadStart`, `sendHandover`; seed state with `seedSnapshot` / `seedSessionOutTail` / +`seedSessionOutPartial` / `seedSessionInTail`; assert against `turn.chunks` and `harness.allChunks`. + +```ts +import { mockChatAgent } from "@trigger.dev/sdk/ai/test"; // BEFORE the agent module +import { myChatAgent } from "./my-chat.js"; + +const harness = mockChatAgent(myChatAgent, { chatId: "test-1", clientData: { model } }); +try { + const turn = await harness.sendMessage({ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] }); + // assert against turn.chunks +} finally { + await harness.close(); +} +``` + +Options include `mode` (`"preload" | "submit-message" | "handover-prepare" | "continuation"`), +`preload`, `continuation`, `previousRunId`, `snapshot`, `taskContext`, and `setupLocals`. Set +`taskContext.ctx.attempt.number > 1` to simulate an OOM-retry attempt. `runInMockTaskContext` drives a +non-chat task offline. + +### 11. Custom transport: the wire protocol + +Endpoints: `POST /api/v1/sessions` (create), `GET /realtime/v1/sessions/{id}/out` (SSE), +`POST /realtime/v1/sessions/{id}/in/append`, `POST /api/v1/sessions/{id}/close`. `ChatInputChunk` is +`{ kind: "message"; payload: ChatTaskWirePayload } | { kind: "stop"; message? }`. The +`ChatTaskWirePayload` carries `chatId`, `trigger` (`submit-message | regenerate-message | preload | +close | action | handover-prepare`), `message?`, `metadata?`, `action?`, `continuation?`, +`previousRunId?`, and more. Control records are header-form: `trigger-control: turn-complete` (with +optional `public-access-token`, `session-in-event-id`) and `trigger-control: upgrade-required`. The +TS helpers `SSEStreamSubscription` and `controlSubtype(headers)` (documented in +`docs/ai-chat/client-protocol.mdx`) handle batch decoding and control-record filtering for you. + +## Common mistakes + +- **CRITICAL: sending a follow-up by re-POSTing `POST /api/v1/sessions`.** + ```ts + // Wrong - a cached re-POST silently drops basePayload.message; basePayload is trigger config, not a channel + await fetch("/api/v1/sessions", { method: "POST", body: JSON.stringify({ ...createBody }) }); + // Correct - append to the session's input channel + await fetch(`/realtime/v1/sessions/${id}/in/append`, { method: "POST", body: JSON.stringify({ kind: "message", payload }) }); + ``` + +- **Using the wrong token for `.in` / `.out`.** Use `publicAccessToken` from the create response + body (session-scoped). The `x-trigger-jwt` response header is run-scoped and cannot subscribe. + +- **Initializing `chat.local` in `onChatStart`.** It is skipped on continuation runs, so `run()` + crashes with `chat.local can only be modified after initialization`. Init in `onBoot`. + +- **`chat.defer` for the message-history write.** A mid-stream refresh would read `[]`. `await` that + write inline before the model streams; reserve `chat.defer` for analytics, audit, cache warming. + +- **Giving the HITL tool an `execute`.** `streamText` calls it immediately. Leave it execute-less; + the frontend supplies the answer via `addToolOutput` + `sendAutomaticallyWhen`. + +- **Declaring sub-agent / heavy tools only on `streamText`.** Also declare them on + `chat.agent({ tools })` (or pass to `convertToModelMessages(uiMessages, { tools })` in a custom + agent) so `toModelOutput` re-applies on every turn. + +- **Importing heavy-execute tools into the Head Start route module.** This is a build-time import + chain problem; runtime strip helpers do not fix it. Keep schemas in an `ai` + `zod`-only module. + +- **Returning a megabyte tool output on the stream.** One `tool-output-available` record over ~1 MiB + throws `ChatChunkTooLargeError`. Persist to your store, write the row first, then emit only an id. + +- **Setting `X-Peek-Settled: 1` on the active-send path.** It races the new turn's first chunk and + closes the stream early. Use it only on reconnect-on-reload paths. + +> Note on docs vocabulary: agent-side examples in some docs still use the legacy +> `trigger:turn-complete` chunk type. That is the agent-emit vocabulary. A custom **reader** must +> filter on the `trigger-control` header, not on `chunk.type`. +> +> MCP-driven agent chats (`list_agents`, `start_agent_chat`, `send_agent_message`, +> `close_agent_chat`) are MCP server tools used from Claude Code / Cursor, not importable SDK +> functions. See `/mcp-tools#agent-chat-tools`. + +## References + +- `authoring-chat-agent` skill - the everyday `chat.agent({...})` definition, lifecycle hooks, and + the `useTriggerChatTransport` happy path. Start there before reaching for this skill. +- `realtime-and-frontend` skill - Realtime hooks and frontend streaming beyond the chat transport. +- `authoring-tasks` skill - base `task()` semantics, `ctx`, and standard lifecycle hooks. +- Docs: /ai-chat/sessions, /ai-chat/server-chat, /ai-chat/client-protocol + +## Version + +Generated for `@trigger.dev/sdk` `{{TRIGGER_SDK_VERSION}}`. Re-run the trigger.dev skills installer +after upgrading. diff --git a/packages/cli-v3/skills/realtime-and-frontend/SKILL.md b/packages/cli-v3/skills/realtime-and-frontend/SKILL.md new file mode 100644 index 00000000000..811fb7d1ff1 --- /dev/null +++ b/packages/cli-v3/skills/realtime-and-frontend/SKILL.md @@ -0,0 +1,281 @@ +--- +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 +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`. + +## Setup + +```bash +npm add @trigger.dev/react-hooks # frontend hooks (React/Next.js/Remix) +# @trigger.dev/sdk is already installed for the backend +``` + +The flow is always: mint a scoped token in the backend, pass it to the +frontend, subscribe with a hook. + +```ts +// backend (API route / server action) +import { auth } from "@trigger.dev/sdk"; + +const publicAccessToken = await auth.createPublicToken({ + scopes: { read: { runs: ["run_1234"] } }, // a token with no scopes is useless +}); +``` + +```tsx +// frontend +"use client"; +import { useRealtimeRun } from "@trigger.dev/react-hooks"; + +export function RunStatus({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) { + const { run, error } = useRealtimeRun(runId, { accessToken: publicAccessToken }); + if (error) return
Error: {error.message}
; + if (!run) return
Loading...
; + return
Run: {run.status}
; +} +``` + +There are two token kinds: Public Access Tokens (read/subscribe, from +`auth.createPublicToken`) and Trigger Tokens (trigger-from-browser, single-use, +from `auth.createTriggerPublicToken`). Both default to a 15 minute expiry. + +## Core patterns + +### 1. Subscribe to a run and render metadata progress + +`metadata` is `Record`, so nested values need a cast. + +```tsx +"use client"; +import { useRealtimeRun } from "@trigger.dev/react-hooks"; +import type { myTask } from "@/trigger/myTask"; + +export function Progress({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) { + const { run, error } = useRealtimeRun(runId, { accessToken: publicAccessToken }); + if (error) return
Error: {error.message}
; + if (!run) return
Loading...
; + const progress = run.metadata?.progress as { percentage?: number } | undefined; + return
{run.status}: {progress?.percentage ?? 0}%
; +} +``` + +Pass `onComplete: (run, error) => {}` to react when the run finishes. + +### 2. Status-only subscription with `skipColumns` + +For a badge or progress bar you do not need `payload`/`output`. Skipping them +reduces wire size and avoids "Large HTTP Payload" warnings. + +```tsx +const { run } = useRealtimeRun(runId, { + accessToken: publicAccessToken, + skipColumns: ["payload", "output"], +}); +``` + +You can skip any of: `payload`, `output`, `metadata`, `startedAt`, `delayUntil`, +`queuedAt`, `expiredAt`, `completedAt`, `number`, `isTest`, `usageDurationMs`, +`costInCents`, `baseCostInCents`, `ttl`, `payloadType`, `outputType`, `runTags`, +`error`. + +### 3. Trigger from the browser with a Trigger Token + +`accessToken` here is a Trigger Token (`auth.createTriggerPublicToken`), not a +Public Access Token. + +```tsx +"use client"; +import { useTaskTrigger } from "@trigger.dev/react-hooks"; +import type { myTask } from "@/trigger/myTask"; + +export function TriggerButton({ publicAccessToken }: { publicAccessToken: string }) { + const { submit, handle, isLoading } = useTaskTrigger("my-task", { + accessToken: publicAccessToken, + }); + if (handle) return
Run ID: {handle.id}
; + return ( + + ); +} +``` + +`submit(payload, options?)` takes the same options as a backend `trigger` call. + +### 4. Trigger and subscribe in one hook + +```tsx +"use client"; +import { useRealtimeTaskTrigger } from "@trigger.dev/react-hooks"; +import type { myTask } from "@/trigger/myTask"; + +export function Runner({ publicAccessToken }: { publicAccessToken: string }) { + const { submit, run, isLoading } = useRealtimeTaskTrigger("my-task", { + accessToken: publicAccessToken, + }); + if (run) return
{run.status}
; + return ; +} +``` + +Use `useRealtimeTaskTriggerWithStreams` when you also +want the task's streams (it returns `{ submit, run, streams, error, isLoading }`). + +### 5. Consume an AI/text stream (SDK 4.1.0+, recommended) + +`useRealtimeStream` takes a defined stream for full type safety, or a `runId` +plus optional stream key. Returns `{ parts, error }`. + +```tsx +"use client"; +import { useRealtimeStream } from "@trigger.dev/react-hooks"; +import { aiStream } from "@/trigger/streams"; // a defined stream -> typed parts + +export function StreamView({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) { + const { parts, error } = useRealtimeStream(aiStream, runId, { + accessToken: publicAccessToken, + timeoutInSeconds: 300, // default 60 + onData: (chunk) => console.log(chunk), + }); + if (error) return
Error: {error.message}
; + if (!parts) return
Loading...
; + return
{parts.join("")}
; +} +``` + +Without a defined stream: `useRealtimeStream(runId, "ai-output", { accessToken })`, +or omit the key to use the default stream. Other options: `baseURL`, `startIndex`, +`throttleInMs` (default 16). The legacy `useRealtimeRunWithStreams(runId, options)` +hook is still supported when you need both the run and all its streams at once. + +### 6. Send input back into a running task + +```tsx +"use client"; +import { useInputStreamSend } from "@trigger.dev/react-hooks"; +import { approval } from "@/trigger/streams"; + +export function ApprovalForm({ runId, accessToken }: { runId: string; accessToken: string }) { + const { send, isLoading, isReady } = useInputStreamSend(approval.id, runId, { accessToken }); + return ( + + ); +} +``` + +### 7. Complete a wait token from React + +```ts +// backend: create the token, return id + publicAccessToken to the frontend +import { wait } from "@trigger.dev/sdk"; +const token = await wait.createToken({ timeout: "10m" }); +return { tokenId: token.id, publicToken: token.publicAccessToken }; +``` + +```tsx +"use client"; +import { useWaitToken } from "@trigger.dev/react-hooks"; + +export function Approve({ tokenId, publicToken }: { tokenId: string; publicToken: string }) { + const { complete } = useWaitToken(tokenId, { accessToken: publicToken }); + return ; +} +``` + +### 8. Subscribe from the backend (async iterators) + +```ts +import { runs, tasks } from "@trigger.dev/sdk"; +import type { myTask } from "./trigger/my-task"; + +const handle = await tasks.trigger("my-task", { some: "data" }); +for await (const run of runs.subscribeToRun(handle.id)) { + console.log(run.payload.some, run.output?.some); // typed +} +``` + +`runs.subscribeToRun` completes when the run finishes, so the loop exits on its own. + +## Common mistakes + +1. **CRITICAL: Triggering from the browser with a Public Access Token.** The + read token from `createPublicToken` cannot trigger tasks. + - Wrong: `useTaskTrigger("my-task", { accessToken: publicAccessTokenFromCreatePublicToken })` + - Correct: mint a single-use Trigger Token with `auth.createTriggerPublicToken("my-task")` and pass that. + +2. **Token with no scopes.** A scopeless token authorizes nothing, so every subscribe 403s. + - Wrong: `await auth.createPublicToken()` + - Correct: `await auth.createPublicToken({ scopes: { read: { runs: ["run_1234"] } } })` + +3. **Polling with `useRun`/SWR for live updates.** `useRun` is the SWR-based + management-API hook (not recommended for live state); set `refreshInterval: 0` + to stop polling if you do use it. + - Wrong: `useRun(runId, { refreshInterval: 1000 })` to track progress + - Correct: `useRealtimeRun(runId, { accessToken })` (no polling, no WebSocket setup) + +4. **Forgetting `"use client"`.** Realtime/trigger hooks cannot run in a server component. + - Wrong: a Next.js App Router server component using `useRealtimeRun` + - Correct: put `"use client";` at the top of any component using these hooks. + +5. **Shipping `payload`/`output` you do not render.** + - Wrong: `useRealtimeRun(runId, { accessToken })` for a status badge (large payloads over the wire) + - Correct: `useRealtimeRun(runId, { accessToken, skipColumns: ["payload", "output"] })` + +6. **Subscribing before the handle exists.** + - Wrong: `useRealtimeRun(handle, { accessToken: handle?.publicAccessToken })` with no guard + - Correct: add `enabled: !!handle` so it subscribes only once the trigger returns a handle. + +## References + +Sibling skills: +- `authoring-tasks` for the task side: `streams.define()`, `metadata.set()`, and `wait.createToken`. +- `authoring-chat-agent` and `chat-agent-advanced` for chat agents, which build on these realtime streams. + +Docs: +- [React hooks: run updates](/realtime/react-hooks/subscribe) +- [React hooks: streaming](/realtime/react-hooks/streams) +- [Realtime auth](/realtime/auth) + +The realtime run object differs from the management-API run object returned by +`useRun`; see [run object reference](/realtime/run-object). For the task side +(`streams.define`, `metadata.set`), see [/tasks/streams](/tasks/streams) and +[/runs/metadata](/runs/metadata). + +## Version + +Generated for @trigger.dev/sdk {{TRIGGER_SDK_VERSION}}. Re-run the trigger.dev skills installer after upgrading. diff --git a/packages/cli-v3/src/cli/index.ts b/packages/cli-v3/src/cli/index.ts index fc482224e58..1278de73430 100644 --- a/packages/cli-v3/src/cli/index.ts +++ b/packages/cli-v3/src/cli/index.ts @@ -17,7 +17,7 @@ import { COMMAND_NAME } from "../consts.js"; import { VERSION } from "../version.js"; import { installExitHandler } from "./common.js"; import { configureInstallMcpCommand } from "../commands/install-mcp.js"; -import { configureInstallRulesCommand } from "../commands/install-rules.js"; +import { configureSkillsCommand } from "../commands/skills.js"; export const program = new Command(); @@ -41,6 +41,6 @@ configurePreviewCommand(program); configureAnalyzeCommand(program); configureMcpCommand(program); configureInstallMcpCommand(program); -configureInstallRulesCommand(program); +configureSkillsCommand(program); installExitHandler(); diff --git a/packages/cli-v3/src/commands/dev.ts b/packages/cli-v3/src/commands/dev.ts index 73e79933dd0..a5aa994df63 100644 --- a/packages/cli-v3/src/commands/dev.ts +++ b/packages/cli-v3/src/commands/dev.ts @@ -26,7 +26,7 @@ import { confirm, isCancel, log } from "@clack/prompts"; import { installMcpServer } from "./install-mcp.js"; import { tryCatch } from "@trigger.dev/core/utils"; import { VERSION } from "@trigger.dev/core"; -import { initiateRulesInstallWizard } from "./install-rules.js"; +import { initiateSkillsInstallWizard } from "./skills.js"; const DevCommandOptions = CommonCommandOptions.extend({ debugOtel: z.boolean().default(false), @@ -43,8 +43,6 @@ const DevCommandOptions = CommonCommandOptions.extend({ disableWarnings: z.boolean().default(false), skipMCPInstall: z.boolean().default(false), skipRulesInstall: z.boolean().default(false), - rulesInstallManifestPath: z.string().optional(), - rulesInstallBranch: z.string().optional(), }); export type DevCommandOptions = z.infer; @@ -87,19 +85,7 @@ export function configureDevCommand(program: Command) { .addOption( new CommandOption( "--skip-rules-install", - "Skip the Trigger.dev Agent rules install wizard" - ).hideHelp() - ) - .addOption( - new CommandOption( - "--rules-install-manifest-path ", - "The path to the rules install manifest" - ).hideHelp() - ) - .addOption( - new CommandOption( - "--rules-install-branch ", - "The branch to install the rules from" + "Skip the Trigger.dev agent skills install wizard" ).hideHelp() ) .addOption(new CommandOption("--disable-warnings", "Suppress warnings output").hideHelp()) @@ -158,12 +144,7 @@ export async function devCommand(options: DevCommandOptions) { typeof options.skipRulesInstall === "boolean" && options.skipRulesInstall; if (!skipRulesInstall) { - await tryCatch( - initiateRulesInstallWizard({ - manifestPath: options.rulesInstallManifestPath, - branch: options.rulesInstallBranch, - }) - ); + await tryCatch(initiateSkillsInstallWizard({})); } } diff --git a/packages/cli-v3/src/commands/install-rules.ts b/packages/cli-v3/src/commands/install-rules.ts deleted file mode 100644 index 7500414bed9..00000000000 --- a/packages/cli-v3/src/commands/install-rules.ts +++ /dev/null @@ -1,612 +0,0 @@ -import { confirm, intro, isCancel, log, multiselect, outro } from "@clack/prompts"; -import { ResolvedConfig } from "@trigger.dev/core/v3/build"; -import chalk from "chalk"; -import { Command, Option as CommandOption } from "commander"; -import { join } from "node:path"; -import * as semver from "semver"; -import { z } from "zod"; -import { OutroCommandError, wrapCommandAction } from "../cli/common.js"; -import { loadConfig } from "../config.js"; -import { - GithubRulesManifestLoader, - loadRulesManifest, - LocalRulesManifestLoader, - ManifestVersion, - RulesManifest, - RulesManifestVersionOption, -} from "../rules/manifest.js"; -import { cliLink } from "../utilities/cliOutput.js"; -import { - readConfigHasSeenRulesInstallPrompt, - readConfigLastRulesInstallPromptVersion, - writeConfigHasSeenRulesInstallPrompt, - writeConfigLastRulesInstallPromptVersion, -} from "../utilities/configFiles.js"; -import { pathExists, readFile, safeWriteFile } from "../utilities/fileSystem.js"; -import { printStandloneInitialBanner } from "../utilities/initialBanner.js"; -import { logger } from "../utilities/logger.js"; -import { tryCatch } from "@trigger.dev/core/utils"; - -const targets = [ - "claude-code", - "cursor", - "vscode", - "windsurf", - "gemini-cli", - "cline", - "agents.md", - "amp", - "kilo", - "ruler", -] as const; - -type TargetLabels = { - [key in (typeof targets)[number]]: string; -}; - -const targetLabels: TargetLabels = { - "claude-code": "Claude Code", - cursor: "Cursor", - vscode: "VSCode", - windsurf: "Windsurf", - "gemini-cli": "Gemini CLI", - cline: "Cline", - "agents.md": "AGENTS.md (OpenAI Codex CLI, Jules, OpenCode)", - amp: "Sourcegraph AMP", - kilo: "Kilo Code", - ruler: "Ruler", -}; - -type SupportedTargets = (typeof targets)[number]; -type ResolvedTargets = SupportedTargets | "unsupported"; - -const InstallRulesCommandOptions = z.object({ - target: z.enum(targets).array().optional(), - manifestPath: z.string().optional(), - branch: z.string().optional(), - logLevel: z.enum(["debug", "info", "log", "warn", "error", "none"]).optional(), - forceWizard: z.boolean().optional(), -}); - -type InstallRulesCommandOptions = z.infer; - -export function configureInstallRulesCommand(program: Command) { - return program - .command("install-rules") - .description("Install the Trigger.dev Agent rules files") - .option( - "--target ", - "Choose the target (or targets) to install the Trigger.dev rules into. We currently support: " + - targets.join(", ") - ) - .option( - "-l, --log-level ", - "The CLI log level to use (debug, info, log, warn, error, none). This does not effect the log level of your trigger.dev tasks.", - "log" - ) - .addOption( - new CommandOption( - "--manifest-path ", - "The path to the rules manifest file. This is useful if you want to install the rules from a local file." - ).hideHelp() - ) - .addOption( - new CommandOption( - "--branch ", - "The branch to install the rules from, the default is main" - ).hideHelp() - ) - .addOption( - new CommandOption( - "--force-wizard", - "Force the rules install wizard to run even if the rules have already been installed." - ).hideHelp() - ) - .action(async (options) => { - await printStandloneInitialBanner(true); - await installRulesCommand(options); - }); -} - -export async function installRulesCommand(options: unknown) { - return await wrapCommandAction( - "installRulesCommand", - InstallRulesCommandOptions, - options, - async (opts) => { - if (opts.logLevel) { - logger.loggerLevel = opts.logLevel; - } - - return await _installRulesCommand(opts); - } - ); -} - -async function _installRulesCommand(options: InstallRulesCommandOptions) { - if (options.forceWizard) { - await initiateRulesInstallWizard(options); - return; - } - - intro("Welcome to the Trigger.dev Agent rules install wizard "); - - const manifestLoader = options.manifestPath - ? new LocalRulesManifestLoader(options.manifestPath) - : new GithubRulesManifestLoader(options.branch ?? "main"); - - const manifest = await loadRulesManifest(manifestLoader); - - writeConfigLastRulesInstallPromptVersion(manifest.currentVersion); - writeConfigHasSeenRulesInstallPrompt(true); - - await installRules(manifest, options); - - outro("You're all set! "); -} - -type InstallRulesResults = Array; - -type InstallRulesResult = { - configPath: string; - targetName: (typeof targets)[number]; -}; - -export type InstallRulesWizardOptions = { - target?: Array<(typeof targets)[number]>; - manifestPath?: string; - branch?: string; -}; - -export async function initiateRulesInstallWizard(options: InstallRulesWizardOptions) { - const manifestLoader = options.manifestPath - ? new LocalRulesManifestLoader(options.manifestPath) - : new GithubRulesManifestLoader(options.branch ?? "main"); - - const manifest = await loadRulesManifest(manifestLoader); - - const hasSeenRulesInstallPrompt = readConfigHasSeenRulesInstallPrompt(); - - if (!hasSeenRulesInstallPrompt) { - writeConfigHasSeenRulesInstallPrompt(true); - writeConfigLastRulesInstallPromptVersion(manifest.currentVersion); - - const installChoice = await confirm({ - message: "Would you like to install the Trigger.dev code agent rules?", - initialValue: true, - }); - - const skipInstall = isCancel(installChoice) || !installChoice; - - if (skipInstall) { - return; - } - - await installRules(manifest, options); - return; - } - - const lastRulesInstallPromptVersion = readConfigLastRulesInstallPromptVersion(); - - if (!lastRulesInstallPromptVersion) { - writeConfigHasSeenRulesInstallPrompt(true); - writeConfigLastRulesInstallPromptVersion(manifest.currentVersion); - - const installChoice = await confirm({ - message: `A new version of the trigger.dev agent rules is available (${manifest.currentVersion}). Do you want to install it?`, - initialValue: true, - }); - - const skipInstall = isCancel(installChoice) || !installChoice; - - if (skipInstall) { - return; - } - - await installRules(manifest, options); - return; - } - - if (semver.gt(manifest.currentVersion, lastRulesInstallPromptVersion)) { - writeConfigHasSeenRulesInstallPrompt(true); - writeConfigLastRulesInstallPromptVersion(manifest.currentVersion); - - const confirmed = await confirm({ - message: `A new version of the trigger.dev agent rules is available (${lastRulesInstallPromptVersion} → ${chalk.greenBright( - manifest.currentVersion - )}). Do you want to install it?`, - initialValue: true, - }); - - if (isCancel(confirmed) || !confirmed) { - return; - } - - await installRules(manifest, options); - } - - return; -} - -async function installRules(manifest: RulesManifest, opts: InstallRulesWizardOptions) { - const [_, config] = await tryCatch( - loadConfig({ - cwd: process.cwd(), - }) - ); - - const currentVersion = await manifest.getCurrentVersion(); - - const targetNames = await resolveTargets(opts); - - if (targetNames.length === 1 && targetNames.includes("unsupported")) { - handleUnsupportedTargetOnly(opts); - return; - } - - const results = []; - - for (const targetName of targetNames) { - const result = await installRulesForTarget( - targetName, - currentVersion, - opts, - config ?? undefined - ); - - if (result) { - results.push(result); - } - } - - if (results.length > 0) { - log.step("Installed the following rules files:"); - - for (const r of results) { - const installationsByLocation = r.installations.reduce( - (acc, i) => { - if (!acc[i.location]) { - acc[i.location] = []; - } - - acc[i.location]!.push(i.option); - - return acc; - }, - {} as Record - ); - - const locationOutput = Object.entries(installationsByLocation).map( - ([location]) => `${chalk.greenBright(location)}` - ); - - for (const message of locationOutput) { - log.info(message); - } - } - - log.info( - `${cliLink("Learn how to use our rules", "https://trigger.dev/docs/agents/rules/overview")}` - ); - } -} - -function handleUnsupportedTargetOnly(options: InstallRulesCommandOptions): InstallRulesResults { - log.info( - `${cliLink("Install the rules manually", "https://trigger.dev/docs/agents/rules/overview")}` - ); - - return []; -} - -async function installRulesForTarget( - targetName: ResolvedTargets, - currentVersion: ManifestVersion, - options: InstallRulesCommandOptions, - config?: ResolvedConfig -) { - if (targetName === "unsupported") { - // This should not happen as unsupported targets are handled separately - // but if it does, provide helpful output - log.message( - `${chalk.yellow("⚠")} Skipping unsupported target - see manual configuration above` - ); - return; - } - - const result = await performInstallForTarget(targetName, currentVersion, options, config); - - return result; -} - -async function performInstallForTarget( - targetName: (typeof targets)[number], - currentVersion: ManifestVersion, - cmdOptions: InstallRulesCommandOptions, - config?: ResolvedConfig -) { - const options = await resolveOptionsForTarget(targetName, currentVersion, cmdOptions); - - const installations = await performInstallOptionsForTarget(targetName, options, config); - - return { - targetName, - installations, - }; -} - -async function performInstallOptionsForTarget( - targetName: (typeof targets)[number], - options: Array, - config?: ResolvedConfig -) { - const results = []; - - for (const option of options) { - const result = await performInstallOptionForTarget(targetName, option, config); - results.push(result); - } - - return results; -} - -async function performInstallOptionForTarget( - targetName: (typeof targets)[number], - option: RulesManifestVersionOption, - config?: ResolvedConfig -) { - switch (option.installStrategy) { - case "default": { - return performInstallDefaultOptionForTarget(targetName, option, config); - } - case "claude-code-subagent": { - return performInstallClaudeCodeSubagentOptionForTarget(option); - } - default: { - throw new Error(`Unknown install strategy: ${option.installStrategy}`); - } - } -} - -async function performInstallDefaultOptionForTarget( - targetName: (typeof targets)[number], - option: RulesManifestVersionOption, - config?: ResolvedConfig -) { - // Get the path to the rules file - const rulesFilePath = resolveRulesFilePathForTargetOption(targetName, option); - const rulesFileContents = await resolveRulesFileContentsForTarget(targetName, option, config); - const mergeStrategy = await resolveRulesFileMergeStrategyForTarget(targetName); - - // Try and read the existing rules file - const rulesFileAbsolutePath = join(process.cwd(), rulesFilePath); - await writeToFile(rulesFileAbsolutePath, rulesFileContents, mergeStrategy, option.name); - - return { option, location: rulesFilePath }; -} - -async function writeToFile( - path: string, - contents: string, - mergeStrategy: "overwrite" | "replace" = "overwrite", - sectionName: string -) { - const exists = await pathExists(path); - - if (exists) { - switch (mergeStrategy) { - case "overwrite": { - await safeWriteFile(path, contents); - break; - } - case "replace": { - const existingContents = await readFile(path); - - const pattern = new RegExp( - `.*?`, - "gs" - ); - - // If the section name is not found, just append the new content - if (!pattern.test(existingContents)) { - await safeWriteFile(path, existingContents + "\n\n" + contents); - break; - } - - const updatedContent = existingContents.replace(pattern, contents); - - await safeWriteFile(path, updatedContent); - break; - } - default: { - throw new Error(`Unknown merge strategy: ${mergeStrategy}`); - } - } - } else { - await safeWriteFile(path, contents); - } -} - -async function performInstallClaudeCodeSubagentOptionForTarget(option: RulesManifestVersionOption) { - const rulesFilePath = ".claude/agents/trigger-dev-task-writer.md"; - const rulesFileContents = option.contents; - - await writeToFile(rulesFilePath, rulesFileContents, "overwrite", option.name); - - return { option, location: rulesFilePath }; -} - -function resolveRulesFilePathForTargetOption( - targetName: (typeof targets)[number], - option: RulesManifestVersionOption -): string { - if (option.installStrategy === "claude-code-subagent") { - return ".claude/agents/trigger-dev-task-writer.md"; - } - - switch (targetName) { - case "claude-code": { - return "CLAUDE.md"; - } - case "cursor": { - return `.cursor/rules/trigger.${option.name}.mdc`; - } - case "vscode": { - return `.github/instructions/trigger-${option.name}.instructions.md`; - } - case "windsurf": { - return `.windsurf/rules/trigger-${option.name}.md`; - } - case "gemini-cli": { - return `GEMINI.md`; - } - case "cline": { - return `.clinerules/trigger-${option.name}.md`; - } - case "agents.md": { - return "AGENTS.md"; - } - case "amp": { - return "AGENT.md"; - } - case "kilo": { - return `.kilocode/rules/trigger-${option.name}.md`; - } - case "ruler": { - return `.ruler/trigger-${option.name}.md`; - } - default: { - throw new Error(`Unknown target: ${targetName}`); - } - } -} - -async function resolveRulesFileMergeStrategyForTarget(targetName: (typeof targets)[number]) { - switch (targetName) { - case "amp": - case "agents.md": - case "gemini-cli": - case "claude-code": { - return "replace"; - } - default: { - return "overwrite"; - } - } -} - -async function resolveRulesFileContentsForTarget( - targetName: (typeof targets)[number], - option: RulesManifestVersionOption, - config?: ResolvedConfig -) { - switch (targetName) { - case "cursor": { - return $output( - frontmatter({ - description: option.label, - globs: option.applyTo ?? "**/trigger/**/*.ts", - alwaysApply: false, - }), - option.contents - ); - } - case "vscode": { - return $output( - frontmatter({ - applyTo: option.applyTo ?? "**/trigger/**/*.ts", - }), - option.contents - ); - } - case "windsurf": { - return $output( - frontmatter({ - trigger: "glob", - globs: option.applyTo ?? "**/trigger/**/*.ts", - }), - option.contents - ); - } - default: { - return $output( - ``, - option.contents, - `` - ); - } - } -} - -function frontmatter(data: Record) { - return $output("---", ...Object.entries(data).map(([key, value]) => `${key}: ${value}`), "---"); -} - -function $output(...strings: string[]) { - return strings.map((s) => s).join("\n"); -} - -async function resolveOptionsForTarget( - targetName: (typeof targets)[number], - currentVersion: ManifestVersion, - cmdOptions: InstallRulesCommandOptions -) { - const possibleOptions = currentVersion.options.filter( - (option) => !option.client || option.client === targetName - ); - - const selectedOptions = await multiselect({ - message: `Choose the rules you want to install for ${targetLabels[targetName]}`, - options: possibleOptions.map((option) => ({ - value: option, - label: option.title, - hint: `${option.label} [~${option.tokens} tokens]`, - })), - required: true, - }); - - if (isCancel(selectedOptions)) { - throw new OutroCommandError("No options selected"); - } - - return selectedOptions; -} - -async function resolveTargets(options: InstallRulesCommandOptions): Promise { - if (options.target) { - return options.target; - } - - const selectOptions: Array<{ - value: string; - label: string; - hint?: string; - }> = targets.map((target) => ({ - value: target, - label: targetLabels[target], - })); - - selectOptions.push({ - value: "unsupported", - label: "Unsupported target", - hint: "We don't support this target yet, but you can still install the rules manually.", - }); - - const $selectOptions = selectOptions as Array<{ - value: ResolvedTargets; - label: string; - hint?: string; - }>; - - const selectedTargets = await multiselect({ - message: "Select one or more targets to install the rules into", - options: $selectOptions, - required: true, - }); - - if (isCancel(selectedTargets)) { - throw new OutroCommandError("No targets selected"); - } - - return selectedTargets; -} diff --git a/packages/cli-v3/src/commands/mcp.ts b/packages/cli-v3/src/commands/mcp.ts index f533a8031e5..392f1fb72ee 100644 --- a/packages/cli-v3/src/commands/mcp.ts +++ b/packages/cli-v3/src/commands/mcp.ts @@ -14,15 +14,13 @@ import { printStandloneInitialBanner } from "../utilities/initialBanner.js"; import { logger } from "../utilities/logger.js"; import { installMcpServer } from "./install-mcp.js"; import { serverMetadata } from "../mcp/config.js"; -import { initiateRulesInstallWizard } from "./install-rules.js"; +import { initiateSkillsInstallWizard } from "./skills.js"; const McpCommandOptions = CommonCommandOptions.extend({ projectRef: z.string().optional(), logFile: z.string().optional(), devOnly: z.boolean().default(false), readonly: z.boolean().default(false), - rulesInstallManifestPath: z.string().optional(), - rulesInstallBranch: z.string().optional(), }); export type McpCommandOptions = z.infer; @@ -42,18 +40,6 @@ export function configureMcpCommand(program: Command) { "Run in read-only mode. Write tools (deploy, trigger_task, cancel_run) are hidden from the AI." ) .option("--log-file ", "The file to log to") - .addOption( - new CommandOption( - "--rules-install-manifest-path ", - "The path to the rules install manifest" - ).hideHelp() - ) - .addOption( - new CommandOption( - "--rules-install-branch ", - "The branch to install the rules from" - ).hideHelp() - ) ).action(async (options) => { wrapCommandAction("mcp", McpCommandOptions, options, async (opts) => { await mcpCommand(opts); @@ -80,10 +66,7 @@ export async function mcpCommand(options: McpCommandOptions) { return; } - await initiateRulesInstallWizard({ - manifestPath: options.rulesInstallManifestPath, - branch: options.rulesInstallBranch, - }); + await initiateSkillsInstallWizard({}); return; } diff --git a/packages/cli-v3/src/commands/skills.ts b/packages/cli-v3/src/commands/skills.ts new file mode 100644 index 00000000000..9d5d2e6af66 --- /dev/null +++ b/packages/cli-v3/src/commands/skills.ts @@ -0,0 +1,497 @@ +import { confirm, intro, isCancel, log, multiselect, outro } from "@clack/prompts"; +import chalk from "chalk"; +import { Command, Option as CommandOption } from "commander"; +import { dirname, join } from "node:path"; +import { readPackageJSON, resolvePackageJSON } from "pkg-types"; +import * as semver from "semver"; +import { z } from "zod"; +import { OutroCommandError, wrapCommandAction } from "../cli/common.js"; +import { + BundledSkillsLoader, + loadRulesManifest, + ManifestVersion, + RulesManifest, + RulesManifestVersionOption, +} from "../rules/manifest.js"; +import { sourceDir } from "../sourceDir.js"; +import { cliLink } from "../utilities/cliOutput.js"; +import { + readConfigHasSeenRulesInstallPrompt, + readConfigLastRulesInstallPromptVersion, + writeConfigHasSeenRulesInstallPrompt, + writeConfigLastRulesInstallPromptVersion, +} from "../utilities/configFiles.js"; +import { pathExists, readFile, safeWriteFile } from "../utilities/fileSystem.js"; +import { printStandloneInitialBanner } from "../utilities/initialBanner.js"; +import { logger } from "../utilities/logger.js"; + +// Only tools with a native agent-skills directory. Rules-file-only tools (windsurf, +// gemini-cli, cline, amp, kilo, ruler) don't support the Agent Skills format yet, so +// they fall under the "Unsupported target" manual path rather than silently no-op. +const targets = ["claude-code", "cursor", "vscode", "agents.md"] as const; + +type TargetLabels = { + [key in (typeof targets)[number]]: string; +}; + +const targetLabels: TargetLabels = { + "claude-code": "Claude Code", + cursor: "Cursor", + vscode: "VSCode (Copilot)", + "agents.md": "AGENTS.md (OpenAI Codex CLI, Jules, OpenCode)", +}; + +type SupportedTargets = (typeof targets)[number]; +type ResolvedTargets = SupportedTargets | "unsupported"; + +const SkillsCommandOptions = z.object({ + target: z.enum(targets).array().optional(), + yes: z.boolean().optional(), + logLevel: z.enum(["debug", "info", "log", "warn", "error", "none"]).optional(), + forceWizard: z.boolean().optional(), +}); + +type SkillsCommandOptions = z.infer; + +export function configureSkillsCommand(program: Command) { + return program + .command("skills") + .alias("install-rules") + .description("Install the Trigger.dev agent skills into your coding agent") + .option( + "--target ", + "Choose the target (or targets) to install the Trigger.dev skills into. Native install is supported for: " + + targets.join(", ") + ) + .option( + "-y, --yes", + "Install all available skills for the selected targets without prompting" + ) + .option( + "-l, --log-level ", + "The CLI log level to use (debug, info, log, warn, error, none). This does not effect the log level of your trigger.dev tasks.", + "log" + ) + .addOption( + new CommandOption( + "--force-wizard", + "Force the skills install wizard to run even if the skills have already been installed." + ).hideHelp() + ) + .action(async (options) => { + await printStandloneInitialBanner(true); + await installSkillsCommand(options); + }); +} + +export async function installSkillsCommand(options: unknown) { + return await wrapCommandAction("installSkillsCommand", SkillsCommandOptions, options, async (opts) => { + if (opts.logLevel) { + logger.loggerLevel = opts.logLevel; + } + + return await _installSkillsCommand(opts); + }); +} + +/** + * Loads the agent skills bundled in this CLI (`/skills`, shipped via `files[]`). + * The skills dir and version are resolved from the CLI's own package.json, anchored at + * `sourceDir` (the CLI's location) rather than the user's cwd. The CLI is the only source + * of skills (there is no remote fallback), so this only returns null in the unexpected + * case that the CLI ships without any skills. + */ +async function loadSkillsManifest(): Promise { + try { + const packageJsonPath = await resolvePackageJSON(sourceDir); + const pkg = await readPackageJSON(packageJsonPath); + const skillsDir = join(dirname(packageJsonPath), "skills"); + const version = typeof pkg.version === "string" ? pkg.version : "0.0.0"; + + return await loadRulesManifest(new BundledSkillsLoader(skillsDir, version)); + } catch { + return null; + } +} + +async function _installSkillsCommand(options: SkillsCommandOptions) { + if (options.forceWizard) { + await initiateSkillsInstallWizard(options); + return; + } + + intro("Welcome to the Trigger.dev agent skills installer "); + + const manifest = await loadSkillsManifest(); + + if (!manifest) { + log.warn("No Trigger.dev agent skills were found in this CLI build."); + outro("Nothing to install."); + return; + } + + writeConfigLastRulesInstallPromptVersion(manifest.currentVersion); + writeConfigHasSeenRulesInstallPrompt(true); + + await installSkills(manifest, options); + + outro("You're all set! "); +} + +export type SkillsWizardOptions = { + target?: Array<(typeof targets)[number]>; + yes?: boolean; +}; + +export async function initiateSkillsInstallWizard(options: SkillsWizardOptions) { + const manifest = await loadSkillsManifest(); + + // The CLI couldn't load its own bundled skills (unexpected); nothing to offer. + if (!manifest) { + return; + } + + const hasSeenPrompt = readConfigHasSeenRulesInstallPrompt(); + + if (!hasSeenPrompt) { + writeConfigHasSeenRulesInstallPrompt(true); + writeConfigLastRulesInstallPromptVersion(manifest.currentVersion); + + const installChoice = await confirm({ + message: "Would you like to install the Trigger.dev agent skills?", + initialValue: true, + }); + + if (isCancel(installChoice) || !installChoice) { + return; + } + + await installSkills(manifest, options); + return; + } + + const lastVersion = readConfigLastRulesInstallPromptVersion(); + + if (!lastVersion) { + writeConfigHasSeenRulesInstallPrompt(true); + writeConfigLastRulesInstallPromptVersion(manifest.currentVersion); + + const installChoice = await confirm({ + message: `A new version of the Trigger.dev agent skills is available (${manifest.currentVersion}). Do you want to install it?`, + initialValue: true, + }); + + if (isCancel(installChoice) || !installChoice) { + return; + } + + await installSkills(manifest, options); + return; + } + + if (semver.gt(manifest.currentVersion, lastVersion)) { + writeConfigHasSeenRulesInstallPrompt(true); + writeConfigLastRulesInstallPromptVersion(manifest.currentVersion); + + const confirmed = await confirm({ + message: `A new version of the Trigger.dev agent skills is available (${lastVersion} → ${chalk.greenBright( + manifest.currentVersion + )}). Do you want to install it?`, + initialValue: true, + }); + + if (isCancel(confirmed) || !confirmed) { + return; + } + + await installSkills(manifest, options); + } +} + +async function installSkills(manifest: RulesManifest, opts: SkillsWizardOptions) { + const currentVersion = await manifest.getCurrentVersion(); + + const targetNames = await resolveTargets(opts); + + if (targetNames.length === 1 && targetNames.includes("unsupported")) { + handleUnsupportedTargetOnly(); + return; + } + + const results = []; + + for (const targetName of targetNames) { + const result = await installSkillsForTarget(targetName, currentVersion, opts); + + if (result) { + results.push(result); + } + } + + if (results.some((r) => r.installations.length > 0 || r.pointer)) { + log.step("Installed the following skills:"); + + for (const r of results) { + for (const installation of r.installations) { + log.info(chalk.greenBright(installation.location)); + } + if (r.pointer) { + log.info(`${chalk.greenBright(r.pointer)} ${chalk.dim("(always-on pointer)")}`); + } + } + + log.info( + `${cliLink("Learn how to use Trigger.dev skills", "https://trigger.dev/docs/agents/rules/overview")}` + ); + } +} + +function handleUnsupportedTargetOnly() { + log.info( + `${cliLink("Install the skills manually", "https://trigger.dev/docs/agents/rules/overview")}` + ); +} + +async function installSkillsForTarget( + targetName: ResolvedTargets, + currentVersion: ManifestVersion, + opts: SkillsWizardOptions +) { + if (targetName === "unsupported") { + // This should not happen as unsupported targets are handled separately, + // but if it does, provide helpful output. + log.message(`${chalk.yellow("⚠")} Skipping unsupported target - see manual configuration above`); + return; + } + + const options = await resolveOptionsForTarget(targetName, currentVersion, opts); + + const installations = []; + + for (const option of options) { + const installation = await performInstallSkillsOptionForTarget(targetName, option); + + if (installation) { + installations.push(installation); + } + } + + // Skills load on demand, so drop one always-on pointer into the tool's primary + // instructions file announcing what's installed (decision 7). Body lives in the + // on-demand skills; only this one-liner is always in context. + let pointer: string | undefined; + const skillsDir = resolveSkillsDirForTarget(targetName); + if (installations.length > 0 && skillsDir) { + pointer = await writeSkillsPointer( + targetName, + skillsDir, + installations.map((i) => i.option.name) + ); + } + + return { targetName, installations, pointer }; +} + +/** + * Skills are whole folders (SKILL.md + optional references). We write the SKILL.md into + * the target tool's native skills directory under the skill's own folder so the tool + * discovers it. Targets without a native skills dir are skipped with a notice. + */ +async function performInstallSkillsOptionForTarget( + targetName: (typeof targets)[number], + option: RulesManifestVersionOption +) { + const skillsDir = resolveSkillsDirForTarget(targetName); + + if (!skillsDir) { + log.message( + `${chalk.yellow("⚠")} ${targetLabels[targetName]} doesn't support agent skills yet, skipping "${option.name}".` + ); + return; + } + + const location = join(skillsDir, option.name, "SKILL.md"); + + await safeWriteFile(join(process.cwd(), location), option.contents); + + return { option, location }; +} + +function resolveSkillsDirForTarget(targetName: (typeof targets)[number]): string | undefined { + switch (targetName) { + case "claude-code": { + return ".claude/skills"; + } + case "cursor": { + return ".cursor/skills"; + } + case "vscode": { + return ".github/skills"; + } + case "agents.md": { + return ".agents/skills"; + } + default: { + return undefined; + } + } +} + +const POINTER_START = ""; +const POINTER_END = ""; + +type SkillsPointer = { file: string; mode: "region" | "dedicated" }; + +/** + * The always-on instructions file for each skills-capable target. "region" files are + * shared (a marked block is upserted so we never clobber other content); "dedicated" + * files are ours to own and overwrite. + */ +function resolveSkillsPointerForTarget(targetName: (typeof targets)[number]): SkillsPointer | undefined { + switch (targetName) { + case "claude-code": { + return { file: "CLAUDE.md", mode: "region" }; + } + case "cursor": { + return { file: ".cursor/rules/trigger-skills.mdc", mode: "dedicated" }; + } + case "vscode": { + return { file: ".github/copilot-instructions.md", mode: "region" }; + } + case "agents.md": { + return { file: "AGENTS.md", mode: "region" }; + } + default: { + return undefined; + } + } +} + +function buildSkillsPointerBody(skillsDir: string, skillNames: string[]): string { + const list = skillNames.map((n) => `\`${n}\``).join(", "); + return [ + "## Trigger.dev agent skills", + "", + `This project has Trigger.dev agent skills installed in \`${skillsDir}/\`. Before writing or changing Trigger.dev code (background tasks, scheduled tasks, realtime, or chat.agent AI agents), load the most relevant skill: ${list}.`, + ].join("\n"); +} + +/** + * Writes/updates the one-line always-on pointer for a target. Idempotent: region files + * replace the marked block (or append it once); the dedicated Cursor rule is overwritten. + * Returns the written path, or undefined for targets without a pointer location. + */ +async function writeSkillsPointer( + targetName: (typeof targets)[number], + skillsDir: string, + skillNames: string[] +): Promise { + const pointer = resolveSkillsPointerForTarget(targetName); + if (!pointer) { + return undefined; + } + + const body = buildSkillsPointerBody(skillsDir, skillNames); + const absolutePath = join(process.cwd(), pointer.file); + + if (pointer.mode === "dedicated") { + // Cursor: a dedicated always-apply rule file we own outright. + const contents = [ + "---", + "description: Trigger.dev agent skills are installed in this repo", + "alwaysApply: true", + "---", + "", + body, + "", + ].join("\n"); + await safeWriteFile(absolutePath, contents); + return pointer.file; + } + + const block = `${POINTER_START}\n${body}\n${POINTER_END}`; + + if (!(await pathExists(absolutePath))) { + await safeWriteFile(absolutePath, `${block}\n`); + return pointer.file; + } + + const existing = await readFile(absolutePath); + const pattern = new RegExp(`${POINTER_START}.*?${POINTER_END}`, "s"); + const next = pattern.test(existing) + ? existing.replace(pattern, block) + : `${existing.trimEnd()}\n\n${block}\n`; + + await safeWriteFile(absolutePath, next); + return pointer.file; +} + +async function resolveOptionsForTarget( + targetName: (typeof targets)[number], + currentVersion: ManifestVersion, + opts: SkillsWizardOptions +) { + const possibleOptions = currentVersion.options.filter( + (option) => !option.client || option.client === targetName + ); + + // Non-interactive: install everything available for this target. + if (opts.yes) { + return possibleOptions; + } + + const selectedOptions = await multiselect({ + message: `Choose the skills you want to install for ${targetLabels[targetName]}`, + options: possibleOptions.map((option) => ({ + value: option, + label: option.title, + hint: `${option.label} [~${option.tokens} tokens]`, + })), + required: true, + }); + + if (isCancel(selectedOptions)) { + throw new OutroCommandError("No options selected"); + } + + return selectedOptions; +} + +async function resolveTargets(options: SkillsWizardOptions): Promise { + if (options.target) { + return options.target; + } + + const selectOptions: Array<{ + value: string; + label: string; + hint?: string; + }> = targets.map((target) => ({ + value: target, + label: targetLabels[target], + })); + + selectOptions.push({ + value: "unsupported", + label: "Unsupported target", + hint: "We don't support this target yet, but you can still install the skills manually.", + }); + + const $selectOptions = selectOptions as Array<{ + value: ResolvedTargets; + label: string; + hint?: string; + }>; + + const selectedTargets = await multiselect({ + message: "Select one or more targets to install the skills into", + options: $selectOptions, + required: true, + }); + + if (isCancel(selectedTargets)) { + throw new OutroCommandError("No targets selected"); + } + + return selectedTargets; +} diff --git a/packages/cli-v3/src/rules/manifest.test.ts b/packages/cli-v3/src/rules/manifest.test.ts new file mode 100644 index 00000000000..24d6f20253e --- /dev/null +++ b/packages/cli-v3/src/rules/manifest.test.ts @@ -0,0 +1,83 @@ +import { afterAll, describe, expect, it } from "vitest"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BundledSkillsLoader, loadRulesManifest } from "./manifest.js"; + +async function makeSkillsDir(opts: { + withSkill: boolean; +}): Promise<{ root: string; skillsDir: string }> { + const root = await mkdtemp(join(tmpdir(), "bundled-skills-")); + const skillsDir = join(root, "skills"); + await mkdir(skillsDir, { recursive: true }); + + if (opts.withSkill) { + const skillDir = join(skillsDir, "authoring-chat-agent"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + join(skillDir, "SKILL.md"), + [ + "---", + "name: authoring-chat-agent", + "description: >", + " Author a durable AI chat agent with chat.agent. Load when building a chat", + " backend or its frontend transport.", + "type: core", + 'library_version: "{{TRIGGER_SDK_VERSION}}"', + "---", + "", + "# Authoring", + "", + "Generated for @trigger.dev/sdk {{TRIGGER_SDK_VERSION}}.", + "", + ].join("\n") + ); + } + + return { root, skillsDir }; +} + +describe("BundledSkillsLoader", () => { + const roots: string[] = []; + + afterAll(async () => { + await Promise.all(roots.map((d) => rm(d, { recursive: true, force: true }))); + }); + + it("synthesizes a manifest at the given version and stamps it into contents", async () => { + const { root, skillsDir } = await makeSkillsDir({ withSkill: true }); + roots.push(root); + + const loader = new BundledSkillsLoader(skillsDir, "9.9.9-test.1"); + const manifest = await loadRulesManifest(loader); + + expect(manifest.currentVersion).toBe("9.9.9-test.1"); + + const version = await manifest.getCurrentVersion(); + expect(version.options).toHaveLength(1); + + const option = version.options[0]!; + expect(option.name).toBe("authoring-chat-agent"); + expect(option.installStrategy).toBe("skills"); + // description extracted from the folded scalar, used as the picker label + expect(option.label).toContain("Author a durable AI chat agent"); + // version stamped into the copied content; placeholder fully substituted + expect(option.contents).toContain("9.9.9-test.1"); + expect(option.contents).not.toContain("{{TRIGGER_SDK_VERSION}}"); + }); + + it("throws when the skills dir has no skills (caller treats as 'nothing to install')", async () => { + const { root, skillsDir } = await makeSkillsDir({ withSkill: false }); + roots.push(root); + + const loader = new BundledSkillsLoader(skillsDir, "9.9.9-test.1"); + + await expect(loadRulesManifest(loader)).rejects.toThrow(); + }); + + it("throws when the skills dir does not exist", async () => { + const loader = new BundledSkillsLoader(join(tmpdir(), "does-not-exist-skills-xyz"), "1.2.3"); + + await expect(loadRulesManifest(loader)).rejects.toThrow(); + }); +}); diff --git a/packages/cli-v3/src/rules/manifest.ts b/packages/cli-v3/src/rules/manifest.ts index 6e8e90bd9bc..06b8bb2efbc 100644 --- a/packages/cli-v3/src/rules/manifest.ts +++ b/packages/cli-v3/src/rules/manifest.ts @@ -1,5 +1,5 @@ -import { readFile } from "fs/promises"; -import { dirname, join } from "path"; +import { readFile, readdir } from "fs/promises"; +import { join } from "path"; import { z } from "zod"; import { RulesFileInstallStrategy } from "./types.js"; @@ -76,7 +76,7 @@ export class RulesManifest { // Omit path const { path, installStrategy, ...rest } = option; - const $installStrategy = RulesFileInstallStrategy.safeParse(installStrategy ?? "default"); + const $installStrategy = RulesFileInstallStrategy.safeParse(installStrategy ?? "skills"); // Skip variants with invalid install strategies if (!$installStrategy.success) { @@ -109,57 +109,138 @@ export interface RulesManifestLoader { loadRulesFile(relativePath: string): Promise; } -export class GithubRulesManifestLoader implements RulesManifestLoader { - constructor(private readonly branch: string = "main") {} +/** + * Loads agent skills bundled inside the `trigger.dev` CLI (the `skills/` folder shipped + * via the package's `files[]`). The CLI is the only source of skills; `skillsDir` and + * `version` are resolved from the CLI's own package by the caller and injected here, so + * this stays a pure reader. Synthesizes the manifest shape the install pipeline consumes + * so skills flow through `loadRulesManifest` -> `getCurrentVersion` -> install, with + * `installStrategy: "skills"`. `version` (== the CLI/SDK version) is stamped into each + * skill on read in place of the `{{TRIGGER_SDK_VERSION}}` placeholder. + */ +export class BundledSkillsLoader implements RulesManifestLoader { + constructor( + private readonly skillsDir: string, + private readonly version: string + ) {} async loadManifestContent(): Promise { - const response = await fetch( - `https://raw.githubusercontent.com/triggerdotdev/trigger.dev/refs/heads/${this.branch}/rules/manifest.json`, - { - signal: AbortSignal.timeout(5000), + let entries: string[] = []; + try { + const dirents = await readdir(this.skillsDir, { withFileTypes: true }); + entries = dirents + .filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")) + .map((d) => d.name) + .sort(); + } catch (error) { + throw new Error(`No skills found in ${this.skillsDir}: ${error}`); + } + + const options = []; + for (const name of entries) { + const skillMdPath = join(this.skillsDir, name, "SKILL.md"); + + let contents: string; + try { + contents = await readFile(skillMdPath, "utf8"); + } catch { + // a directory without a SKILL.md isn't a skill + continue; } - ); - if (!response.ok) { - throw new Error(`Failed to load rules manifest: ${response.status} ${response.statusText}`); + const description = extractSkillDescription(contents) ?? humanizeSkillName(name); + + options.push({ + name, + title: humanizeSkillName(name), + label: description, + path: join(name, "SKILL.md"), + tokens: Math.max(1, Math.round(contents.length / 4)), + installStrategy: "skills", + }); + } + + if (options.length === 0) { + throw new Error(`No skills with a SKILL.md found in ${this.skillsDir}`); } - return response.text(); + return JSON.stringify({ + name: "trigger.dev", + description: "Trigger.dev agent skills", + currentVersion: this.version, + versions: { + [this.version]: { options }, + }, + }); } async loadRulesFile(relativePath: string): Promise { - const response = await fetch( - `https://raw.githubusercontent.com/triggerdotdev/trigger.dev/refs/heads/${this.branch}/rules/${relativePath}` - ); + const path = join(this.skillsDir, relativePath); - if (!response.ok) { - throw new Error( - `Failed to load rules file: ${relativePath} - ${response.status} ${response.statusText}` - ); + try { + const raw = await readFile(path, "utf8"); + // Stamp the CLI/SDK version into the skill so the copy on disk reflects the + // version the user is on, not a hardcoded number that drifts. + return raw.replace(/\{\{TRIGGER_SDK_VERSION\}\}/g, this.version); + } catch (error) { + throw new Error(`Failed to load skill file: ${relativePath} - ${error}`); } - - return response.text(); } } -export class LocalRulesManifestLoader implements RulesManifestLoader { - constructor(private readonly path: string) {} +function humanizeSkillName(name: string): string { + const spaced = name.replace(/[-_]+/g, " ").trim(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +/** + * Best-effort extraction of the `description` field from a SKILL.md YAML frontmatter + * block, used only for the picker label. Handles single-line, quoted, and folded/literal + * (`>`, `|`) scalars. Returns undefined if there's no frontmatter or description. + */ +function extractSkillDescription(skillMd: string): string | undefined { + const match = skillMd.match(/^---\r?\n([\s\S]*?)\r?\n---/); + const frontmatter = match?.[1]; + if (!frontmatter) { + return undefined; + } - async loadManifestContent(): Promise { - try { - return await readFile(this.path, "utf8"); - } catch (error) { - throw new Error(`Failed to load rules manifest: ${this.path} - ${error}`); + const lines = frontmatter.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined) { + continue; } - } - async loadRulesFile(relativePath: string): Promise { - const path = join(dirname(this.path), relativePath); + const m = line.match(/^description:\s*(.*)$/); + if (!m) { + continue; + } - try { - return await readFile(path, "utf8"); - } catch (error) { - throw new Error(`Failed to load rules file: ${relativePath} - ${error}`); + const value = (m[1] ?? "").trim(); + + // Folded (>) or literal (|) block scalar: collect the indented continuation lines. + if (/^[>|][+-]?$/.test(value)) { + const collected: string[] = []; + for (let j = i + 1; j < lines.length; j++) { + const next = lines[j]; + if (next === undefined) { + break; + } + if (/^\s+\S/.test(next)) { + collected.push(next.trim()); + } else if (next.trim() === "") { + collected.push(""); + } else { + break; + } + } + return collected.join(" ").replace(/\s+/g, " ").trim() || undefined; } + + // Inline scalar, possibly quoted. + return value.replace(/^["']|["']$/g, "").trim() || undefined; } + + return undefined; } diff --git a/packages/cli-v3/src/rules/types.ts b/packages/cli-v3/src/rules/types.ts index 70682c251a9..5f17ab27df3 100644 --- a/packages/cli-v3/src/rules/types.ts +++ b/packages/cli-v3/src/rules/types.ts @@ -1,4 +1,4 @@ import { z } from "zod"; -export const RulesFileInstallStrategy = z.enum(["default", "claude-code-subagent"]); +export const RulesFileInstallStrategy = z.enum(["skills"]); export type RulesFileInstallStrategy = z.infer;