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