# Actions
Source: https://trigger.dev/docs/ai-chat/actions

Custom commands sent from the frontend that mutate chat state without consuming a turn — undo, rollback, edit, regenerate.

## Overview

Custom actions let the frontend send structured commands (undo, rollback, edit, regenerate) that modify the conversation state. **Actions are not turns**: they fire `hydrateMessages` (if set) and `onAction` only. No turn lifecycle hooks (`onTurnStart` / `prepareMessages` / `onBeforeTurnComplete` / `onTurnComplete`), no `run()`, no turn-counter increment. The trace span is named `chat action`.

Actions wake the agent from suspension the same way a new message does, run their handler against the latest accumulator state, and emit a `trigger:turn-complete` chunk so the frontend's `useChat` knows the action has been applied.

## Defining an action handler

Define an `actionSchema` for validation and an `onAction` handler that uses [`chat.history`](/docs/ai-chat/backend#chat-history) to modify state:

```ts theme={"theme":"css-variables"}
import { z } from "zod";

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() }),
    z.object({ type: z.literal("edit"), messageId: z.string(), text: z.string() }),
  ]),

  onAction: async ({ action }) => {
    switch (action.type) {
      case "undo":
        chat.history.slice(0, -2); // Remove last user + assistant exchange
        break;
      case "rollback":
        chat.history.rollbackTo(action.targetMessageId);
        break;
      case "edit":
        chat.history.replace(action.messageId, {
          id: action.messageId,
          role: "user",
          parts: [{ type: "text", text: action.text }],
        });
        break;
    }
    // returning void → side-effect-only, no model call
  },

  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

**Lifecycle flow:** Wake → parse action against `actionSchema` → `hydrateMessages` (if set) → **`onAction`** → apply `chat.history` mutations → emit `trigger:turn-complete` → wait for next message.

## Returning a model response from an action

`onAction` can return a `StreamTextResult`, `string`, or `UIMessage` to produce a response. The returned stream is auto-piped to the frontend just like a normal turn, but the rest of the turn machinery (`onTurnStart`, `onTurnComplete`, etc.) still does not fire.

```ts theme={"theme":"css-variables"}
onAction: async ({ action, messages }) => {
  if (action.type === "regenerate") {
    chat.history.slice(0, -1); // drop the last assistant
    return streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      stopWhen: stepCountIs(15),
    });
  }
  // other actions return void → side-effect only
}
```

This is useful for actions that both mutate state and want a fresh model response (regenerate-from-here, retry-with-different-style). Persistence is your responsibility inside `onAction` itself; you have access to the streamed response object.

## Gating actions on HITL state

If you have a [human-in-the-loop](/docs/ai-chat/patterns/human-in-the-loop) tool waiting on `addToolOutput`, you usually want to refuse competing actions like `regenerate` until the answer arrives. [`chat.history.getPendingToolCalls()`](/docs/ai-chat/backend#chat-history) gives you exactly that signal:

```ts theme={"theme":"css-variables"}
onAction: async ({ action, messages, signal }) => {
  if (action.type === "regenerate") {
    if (chat.history.getPendingToolCalls().length > 0) return; // gated
    chat.history.slice(0, -1);
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  }
},
```

## Sending actions from the frontend

```ts theme={"theme":"css-variables"}
// Browser — TriggerChatTransport
const stream = await transport.sendAction(chatId, { type: "undo" });

// Server — AgentChat
const stream = await agentChat.sendAction({ type: "rollback", targetMessageId: "msg-3" });
```

The action payload is validated against `actionSchema` on the backend; invalid actions throw and surface as a stream error. The `action` parameter in `onAction` is fully typed from the schema.

<Note>
  For silent state changes that should never appear as a turn (e.g. injecting background context), use [`chat.inject()`](/docs/ai-chat/background-injection) instead. Actions are explicit user-driven mutations; injections are agent-side context updates.
</Note>

## See also

* [`chat.history`](/docs/ai-chat/backend#chat-history) — the imperative API actions use to mutate state
* [Sending actions from the frontend](/docs/ai-chat/frontend#sending-actions) — `transport.sendAction` ergonomics
* [`hydrateMessages`](/docs/ai-chat/lifecycle-hooks#hydratemessages) — fires before `onAction` when set
* [Branching conversations](/docs/ai-chat/patterns/branching-conversations) — pairs action handlers with backend-controlled history
* [Human-in-the-loop](/docs/ai-chat/patterns/human-in-the-loop) — gating fresh actions while a tool is waiting


# Anatomy of an agent
Source: https://trigger.dev/docs/ai-chat/anatomy

The moving parts of a chat agent — the agent task, the session, the frontend transport — and which page covers each.

**A chat agent is three parts: a long-lived agent task that runs the turn loop, a durable Session carrying messages in and the response stream out, and a frontend transport that plugs the session into `useChat`.** The pages in this section each own one part of that picture. This page is the map — if you'd rather read mechanics end to end, skip to [How it works](/docs/ai-chat/how-it-works).

```mermaid theme={"theme":"css-variables"}
flowchart LR
  FE["Frontend<br/>useChat + transport"] -- "user messages" --> IN([Session .in])
  IN --> AGENT["Agent task<br/>turn loop + hooks"]
  AGENT --> OUT([Session .out])
  OUT -- "streamed response" --> FE
```

Everything below maps onto one annotated agent:

```ts trigger/my-agent.ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

export const myAgent = chat.agent({
  id: "my-agent",

  // Tools declared on the config survive history re-conversion
  // across turns — see Tools.
  tools: { searchDocs },

  // Hooks fire around each turn: validation, persistence,
  // post-turn work — see Lifecycle hooks.
  onTurnComplete: async ({ responseMessage }) => {
    await db.messages.save(responseMessage);
  },

  // The turn loop. Messages arrive accumulated; you stream back.
  // Options, levels, and alternatives — see Backend.
  run: async ({ messages, tools, signal }) =>
    streamText({
      ...chat.toStreamTextOptions({ tools }),
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    }),
});
```

The frontend side is one hook — `useTriggerChatTransport` connects `useChat` to the agent's session, no API routes ([Frontend](/docs/ai-chat/frontend)). Underneath, the conversation lives on a [Session](/docs/ai-chat/sessions): a pair of durable streams keyed on your `chatId` that survives refreshes, deploys, and run boundaries.

## Where each part is covered

| Part                                                  | Page                                        |
| ----------------------------------------------------- | ------------------------------------------- |
| `chat.agent()` options, the turn loop, piping         | [Backend](/docs/ai-chat/backend)                 |
| Hooks around each turn (`onTurnComplete`, hydration)  | [Lifecycle hooks](/docs/ai-chat/lifecycle-hooks) |
| Declaring tools, typed payloads, `toModelOutput`      | [Tools](/docs/ai-chat/tools)                     |
| `useChat` wiring, tokens, starting sessions           | [Frontend](/docs/ai-chat/frontend)               |
| Driving a chat from your server instead of a browser  | [Server-side chat](/docs/ai-chat/server-chat)    |
| The durable substrate under every agent               | [Sessions](/docs/ai-chat/sessions)               |
| Per-run typed state inside the loop                   | [chat.local](/docs/ai-chat/chat-local)           |
| Type-safe payloads, client data, and messages         | [Types](/docs/ai-chat/types)                     |
| Building without the managed lifecycle                | [Custom agents](/docs/ai-chat/custom-agents)     |
| End-to-end mechanics: what survives a refresh and why | [How it works](/docs/ai-chat/how-it-works)       |

Beyond this section: [Features](/docs/ai-chat/fast-starts) covers opt-in capabilities (Head Start, compaction, steering, actions), and [Patterns](/docs/ai-chat/patterns/sub-agents) covers production recipes (sub-agents, HITL approvals, persistence, recovery).


# Backend
Source: https://trigger.dev/docs/ai-chat/backend

Three approaches to building your chat backend — chat.agent(), session iterator, or raw task primitives.

There are three abstraction levels for a chat backend. All three speak the same wire protocol, so the [frontend transport](/docs/ai-chat/frontend) works unchanged whichever you pick.

| Capability                            | `chat.agent()` | `chat.createSession()`                                                         | Raw primitives |
| ------------------------------------- | -------------- | ------------------------------------------------------------------------------ | -------------- |
| Turn loop, stop signals, accumulation | Managed        | Managed                                                                        | You write it   |
| Lifecycle hooks                       | Yes            | No — inline code per turn                                                      | No             |
| Continuation recovery on new runs     | Automatic      | [Manual seeding](/docs/ai-chat/custom-agents#continuation-runs-and-history-seeding) | Manual seeding |
| Compaction / steering                 | Built-in       | Built-in                                                                       | Manual         |
| Head Start, actions, tool approvals   | Yes            | No                                                                             | No             |
| Custom stream conversion              | No             | Limited                                                                        | Full control   |
| Agent dashboard visibility            | Yes            | Yes (via `customAgent`)                                                        | Yes            |

The raw-primitives column assumes [`chat.customAgent()`](/docs/ai-chat/custom-agents) as the wrapper, which is what makes the task visible to the agent dashboard.

Start with `chat.agent()`. Drop to `chat.createSession()` when you want to own the per-turn code (model routing, persistence, custom telemetry) without rebuilding the turn loop. Drop to raw primitives only when you need full control over stream conversion or a custom protocol.

## chat.agent()

The highest-level approach. Handles message accumulation, stop signals, turn lifecycle, and auto-piping automatically.

### Simple: return a StreamTextResult

Return the `streamText` result from `run` and it's automatically piped to the frontend:

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

export const simpleChat = chat.agent({
  id: "simple-chat",
  run: async ({ messages, signal }) => {
    return streamText({
      ...chat.toStreamTextOptions(), // prepareStep, system, telemetry (see note below)
      model: anthropic("claude-sonnet-4-5"),
      system: "You are a helpful assistant.",
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

<Warning>
  **Always spread `chat.toStreamTextOptions()` first** (as above) so your explicit overrides win. It wires up the `prepareStep` callback behind [compaction](/docs/ai-chat/compaction), [steering](/docs/ai-chat/pending-messages), and [background injection](/docs/ai-chat/background-injection), all of which silently no-op without it, and injects the system prompt from `chat.prompt()`, the resolved model (when you pass a `registry`), and telemetry metadata. Examples below keep the spread implicit for brevity, so include it in real code.
</Warning>

### Using chat.pipe() for complex flows

For complex agent flows where `streamText` is called deep inside your code, use `chat.pipe()`. It works from **anywhere inside a task** — even nested function calls.

```ts trigger/agent-chat.ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import type { ModelMessage } from "ai";

export const agentChat = chat.agent({
  id: "agent-chat",
  run: async ({ messages }) => {
    // Don't return anything — chat.pipe is called inside
    await runAgentLoop(messages);
  },
});

async function runAgentLoop(messages: ModelMessage[]) {
  // ... agent logic, tool calls, etc.

  const result = streamText({
    model: anthropic("claude-sonnet-4-5"),
    messages,
    stopWhen: stepCountIs(15),
  });

  // Pipe from anywhere — no need to return it
  await chat.pipe(result);
}
```

### Custom data parts

Add custom `data-*` parts to the assistant's response message via `chat.response.write()` (from `run()`) or the `writer` parameter in lifecycle hooks. Non-transient `data-*` chunks are automatically added to `responseMessage.parts` and surface in `onTurnComplete` for persistence:

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  onBeforeTurnComplete: async ({ writer, turn }) => {
    // This data part will be in responseMessage.parts in onTurnComplete
    writer.write({
      type: "data-metadata",
      data: { turn, model: "gpt-4o", timestamp: Date.now() },
    });
  },
  onTurnComplete: async ({ responseMessage }) => {
    // responseMessage.parts includes the data-metadata part
    await db.messages.save(responseMessage);
  },
  run: async ({ messages, signal }) => {
    // Also works from run() via chat.response
    chat.response.write({
      type: "data-context",
      data: { searchResults: results },
    });

    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

Add `transient: true` to data chunks that should stream to the frontend but NOT persist in the response message. Use this for progress indicators, loading states, and other temporary UI:

```ts theme={"theme":"css-variables"}
// Transient — frontend sees it, but NOT in onTurnComplete's responseMessage
writer.write({
  type: "data-progress",
  id: "search",
  data: { percent: 50 },
  transient: true,
});
```

<Info>
  This matches the AI SDK's semantics: `data-*` chunks persist to `message.parts` by default. Only `transient: true` chunks are ephemeral. Non-data chunks (`text-delta`, `tool-*`, etc.) are handled by `streamText` and captured via `onFinish` — they don't need `chat.response`.
</Info>

<Note>
  `chat.response` and the `writer` accumulation behavior work with `chat.agent` and `chat.createSession`. If you're using [`chat.customAgent`](/docs/ai-chat/custom-agents), you own the accumulator — see the raw-task example for the manual pattern.
</Note>

### Raw streaming with `chat.stream`

For low-level stream access (piping from subtasks, reading streams by run ID), use `chat.stream`. Chunks written via `chat.stream` go directly to the realtime output — they are **NOT** accumulated into the response message regardless of the `transient` flag.

```ts theme={"theme":"css-variables"}
// Raw stream — always ephemeral, never in responseMessage
const { waitUntilComplete } = chat.stream.writer({
  execute: ({ write }) => {
    write({ type: "data-status", data: { message: "Processing..." } });
  },
});
await waitUntilComplete();
```

<Tip>
  Use `data-*` chunk types (e.g. `data-status`, `data-progress`) for custom data. The AI SDK processes these into `DataUIPart` objects in `message.parts` on the frontend. Writing the same `type` + `id` again updates the existing part instead of creating a new one — useful for live progress.
</Tip>

`chat.stream` exposes the full stream API:

| Method                                | Description                                |
| ------------------------------------- | ------------------------------------------ |
| `chat.stream.writer(options)`         | Write individual chunks via a callback     |
| `chat.stream.pipe(stream, options?)`  | Pipe a `ReadableStream` or `AsyncIterable` |
| `chat.stream.append(value, options?)` | Append raw data                            |
| `chat.stream.read(runId, options?)`   | Read the stream by run ID                  |

For piping streams from subtasks to the parent chat (via `target: "root"`), see the [Sub-agents pattern](/docs/ai-chat/patterns/sub-agents).

### Backed by a Session

Every `chat.agent` conversation is backed by a durable [Session](/docs/ai-chat/sessions): `externalId` is your `chatId`, `type` is `"chat.agent"`, and `taskIdentifier` is the agent's task ID. The session is the run manager. It owns the chat's runs, persists across run lifecycles, and orchestrates handoffs (idle continuation, `chat.requestUpgrade`). You rarely touch it directly, since `chat.stream`, `chat.messages`, and `chat.stopSignal` wrap everything, but `payload.sessionId` is there when you need to reach in, e.g. `sessions.open(payload.sessionId)` to write from a sub-agent or from outside the turn loop.

### Tools

Declare your tools on the agent config, then read them back (typed) from the `run()` payload. Declaring them on the config, not just on `streamText`, is what lets the SDK re-apply each tool's `toModelOutput` when it re-converts history on later turns.

```ts theme={"theme":"css-variables"}
const tools = { searchDocs };

export const myChat = chat.agent({
  id: "my-chat",
  tools,
  run: async ({ messages, tools, signal }) =>
    streamText({
      ...chat.toStreamTextOptions({ tools }),
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    }),
});
```

See [Tools](/docs/ai-chat/tools) for `toModelOutput` across turns, per-turn dynamic tools, the typed run payload, and how config tools relate to skills.

### Lifecycle hooks

`chat.agent({ ... })` accepts hooks that fire in a fixed order around each turn, plus dedicated suspend/resume hooks. The full reference lives on its own page:

* [Lifecycle hooks](/docs/ai-chat/lifecycle-hooks) — `onPreload`, `onChatStart`, `onValidateMessages`, `hydrateMessages`, `onTurnStart`, `onBeforeTurnComplete`, `onTurnComplete`, `onChatSuspend` / `onChatResume`, `exitAfterPreloadIdle`, plus how `ctx` plumbs through every callback.

**Per-turn order:** `onValidateMessages` → `hydrateMessages` → `onChatStart` (chat's first message only) → `onTurnStart` → `run()` → `onBeforeTurnComplete` → `onTurnComplete`.

### Using prompts

Use [AI Prompts](/docs/ai/prompts) to manage your system prompt as versioned, overridable config. Store the resolved prompt in a lifecycle hook with `chat.prompt.set()`, then spread `chat.toStreamTextOptions()` into `streamText` — it includes the system prompt, model, config, and telemetry automatically.

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { prompts } from "@trigger.dev/sdk";
import { streamText, createProviderRegistry } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

const registry = createProviderRegistry({ anthropic });

const systemPrompt = prompts.define({
  id: "my-chat-system",
  model: "anthropic:claude-sonnet-4-5",
  config: { temperature: 0.7 },
  variables: z.object({ name: z.string() }),
  content: `You are a helpful assistant for {{name}}.`,
});

export const myChat = chat.agent({
  id: "my-chat",
  clientDataSchema: z.object({ userId: z.string() }),
  onChatStart: async ({ clientData }) => {
    const user = await db.user.findUnique({ where: { id: clientData.userId } });
    const resolved = await systemPrompt.resolve({ name: user.name });
    chat.prompt.set(resolved);
  },
  run: async ({ messages, signal }) => {
    return streamText({
      ...chat.toStreamTextOptions({ registry }), // system, model, config, telemetry
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

`chat.toStreamTextOptions()` returns an object with `system`, `model` (resolved via the registry), `temperature`, and `experimental_telemetry` — all from the stored prompt. Properties you set after the spread (like a client-selected model) take precedence.

**Which form to call:**

| Form                                            | Use when                                                                                                                                                                                                                              |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chat.toStreamTextOptions()`                    | Default. Wires up `prepareStep` (compaction, steering, background injection), the stored prompt's `system` / `model` / `config`, and telemetry metadata.                                                                              |
| `chat.toStreamTextOptions({ registry })`        | You're using [Prompts](/docs/ai/prompts) with a provider-prefixed model string (e.g. `"anthropic:claude-sonnet-4-5"`). The registry resolves the prefix to a real model instance via `createProviderRegistry({ anthropic, openai, ... })`. |
| `chat.toStreamTextOptions({ tools })`           | You want HITL tool approvals — pass the same `tools` object you give to `streamText`. The SDK then knows which tool calls need to pause on `needsApproval: true`.                                                                     |
| `chat.toStreamTextOptions({ registry, tools })` | Both of the above.                                                                                                                                                                                                                    |

<Tip>
  See [Prompts](/docs/ai/prompts) for the full guide — defining templates, variable schemas, dashboard
  overrides, and the management SDK.
</Tip>

### Stop generation

#### How stop works

Calling `stop()` from `useChat` sends a stop signal to the running task via input streams. The task's `streamText` call aborts (if you passed `signal` or `stopSignal`), but the **run stays alive** and waits for the next message. The partial response is captured and accumulated normally.

#### Abort signals

The `run` function receives three abort signals:

| Signal         | Fires when                                  | Use for                                                                |
| -------------- | ------------------------------------------- | ---------------------------------------------------------------------- |
| `signal`       | Stop **or** cancel                          | Pass to `streamText` — handles both cases. **Use this in most cases.** |
| `stopSignal`   | Stop only (per-turn, reset each turn)       | Custom logic that should only run on user stop, not cancellation       |
| `cancelSignal` | Run cancel, expire, or maxDuration exceeded | Cleanup that should only happen on full cancellation                   |

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  run: async ({ messages, signal, stopSignal, cancelSignal }) => {
    return streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal, // Handles both stop and cancel
      stopWhen: stepCountIs(15),
    });
  },
});
```

<Tip>
  Use `signal` (the combined signal) in most cases. The separate `stopSignal` and `cancelSignal` are
  only needed if you want different behavior for stop vs cancel.
</Tip>

#### Detecting stop in callbacks

The `onTurnComplete` event includes a `stopped` boolean that indicates whether the user stopped generation during that turn:

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  onTurnComplete: async ({ chatId, uiMessages, stopped }) => {
    await db.chat.update({
      where: { id: chatId },
      data: { messages: uiMessages, lastStoppedAt: stopped ? new Date() : undefined },
    });
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

You can also check stop status from **anywhere** during a turn using `chat.isStopped()`. This is useful inside `streamText`'s `onFinish` callback where the AI SDK's `isAborted` flag can be unreliable (e.g. when using `createUIMessageStream` + `writer.merge()`):

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { streamText } from "ai";

export const myChat = chat.agent({
  id: "my-chat",
  run: async ({ messages, signal }) => {
    return streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      onFinish: ({ isAborted }) => {
        // isAborted may be false even after stop when using createUIMessageStream
        const wasStopped = isAborted || chat.isStopped();
        if (wasStopped) {
          // handle stop — e.g. log analytics
        }
      },
      stopWhen: stepCountIs(15),
    });
  },
});
```

#### Cleaning up aborted messages

When stop happens mid-stream, the captured response message can contain parts in an incomplete state — tool calls stuck in `partial-call`, reasoning blocks still marked as `streaming`, etc. These can cause UI issues like permanent spinners.

`chat.agent` automatically cleans up the `responseMessage` when stop is detected before passing it to `onTurnComplete`. If you use `chat.pipe()` manually and capture response messages yourself, use `chat.cleanupAbortedParts()`:

```ts theme={"theme":"css-variables"}
const cleaned = chat.cleanupAbortedParts(rawResponseMessage);
```

This removes tool invocation parts stuck in `partial-call` state and marks any `streaming` text or reasoning parts as `done`.

<Note>
  Stop signal delivery is best-effort. There is a small race window where the model may finish
  before the stop signal arrives, in which case the turn completes normally with `stopped: false`.
  This is expected and does not require special handling.
</Note>

### Tool approvals

Tools with `needsApproval: true` pause execution until the user approves or denies via the frontend. Define the tool as normal and pass it to `streamText` — `chat.agent` handles the rest:

```ts theme={"theme":"css-variables"}
const sendEmail = tool({
  description: "Send an email. Requires human approval.",
  inputSchema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
  needsApproval: true,
  execute: async ({ to, subject, body }) => {
    await emailService.send({ to, subject, body });
    return { sent: true };
  },
});

export const myChat = chat.agent({
  id: "my-chat",
  run: async ({ messages, signal }) => {
    return streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      tools: { sendEmail },
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

When the model calls an approval-required tool, the turn completes with the tool in `approval-requested` state. After the user approves on the frontend, the updated message is sent back and `chat.agent` replaces it in the conversation accumulator by matching the message ID. `streamText` then executes the approved tool and continues.

See [Tool approvals](/docs/ai-chat/frontend#tool-approvals) in the frontend docs for the UI setup.

### Persistence

To build a chat app that survives page refreshes you persist two things, both server-side from inside the agent:

1. **Conversation state.** Full `UIMessage[]` keyed by `chatId`. Written from `onTurnStart` (so the user message is durable before streaming begins) and `onTurnComplete` (so the assistant reply lands).
2. **Session state.** The transport's reconnect metadata: `publicAccessToken` and `lastEventId`. Written alongside the messages from the same hooks.

<Note>
  Sessions let the transport reconnect to an existing run after a page refresh. Without them, every page load would start a new run, losing the conversation context that was accumulated in the previous run.
</Note>

For the full per-hook breakdown, race-condition warnings (atomic `lastEventId` writes, why not to use `chat.defer` in `onTurnStart`), token renewal via the `accessToken` callback, and an end-to-end three-file example, see [Database persistence](/docs/ai-chat/patterns/database-persistence).

### Pending messages (steering)

Users can send messages while the agent is executing tool calls. With `pendingMessages`, these messages are injected between tool-call steps, steering the agent mid-execution:

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  pendingMessages: {
    shouldInject: ({ steps }) => steps.length > 0,
  },
  run: async ({ messages, signal }) => {
    return streamText({
      ...chat.toStreamTextOptions({ registry }),
      messages,
      tools: {
        /* ... */
      },
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

On the frontend, the `usePendingMessages` hook handles sending, tracking, and rendering injection points.

<Tip>
  See [Pending Messages](/docs/ai-chat/pending-messages) for the full guide — backend configuration,
  frontend hook, queuing vs steering, and how injection works with all three chat variants.
</Tip>

### Background injection

Inject context from background work into the conversation using `chat.inject()`. Combine with `chat.defer()` to run analysis between turns and inject results before the next response — self-review, RAG augmentation, safety checks, etc.

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  onTurnComplete: async ({ messages }) => {
    chat.defer(
      (async () => {
        const review = await generateObject({
          /* ... */
        });
        if (review.object.needsImprovement) {
          chat.inject([
            {
              role: "system",
              content: `[Self-review]\n${review.object.suggestions.join("\n")}`,
            },
          ]);
        }
      })()
    );
  },
  run: async ({ messages, signal }) => {
    return streamText({ ...chat.toStreamTextOptions({ registry }), messages, abortSignal: signal });
  },
});
```

<Tip>
  See [Background Injection](/docs/ai-chat/background-injection) for the full guide — timing, self-review
  example, and how it differs from pending messages.
</Tip>

### Actions

Custom actions let the frontend send structured commands (undo, rollback, edit, regenerate) that modify the conversation state. **Actions are not turns**: they fire `hydrateMessages` (if set) and `onAction` only. The full surface (defining `actionSchema`, returning a model response from `onAction`, gating against pending HITL tool calls, and sending actions from the frontend) lives on its own page.

See [Actions](/docs/ai-chat/actions).

### Chat history

Imperative API for reading and modifying the accumulated message history. Works from any hook (`onAction`, `onTurnStart`, `onBeforeTurnComplete`, `onTurnComplete`, `hydrateMessages`) or from `run()` and AI SDK tools.

<Note>
  The agent's accumulator — not `session.out` — is the source of truth for the full conversation. The `.out` stream is a bounded sliding window (roughly one turn at steady state, see [Records on `session.out`](/docs/ai-chat/client-protocol#records-on-session-out)); the durable history lives in the agent's accumulator and is persisted to S3 between turns for fast next-run boots. `chat.history` reads and mutates that accumulator directly.
</Note>

**Reads.** Synchronous against the current accumulator state.

| Method                                        | Description                                                                                                                                                                         |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chat.history.all()`                          | Returns a copy of the current accumulated UI messages.                                                                                                                              |
| `chat.history.getChain()`                     | Same as `all()`. Use whichever name reads better in context.                                                                                                                        |
| `chat.history.findMessage(messageId)`         | Returns the message with that id, or `undefined`.                                                                                                                                   |
| `chat.history.getPendingToolCalls()`          | Tool calls on the most recent assistant message that are still in `input-available` state (waiting on `addToolOutput`).                                                             |
| `chat.history.getResolvedToolCalls()`         | All tool calls in the chain in `output-available` or `output-error` state.                                                                                                          |
| `chat.history.extractNewToolResults(message)` | Tool results in `message` whose `toolCallId` is not already resolved in the chain. Most useful in `hydrateMessages` against an incoming wire message, before the runtime merges it. |

Each pending and resolved entry is shaped `{ toolCallId, toolName, messageId }`. Each new-result entry is `{ toolCallId, toolName, output, errorText? }`, where `errorText` is set only for `output-error` parts.

**Mutations.** Applied at lifecycle checkpoints (after hooks return). Multiple mutations in the same hook compose correctly.

| Method                                     | Description                                            |
| ------------------------------------------ | ------------------------------------------------------ |
| `chat.history.set(messages)`               | Replace all messages. Same as `chat.setMessages()`.    |
| `chat.history.remove(messageId)`           | Remove a specific message by ID.                       |
| `chat.history.rollbackTo(messageId)`       | Keep messages up to and including the given ID (undo). |
| `chat.history.replace(messageId, message)` | Replace a specific message by ID (edit).               |
| `chat.history.slice(start, end?)`          | Keep only messages in the given range.                 |

```ts theme={"theme":"css-variables"}
// Undo the last exchange in onAction
onAction: async ({ action }) => {
  if (action.type === "undo") {
    chat.history.slice(0, -2);
  }
},

// Trim history in onTurnComplete
onTurnComplete: async ({ uiMessages }) => {
  if (uiMessages.length > 50) {
    chat.history.slice(-20);
  }
},
```

The HITL reads let an action or hook decide what to do without walking the accumulator manually:

```ts theme={"theme":"css-variables"}
// Refuse a regenerate while a tool call is still awaiting an answer
onAction: async ({ action }) => {
  if (action.type === "regenerate") {
    if (chat.history.getPendingToolCalls().length > 0) return;
    chat.history.slice(0, -1);
  }
},

// Side-effect once per net-new tool result when wire messages come in
hydrateMessages: async ({ incomingMessages }) => {
  for (const msg of incomingMessages) {
    for (const r of chat.history.extractNewToolResults(msg)) {
      await onToolResolved({ id: r.toolCallId, output: r.output, errorText: r.errorText });
    }
  }
  return incomingMessages;
},
```

`extractNewToolResults` compares against the *current* chain. Inside `onTurnComplete`, the chain already contains the just-finished `responseMessage`, so it returns `[]`. Use it where the message is from outside the accumulator: `hydrateMessages` (incoming wire), `onAction` if the action carries a message, or any custom pre-merge code path.

### prepareMessages

Transform model messages before they're used anywhere — in `run()`, in compaction rebuilds, and in compaction results. Define once, applied everywhere.

Use this for Anthropic cache breaks, injecting system context, stripping PII, etc.

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  prepareMessages: ({ messages, reason }) => {
    // Add Anthropic cache breaks to the last message
    if (messages.length === 0) return messages;
    const last = messages[messages.length - 1];
    return [
      ...messages.slice(0, -1),
      {
        ...last,
        providerOptions: {
          ...last.providerOptions,
          anthropic: { cacheControl: { type: "ephemeral" } },
        },
      },
    ];
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

The `reason` field tells you why messages are being prepared:

| Reason                 | Description                                       |
| ---------------------- | ------------------------------------------------- |
| `"run"`                | Messages being passed to `run()` for `streamText` |
| `"compaction-rebuild"` | Rebuilding from a previous compaction summary     |
| `"compaction-result"`  | Fresh compaction just produced these messages     |

### Version upgrades

Chat agent runs are pinned to the worker version they started on. When you deploy a new version, suspended runs resume on the old code. Call `chat.requestUpgrade()` in `onTurnStart` to skip `run()` and exit immediately — the transport re-triggers the same message on the latest version. See the [Version Upgrades pattern](/docs/ai-chat/patterns/version-upgrades) for the full guide.

### Ending a run on your terms

By default, a chat agent stays idle after each turn waiting for the next user message. Call `chat.endRun()` from `run()`, `chat.defer()`, `onBeforeTurnComplete`, or `onTurnComplete` to exit the loop once the current turn finishes — no upgrade signal, no idle wait.

```ts theme={"theme":"css-variables"}
chat.agent({
  id: "one-shot",
  run: async ({ messages, signal }) => {
    // Single-response agent — exit after this turn.
    chat.endRun();
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

The current turn streams through normally, `onBeforeTurnComplete` / `onTurnComplete` fire, the turn-complete chunk is written, and the run exits instead of suspending. The next user message on the same `chatId` starts a fresh run via the standard continuation flow.

Use this when the agent knows its work is done (budget exhausted, goal achieved, one-shot response) rather than relying on the idle timeout. Unlike `chat.requestUpgrade()`, no `upgrade-required` signal is sent to the client, so there's no version-migration semantics.

<Warning>
  If you persist `lastEventId` to your own storage for cross-page-load resume, **don't clear it on `chat.endRun()`**. The cursor is sessionId-keyed and stays valid across Run boundaries — clearing it forces the next `sendMessages` to subscribe from `seq_num=0`, where it may hit the prior turn's stale `turn-complete` record and close the stream empty before the new Run's chunks arrive.
</Warning>

### Runtime configuration

#### chat.setTurnTimeout()

Override how long the run stays suspended waiting for the next message. Call from inside `run()`:

```ts theme={"theme":"css-variables"}
run: async ({ messages, signal }) => {
  chat.setTurnTimeout("2h"); // Wait longer for this conversation
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
```

#### chat.setIdleTimeoutInSeconds()

Override how long the run stays idle (active, using compute) after each turn:

```ts theme={"theme":"css-variables"}
run: async ({ messages, signal }) => {
  chat.setIdleTimeoutInSeconds(60); // Stay idle for 1 minute
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
```

<Info>
  Longer idle timeout means faster responses but more compute usage. Set to `0` to suspend
  immediately after each turn (minimum latency cost, slight delay on next message).
</Info>

#### Stream options

Control how `streamText` results are converted to the frontend stream via `toUIMessageStream()`. Set static defaults on the task, or override per-turn.

##### Error handling with onError

When `streamText` encounters an error mid-stream (rate limits, API failures, network errors), the `onError` callback converts it to a string that's sent to the frontend as an `{ type: "error", errorText }` chunk. The AI SDK's `useChat` receives this via its `onError` callback.

By default, the raw error message is sent to the frontend. Use `onError` to sanitize errors and avoid leaking internal details:

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  uiMessageStreamOptions: {
    onError: (error) => {
      // Log the full error server-side for debugging
      console.error("Stream error:", error);
      // Return a sanitized message — this is what the frontend sees
      if (error instanceof Error && error.message.includes("rate limit")) {
        return "Rate limited — please wait a moment and try again.";
      }
      return "Something went wrong. Please try again.";
    },
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

`onError` is also called for tool execution errors, so a single handler covers both LLM errors and tool failures.

On the frontend, handle the error in `useChat`:

```tsx theme={"theme":"css-variables"}
const { messages, sendMessage } = useChat({
  transport,
  onError: (error) => {
    // error.message contains the string returned by your onError handler
    toast.error(error.message);
  },
});
```

##### Reasoning and sources

Control which AI SDK features are forwarded to the frontend:

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  uiMessageStreamOptions: {
    sendReasoning: true, // Forward model reasoning (default: true)
    sendSources: true, // Forward source citations (default: false)
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

##### Custom message IDs

By default, response message IDs are generated using the AI SDK's built-in `generateId`. Pass a custom `generateMessageId` function to use your own ID format (e.g. UUID-v7):

```ts theme={"theme":"css-variables"}
import { v7 as uuidv7 } from "uuid";

export const myChat = chat.agent({
  id: "my-chat",
  uiMessageStreamOptions: {
    generateMessageId: () => uuidv7(),
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

With the `.withUIMessage()` builder, set it under `streamOptions`:

```ts theme={"theme":"css-variables"}
import { v7 as uuidv7 } from "uuid";

export const myChat = chat
  .withUIMessage<MyChatUIMessage>({
    streamOptions: {
      generateMessageId: () => uuidv7(),
      sendReasoning: true,
    },
  })
  .agent({
    id: "my-chat",
    run: async ({ messages, signal }) => {
      return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
    },
  });
```

<Info>
  The generated ID is sent to the frontend in the stream's `start` chunk, so frontend and backend
  always reference the same ID for each message. This is important for features like tool
  approvals, where the frontend resends an assistant message and the backend needs to match it
  by ID in the conversation accumulator.
</Info>

##### Per-turn overrides

Override per-turn with `chat.setUIMessageStreamOptions()` — per-turn values merge with the static config (per-turn wins on conflicts). The override is cleared automatically after each turn.

```ts theme={"theme":"css-variables"}
run: async ({ messages, clientData, signal }) => {
  // Enable reasoning only for certain models
  if (clientData.model?.includes("claude")) {
    chat.setUIMessageStreamOptions({ sendReasoning: true });
  }
  return streamText({ model: openai(clientData.model ?? "gpt-4o"), messages, abortSignal: signal });
},
```

`chat.setUIMessageStreamOptions()` works across all abstraction levels — `chat.agent()`, `chat.createSession()` / `turn.complete()`, and `chat.pipeAndCapture()`.

See [ChatUIMessageStreamOptions](/docs/ai-chat/reference#chatuimessagestreamoptions) for the full reference.

<Note>
  `onFinish` is managed internally for response capture and cannot be overridden here. Use
  `streamText`'s `onFinish` callback for custom finish handling, or use [raw task
  mode](/docs/ai-chat/custom-agents) for full control over `toUIMessageStream()`.
</Note>

### Manual mode with task()

If you need full control over task options, use the standard `task()` with `ChatTaskPayload` and `chat.pipe()`:

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { chat, type ChatTaskPayload } from "@trigger.dev/sdk/ai";
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

export const manualChat = task({
  id: "manual-chat",
  retry: { maxAttempts: 3 },
  queue: { concurrencyLimit: 10 },
  run: async (payload: ChatTaskPayload) => {
    const result = streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages: payload.messages,
      stopWhen: stepCountIs(15),
    });

    await chat.pipe(result);
  },
});
```

<Warning>
  Manual mode does not get automatic message accumulation or the `onTurnComplete`/`onChatStart`
  lifecycle hooks. The `responseMessage` field in `onTurnComplete` will be `undefined` when using
  `chat.pipe()` directly. Use `chat.agent()` for the full multi-turn experience.
</Warning>

***

<a />

<a />

<a />

## Custom agents

Both lower levels — `chat.createSession()` (managed turn iterator, your turn body) and `chat.customAgent()` with raw primitives (hand-rolled loop, full stream-conversion control) — are covered together on the Custom agents page, including the `ChatTurn` surface, the continuation-seeding pattern, and the hand-rolled-loop checklist:

<Card title="Custom agents" icon="screwdriver-wrench" href="/ai-chat/custom-agents">
  Build agents without the managed lifecycle — createSession or raw primitives.
</Card>


# Background injection
Source: https://trigger.dev/docs/ai-chat/background-injection

Inject context from background work into the agent's conversation — self-review, RAG augmentation, or any async analysis.

## Overview

`chat.inject()` queues model messages for injection into the conversation. Messages are picked up at the start of the next turn or at the next `prepareStep` boundary (between tool-call steps).

This is the backend counterpart to [pending messages](/docs/ai-chat/pending-messages) — pending messages come from the user via the frontend, while `chat.inject()` comes from your task code.

## Basic usage

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";

// Queue a system message for injection
chat.inject([
  {
    role: "system",
    content: "The user's account was just upgraded to Pro.",
  },
]);
```

Messages are appended to the model messages before the next LLM inference call. The LLM sees them as part of the conversation context.

## Common pattern: defer + inject

The most powerful pattern combines `chat.defer()` (background work) with `chat.inject()` (inject results). Background work runs in parallel with the idle wait between turns, and results are injected before the next response.

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  onTurnComplete: async ({ messages }) => {
    // Kick off background analysis — doesn't block the turn
    chat.defer(
      (async () => {
        const analysis = await analyzeConversation(messages);
        chat.inject([
          {
            role: "system",
            content: `[Analysis of conversation so far]\n\n${analysis}`,
          },
        ]);
      })()
    );
  },
  run: async ({ messages, signal }) => {
    return streamText({
      ...chat.toStreamTextOptions({ registry }),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

### Timing

1. Turn completes, `onTurnComplete` fires
2. `chat.defer()` registers the background work
3. The run immediately starts waiting for the next message (no blocking)
4. Background work completes, `chat.inject()` queues the messages
5. User sends next message, turn starts
6. Injected messages are appended before `run()` executes
7. The LLM sees the injected context alongside the new user message

If the background work finishes *during* a tool-call loop (not between turns), the messages are picked up at the next `prepareStep` boundary instead.

## Example: self-review

A cheap model reviews the agent's response after each turn and injects coaching for the next one. Uses [Prompts](/docs/ai/prompts) for the review prompt and `generateObject` for structured output.

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { prompts } from "@trigger.dev/sdk";
import { streamText, generateObject, createProviderRegistry, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

const registry = createProviderRegistry({ anthropic });

const selfReviewPrompt = prompts.define({
  id: "self-review",
  model: "anthropic:claude-haiku-4-5",
  content: `You are a conversation quality reviewer. Analyze the assistant's most recent response.

Focus on:
- Whether the response answered the user's question
- Missed opportunities to use tools or provide more detail
- Tone mismatches

Be concise. Only flag issues worth fixing.`,
});

export const myChat = chat.agent({
  id: "my-chat",
  onTurnComplete: async ({ messages }) => {
    chat.defer(
      (async () => {
        const resolved = await selfReviewPrompt.resolve({});

        const review = await generateObject({
          model: registry.languageModel(resolved.model ?? "anthropic:claude-haiku-4-5"),
          ...resolved.toAISDKTelemetry(),
          system: resolved.text,
          prompt: messages
            .filter((m) => m.role === "user" || m.role === "assistant")
            .map((m) => {
              const text =
                typeof m.content === "string"
                  ? m.content
                  : Array.isArray(m.content)
                    ? m.content
                        .filter((p: any) => p.type === "text")
                        .map((p: any) => p.text)
                        .join("")
                    : "";
              return `${m.role}: ${text}`;
            })
            .join("\n\n"),
          schema: z.object({
            needsImprovement: z.boolean(),
            suggestions: z.array(z.string()),
          }),
        });

        if (review.object.needsImprovement) {
          chat.inject([
            {
              role: "system",
              content: `[Self-review]\n\n${review.object.suggestions.map((s) => `- ${s}`).join("\n")}\n\nApply these naturally.`,
            },
          ]);
        }
      })()
    );
  },
  run: async ({ messages, signal }) => {
    return streamText({
      ...chat.toStreamTextOptions({ registry }),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

The self-review runs on `claude-haiku-4-5` (fast, cheap) in the background. If the user sends another message before it completes, the coaching is still injected — `chat.inject()` persists across the idle wait.

## Other use cases

* **RAG augmentation**: After each turn, fetch relevant documents and inject them as context for the next response
* **Safety checks**: Run a moderation model on the response, inject warnings if issues are detected
* **Fact-checking**: Verify claims in the response using search tools, inject corrections
* **Context enrichment**: Look up user/account data based on what was discussed, inject it as system context

## `chat.defer` standalone

`chat.defer()` is also useful on its own, without `chat.inject()`. Any work whose timing has no resume implication — analytics, audit logs, search-index writes, cache warming — can run in parallel with streaming instead of in the critical path. All deferred promises are awaited (with a 5s timeout) before `onTurnComplete` fires.

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  onTurnStart: async ({ chatId, runId }) => {
    // Analytics — fire-and-forget, irrelevant to resume.
    chat.defer(analytics.track("turn_started", { chatId, runId }));
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

`chat.defer()` can be called from anywhere during a turn — hooks, `run()`, or nested helpers. All deferred promises are collected and awaited together before `onTurnComplete`.

<Warning>
  **Don't use `chat.defer()` for the message-history write in `onTurnStart`.** That write must land *before* the model starts streaming, otherwise a mid-stream page refresh will read `[]` from your DB and lose the user's message from the rendered conversation. See [Database persistence — `onTurnStart`](/docs/ai-chat/patterns/database-persistence#onturnstart). Reserve `chat.defer` for writes whose timing has no resume implication.
</Warning>

## How it differs from pending messages

|                         | `chat.inject()`                                     | [Pending messages](/docs/ai-chat/pending-messages) |
| ----------------------- | --------------------------------------------------- | --------------------------------------------- |
| **Source**              | Backend task code                                   | Frontend user input                           |
| **Triggered by**        | Your code (e.g. `onTurnComplete` + `chat.defer()`)  | User sending a message during streaming       |
| **Injection point**     | Start of next turn, or next `prepareStep` boundary  | Next `prepareStep` boundary only              |
| **Message role**        | Any (`system`, `user`, `assistant`)                 | Typically `user`                              |
| **Frontend visibility** | Not visible unless you write custom `data-*` chunks | Visible via `usePendingMessages` hook         |

## API reference

### chat.inject()

```ts theme={"theme":"css-variables"}
chat.inject(messages: ModelMessage[]): void
```

Queue model messages for injection at the next opportunity. Messages persist across the idle wait between turns — they are not reset when a new turn starts.

**Parameters:**

| Parameter  | Type             | Description                                      |
| ---------- | ---------------- | ------------------------------------------------ |
| `messages` | `ModelMessage[]` | Model messages to inject (from the `ai` package) |

Messages are drained (consumed) when:

1. A new turn starts — before `run()` executes
2. A `prepareStep` boundary is reached — between tool-call steps during streaming

<Note>
  `chat.inject()` writes to an in-memory queue in the current process. It works from any code running in the same task — lifecycle hooks, deferred work, tool execute functions, etc. It does not work from subtasks or other runs.
</Note>


# AI Agents SDK changelog
Source: https://trigger.dev/docs/ai-chat/changelog

Changelog for the AI Agents SDK.

<Update label="July 2, 2026" description="4.5.0">
  ## Point chat sessions at a different project or environment

  [`chat.headStart`](/docs/ai-chat/fast-starts#head-start) and [`chat.createStartSessionAction`](/docs/ai-chat/sessions) now take an `apiClient` option (`baseURL` + `accessToken`). The server that creates the session and triggers the run can target a different project or environment than its ambient Trigger config, without setting a global `TRIGGER_SECRET_KEY`. Useful when your `chat.agent` lives in a separate project from the app serving the route, or when one server starts chats across more than one environment. Your LLM provider keys stay in the `run` callback and are unaffected. ([#4018](https://github.com/triggerdotdev/trigger.dev/pull/4018))

  ```ts theme={"theme":"css-variables"}
  export const POST = chat.headStart({
    agentId: "my-agent",
    apiClient: { baseURL, accessToken },
    run: async ({ chat }) =>
      streamText({ ...chat.toStreamTextOptions({ tools }), model: anthropic("claude-sonnet-4-6") }),
  });
  ```

  ```ts theme={"theme":"css-variables"}
  const startSession = chat.createStartSessionAction("my-chat", {
    apiClient: { baseURL, accessToken },
  });

  await startSession({ chatId, clientData });
  ```

  ## Fix: chat agents on preview branches

  Messaging a `chat.agent` (or `AgentChat`) deployed to a preview branch failed with `x-trigger-branch header required for preview env`. The realtime message-append and stream-subscribe calls now send the `x-trigger-branch` header, resolved the same way `sessions.start` resolves it, so preview-branch chat agents work. ([#4018](https://github.com/triggerdotdev/trigger.dev/pull/4018))

  ## Fix: Head Start handovers with a prepareMessages hook

  A [Head Start](/docs/ai-chat/fast-starts#head-start) handover passes the first turn's pending tool call to the agent as a tool-approval round whose trailing tool message must reach the model untouched. A `prepareMessages` hook that rewrites the last message (for example the recommended [prompt-caching](/docs/ai-chat/prompt-caching) breakpoint) could disturb that tail, so the turn failed with `tool_use ids were found without tool_result`. The agent now preserves the approval tail across `prepareMessages`, so prompt caching and Head Start compose cleanly. ([#4018](https://github.com/triggerdotdev/trigger.dev/pull/4018))
</Update>

<Update label="June 17, 2026" description="4.5.0-rc.7">
  ## chat.headStart now works for custom agents and sessions

  [Head Start](/docs/ai-chat/fast-starts#head-start) — running the first `streamText` step in your warm server while the agent boots in parallel — now works with the `chat.customAgent` and `chat.createSession` backends, not just the managed `chat.agent`. The warm step-1 response hands over to your loop the same way it does for a managed agent. ([#3963](https://github.com/triggerdotdev/trigger.dev/pull/3963))

  In a `chat.customAgent` loop, consume the handover on turn 0:

  ```ts theme={"theme":"css-variables"}
  const conversation = new chat.MessageAccumulator();
  const { isFinal, skipped } = await conversation.consumeHandover({ payload });
  if (skipped) return; // warm handler aborted, so exit without a turn
  if (isFinal) {
    await chat.writeTurnComplete(); // step 1 is the response, no streamText
  } else {
    const result = streamText({ model, messages: conversation.modelMessages, tools });
    // Pass originalMessages so the handed-over tool round merges into the
    // step-1 assistant instead of starting a new message.
    const { message } = await chat.pipeAndCapture(result, {
      originalMessages: conversation.uiMessages,
    });
    if (message) await conversation.addResponse(message);
  }
  ```

  With `chat.createSession`, the iterator surfaces it as `turn.handover`; call `turn.complete()` with no argument on a final handover. The lower-level `chat.waitForHandover()` and `accumulator.applyHandover()` are also exported for hand-rolled loops. See [Handover with custom agents](/docs/ai-chat/fast-starts#handover-with-custom-agents).

  ## Cache your system prompt with Anthropic prompt caching

  `chat.toStreamTextOptions()` can now emit the system prompt as a cacheable message when you opt in, so a large, stable system block is billed at cache-read rates on every turn instead of full price. Without an option, `system` stays a plain string. ([#3952](https://github.com/triggerdotdev/trigger.dev/pull/3952))

  ```ts theme={"theme":"css-variables"}
  // at the streamText call site (Anthropic sugar)
  streamText({
    ...chat.toStreamTextOptions({ cacheControl: { type: "ephemeral" } }),
    messages,
  });

  // provider-agnostic equivalent
  chat.toStreamTextOptions({
    systemProviderOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
  });

  // or where the prompt is defined
  chat.prompt.set(SYSTEM_PROMPT, {
    providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
  });
  ```

  Pairs with a `prepareMessages` cache breakpoint to cache the conversation prefix across turns too. See the [Prompt caching](/docs/ai-chat/prompt-caching) guide.

  ## Custom agent loop fixes

  Three fixes for custom agent loops (`chat.customAgent`, `chat.createSession`, and hand-rolled `MessageAccumulator` loops): ([#3936](https://github.com/triggerdotdev/trigger.dev/pull/3936))

  * **Continuations no longer replay answered messages.** The `.in` resume cursor is now seeded before any listener attaches (the same boot logic `chat.agent` uses), so a chat that continues after a cancel, crash, or upgrade only sees genuinely new messages.
  * **Steering mid-stream keeps the in-flight response.** `chat.pipeAndCapture` now stamps a server-generated message id on the stream, so a `prepareStep` injection keeps the partial text instead of replacing the message.
  * **Task-backed tools work from custom loops.** `ai.toolExecute` now threads the parent's session to the child run, so child tasks can stream progress into the chat with `chat.stream.writer({ target: "root" })` instead of failing with "session handle is not initialized".

  See [Custom agents](/docs/ai-chat/custom-agents).

  ## trigger skills: namespacing, docs bundling, and a cost-savings skill

  Follow-ups to the [`trigger skills`](/docs/ai-chat/patterns/skills) command shipped in rc.6:

  * The skills installed into your coding assistant are now namespaced with a `trigger-` prefix (e.g. `trigger-authoring-tasks`, `trigger-getting-started`) so they don't collide with unrelated skills in your skills directory. ([#3970](https://github.com/triggerdotdev/trigger.dev/pull/3970))
  * `@trigger.dev/sdk` now bundles the Trigger.dev agent skills and the full Trigger.dev documentation those skills reference. The installed skills read this content from `node_modules`, so the guidance your AI assistant follows is pinned to the SDK version in your project and stays current across upgrades instead of going stale until the next reinstall. ([#3937](https://github.com/triggerdotdev/trigger.dev/pull/3937), [#3970](https://github.com/triggerdotdev/trigger.dev/pull/3970))
  * New `trigger-cost-savings` skill for auditing and reducing compute spend — right-sizing machines, `maxDuration`, batching, and debounce. ([#3970](https://github.com/triggerdotdev/trigger.dev/pull/3970))

  ```bash theme={"theme":"css-variables"}
  npx trigger.dev@4.5.0-rc.7 skills
  ```
</Update>

<Update label="June 12, 2026" description="4.5.0-rc.6">
  ## chat.agent reliability fixes

  A batch of fixes for edge cases around message delivery, stopping, and error handling:

  * **No more duplicate turns from mid-stream sends.** A user message sent while the agent was streaming could be delivered twice — once via steering and again on the next turn — running a duplicate turn. Delivery is now deduplicated.
  * **Idempotent input appends.** Sends to `session.in` carry an idempotency key, so a client retry after a network blip can't append the same message twice.
  * **Stop clears streaming state.** Stopping a generation now clears the session's streaming snapshot, so a page reload right after a stop no longer replays the stopped turn.
  * **`onTurnComplete` fires on errored turns.** When `run()` or a lifecycle hook throws, `onTurnComplete` now runs with `error` carrying the thrown value and `finishReason: "error"`, and the failed turn's user message is persisted so it isn't lost on the next run. Use this to mark the turn failed in your own storage. See [error handling](/docs/ai-chat/error-handling#using-onturncomplete).

    ```ts theme={"theme":"css-variables"}
    onTurnComplete: async ({ chatId, uiMessages, stopped, error }) => {
      await db.chat.update({
        where: { id: chatId },
        data: {
          messages: uiMessages,
          lastTurnStatus: error ? "errored" : stopped ? "stopped" : "ok",
        },
      });
    },
    ```
  * **Full tag sets on chat runs.** Runs triggered by chat sessions can now carry the full set of dashboard tags instead of being silently truncated.
  * **Stream hygiene for custom agents.** Manual `chat.writeTurnComplete()` callers now trim the output stream the way `chat.agent` does, sending a custom action no longer leaves a second stream reader running, and a long-lived `watch` subscription no longer grows its dedupe set without bound.

  ## Continuation boots no longer stall

  Continuation runs (after a cancel, crash, or version upgrade) used to stall around 10 seconds before the first turn: finding the `session.in` resume cursor drained an SSE long-poll that always waited out its full 5 second inactivity window, twice per boot. The cursor is now found with a non-blocking records read, the boot reads run concurrently, and chat snapshots carry the cursor so subsequent boots skip the scan entirely.

  ## chat.headStart: hydration and reasoning fixes

  Two fixes for the [Head Start](/docs/ai-chat/fast-starts) handover:

  * With `hydrateMessages` registered, the warm route's step-1 partial now reaches the agent's accumulator, so `onTurnComplete` carries the full first turn, tool-call handovers resume from step 2 instead of re-running step 1, and the assistant `messageId` stays stable across the handover.
  * Extended-thinking models' step-1 reasoning now lands in the durable session history (and `onTurnComplete`) under the same assistant `messageId`, with provider metadata intact so Anthropic thinking signatures survive replays.

  ## chat.createSession: stop and continuation fixes

  Stopping a generation no longer wedges the run: `turn.complete()` bare-awaited the AI SDK's `totalUsage` promise, which never settles after a stop-abort, so the loop hung inside the stopped turn and the chat couldn't take another message. It's now raced with a timeout, the same guard `chat.agent`'s turn loop uses.

  Continuation runs also no longer invoke the model with an empty prompt: a message-less continuation boot now waits for the next session input, and `turn.continuation` is preserved so your loop can seed stored history on the first turn:

  ```ts theme={"theme":"css-variables"}
  for await (const turn of session) {
    if (turn.continuation && turn.number === 0) {
      const stored = await loadMessages(turn.chatId);
      const incoming = turn.uiMessages.filter((m) => !stored.some((s) => s.id === m.id));
      await turn.setMessages([...stored, ...incoming]);
    }

    // ... streamText + turn.complete as usual
  }
  ```

  See [chat.createSession](/docs/ai-chat/backend#chat-createsession).

  ## trigger skills: agent skills for your coding assistant

  The CLI's new `trigger skills` command installs Trigger.dev agent skills — including the chat.agent authoring skill — into your coding assistant's native skills directory (Claude Code, Cursor, GitHub Copilot, and AGENTS-compatible tools such as Codex). The skills ship inside the CLI, versioned with it, and `trigger dev` offers to install them on first run. `trigger init` can now also set up the MCP server and skills as part of project scaffolding.

  ```bash theme={"theme":"css-variables"}
  npx trigger.dev@4.5.0-rc.6 skills
  ```
</Update>

<Update label="June 5, 2026" description="4.5.0-rc.5">
  ## AI SDK 7 support

  `chat.agent` and the chat surfaces now work against Vercel AI SDK 7. The `ai` peer range widened to include v7, so you can build your agent against v5, v6, or v7 with the same `@trigger.dev/sdk/ai`, `chat`, and `chat/react` imports; your installed `ai` major drives the types. v5 and v6 are unchanged.

  On v7, model-call spans moved out of `ai` core into the separate `@ai-sdk/otel` adapter, so `experimental_telemetry` alone produces nothing until an integration is registered. Install `@ai-sdk/otel` alongside `ai@7` and the SDK registers it for you once per worker at chat agent boot, so your `streamText` spans keep flowing into the run trace with no extra setup:

  ```sh theme={"theme":"css-variables"}
  npm install @ai-sdk/otel
  ```

  If you (or a library you import) already register `@ai-sdk/otel`, the SDK detects the existing integration and skips its own registration, so you won't get duplicate spans. Set `TRIGGER_AI_SDK_OTEL_AUTOREGISTER=0` to disable auto-registration entirely. See [supported AI SDK versions](/docs/ai-chat/reference#compatibility) and [AI SDK 7 telemetry](/docs/ai-chat/reference#ai-sdk-7-telemetry) in the reference.

  Task-backed tools wired in with `ai.toolExecute` also propagate their tool `context` on v7, which renamed the field from v6's `experimental_context`.

  ## `useTriggerChatTransport` recovers a stale session

  When a chat's restored session state pointed at a session that no longer exists in the current environment (restored from a different environment, or from before the sessions model), the transport assumed it was live and never created a real one, so the next message 404'd and the chat could not send. The transport now treats a 404 from a session call as a missing session: after the existing token refresh it recreates the session via `startSession`, drops the stale resume cursor, and retries the send once.
</Update>

<Update label="June 1, 2026" description="4.5.0-rc.4">
  ## `tools` option on `chat.agent`: `toModelOutput` survives across turns

  `chat.agent` now takes a `tools` option. Until now tools only went to `streamText` inside `run()`, which meant the SDK had no tools when it re-converted the persisted `UIMessage` history at the start of each turn. Any tool with a `toModelOutput` (raw image bytes turned into an image content part, or a sub-agent transcript compressed to a summary) had its transform applied on turn 1 and skipped from turn 2 onward, so the raw output got stringified back into the prompt.

  Declare your tools on the config and the SDK threads them into that conversion, so `toModelOutput` is re-applied every turn. The resolved set is handed back, typed, on the `run()` payload as `tools`, so you declare them once:

  ```ts theme={"theme":"css-variables"}
  const tools = { searchDocs, renderChart };

  export const myChat = chat.agent({
    tools,
    run: async ({ messages, tools, signal }) =>
      streamText({ ...chat.toStreamTextOptions({ tools }), messages, abortSignal: signal }),
  });
  ```

  `tools` also accepts a per-turn function (`(event) => ToolSet`) for tools that depend on the user or a feature flag. Only `inputSchema` and `toModelOutput` are read during conversion, never `execute`. No behavior change for agents that don't declare `tools`.

  A new `InferChatUIMessageFromTools<typeof tools>` helper derives the chat `UIMessage` type (with typed tool parts) directly from a tool set. See the new [Tools](/docs/ai-chat/tools) guide.
</Update>

<Update label="May 23, 2026" description="4.5.0-rc.2">
  ## HITL continuations — slim wire by default + field-level merge

  `chat.addToolOutput(...)` and `chat.addToolApproveResponse(...)` continuations on reasoning-heavy agent loops used to fail two ways: either the wire body crossed the `/in/append` cap (encrypted reasoning blobs + tool input routinely > 512 KiB), or apps that slimmed the wire as a workaround landed a tool call with no `arguments` on the next LLM step (the per-turn merge replaced the hydrated message wholesale instead of overlaying only the new tool-state advance). Both modes are fixed.

  The transport (`TriggerChatTransport.sendMessages`, `AgentChat.sendRaw`) now slims the assistant message itself on `submit-message` turns whose assistant carries resolved or approval-responded tool parts. The wire shape ships as `{ id, role: "assistant", parts: [<resolved tool part only>] }` — `state` plus `output` / `errorText` / `approval`, depending on the new state. Everything else (reasoning blobs, prior text, tool `input`, provider metadata) is reconstructed server-side from `hydrateMessages` or the durable snapshot. Continuation payloads typically drop from 600 KiB – 1 MiB to \~1 KiB.

  The per-turn merge now overlays only the tool-part state advances (`output-available` / `output-error` / `approval-responded` / `output-denied`) from the wire copy onto the matching hydrated entry. Hydrated `input`, text, reasoning, and provider metadata stay put. The agent still accepts a fuller `UIMessage` on the wire (the merge only reads the resolved fields), so custom transports that ship more don't break — they just waste bytes.

  ### `hydrateMessages` upsert-by-id

  If your `hydrateMessages` hook persists the incoming message, **upsert by id** — don't unconditionally push. HITL continuations ship the existing assistant's id with a slim payload; a blind `stored.push(newMsg)` duplicates the row in the chain you return, the merge updates the first match, and the slim duplicate hits `toModelMessages` with no `input`.

  A new `upsertIncomingMessage` helper is exported from `@trigger.dev/sdk/ai` to handle this for the common case:

  ```ts theme={"theme":"css-variables"}
  import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";

  chat.agent({
    hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
      const record = await db.chat.findUnique({ where: { id: chatId } });
      const stored = record?.messages ?? [];
      if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
        await db.chat.update({ where: { id: chatId }, data: { messages: stored } });
      }
      return stored;
    },
  });
  ```

  The helper pushes fresh user messages, no-ops on HITL continuations (so the runtime can overlay the new tool-state advance), and skips on non-`submit-message` triggers. Returns `true` if it mutated `stored`. The examples in [lifecycle hooks](/docs/ai-chat/lifecycle-hooks#hydratemessages), [Database persistence](/docs/ai-chat/patterns/database-persistence#alternative-hydratemessages), and [Persistence and replay](/docs/ai-chat/patterns/persistence-and-replay) have all been updated. Custom hydrate logic (branching, rollback, etc.) can still write the upsert by hand — the helper is a convenience for the common shape.

  ### `onValidateMessages` slim wire caveat

  The slim wire is what arrives in `onValidateMessages` on HITL turns. `validateUIMessages` from `ai` rejects the slim shape (the AI SDK schema requires `input` on resolved tool parts), so filter to user messages first (or skip validation entirely on those turns). See the updated example in [lifecycle hooks](/docs/ai-chat/lifecycle-hooks#onvalidatemessages).

  ### `/in/append` 413 + precise cap

  In parallel:

  * The 413 response now carries CORS headers, so browser fetches can read the status instead of failing as opaque `TypeError: Failed to fetch`. App-side retry-on-disconnect loops no longer spin forever on a permanently-rejected payload.
  * The per-record cap is now computed precisely against S2's actual ceiling instead of the conservative 512 KiB floor. Legitimate \~600 – 900 KiB tool outputs (search results, file content) now succeed; pathological all-quote content that would double under JSON escape still rejects cleanly with a clear error.

  See the updated [413 row in the client protocol](/docs/ai-chat/client-protocol#step-3-send-messages-stops-and-actions).
</Update>

<Update label="May 21, 2026" description="4.5.0-rc.1">
  ## v4.5.0-rc.1 — two bug fixes

  Patch release on top of `4.5.0-rc.0`. Upgrade with:

  ```sh theme={"theme":"css-variables"}
  npx trigger.dev@4.5.0-rc.1 update              # npm
  pnpm dlx trigger.dev@4.5.0-rc.1 update         # pnpm
  yarn dlx trigger.dev@4.5.0-rc.1 update         # yarn
  bunx trigger.dev@4.5.0-rc.1 update             # bun
  ```

  ### Fixes

  * **Agent Skills silently missing in `trigger dev`** for projects whose task files read `process.env` at module top level (e.g. a third-party SDK client initialized at import). [Skill folders](/docs/ai-chat/patterns/skills) now bundle into `.trigger/skills/` reliably regardless of which env vars are set when the CLI launches. ([#3690](https://github.com/triggerdotdev/trigger.dev/pull/3690))
  * **`COULD_NOT_FIND_EXECUTOR`** when a task's definition is loaded via `await import(...)` from inside another task's `run()` — common when lazy-loading sub-agent tasks. Runtime workers now register such tasks with a sentinel file context, and the catalog logs a one-time warning per task id. ([#3688](https://github.com/triggerdotdev/trigger.dev/pull/3688))
</Update>

<Update label="May 21, 2026" description="4.5.0-rc.0">
  ## v4.5.0-rc.0 — AI Agents graduate from chat-prerelease

  First release candidate of v4.5. Everything covered by the `0.0.0-chat-prerelease-*` entries below now ships under a stable semver tag. Install:

  ```bash theme={"theme":"css-variables"}
  pnpm add @trigger.dev/sdk@rc
  ```

  (Or pin `4.5.0-rc.0` explicitly.)

  ### What's in the box

  * **`chat.agent`** — multi-turn AI chat backends as durable Trigger.dev tasks. Lifecycle hooks, recovery from cancel/crash/OOM, version upgrades, all in. See [Overview](/docs/ai-chat/overview) and [Quick Start](/docs/ai-chat/quick-start).
  * **Sessions** — the durable bi-directional stream primitive that backs `chat.agent`. Use it directly for any pattern that needs durable bi-directional streaming across runs. See [Sessions](/docs/ai-chat/sessions).
  * **`useTriggerChatTransport`** — a custom AI SDK `ChatTransport` for `useChat`. No API routes. See [Frontend](/docs/ai-chat/frontend).
  * **Head Start** — opt-in route handler that runs the first `streamText` step in your warm server while the agent boots in parallel. Cuts cold-start TTFC roughly in half. See [Fast starts](/docs/ai-chat/fast-starts#head-start).
  * **AI Prompts** — code-defined, deploy-versioned templates with dashboard overrides for text + model. Integrates with `chat.agent` via `chat.prompt.set()` + `chat.toStreamTextOptions()`. See [Prompts](/docs/ai/prompts).
  * **`ai.toolExecute`** — wire any Trigger subtask in as the `execute` of an AI SDK `tool()`. See [Sub-agents](/docs/ai-chat/patterns/sub-agents).

  ### Compatibility

  `@trigger.dev/sdk@4.5.0-rc.0` requires `ai` `^5.0.0 || ^6.0.0` (Vercel AI SDK), React `^18.0 || ^19.0` (for the `chat/react` subpath), and Node.js `>=18.20.0`. Full matrix on the [API Reference](/docs/ai-chat/reference#compatibility).

  ### Docs

  This release ships with a refreshed AI Agents documentation set covering [Backend](/docs/ai-chat/backend), [Frontend](/docs/ai-chat/frontend), [Sessions](/docs/ai-chat/sessions), [Lifecycle hooks](/docs/ai-chat/lifecycle-hooks), [`chat.local`](/docs/ai-chat/chat-local), the [Patterns](/docs/ai-chat/patterns/sub-agents) library, [Testing](/docs/ai-chat/testing), and a full [API Reference](/docs/ai-chat/reference).
</Update>

<Update label="May 19, 2026" description="0.0.0-chat-prerelease-20260520150857">
  ## Recovery boot — context-preserving continuation after cancel / crash / OOM

  When a `chat.agent` run dies mid-stream (the user cancels, the worker OOMs, an unhandled exception kills the process), the next continuation run now reconstructs the conversation context automatically. Follow-ups like "keep going" continue the partial response; fresh follow-ups like "scrap that, what's 7+8?" abandon it and answer the new question. No customer code required.

  Under the hood: the boot now reads BOTH stream tails — `session.out` for any partial assistant the dead run was streaming, `session.in` for any user messages it never acknowledged — and splices `[firstInFlightUser, partialAssistant]` onto the chain when both are present. The model sees full prior context plus the latest user message.

  For policies different from "preserve context" — drop the partial entirely, synthesize tool results for an interrupted tool call, emit a recovery banner to the UI — register the new `onRecoveryBoot` hook:

  ```ts theme={"theme":"css-variables"}
  import { chat } from "@trigger.dev/sdk/ai";

  export const myChat = chat.agent({
    id: "my-chat",
    onRecoveryBoot: async ({ partialAssistant, inFlightUsers, writer, cause, previousRunId }) => {
      writer.write({
        type: "data-chat-recovery",
        data: { cause, previousRunId, partialPresent: partialAssistant !== undefined },
        transient: true,
      });
      // return nothing → smart default applies
    },
    run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
  });
  ```

  The hook receives `settledMessages`, `inFlightUsers`, `partialAssistant`, `pendingToolCalls`, `previousRunId`, `cause`, and a lazy `writer`. Return any of `chain`, `recoveredTurns`, or `beforeBoot` to override the default. Agents using `hydrateMessages` skip the hook — customer-owned persistence is the source of truth.

  Also retracts the OOM resilience caveat: model context on retry is no longer "incomplete" without `hydrateMessages`. The smart default reconstructs full context from `session.out` replay.

  See [Recovery boot](/docs/ai-chat/patterns/recovery-boot) for the full guide.
</Update>

<Update label="May 16, 2026" description="0.0.0-chat-prerelease-20260519091352">
  ## `session.out` is now bounded — header-form control records + per-turn trim

  Long-lived chats were accumulating `session.out` records forever (every turn appends; nothing trimmed). The Sessions dashboard re-streamed the entire history from `seq_num=0` on every page load, and OOM-retry boot scanned the whole stream to find the last turn-complete.

  After this release `session.out` stays roughly **one turn long forever** at steady state. After each `turn-complete`, the agent appends an S2 `trim` command record pointing back to the previous turn-complete's seq\_num. Full conversation history continues to live in the durable S3 snapshot, not on the stream. Resume across a single turn boundary still works (the previous `turn-complete` is still on the stream and S2's eventually-consistent trim window gives 10-60s of grace); resume across multiple turns of inactivity falls back to the snapshot.

  ### What changed on the wire

  `trigger:turn-complete` and `trigger:upgrade-required` are no longer JSON data chunks on `session.out`. They're now **header-form control records** under a uniform `trigger-control` namespace:

  ```
  headers:
    ["trigger-control", "turn-complete"]
    ["public-access-token", "eyJ..."]   // optional, refreshed JWT on turn-complete
  body: ""
  ```

  ```
  headers:
    ["trigger-control", "upgrade-required"]
  body: ""
  ```

  The control event names ("turn-complete", "upgrade-required") are unchanged conceptually — they just moved from `chunk.type` into a `trigger-control` header value. Body is always empty; metadata that previously rode in the chunk (e.g. `publicAccessToken`) now rides on sibling headers.

  `turn-complete` also picks up a new optional sibling header — `["session-in-event-id", "<seq>"]` — carrying the agent's committed-consume cursor on `.in` as of this turn. It's an agent-internal contract that lets the next worker boot seed its `.in` SSE subscription past already-processed user messages, without relying on a wall-clock-derived dedup cutoff. Custom transports should ignore the header; it has no client-side meaning.

  ### Custom transport implementers

  Built-in SDK transports (`TriggerChatTransport`, `AgentChat`) handle this transparently — `onTurnComplete` fires the same way with the same payload. Custom transports filtering on `chunk.type === "trigger:turn-complete"` need to switch to the header-based filter:

  ```ts theme={"theme":"css-variables"}
  import { controlSubtype } from "@trigger.dev/core/v3";

  const control = controlSubtype(record.headers);
  if (control === "turn-complete") {
    // refresh token from record.headers, end turn, etc.
  }
  ```

  The full uniform filter rule (data records vs control records vs S2 command records like `trim`) is documented at [Records on `session.out`](/docs/ai-chat/client-protocol#records-on-session-out).

  ### Sessions dashboard snapshot read

  The Sessions detail page in the trigger.dev dashboard now reads the agent's S3 snapshot first via a presigned URL, then SSE-tails from `snapshot.lastOutEventId`. Bandwidth and time-to-first-render are O(unread turns) instead of O(session lifetime). Sessions that registered a `hydrateMessages` hook (which skips snapshot writes) show only the most recent turn — those customers typically have their own DB-backed dashboards.

  ### Breaking surface

  * Custom transports parsing `chunk.type` for turn-complete / upgrade-required must switch to the `trigger-control` header check.
  * Snapshot consumers should import `ChatSnapshotV1` / `ChatSnapshotV1Schema` from `@trigger.dev/core/v3` (now an exported shape, not SDK-internal).

  Hard cutover — no compat shim. v4.5 is prerelease.

  ### Docs

  * [Records on `session.out`](/docs/ai-chat/client-protocol#records-on-session-out) — full filter rule for data / control / command records.
  * [Resuming a stream](/docs/ai-chat/client-protocol#resuming-a-stream) — explicit single-turn vs multi-turn-away semantics.
  * [`turn-complete` control record](/docs/ai-chat/client-protocol#turn-complete-control-record) and [`upgrade-required` control record](/docs/ai-chat/client-protocol#upgrade-required-control-record) — replaced the old chunk-shape docs.
</Update>

<Update label="May 8, 2026" description="0.0.0-chat-prerelease-20260519091352">
  ## 512 KiB `/in/append` ceiling removed for long chats — slim wire + S3 snapshot

  `chat.agent` long-running chats with heavy tool results were hitting the realtime API's 512 KiB body cap on `/realtime/v1/sessions/{id}/in/append` once the accumulated `UIMessage[]` history (which the wire shipped in full on every send) crossed the limit. The 413 surfaced as a CORS error in browsers and stalled chats around turn 10–30 with tool use.

  The wire is now **delta-only**: each `.in/append` carries at most one new `UIMessage` (the new user turn or a tool-approval response) instead of the full history. The agent rebuilds prior history at run boot from a durable JSON snapshot in object storage plus a replay of the `session.out` tail. The 512 KiB ceiling stops being pressure — slim payloads are normally a few KB regardless of chat length.

  ```ts theme={"theme":"css-variables"}
  // Before — full history shipped on every send
  { messages: [u1, a1, u2, a2, /* ... 30 turns ... */, u31], chatId, trigger: "submit-message" }

  // After — only the new turn
  { message: u31, chatId, trigger: "submit-message" }
  ```

  ### What changed

  * **`ChatTaskWirePayload`**: `messages: UIMessage[]` is removed. Replaced by `message?: UIMessage` (singular, optional) and a dedicated `headStartMessages?: UIMessage[]` field used only by `chat.headStart` first-turn handover.
  * **Run boot**: when `hydrateMessages` is not registered, the runtime reads `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json` from object storage and replays any `session.out` chunks landed since the snapshot's cursor. Snapshot writes happen after every `onTurnComplete`, awaited so they survive an idle suspend.
  * **`hydrateMessages` short-circuit**: registering the hook skips snapshot read/write and replay entirely. Customer is the source of truth for history, same as today.
  * **`hydrateMessages.incomingMessages`**: now consistently 0-or-1-length across every trigger type. Previously `regenerate-message` and continuations occasionally shipped full history; they now ship none.
  * **`onChatStart` is now once-per-chat**: fires only on the chat's very first user message; does NOT fire on continuation runs (post-`endRun`, post-waitpoint-timeout, post-`chat.requestUpgrade`) or on OOM-retry attempts. The `continuation` and `previousRunId` fields on `ChatStartEvent` are now `@deprecated` (always `false` / `undefined` when the hook fires). Drop any `if (continuation) return;` gates from `onChatStart` — they're now unreachable. For per-turn setup that runs on continuations too, move to `onTurnStart`.
  * **Continuation boot payload**: the server now strips `message` / `messages` / `trigger` from the cached `basePayload` on continuation runs, and the SDK enters a new continuation-wait branch that waits silently on `session.in` for the next user message. Fixes a phantom-turn bug where stale boot-payload fields were replayed on every resume.
  * **OOM-retry boot**: uses the snapshot's `lastOutTimestamp` as the `session.in` cutoff, saving one stream subscription per retry.
  * **Built-in transports**: `TriggerChatTransport`, `AgentChat`, mid-stream pending-message handling, and `chat.headStart` route handler all updated to the slim shape. Existing customer code calling `transport.sendMessage(...)` / `agentChat.sendMessage(...)` is unaffected — the change is below those surfaces.

  ### Object store configuration

  Snapshot read/write reuses Trigger.dev's existing object-store infrastructure — the same presigned-URL routes used for large payloads. Set `OBJECT_STORE_*` env vars on your webapp deployment if you haven't already; MinIO works locally via `OBJECT_STORE_DEFAULT_PROTOCOL`.

  If no object store is configured **and** no `hydrateMessages` hook is registered, conversations don't survive run boundaries (the runtime logs a warning at registration time). Either configure an object store or register `hydrateMessages`.

  ### Breaking surface

  * **Custom transports**: any code constructing `ChatTaskWirePayload` directly must drop `messages` and use `message`. See the rewritten [Client Protocol](/docs/ai-chat/client-protocol).
  * **Client-side `setMessages` no longer round-trips**: full-history mutations on the client never reached the agent before this release either, but the slim wire makes that explicit. Use server-side [`chat.history.set()`](/docs/ai-chat/backend#chat-history) inside `onTurnStart` for compaction.
  * **Custom server-to-server senders**: code calling `apiClient.appendToSessionInput(sessionId, ...)` or hitting `/realtime/v1/sessions/{id}/in/append` directly must switch to the slim shape.

  Hard cutover — there is no compat shim. v4.5 is prerelease.

  ### Docs

  * Rewritten [Client Protocol](/docs/ai-chat/client-protocol) — slim payload, new `headStartMessages` field, new "How history is rebuilt" and "Head-start protocol caveat" sections.
  * New [Persistence and replay](/docs/ai-chat/patterns/persistence-and-replay) — end-to-end walkthrough of the snapshot model, OOM-retry interaction, crash semantics, `hydrateMessages` short-circuit.
  * New [Tool result auditing](/docs/ai-chat/patterns/tool-result-auditing) — the `extractNewToolResults` + `onTurnComplete` / `hydrateMessages` pattern for HITL audit logging.
  * [v4.5 section of the upgrade guide](/docs/ai-chat/upgrade-guide#v45-wire-format-change) — migration steps for custom transports and `hydrateMessages` consumers.
  * [`hydrateMessages`](/docs/ai-chat/lifecycle-hooks#hydratemessages), [`onChatStart`](/docs/ai-chat/lifecycle-hooks#onchatstart) — clarifications on the new `incomingMessages` and `messages` shapes.
</Update>

<Update label="May 7, 2026" description="0.0.0-chat-prerelease-20260507131256">
  ## `chat.history` read primitives for HITL flows

  Customers building human-in-the-loop tools were re-implementing the same accumulator-walking logic to figure out which tool calls were pending, which were resolved, and which results in an incoming wire message were actually new. Lifted into the SDK as five new methods on `chat.history`:

  | Method                                        | Description                                                                                                                                                                         |
  | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
  | `chat.history.getPendingToolCalls()`          | Tool calls on the most recent assistant message in `input-available` state — gates fresh user turns during HITL.                                                                    |
  | `chat.history.getResolvedToolCalls()`         | All tool calls in the chain in `output-available` or `output-error` state.                                                                                                          |
  | `chat.history.extractNewToolResults(message)` | Tool results in `message` whose `toolCallId` is not already resolved on the chain. Most useful in `hydrateMessages` against an incoming wire message, before the runtime merges it. |
  | `chat.history.getChain()`                     | Same as `chat.history.all()` — alias that reads better alongside parent-aware APIs.                                                                                                 |
  | `chat.history.findMessage(messageId)`         | Direct lookup; `undefined` if absent.                                                                                                                                               |

  ```ts theme={"theme":"css-variables"}
  // Refuse a regenerate while a tool call is awaiting an answer
  onAction: async ({ action }) => {
    if (action.type === "regenerate") {
      if (chat.history.getPendingToolCalls().length > 0) return;
      chat.history.slice(0, -1);
    }
  },

  // Side-effect once per net-new tool result on incoming wire messages
  hydrateMessages: async ({ incomingMessages }) => {
    for (const msg of incomingMessages) {
      for (const r of chat.history.extractNewToolResults(msg)) {
        await auditLog.record({ id: r.toolCallId, output: r.output, errorText: r.errorText });
      }
    }
    return incomingMessages;
  },
  ```

  See [`chat.history`](/docs/ai-chat/backend#chat-history) and [Human-in-the-loop](/docs/ai-chat/patterns/human-in-the-loop).

  ## Fix: HITL `addToolOutput` resume preserves the assistant message id

  In some HITL flows the AI SDK regenerated the assistant message id when the user's `addToolOutput` answer round-tripped back to the agent. The fresh id slipped past the runtime's id-based merge, leaving the resolved tool answer attached to a sibling assistant message instead of the head, which broke downstream dedup and rendered the tool answer twice.

  The runtime now records `toolCallId → head messageId` whenever an assistant with tool parts lands in the accumulator and rewrites the incoming id back via that map before the merge. Customers who had a content-match workaround for this can drop it.
</Update>

<Update label="May 6, 2026" description="0.0.0-chat-prerelease-20260506093419">
  ## `chat.agent` actions are no longer turns

  Submitting an action via `transport.sendAction()` previously fell through to the regular turn machinery, calling `onTurnStart`, `run()`, `onTurnComplete`, etc. — meaning every action fired an LLM call by default. The workaround was a `chat.local`-based `skipModelCall` flag read in `run()`.

  Actions now fire `hydrateMessages` and `onAction` only. No `onTurnStart` / `prepareMessages` / `onBeforeTurnComplete` / `onTurnComplete`, no `run()` invocation, no turn-counter increment. The trace span is named `chat action` instead of `chat turn N`.

  `onAction`'s return type widens: returning `void` is side-effect-only (default); returning a `StreamTextResult`, `string`, or `UIMessage` produces a model response that's auto-piped back to the frontend.

  ### Migration

  If you had `run()` branching on `payload.trigger === "action"` for a model response, return your `streamText(...)` from `onAction` instead. If you persisted in `onTurnComplete`, do that work inside `onAction`. For state-only actions, just remove the skip-the-model workaround.

  ```ts theme={"theme":"css-variables"}
  // before
  onAction: async ({ action }) => {
    if (action.type === "regenerate") {
      runState.skipModelCall = false;
      chat.history.slice(0, -1);
    }
  },
  run: async ({ messages, signal }) => {
    if (runState.skipModelCall) return;
    return streamText({ model, messages, abortSignal: signal });
  },

  // after
  onAction: async ({ action, messages, signal }) => {
    if (action.type === "regenerate") {
      chat.history.slice(0, -1);
      return streamText({ model, messages, abortSignal: signal });
    }
  },
  run: async ({ messages, signal }) =>
    streamText({ model, messages, abortSignal: signal }),
  ```

  Actions arriving when no `onAction` handler is configured now `console.warn` once and are ignored — previously they silently fell through to `run()` with an empty wire payload.
</Update>

<Update label="May 5, 2026" description="0.0.0-chat-prerelease-20260505140031">
  ## Fix: duplicate turn after `chat.agent` idle-suspends

  Every message sent to a `chat.agent` after the run idle-suspended produced two turns on the agent side instead of one — same user message, two LLM calls. Internal session-stream reconnect logic was racing the waitpoint and feeding the just-consumed message back into the next turn's input buffer. No public API change.
</Update>

<Update label="May 5, 2026" description="0.0.0-chat-prerelease-20260505084711">
  ## `chat.headStart` — fast first-turn for chat.agent

  A new opt-in flow that cuts first-turn TTFC roughly in half by running step 1's LLM call in your warm process while the chat.agent run boots in parallel. On the LLM's `tool-calls` boundary, ownership of the durable stream hands over to the agent for tool execution and step 2+. Pure-text first turns finish on the customer side with no LLM call from the trigger run at all.

  Measured on `claude-sonnet-4-6` (same model both sides): TTFT 2801ms → 1218ms (−57%), total turn 4180ms → 2345ms (−44%). With Head Start, first-text time is essentially the LLM TTFB floor.

  ### Setup

  ```ts app/api/chat/route.ts theme={"theme":"css-variables"}
  import { chat } from "@trigger.dev/sdk/chat-server";
  import { streamText } from "ai";
  import { anthropic } from "@ai-sdk/anthropic";
  import { headStartTools } from "@/lib/chat-tools/schemas";

  export const POST = chat.headStart({
    agentId: "my-chat",
    run: async ({ chat: helper }) =>
      streamText({
        ...helper.toStreamTextOptions({ tools: headStartTools }),
        model: anthropic("claude-sonnet-4-6"),
        system: "You are a helpful assistant.",
      }),
  });
  ```

  ```tsx components/chat.tsx theme={"theme":"css-variables"}
  const transport = useTriggerChatTransport({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, taskId, clientData }) =>
      startChatSession({ chatId, taskId, clientData }),
    headStart: "/api/chat",
  });
  ```

  ### Bundle isolation

  Tool schemas (`description` + `inputSchema`) live in their own module that imports only `ai` and `zod`. The agent task imports those schemas and adds heavy `execute` fns. The route handler imports schemas only — keeping the warm-process bundle light is what makes the win possible. Runtime "strip executes" helpers don't solve this — bundlers resolve imports at build time. See [Fast starts → Head Start setup](/docs/ai-chat/fast-starts#setup) for the full split.

  ### Compared to Preload

  Preload eagerly triggers the run on page load (good when you're confident the user *will* send a message — trades idle compute for fast TTFC). Head Start gates the run on a real first message — no idle compute, customer's process runs step 1 directly. Pick one per chat.

  ### Works on every runtime

  `chat.headStart` returns a standard Web Fetch handler — `(req: Request) => Promise<Response>` — so it slots into Next.js App Router, Hono, SvelteKit, Remix / React Router v7, TanStack Start, Astro, Nitro/Nuxt, Elysia, Cloudflare Workers, Bun, Deno, and any other runtime that speaks Web Fetch. Verified runtimes: Node 18+, Bun, Deno, Workers, Vercel (Node and Edge), Netlify (Functions and Edge).

  For Node-only frameworks (Express, Fastify, Koa, raw `node:http`), the SDK ships `chat.toNodeListener(handler)` — converts any Web Fetch handler into a Node `(req, res)` listener with proper streaming, header translation, and client-disconnect propagation.

  ```ts theme={"theme":"css-variables"}
  import express from "express";
  import { chat } from "@trigger.dev/sdk/chat-server";

  const handler = chat.headStart({ agentId: "my-chat", run: ... });

  const app = express();
  app.post("/api/chat", chat.toNodeListener(handler));
  ```

  ## Docs

  * New [Head Start guide](/docs/ai-chat/fast-starts#head-start) — bundle isolation, schema/execute split, route handler setup, transport option, lifecycle, limitations.
  * [Reference](/docs/ai-chat/reference#triggerchattransport-options) — `headStart` transport option.
</Update>

<Update label="May 2, 2026" description="0.0.0-chat-prerelease-20260502065709">
  ## Resilient SSE reconnection

  The chat transport now retries indefinitely on network drops with bounded exponential backoff (100ms initial, 5s cap, 50% jitter) instead of giving up after 5 attempts. Reconnects are immediate on `online`, on tab refocus after a long background, and on Safari bfcache restore (`pageshow` with `event.persisted`).

  A 60s stall detector catches silent-dead-socket cases on mobile where the OS killed the TCP socket without the reader noticing. A 30s per-attempt fetch timeout prevents stuck connections from blocking the retry loop.

  Resume continues to use `Last-Event-ID`, so no chunks are lost when the connection comes back. No public API change — these are defaults on `TriggerChatTransport`. Customers who built `hasActiveStream` / `isStreaming` flag tracking on their side can drop it: the transport handles the silent-but-stale case internally now.

  `SSEStreamSubscription` (used by `TriggerChatTransport` and `AgentChat`) gained `retryNow()` and `forceReconnect()` for callers writing custom transports, plus options to tune `maxRetries` / `retryDelayMs` / `maxRetryDelayMs` / `retryJitter` / `fetchTimeoutMs` / `stallTimeoutMs` / `nonRetryableStatuses`. `404` and `410` short-circuit retry by default (stream gone / session closed).
</Update>

<Update label="April 24, 2026" description="0.0.0-chat-prerelease-20260501122331">
  ## `chat.agent` now runs on Sessions

  Every chat is backed by a durable Session row that outlives any single run. `externalId` = your chat ID, `type` = `"chat.agent"`. Under the hood:

  * Output chunks stream on `session.out` (was a run-scoped `streams.writer("chat")`).
  * Client messages and stops land on `session.in` as a [`ChatInputChunk`](/docs/ai-chat/reference#chatinputchunk) tagged union (was two run-scoped `streams.input` definitions).
  * Wire endpoints moved from `/realtime/v1/streams/{runId}/...` to `/realtime/v1/sessions/{sessionId}/...`. See the rewritten [Client Protocol](/docs/ai-chat/client-protocol).

  Public surface (`chat.agent()`, `TriggerChatTransport`, `AgentChat`, `chat.stream` / `chat.messages` / `chat.stopSignal`) is unchanged — existing apps keep working. What's new is:

  * **Cross-run resume is free.** A chat you were in yesterday resumes against the same `sessionId` today, even if the original run long since exited. No more lost conversations when a run idle-times-out.
  * **Inbox views via `sessions.list({type: "chat.agent"})`.** Enumerate every chat in your environment, filter by tag or status.
  * **`TriggerChatTaskResult.sessionId`** + **`ChatTaskRunPayload.sessionId`** — you can reach into the raw session via `sessions.open(payload.sessionId)` for advanced cases (writing from a sub-agent, custom transport).
  * **Dashboard Agent tab** resolves via `sessionId` and stays in sync with the live stream across runs.

  The full wire-level protocol (session create, channel routes, JWT scopes) is documented in [Client Protocol](/docs/ai-chat/client-protocol).

  ## `X-Session-Settled` — fast reconnect on idle chats

  When a client reconnects to `session.out` and the tail record is a `trigger:turn-complete` marker (agent finished a turn, idle-waiting or exited), the server sets `X-Session-Settled: true` and uses `wait=0` on the underlying S2 read. The SSE drains any remaining records then closes in \~1s instead of long-polling for 60s.

  Practical impact: `TriggerChatTransport.reconnectToStream` no longer needs a client-side `isStreaming` flag. You can drop the field from your persisted `ChatSession` state entirely — the server decides. Existing callers that still persist `isStreaming` are unaffected; `reconnectToStream` keeps the fast-path short-circuit when it's `false`.

  ## Migration

  See the [Sessions Upgrade Guide](/docs/ai-chat/upgrade-guide) for the full step-by-step — auth callback split, persisted `ChatSession` shape, server-side helpers (`chat.createStartSessionAction`, `chat.createAccessToken` for renewal), and the `clientData` validation pivot.

  ## Docs

  * Rewritten [Client Protocol](/docs/ai-chat/client-protocol) — full wire format for the new `/realtime/v1/sessions/{sessionId}/...` endpoints, JWT scopes, S2 direct-write credentials, and `Last-Event-ID` resume.
  * [Database persistence pattern](/docs/ai-chat/patterns/database-persistence) — new `chatId`-keyed `ChatSession` shape (no more `runId`) and a warning on the `onTurnComplete` race that requires a single atomic write of `messages` + `lastEventId`.
  * [Reference](/docs/ai-chat/reference) — added `chat.createStartSessionAction`, `chat.createAccessToken`, `ChatInputChunk`, `TriggerChatTaskResult.sessionId`, `ChatTaskRunPayload.sessionId`. The old run-scoped stream-ID constants are gone.
  * Refreshed [Backend](/docs/ai-chat/backend), [Frontend](/docs/ai-chat/frontend), [Server Chat](/docs/ai-chat/server-chat), [Quick start](/docs/ai-chat/quick-start), [Overview](/docs/ai-chat/overview), [Types](/docs/ai-chat/types), [Error handling](/docs/ai-chat/error-handling), and [Testing](/docs/ai-chat/testing) for the session-based wiring.
</Update>

<Update label="April 19, 2026" description="0.0.0-chat-prerelease-20260419173457">
  ## Agent Skills

  Ship reusable capabilities as folders — a `SKILL.md` plus optional scripts, references, and assets. The agent sees short descriptions in its system prompt, loads full instructions on demand via `loadSkill`, and invokes bundled scripts via `bash` — no manual wiring.

  `skills.define({ id, path })` registers the skill; the CLI bundles the folder into the deploy image. `chat.skills.set([...])` activates skills for the run; `chat.toStreamTextOptions()` auto-injects the preamble and tools.

  See the new [Agent Skills guide](/docs/ai-chat/patterns/skills).
</Update>

<Update label="April 18, 2026" description="0.0.0-chat-prerelease-20260418174118">
  ## `chat.endRun()` — exit on your own terms

  New imperative API to exit the loop after the current turn completes, without the upgrade-required signal that `chat.requestUpgrade()` sends. Use for one-shot agents, budget-exhausted exits, or goal-reached completions.

  ```ts theme={"theme":"css-variables"}
  chat.agent({
    id: "one-shot",
    run: async ({ messages, signal }) => {
      chat.endRun();
      return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
    },
  });
  ```

  The current turn streams normally, `onBeforeTurnComplete` / `onTurnComplete` fire, the turn-complete chunk is written, and the run exits instead of suspending. Callable from `run()`, `chat.defer()`, `onBeforeTurnComplete`, or `onTurnComplete`. See [Ending a run on your terms](/docs/ai-chat/backend#ending-a-run-on-your-terms).

  ## `finishReason` on turn-complete events

  `TurnCompleteEvent` and `BeforeTurnCompleteEvent` now include the AI SDK's `finishReason` (`"stop" | "tool-calls" | "length" | "content-filter" | "error" | "other"`). Clean signal for distinguishing a normal turn end from one paused on a pending tool call (HITL flows like `ask_user`):

  ```ts theme={"theme":"css-variables"}
  onTurnComplete: async ({ finishReason, responseMessage }) => {
    if (finishReason === "tool-calls") {
      // Paused — assistant message has a pending tool call waiting for user input
      await persistCheckpoint(responseMessage);
    } else {
      await persistCompleted(responseMessage);
    }
  };
  ```

  Undefined for manual `chat.pipe()` flows or aborted streams. See the new [Human-in-the-loop pattern](/docs/ai-chat/patterns/human-in-the-loop).

  ## User-initiated compaction pattern

  The [Compaction guide](/docs/ai-chat/compaction) now covers how to wire a "Summarize conversation" button or `/compact` slash command via `actionSchema` + `onAction`. The agent summarizes on demand, rewrites history with `chat.history.set()`, and short-circuits the LLM call for action turns.

  Needed a small type fix for this: `ChatTaskPayload.trigger` now correctly includes `"action"`, so `run()` handlers can short-circuit with `if (trigger === "action") return` when an action doesn't need a response.

  ## Human-in-the-loop pattern page

  New [Human-in-the-loop](/docs/ai-chat/patterns/human-in-the-loop) page walks through `ask_user`-style mid-turn user input end-to-end: defining a no-execute tool, rendering pending tool calls on the frontend with `addToolOutput` + `sendAutomaticallyWhen`, detecting paused turns via `finishReason`, and two persistence strategies (overwrite vs. checkpoint nodes).
</Update>

<Update label="April 18, 2026" description="0.0.0-chat-prerelease-20260418083610">
  ## Offline test harness for `chat.agent`

  `@trigger.dev/sdk/ai/test` now ships `mockChatAgent`, a harness that drives a `chat.agent` definition through real turns without network or task runtime. Send messages, actions, and stop signals; inspect emitted chunks; assert on hook order.

  ```ts theme={"theme":"css-variables"}
  import { mockChatAgent } from "@trigger.dev/sdk/ai/test";
  import { MockLanguageModelV3 } from "ai/test";
  import { myAgent } from "./my-agent";

  const harness = mockChatAgent(myAgent, {
    chatId: "test-1",
    clientData: {
      model: new MockLanguageModelV3({
        /* ... */
      }),
    },
  });

  const turn = await harness.sendMessage({
    id: "u1",
    role: "user",
    parts: [{ type: "text", text: "hi" }],
  });
  expect(turn.chunks).toContainEqual(expect.objectContaining({ type: "text-delta", delta: "hello" }));
  await harness.close();
  ```

  ### Dependency injection via locals

  `setupLocals` pre-seeds `locals` before `run()` starts — the pattern for injecting database clients, service stubs, and other server-side dependencies that shouldn't leak through untrusted `clientData`:

  ```ts theme={"theme":"css-variables"}
  import { dbKey } from "./db";

  const harness = mockChatAgent(agent, {
    chatId: "test-1",
    setupLocals: ({ set }) => {
      set(dbKey, testDb);
    },
  });
  ```

  Hooks then read the seeded value with `locals.get(dbKey)`. Falls through to the production client in real runs.

  See [Testing](/docs/ai-chat/testing).

  ## `runInMockTaskContext` — lower-level test harness

  `@trigger.dev/core/v3/test` now exports `runInMockTaskContext` for unit-testing any task code offline (not just chat agents). Installs in-memory managers for `locals`, `lifecycleHooks`, `runtime`, `inputStreams`, and `realtimeStreams`, plus a mock `TaskContext`. Drivers let you push data into input streams and inspect chunks written to output streams.
</Update>

<Update label="April 17, 2026" description="0.0.0-chat-prerelease-20260417152143">
  ## Multi-tab coordination

  Prevent duplicate messages when the same chat is open in multiple browser tabs. Enable with `multiTab: true` on the transport.

  ```tsx theme={"theme":"css-variables"}
  const transport = useTriggerChatTransport({ task: "my-chat", multiTab: true, accessToken });
  const { messages, setMessages } = useChat({ id: chatId, transport });
  const { isReadOnly } = useMultiTabChat(transport, chatId, messages, setMessages);
  ```

  Only one tab can send at a time. Other tabs enter read-only mode with real-time message updates via `BroadcastChannel`. When the active tab's turn completes, any tab can send next. Crashed tabs are detected via heartbeat timeout (10s).

  See [Multi-tab coordination](/docs/ai-chat/frontend#multi-tab-coordination) and [`useMultiTabChat`](/docs/ai-chat/reference#usemultitabchat).

  ## Error stack truncation

  Large error stacks no longer OOM the worker process. Stacks are capped at 50 frames (top 5 + bottom 45), individual lines at 1024 chars, messages at 1000 chars. Applied in `parseError`, `sanitizeError`, and OTel span recording.
</Update>

<Update label="April 15, 2026" description="0.0.0-chat-prerelease-20260415164455">
  ## Fix: `resume: true` hangs on completed turns

  When refreshing a page after a turn completed, `useChat` with `resume: true` would hang indefinitely — `reconnectToStream` opened an SSE connection that never received data.

  Added `isStreaming` to session state. The transport sets it to `true` when streaming starts and `false` on `trigger:turn-complete`. `reconnectToStream` returns `null` immediately when `isStreaming` is false, so `resume: initialMessages.length > 0` is now safe to pass unconditionally.

  The flag flows through `onSessionChange` and is restored from `sessions` — no extra persistence code needed.
</Update>

<Update label="April 15, 2026" description="0.0.0-chat-prerelease-20260415152704">
  ## `hydrateMessages` — backend-controlled message history

  Load message history from your database on every turn instead of trusting the frontend accumulator. The hook replaces the built-in linear accumulation entirely — the backend is the source of truth.

  ```ts theme={"theme":"css-variables"}
  chat.agent({
    id: "my-chat",
    hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
      const stored = await db.getMessages(chatId);
      if (trigger === "submit-message" && incomingMessages.length > 0) {
        stored.push(incomingMessages[incomingMessages.length - 1]!);
        await db.persistMessages(chatId, stored);
      }
      return stored;
    },
  });
  ```

  Tool approval updates are auto-merged after hydration — no extra handling needed.

  See [hydrateMessages](/docs/ai-chat/lifecycle-hooks#hydratemessages).

  ## `chat.history` — imperative message mutations

  Modify the accumulated message history from any hook or `run()`:

  ```ts theme={"theme":"css-variables"}
  chat.history.rollbackTo(messageId); // Undo — keep up to this message
  chat.history.remove(messageId); // Remove one message
  chat.history.replace(id, newMsg); // Edit a message
  chat.history.slice(0, -2); // Remove last 2 messages
  chat.history.all(); // Read current state
  ```

  See [chat.history](/docs/ai-chat/backend#chat-history).

  ## Custom actions — `actionSchema` + `onAction`

  Send typed actions (undo, rollback, edit) from the frontend via `transport.sendAction()`. Actions wake the agent, fire `onAction`, then trigger a normal `run()` turn.

  ```ts theme={"theme":"css-variables"}
  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);
    },
  });
  ```

  Frontend: `transport.sendAction(chatId, { type: "undo" })`
  Server: `agentChat.sendAction({ type: "undo" })`

  See [Actions](/docs/ai-chat/actions) and [Sending actions](/docs/ai-chat/frontend#sending-actions).
</Update>

<Update label="April 14, 2026" description="0.0.0-chat-prerelease-20260414181032">
  ## `chat.response` — persistent data parts

  Added `chat.response.write()` for writing data parts that both stream to the frontend AND persist in `onTurnComplete`'s `responseMessage` and `uiMessages`.

  ```ts theme={"theme":"css-variables"}
  // Persists to responseMessage.parts — available in onTurnComplete
  chat.response.write({ type: "data-handover", data: { context: summary } });

  // Transient — streams to frontend only, not in responseMessage
  writer.write({ type: "data-progress", data: { percent: 50 }, transient: true });
  ```

  Non-transient `data-*` chunks written via lifecycle hook `writer.write()` now automatically persist to the response message, matching the AI SDK's default semantics. Add `transient: true` for ephemeral chunks (progress indicators, status updates).

  See [Custom data parts](/docs/ai-chat/backend#custom-data-parts).

  ## Tool approvals

  Added support for AI SDK tool approvals (`needsApproval: true`). When the model calls a tool that needs approval, the turn completes and the frontend shows approve/deny buttons. After approval, the updated assistant message is sent back and matched by ID in the accumulator.

  ```ts theme={"theme":"css-variables"}
  const sendEmail = tool({
    description: "Send an email. Requires human approval.",
    inputSchema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
    needsApproval: true,
    execute: async ({ to, subject, body }) => {
      /* ... */
    },
  });
  ```

  Frontend setup requires `sendAutomaticallyWhen` and `addToolApprovalResponse` from `useChat`. See [Tool approvals](/docs/ai-chat/frontend#tool-approvals).

  ## `transport.stopGeneration(chatId)`

  Added `stopGeneration` method to `TriggerChatTransport` for reliable stop after page refresh / stream reconnect. Works regardless of whether the AI SDK passes `abortSignal` through `reconnectToStream`.

  ```tsx theme={"theme":"css-variables"}
  const stop = useCallback(() => {
    transport.stopGeneration(chatId);
    aiStop(); // also update useChat state
  }, [transport, chatId, aiStop]);
  ```

  See [Stop generation](/docs/ai-chat/frontend#stop-generation).

  ## `generateMessageId` support

  `generateMessageId` can now be passed via `uiMessageStreamOptions` to control response message ID generation (e.g. UUID-v7). The backend automatically passes `originalMessages` to `toUIMessageStream` so message IDs are consistent between frontend and backend.

  ## Bug fixes

  * **`onTurnComplete` not called**: Fixed `turnCompleteResult?.lastEventId` TypeError that silently skipped `onTurnComplete` when `writeTurnCompleteChunk` returned undefined in dev.
  * **Stop during streaming**: Added 2s timeout on `onFinishPromise` so `onBeforeTurnComplete` and `onTurnComplete` fire even when the AI SDK's `onFinish` doesn't fire after abort.
  * **`toStreamTextOptions` without `chat.prompt.set()`**: `prepareStep` injection (compaction, steering, background context) now works even when the user passes `system` directly to `streamText` instead of using `chat.prompt.set()`.
  * **Background queue vs tool approvals**: Background context injection is now skipped when the last accumulated message is a `tool` message, preventing it from breaking `streamText`'s `collectToolApprovals`.
</Update>


# chat.local
Source: https://trigger.dev/docs/ai-chat/chat-local

Typed, run-scoped data accessible from hooks, run(), tools, and subtasks. Survives across turns, auto-cleared between runs, auto-hydrated into subtasks.

Use `chat.local` to create typed, run-scoped data that persists across turns and is accessible from anywhere — the run function, tools, nested helpers. Each run gets its own isolated copy, and locals are automatically cleared between runs.

Lifecycle hooks and **`run`** also receive **`ctx`** ([`TaskRunContext`](/docs/ai-chat/reference#task-context-ctx)) — the same object as on a standard `task()` — for tags, metadata, and cleanup that needs the full run record.

When a subtask is invoked via `ai.toolExecute()` (or the deprecated `ai.tool()`), initialized locals are automatically serialized into the subtask's metadata and hydrated on first access — no extra code needed. Subtask changes to hydrated locals are local to the subtask and don't propagate back to the parent.

## Declaring and initializing

Declare locals at module level with a unique `id`, then initialize them inside a lifecycle hook where you have context (chatId, clientData, etc.):

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, tool, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
import { db } from "@/lib/db";

// Declare at module level — each local needs a unique id
const userContext = chat.local<{
  userId: string;
  name: string;
  plan: "free" | "pro";
  messageCount: number;
}>({ id: "userContext" });

export const myChat = chat.agent({
  id: "my-chat",
  clientDataSchema: z.object({ userId: z.string() }),
  onBoot: async ({ clientData }) => {
    // Initialize with real data from your database
    const user = await db.user.findUnique({
      where: { id: clientData.userId },
    });
    userContext.init({
      userId: clientData.userId,
      name: user.name,
      plan: user.plan,
      messageCount: user.messageCount,
    });
  },
  run: async ({ messages, signal }) => {
    userContext.messageCount++;

    return streamText({
      model: anthropic("claude-sonnet-4-5"),
      system: `Helping ${userContext.name} (${userContext.plan} plan).`,
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

<Warning>
  Initialize `chat.local` in [`onBoot`](/docs/ai-chat/lifecycle-hooks#onboot), not `onChatStart`. `onBoot` fires on every fresh worker — including continuation runs (post-cancel, crash, `endRun`, `requestUpgrade`, OOM retry) — whereas `onChatStart` only fires on the chat's very first message. Initializing in `onChatStart` means `run()` will crash on continuation runs with `chat.local can only be modified after initialization`.
</Warning>

## Accessing from tools

Locals are accessible from anywhere during task execution — including AI SDK tools:

```ts theme={"theme":"css-variables"}
const userContext = chat.local<{ plan: "free" | "pro" }>({ id: "userContext" });

const premiumTool = tool({
  description: "Access premium features",
  inputSchema: z.object({ feature: z.string() }),
  execute: async ({ feature }) => {
    if (userContext.plan !== "pro") {
      return { error: "This feature requires a Pro plan." };
    }
    // ... premium logic
  },
});
```

## Accessing from subtasks

When you use `ai.toolExecute()` inside AI SDK `tool()` to expose a subtask, chat locals are automatically available read-only:

```ts theme={"theme":"css-variables"}
import { chat, ai } from "@trigger.dev/sdk/ai";
import { schemaTask } from "@trigger.dev/sdk";
import { streamText, tool } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

const userContext = chat.local<{ name: string; plan: "free" | "pro" }>({ id: "userContext" });

export const analyzeDataTask = schemaTask({
  id: "analyze-data",
  schema: z.object({ query: z.string() }),
  run: async ({ query }) => {
    // userContext.name just works — auto-hydrated from parent metadata
    console.log(`Analyzing for ${userContext.name}`);
    // Changes here are local to this subtask and don't propagate back
  },
});

const analyzeData = tool({
  description: analyzeDataTask.description ?? "",
  inputSchema: analyzeDataTask.schema!,
  execute: ai.toolExecute(analyzeDataTask),
});

export const myChat = chat.agent({
  id: "my-chat",
  onBoot: async ({ clientData }) => {
    userContext.init({ name: "Alice", plan: "pro" });
  },
  run: async ({ messages, signal }) => {
    return streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      tools: { analyzeData },
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

<Note>
  Values must be JSON-serializable for subtask access. Non-serializable values (functions, class instances, etc.) will be lost during transfer.
</Note>

## Dirty tracking and persistence

The `hasChanged()` method returns `true` if any property was set since the last check, then resets the flag. Use it in lifecycle hooks to only persist when data actually changed:

```ts theme={"theme":"css-variables"}
onTurnComplete: async ({ chatId }) => {
  if (userContext.hasChanged()) {
    await db.user.update({
      where: { id: userContext.get().userId },
      data: {
        messageCount: userContext.messageCount,
      },
    });
  }
},
```

## API

| Method                  | Description                                                     |
| ----------------------- | --------------------------------------------------------------- |
| `chat.local<T>({ id })` | Create a typed local with a unique id (declare at module level) |
| `local.init(value)`     | Initialize with a value (call in hooks or `run`)                |
| `local.hasChanged()`    | Returns `true` if modified since last check, resets flag        |
| `local.get()`           | Returns a plain object copy (for serialization)                 |
| `local.property`        | Direct property access (read/write via Proxy)                   |

<Note>
  Locals use shallow proxying. Nested object mutations like `local.prefs.theme = "dark"` won't trigger the dirty flag. Instead, replace the whole property: `local.prefs = { ...local.prefs, theme: "dark" }`.
</Note>

## See also

* [Lifecycle hooks](/docs/ai-chat/lifecycle-hooks) — `onBoot` is the canonical init site for `chat.local`.
* [Database persistence pattern](/docs/ai-chat/patterns/database-persistence) — full per-hook breakdown using `chat.local` alongside DB rows.
* [Code execution sandbox pattern](/docs/ai-chat/patterns/code-sandbox) — example of using `chat.local` to hold a sandbox handle across turns.
* [Database connections](/docs/database-connections) — why the database client and its connection pool belong at module scope, not in `chat.local`.


# Client Protocol
Source: https://trigger.dev/docs/ai-chat/client-protocol

The wire protocol for building custom chat transports — how clients communicate with chat agents over Sessions and SSE.

This page documents the protocol that chat clients use to communicate with `chat.agent()` tasks. Use this if you're building a custom transport (e.g., for a Slack bot, CLI tool, or native app) instead of using the built-in `TriggerChatTransport` or `AgentChat`.

<Note>
  Most users don't need this. Use [`TriggerChatTransport`](/docs/ai-chat/frontend) for browser apps or [`AgentChat`](/docs/ai-chat/server-chat) for server-side code. This page is for building your own from scratch.
</Note>

## Overview

`chat.agent` is built on a durable Session row — the unit of state that owns the chat's runs across their full lifecycle. A conversation is one session; a session can host many runs over its lifetime.

The protocol has three parts:

1. **Create the session** — idempotent on your chat ID. Creates the row **and** triggers the first run in one call. Returns the `publicAccessToken` you'll use for everything else.
2. **Subscribe to `.out`** — receive `UIMessageChunk` events via SSE.
3. **Append to `.in`** — send subsequent user messages, stops, or actions.

```mermaid theme={"theme":"css-variables"}
sequenceDiagram
  participant Client
  participant API as Trigger.dev API
  participant Agent as Chat Agent Run

  Client->>API: POST /api/v1/sessions { type: "chat.agent", externalId, taskIdentifier, triggerConfig.basePayload }
  API-->>Client: { id: sessionId, runId, publicAccessToken, ... }
  Client->>API: GET /realtime/v1/sessions/{sessionId}/out (SSE subscribe)
  Agent-->>Client: UIMessageChunk stream...
  Agent-->>Client: turn-complete control record
  Client->>API: POST /realtime/v1/sessions/{sessionId}/in/append { kind: "message", payload: { message, ... } }
  Agent-->>Client: UIMessageChunk stream...
  Agent-->>Client: turn-complete control record
```

<Note>
  **Stream lifetime.** `session.out` is bounded. After each turn-complete control record, the agent appends an S2 `trim` command record back to the previous turn-complete's seq\_num — the stream stays roughly one turn long forever at steady state. Full conversation history lives in a durable S3 snapshot, not on the stream. The transport's `lastEventId` bookmark plus S2's eventually-consistent trim window (10-60s) keeps single-turn-boundary resume working; multi-turn-away resume falls back to the snapshot. See [Resuming a stream](#resuming-a-stream) and [How history is rebuilt](#how-history-is-rebuilt).
</Note>

<Note>
  **Session create triggers a run.** Unlike `POST /api/v1/tasks/{taskId}/trigger`, `POST /api/v1/sessions` is the **only** entry point for chat-agent runs. The session row is task-bound and the first run is triggered atomically as part of the create call. Don't call `/tasks/{taskId}/trigger` directly for `chat.agent` tasks — the resulting run won't be bound to a session and `.in`/`.out` won't reach it.
</Note>

<Note>
  **One message per record.** Each `.in/append` carries at most one new `UIMessage` — the new user turn or a tool-approval response. The agent rebuilds prior history at run boot from a durable object-store snapshot plus a replay of the `session.out` tail; clients never ship full conversation history on the wire. See [How history is rebuilt](#how-history-is-rebuilt).
</Note>

## End-to-end curl recipe

A single-shell walk-through of the whole protocol — copy, fill in `BASE_URL` / `SECRET_KEY` / `TASK_ID`, and run. Drives a two-turn conversation (`pong` → `echo`) using only `curl` and `jq`.

```bash theme={"theme":"css-variables"}
BASE_URL="https://api.trigger.dev"   # or your local webapp
SECRET_KEY="tr_dev_..."              # secret API key for the env
TASK_ID="ai-chat"                    # your chat.agent task id
CHAT_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')

# 1. Create session + trigger first run with the user's first message.
RESP=$(curl -sS -X POST "$BASE_URL/api/v1/sessions" \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<JSON
{
  "type": "chat.agent",
  "externalId": "$CHAT_ID",
  "taskIdentifier": "$TASK_ID",
  "triggerConfig": {
    "basePayload": {
      "chatId": "$CHAT_ID",
      "trigger": "submit-message",
      "message": {
        "id": "u1",
        "role": "user",
        "parts": [{ "type": "text", "text": "Reply with the single word: pong." }]
      },
      "metadata": { "userId": "demo-user" }
    }
  }
}
JSON
)
SESSION_ID=$(echo "$RESP" | jq -r .id)
PAT=$(echo "$RESP" | jq -r .publicAccessToken)
echo "Session: $SESSION_ID  PAT: ${PAT:0:24}..."

# 2. Subscribe to .out and read until the turn-complete control record.
#    The doc's self-contained parser (Step 2) shows full SSE handling;
#    this recipe just greps for `text-delta` data records and the
#    `trigger-control` header on the `turn-complete` control record,
#    then extracts the last seq_num so step 4 can resume from it.
SSE=$(curl -sS --max-time 30 -N \
  -H "Authorization: Bearer $PAT" \
  -H "Accept: text/event-stream" \
  -H "Timeout-Seconds: 20" \
  "$BASE_URL/realtime/v1/sessions/$SESSION_ID/out")
echo "$SSE" | grep -E 'text-delta|trigger-control' | head -2
LAST_SEQ=$(echo "$SSE" | grep -oE '"seq_num":[0-9]+' | tail -1 | grep -oE '[0-9]+')
echo "lastSeq: $LAST_SEQ"

# 3. Send a follow-up via .in/append.
curl -sS -X POST "$BASE_URL/realtime/v1/sessions/$SESSION_ID/in/append" \
  -H "Authorization: Bearer $PAT" \
  -H "Content-Type: application/json" \
  -d @- <<JSON
{
  "kind": "message",
  "payload": {
    "chatId": "$CHAT_ID",
    "trigger": "submit-message",
    "message": {
      "id": "u2",
      "role": "user",
      "parts": [{ "type": "text", "text": "Now reply with: echo." }]
    },
    "metadata": { "userId": "demo-user" }
  }
}
JSON

# 4. Re-subscribe with Last-Event-ID so we resume past turn 1's records
#    and read turn 2 only.
curl -sS --max-time 30 -N \
  -H "Authorization: Bearer $PAT" \
  -H "Accept: text/event-stream" \
  -H "Timeout-Seconds: 20" \
  -H "Last-Event-ID: $LAST_SEQ" \
  "$BASE_URL/realtime/v1/sessions/$SESSION_ID/out" \
  | grep -m 2 -E 'text-delta|trigger-control'
```

The rest of this page details each step.

## Step 1: Create the session and trigger the first run

`POST /api/v1/sessions` does two things atomically: it creates (or returns) a session row, and triggers the first run for that session. Use your stable chat ID as `externalId` to make creation idempotent — two concurrent clients for the same chat converge on the same row, and a repeat call returns the existing session without triggering a duplicate run.

The `trigger` value inside `basePayload` decides what the first run does:

<CodeGroup>
  ```bash trigger: "preload" — warm the agent, no message yet theme={"theme":"css-variables"}
  POST /api/v1/sessions
  Authorization: Bearer <secret-key-or-jwt>
  Content-Type: application/json

  {
    "type": "chat.agent",
    "externalId": "conversation-123",
    "taskIdentifier": "ai-chat",
    "triggerConfig": {
      "basePayload": {
        "chatId": "conversation-123",
        "trigger": "preload",
        "metadata": { "userId": "user-456" }
      }
    },
    "tags": ["chat:conversation-123"]
  }
  ```

  ```bash trigger: "submit-message" — process first user message immediately theme={"theme":"css-variables"}
  POST /api/v1/sessions
  Authorization: Bearer <secret-key-or-jwt>
  Content-Type: application/json

  {
    "type": "chat.agent",
    "externalId": "conversation-123",
    "taskIdentifier": "ai-chat",
    "triggerConfig": {
      "basePayload": {
        "chatId": "conversation-123",
        "trigger": "submit-message",
        "message": {
          "id": "msg-1",
          "role": "user",
          "parts": [{ "type": "text", "text": "Hello!" }]
        },
        "metadata": { "userId": "user-456" }
      }
    },
    "tags": ["chat:conversation-123"]
  }
  ```
</CodeGroup>

Pick `"preload"` when the UI has rendered but the user hasn't typed (warms the agent so the first response is fast); pick `"submit-message"` when you already have the first message and want it processed in the same call.

### Required fields

| Field                       | Type     | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`                      | `string` | Discriminator. Use `"chat.agent"`.                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `taskIdentifier`            | `string` | The `id` you passed to `chat.agent({ id: ... })` — e.g. `"ai-chat"`.                                                                                                                                                                                                                                                                                                                                                                                           |
| `triggerConfig.basePayload` | `object` | The wire payload sent to the **first run** created by this call. Same shape as [`ChatTaskWirePayload`](#chattaskwirepayload) in Step 3. Durable fields (`chatId`, `metadata`, `idleTimeoutInSeconds`, `sessionId`) flow through to continuation runs too; first-turn-only fields (`message`, `trigger`) are stripped on continuations — those are session-create concerns and don't replay. See [What goes in `basePayload`](#what-goes-in-basepayload) below. |

### Optional fields

| Field                                | Type                | Description                                                                                                             |
| ------------------------------------ | ------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `externalId`                         | `string`            | Your stable chat ID. Strongly recommended — without it, repeat calls create new sessions. Cannot start with `session_`. |
| `tags`                               | `string[]`          | Up to 10 dashboard tags.                                                                                                |
| `metadata`                           | `object`            | Arbitrary JSON metadata stored on the session row (separate from `basePayload.metadata`, which goes to the agent).      |
| `expiresAt`                          | `string` (ISO date) | Retention cap.                                                                                                          |
| `triggerConfig.machine`              | `string`            | Machine preset (`micro`, `small-1x`, …) for every run.                                                                  |
| `triggerConfig.queue`                | `string`            | Queue name.                                                                                                             |
| `triggerConfig.tags`                 | `string[]`          | Tags applied to every run (in addition to session-level `tags`).                                                        |
| `triggerConfig.maxAttempts`          | `number`            | Per-run retry cap (1–10).                                                                                               |
| `triggerConfig.maxDuration`          | `number`            | Per-run wall-clock cap, seconds.                                                                                        |
| `triggerConfig.lockToVersion`        | `string`            | Pin every run to a specific worker version.                                                                             |
| `triggerConfig.region`               | `string`            | Region preference.                                                                                                      |
| `triggerConfig.idleTimeoutInSeconds` | `number`            | Surfaced to the agent through the wire payload (1–3600).                                                                |

### What goes in `basePayload`

`basePayload` is the [`ChatTaskWirePayload`](#chattaskwirepayload) sent to the agent at run boot — the same shape used for every subsequent `.in/append` (Step 3). Two fields you must always include:

* `chatId` — should equal your `externalId`. The agent uses this as its conversation identity (e.g. as a DB key in `hydrateMessages`); the `externalId` is what the URL routes resolve. Setting them to the same value is the standard pattern and the only way the built-in clients work.
* `trigger` — see the two examples above. `"preload"` and `"submit-message"` are the only valid choices for the first run; the others (`"regenerate-message"`, `"action"`, `"close"`, `"handover-prepare"`) are for subsequent `.in/append` calls.

The agent's typed `clientData` (declared via `chat.withClientData({ schema: ... })`) is read from `basePayload.metadata`. If your agent declares `clientData: { userId: string }`, then `metadata.userId` is required on every run — including the first one in `basePayload`.

### Response

```http theme={"theme":"css-variables"}
HTTP/1.1 201 Created
content-type: application/json; charset=utf-8
x-trigger-jwt: eyJhbGciOi...
x-trigger-jwt-claims: {"sub":"...","scopes":["read:runs:run_abc123","write:inputStreams:run_abc123"]}

{
  "id": "session_cm4z2plfh000abcd1efgh",
  "externalId": "conversation-123",
  "type": "chat.agent",
  "taskIdentifier": "ai-chat",
  "triggerConfig": { "basePayload": { /* echoed back */ } },
  "currentRunId": "run_abc123",
  "tags": ["chat:conversation-123"],
  "metadata": null,
  "closedAt": null,
  "closedReason": null,
  "expiresAt": null,
  "createdAt": "2026-04-24T09:00:00.000Z",
  "updatedAt": "2026-04-24T09:00:00.000Z",
  "runId": "run_abc123",
  "publicAccessToken": "eyJhbGciOi...",
  "isCached": false
}
```

| Field                    | Description                                                                                                                                                                                                                                             |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                     | The `session_*` friendly ID. Stable for the life of the conversation.                                                                                                                                                                                   |
| `runId` / `currentRunId` | Friendly ID of the first run. Identical on a fresh create; will diverge over the conversation (see [Continuations](#continuations)).                                                                                                                    |
| `publicAccessToken`      | Session-scoped JWT carrying `read:sessions:{externalId}` + `write:sessions:{externalId}`. **This is the token you use for every subsequent `.in`/`.out` call.** Persist it. Lifetime is 60 minutes — see [Refreshing the token](#refreshing-the-token). |
| `isCached`               | `true` if the session existed already (idempotent re-create). HTTP status is 200 in that case, 201 on a fresh create.                                                                                                                                   |

<Warning>
  **Use `publicAccessToken` from the body, not the `x-trigger-jwt` response header.** The header is included by the underlying run-trigger machinery and carries **run-scoped** scopes (`read:runs:{runId}` + `write:inputStreams:{runId}`) — it cannot subscribe to `.out` or append to `.in`. The body's `publicAccessToken` is the only token with the correct session-level scopes.
</Warning>

### Idempotency

Re-calling `POST /api/v1/sessions` with the same `(taskIdentifier, externalId)` pair is **idempotent for the lifetime of the session**:

* If the session is still alive: returns the existing row with `isCached: true`, `runId` unchanged, and a **fresh** 60-minute `publicAccessToken`. No duplicate run is triggered. (Idle/exited runs are different — see [Continuations](#continuations).)
* If the session has been closed (`POST /api/v1/sessions/{id}/close`): returns **HTTP 409**. Closed is one-way; reuse a different `externalId` to start a new conversation.
* Any tags / metadata / expiresAt / triggerConfig fields you send on the cached path are written through to the row, so you can update e.g. `triggerConfig.basePayload.metadata` mid-conversation. The new fields apply to **future** runs (continuations); the currently-live run keeps its original config.

<Warning>
  **A cached re-POST does not deliver a new `basePayload.message`.** `basePayload` is run-trigger config, not a message channel — the existing run keeps streaming and your message is silently dropped. To send a follow-up message, use `POST /realtime/v1/sessions/{sessionId}/in/append` (Step 3).
</Warning>

### Refreshing the token

The `publicAccessToken` returned by `POST /api/v1/sessions` is valid for 60 minutes. Two ways to keep going past that:

1. **Take refreshed tokens from the stream.** Most `turn-complete` control records on `.out` carry a `public-access-token` header with a refreshed JWT (see [`turn-complete` control record](#turn-complete-control-record)). The header is optional and may be absent on some turns (for example an errored turn), so replace your stored token whenever the header is present rather than expecting it every turn. For active conversations it rolls on its own.
2. **Re-call `POST /api/v1/sessions`.** Idempotent, returns `isCached: true` and a brand-new 60-minute token. Use this if a chat goes idle long enough that the SSE stream has closed and you need to resume.

<Note>
  The built-in SDK clients (`TriggerChatTransport` from `@trigger.dev/sdk`, `AgentChat` from `@trigger.dev/sdk/chat`) call this endpoint and persist the refreshed `publicAccessToken` automatically, refreshing on every `turn-complete` control record.
</Note>

## Step 2: Subscribe to `.out`

Subscribe to the agent's response via SSE on the session's `.out` channel:

```
GET /realtime/v1/sessions/{sessionId}/out
Authorization: Bearer <publicAccessToken>
Accept: text/event-stream
```

`Accept: text/event-stream` is required — without it the request is rejected as a non-SSE caller.

The URL accepts either form for `{sessionId}`: the friendly `session_*` ID, or your `externalId` (the chat ID you created the session with). The `publicAccessToken` from session-create authorizes both forms. Pick whichever your client already has on hand.

A session's `.out` stays the same across runs, so the client doesn't need to re-subscribe when a new run starts on the same chat. `seq_num` is **monotonically increasing across the entire session**, not just within one run — turn 1 might emit seq 0–9, turn 2 picks up at seq 10+, a continuation run on the same session continues numbering from there. This is why a single `Last-Event-ID` cursor is sufficient to resume across turns and across runs.

### Stream timeout

The SSE long-polls until either a record arrives or the timeout expires. The default is **60 seconds**; cap it explicitly via the `Timeout-Seconds` request header (1–600):

```
GET /realtime/v1/sessions/{sessionId}/out
Authorization: Bearer <publicAccessToken>
Accept: text/event-stream
Timeout-Seconds: 30
```

If nothing arrives by the deadline, the server sends `data: [DONE]` and closes. Reconnect with `Last-Event-ID` to continue (see [Resuming a stream](#resuming-a-stream)).

### Stream format (S2)

The output stream uses [S2](https://s2.dev) under the hood and follows the standard SSE wire format ([WHATWG spec](https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream)). Three event types arrive on the wire:

| Event                                | Meaning                                                                                                                                                                                                                                                                                 |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `batch`                              | One or more records. The records you actually care about.                                                                                                                                                                                                                               |
| `ping`                               | Keepalive (\~every 5s on idle). Body is `{"timestamp": <ms>}`, and on backends that report it a `tail` (same `{seq_num, timestamp}` shape as a batch tail). The tail is what lets you tell you have caught up to the live edge; if you don't need caught-up detection, ignore the ping. |
| *(no `event:`, just `data: [DONE]`)* | Stream is closing — server sends this once before EOF.                                                                                                                                                                                                                                  |

A `batch` event in raw SSE format looks like this — note the `data` is a single line of JSON, no embedded newlines (per the SSE spec):

```
id: 0,1,106
event: batch
data: {"records":[{"seq_num":0,"timestamp":1712150400000,"body":"{\"data\":{\"type\":\"text-delta\",\"id\":\"msg_1\",\"delta\":\"pong\"},\"id\":\"abc\"}"}],"tail":{"seq_num":10,"timestamp":1712150400500}}

```

The `id:` line on the wire is a comma-separated triple internal to S2 (`startSeq,endSeq,byteOffset`) — **don't try to parse it**. Use `record.seq_num` from inside the `data` body instead (see [Resuming a stream](#resuming-a-stream)).

Decoded `data` payload:

```json theme={"theme":"css-variables"}
{
  "records": [
    {
      "seq_num": 0,
      "timestamp": 1712150400000,
      "body": "{\"data\":{\"type\":\"text-delta\",\"id\":\"msg_1\",\"delta\":\"pong\"},\"id\":\"abc\"}"
    }
  ],
  "tail": {
    "seq_num": 10,
    "timestamp": 1712150400500
  }
}
```

| Field                 | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `records[]`           | One or more records delivered in this batch, in arrival order.                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `records[].seq_num`   | Monotonic per-record cursor. Use the **last** one you successfully processed as your `Last-Event-ID` on resume.                                                                                                                                                                                                                                                                                                                                                                    |
| `records[].timestamp` | Unix ms when the record was written to S2.                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `records[].body`      | For data records: a JSON-encoded **string** wrapping `{ data: UIMessageChunk, id: string }`. For control records: an empty string (semantics live in `headers`). For S2 command records: opaque bytes. See [Records on session.out](#records-on-session-out).                                                                                                                                                                                                                      |
| `records[].headers`   | Optional `[name, value]` pairs. Empty for data records; a `trigger-control` entry for control records; a single empty-name `["", "<op>"]` entry for S2 command records.                                                                                                                                                                                                                                                                                                            |
| `tail.seq_num`        | Latest known tail of the S2 stream, useful for detecting how far behind the live edge you are. Track the **highest `seq_num` you have received**, counting every record in the batch (including the command records you skip, see below), not just the last application-visible one. When `highest received seq_num + 1 === tail.seq_num` you have drained the backlog and are caught up to the live edge. The same `tail` also rides on `ping` events. Skip if you don't need it. |
| `tail.timestamp`      | Timestamp of `tail.seq_num`.                                                                                                                                                                                                                                                                                                                                                                                                                                                       |

### Records on `session.out`

Three kinds of records can arrive on the wire. They all share the `batch` envelope above; you tell them apart by `headers`.

| Kind                       | `headers[0][0]`                 | `headers` carries                                                                                                                                               | `body`                                                   |
| -------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| **Data record**            | *empty array or non-empty name* | (currently none from the agent)                                                                                                                                 | JSON envelope `{"data": UIMessageChunk, "id": <partId>}` |
| **Trigger control record** | `"trigger-control"`             | `["trigger-control", <subtype>]` plus subtype-specific siblings (e.g. `["public-access-token", <jwt>]` and `["session-in-event-id", <seq>]` on `turn-complete`) | empty string                                             |
| **S2 command record**      | `""` (empty name)               | `["", "<op>"]` (currently `"trim"`)                                                                                                                             | opaque bytes — S2-interpreted                            |

**Uniform filter rule for custom readers:**

```ts theme={"theme":"css-variables"}
// Always advance the resume cursor — even for records you skip.
lastEventId = String(record.seq_num);

// S2 command record: bump cursor, don't dispatch.
if (record.headers?.[0]?.[0] === "") continue;

// Trigger control record: route by `trigger-control` value, don't
// dispatch as a UIMessageChunk.
const controlValue = record.headers?.find(([name]) => name === "trigger-control")?.[1];
if (controlValue === "turn-complete") {
  const token = record.headers.find(([name]) => name === "public-access-token")?.[1];
  // ...fire your turn-complete handler with the optional refreshed token...
  continue;
}
if (controlValue === "upgrade-required") {
  // ...your upgrade flow, if any. The server has already swapped the run
  // by the time this arrives — subsequent chunks are from the new run...
  continue;
}

// Otherwise: data record. Parse the body, dispatch the UIMessageChunk.
const { data: chunk } = JSON.parse(record.body);
```

Built-in SDK transports (`TriggerChatTransport`, `AgentChat`) handle all of this for you — control records surface via `onTurnComplete({ chatId, lastEventId, publicAccessToken })` and the upgrade flow. Custom transports need the routing above.

<Note>
  **Prior wire shape.** Earlier SDK versions emitted `trigger:turn-complete` and `trigger:upgrade-required` as `UIMessageChunk`-shaped data records with `chunk.type === "trigger:turn-complete"`. Current versions use the header-form control records described above. Built-in SDK transports handle the new shape transparently; custom transports filtering on `chunk.type` need to switch to the `trigger-control` header check.
</Note>

### Built-in parser (recommended for SDK users)

If you're working in TypeScript and depending on `@trigger.dev/core/v3` is acceptable, use `SSEStreamSubscription` — it handles batch decoding, deduplication, command-record filtering, and `Last-Event-ID` tracking for you:

```ts theme={"theme":"css-variables"}
import { SSEStreamSubscription, controlSubtype } from "@trigger.dev/core/v3";

const subscription = new SSEStreamSubscription(
  `${baseUrl}/realtime/v1/sessions/${sessionId}/out`,
  {
    headers: { Authorization: `Bearer ${publicAccessToken}` },
    timeoutInSeconds: 120,
    lastEventId,
  }
);

const stream = await subscription.subscribe();
const reader = stream.getReader();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  // value is { id, chunk, timestamp, headers }. S2 command records are
  // filtered out of this stream entirely (cursor still advances). Trigger
  // control records pass through with `chunk === undefined` and a
  // `trigger-control` header.
  const control = controlSubtype(value.headers);
  if (control === "turn-complete") break;
  if (control === "upgrade-required") continue;

  const chunk = value.chunk as { type?: string; delta?: string } | undefined;
  if (chunk?.type === "text-delta") process.stdout.write(chunk.delta ?? "");
}
```

### Self-contained parser (for custom transports)

If you're building a transport in another language or don't want the dependency, here's a complete reader. It handles the SSE framing, the comma-separated `id:` line, batch unwrapping, the inner `body` string, and `ping` / `[DONE]` events:

```ts theme={"theme":"css-variables"}
async function* readSessionOut(
  url: string,
  publicAccessToken: string,
  opts: { lastEventId?: string; timeoutSeconds?: number } = {}
) {
  const headers: Record<string, string> = {
    Authorization: `Bearer ${publicAccessToken}`,
    Accept: "text/event-stream",
  };
  if (opts.lastEventId) headers["Last-Event-ID"] = opts.lastEventId;
  if (opts.timeoutSeconds) headers["Timeout-Seconds"] = String(opts.timeoutSeconds);

  const res = await fetch(url, { headers });
  if (!res.ok || !res.body) throw new Error(`SSE failed: ${res.status}`);

  const decoder = new TextDecoder();
  const reader = res.body.getReader();
  let buf = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) return;
    buf += decoder.decode(value, { stream: true });

    // SSE events are separated by blank lines (CRLF or LF).
    const events = buf.split(/\r?\n\r?\n/);
    buf = events.pop() ?? ""; // last chunk is incomplete

    for (const raw of events) {
      let eventType = "message"; // SSE default
      const dataLines: string[] = [];
      for (const line of raw.split(/\r?\n/)) {
        if (line.startsWith("event:")) eventType = line.slice(6).trim();
        else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
        // We deliberately ignore `id:` — use record.seq_num for resume cursors.
      }
      const data = dataLines.join("\n");
      if (!data) continue;

      if (eventType === "ping") continue;
      if (data === "[DONE]") return;

      if (eventType === "batch") {
        const batch = JSON.parse(data) as {
          records: Array<{
            seq_num: number;
            timestamp: number;
            body: string;
            headers?: Array<[string, string]>;
          }>;
        };
        for (const record of batch.records) {
          const firstHeaderName = record.headers?.[0]?.[0];

          // S2 command record (trim/fence) — bump cursor, skip dispatch.
          if (firstHeaderName === "") {
            yield { seqNum: record.seq_num, timestamp: record.timestamp, kind: "command" };
            continue;
          }

          // Trigger control record (turn-complete, upgrade-required) —
          // semantics live in headers, body is empty. Route by header.
          const controlValue = record.headers?.find(([n]) => n === "trigger-control")?.[1];
          if (controlValue) {
            const token = record.headers?.find(([n]) => n === "public-access-token")?.[1];
            yield {
              seqNum: record.seq_num,
              timestamp: record.timestamp,
              kind: "control",
              subtype: controlValue,
              publicAccessToken: token,
            };
            continue;
          }

          // Data record — UIMessageChunk wrapped in `{ data, id }`.
          const inner = JSON.parse(record.body) as { data: unknown; id: string };
          yield {
            seqNum: record.seq_num, // use this for Last-Event-ID on resume
            timestamp: record.timestamp,
            kind: "data",
            chunk: inner.data, // the actual UIMessageChunk
          };
        }
      }
    }
  }
}
```

Driving it:

```ts theme={"theme":"css-variables"}
let lastSeq: string | undefined;
for await (const ev of readSessionOut(sseUrl, publicAccessToken)) {
  lastSeq = String(ev.seqNum);                       // always advance the cursor

  if (ev.kind === "command") continue;               // S2 trim/fence — skip
  if (ev.kind === "control") {
    if (ev.subtype === "turn-complete") break;       // turn done
    if (ev.subtype === "upgrade-required") continue; // run swap handled server-side
    continue;
  }

  // ev.kind === "data" — the UIMessageChunk
  const chunk = ev.chunk as { type: string; delta?: string };
  if (chunk.type === "text-delta") process.stdout.write(chunk.delta ?? "");
}
// On reconnect, pass `lastEventId: lastSeq` to resume from the next record.
```

### Chunk types

Data records on the stream carry a `UIMessageChunk` from the [AI SDK](https://ai-sdk.dev/docs/ai-sdk-ui/ui-message-stream). Two Trigger.dev-specific control events ride alongside as **header-form control records** (see [Records on session.out](#records-on-session-out)).

Within a single assistant turn the AI SDK chunk types you'll typically see, in order:

| Chunk type                                                       | Shape                                       | Notes                                                                                                                                                                       |
| ---------------------------------------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `start`                                                          | `{ type: "start", messageId: string }`      | First chunk of a new assistant message. **Persist `messageId`** — you'll need it to send tool-approval responses (see [Tool approval responses](#tool-approval-responses)). |
| `start-step`                                                     | `{ type: "start-step" }`                    | New `prepareStep` boundary.                                                                                                                                                 |
| `text-start` / `text-delta` / `text-end`                         | `{ type: ..., id: string, delta?: string }` | Streaming text. Concatenate `delta`s for the visible reply.                                                                                                                 |
| `tool-input-start` / `tool-input-delta` / `tool-input-available` | tool-call argument streaming                | The tool the model is calling.                                                                                                                                              |
| `tool-output-available`                                          | tool result                                 | After the agent runs the tool.                                                                                                                                              |
| `data-*`                                                         | `{ type: "data-<name>", data: ... }`        | Custom data parts written by the agent's hooks.                                                                                                                             |
| `finish-step` / `finish`                                         | end markers for the assistant message       | Followed by the `turn-complete` control record.                                                                                                                             |

Refer to the AI SDK docs linked above for the full union — only the two control records below are Trigger.dev-specific.

### `turn-complete` control record

Signals that the agent's turn is finished — stop reading and wait for user input.

```
headers:
  ["trigger-control", "turn-complete"]
  ["public-access-token", "eyJ..."]   // optional, refreshed JWT
  ["session-in-event-id", "42"]       // optional, send-correlation + resume cursor
body: ""
```

| Header                                  | Description                                                                                                                                                                                                                                                                                                                                    |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `trigger-control: turn-complete`        | Always present on this record.                                                                                                                                                                                                                                                                                                                 |
| `public-access-token: <jwt>` (optional) | A refreshed JWT with the same session + run scopes. If present, replace your stored token.                                                                                                                                                                                                                                                     |
| `session-in-event-id: <seq>` (optional) | The agent's committed `.in` cursor for this turn: the seq of the last user record it had consumed when the turn finished. Compare it to the `seq` from your `.in/append` to tell whether this turn-complete is *yours* (see the warning below). Also used internally to resume `.in` across worker boots without replaying processed messages. |

When you receive this record:

1. Update `publicAccessToken` if one is included on the headers.
2. Close the stream reader (unless you want to keep it open across turns — see [Resuming a stream](#resuming-a-stream)).
3. Wait for the next user message before sending on `.in`.

<Warning>
  **Correlate turn-completes to your send.** A `turn-complete` on `.out` can belong to an *earlier* turn than the message you just sent, for example a concurrent action (an undo) whose completion lands on the stream first. Only treat a `turn-complete` as terminal for your send if its `session-in-event-id` is greater than or equal to the `seq` returned by that send's `.in/append`; skip any lower one and keep reading. Closing on the wrong turn drops your response until the next reload. The built-in `TriggerChatTransport` does this correlation for you.
</Warning>

### `upgrade-required` control record

Signals that the agent cannot handle this message on its current version and a new run has been started. Emitted when the agent calls [`chat.requestUpgrade()`](/docs/ai-chat/patterns/version-upgrades).

```
headers:
  ["trigger-control", "upgrade-required"]
body: ""
```

The server has already swapped the run on the same session by the time this record is delivered. Subsequent records on the same SSE subscription come from the new run.

When you receive this record:

1. Treat it as informational — no client action required. The same SSE keeps streaming the new run's chunks on the same session.
2. Optionally surface a "switched to vN.N+1" indicator in your UI.

The built-in clients handle this transparently.

### Resuming a stream

If the SSE connection drops, reconnect with the `Last-Event-ID` header set to the **last `record.seq_num` you successfully processed** (decoded from the batch body — not the SSE `id:` line, which is a comma-list internal to S2):

```
GET /realtime/v1/sessions/{sessionId}/out
Authorization: Bearer <publicAccessToken>
Accept: text/event-stream
Last-Event-ID: 42
```

The server resumes streaming from `seq_num = 43` onward. `Last-Event-ID` is a single non-negative integer; passing the SSE `id:` line value verbatim (e.g. `0,1,106`) silently falls back to "start from the beginning."

`SSEStreamSubscription` tracks this automatically via its `lastEventId` option.

<Note>
  **What "resumable" means.** `session.out` is trimmed back to the previous `turn-complete` control record after each turn finishes. In practice:

  * **Resume across a single turn boundary always works** — your bookmark is the last turn's `turn-complete` record, which is still on the stream.
  * **The S2 trim is eventually consistent** (10-60s typical), so close-then-reload-quickly cases reliably still see records that are about to be trimmed.
  * **Resume across multiple turns of inactivity** may find your bookmark trimmed. The S2 read silently clamps forward to the first surviving record; the cleanest recovery is to fetch the latest snapshot and treat the SSE as fresh from there (or rehydrate via your own DB if you use `hydrateMessages`). See [How history is rebuilt](#how-history-is-rebuilt).
</Note>

### `X-Peek-Settled` / `X-Session-Settled` — opt-in fast close on idle reconnects

On **reconnect-on-reload** paths (resuming a chat where nothing may be streaming), send `X-Peek-Settled: 1` as a request header when opening the SSE. When present, the server peeks the tail of `.out` and walks past any trailing S2 trim command record to find the most recent data/control record underneath. If that record is a `turn-complete` control record (agent finished a turn and is idle-waiting or exited), the SSE:

* Uses `wait=0` internally — drains any residual records and closes in \~1s instead of long-polling for 60s.
* Sets the `X-Session-Settled: true` response header so the client can tell the close is terminal rather than a mid-stream drop.

**Do not send `X-Peek-Settled` on the active-send response-stream path.** The peek would race the newly-triggered turn's first chunk — if the agent hasn't written the new turn's first record yet, the peek sees the prior turn's `turn-complete` and closes the SSE before the response lands on S2. The built-in `TriggerChatTransport.reconnectToStream` sets the header; `sendMessages → subscribeToStream` does not.

If you use `TriggerChatTransport` (or `useChat`, which builds on it), this is handled for you: the reconnect path sends `X-Peek-Settled`, so a settled idle reconnect closes promptly on the server's `wait=0` fast-close instead of waiting out the long poll. Consuming `.out` directly, send the same header on your reconnect reads and treat an `X-Session-Settled: true` response as a terminal close.

```ts theme={"theme":"css-variables"}
// Reconnect path (page reload)
const response = await fetch(sseUrl, {
  headers: {
    Authorization: `Bearer ${publicAccessToken}`,
    "X-Peek-Settled": "1",
    "Last-Event-ID": lastEventId,
  },
});
const settled = response.headers.get("X-Session-Settled") === "true";
// ...subscribe as normal; if settled and nothing arrives, you're done.

// Active send path — no X-Peek-Settled, keep long-poll semantics
const liveResponse = await fetch(sseUrl, {
  headers: {
    Authorization: `Bearer ${publicAccessToken}`,
    "Last-Event-ID": lastEventId,
  },
});
```

## Step 3: Send messages, stops, and actions

All client-to-agent signals are appended to the session's `.in` channel:

```
POST /realtime/v1/sessions/{sessionId}/in/append
Authorization: Bearer <publicAccessToken>
Content-Type: application/json
```

`{sessionId}` accepts the same friendly-or-external forms as `.out`. The `publicAccessToken` from session-create authorizes both.

The body is a JSON-serialized [`ChatInputChunk`](#chatinputchunk), a tagged union covering messages, stops, and actions. Send them as raw JSON strings (not wrapped in a `data` field). On success the response is `200 OK` with body `{ "ok": true, "seq": <number> }`, where `seq` is the appended record's `.in` sequence number. Use it to correlate this send to the turn that consumes it (see [`turn-complete` control record](#turn-complete-control-record)). On failure it's `4xx`/`5xx` with `{ "ok": false, "error": "<message>" }`. Common failures:

| Status | When                                                                                                                                                                                                                                                                                                                                      |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401`  | Missing or invalid `Authorization` header.                                                                                                                                                                                                                                                                                                |
| `403`  | Token doesn't carry `write:sessions:{externalId}`.                                                                                                                                                                                                                                                                                        |
| `409`  | The session is closed — `{ "ok": false, "error": "Cannot append to a closed session" }`.                                                                                                                                                                                                                                                  |
| `413`  | Body exceeds 1 MiB **or** the wrapped record would exceed S2's \~1 MiB per-record metered ceiling. A normal `kind: "message"` payload is a few KB; if you hit this you're shipping more than one message per record or pushing a single tool output that's itself oversized. Carries CORS headers so browser fetches can read the status. |
| `500`  | Transient backend failure on the durable stream. Safe to retry — appends are idempotent on `(externalId, X-Part-Id)` if you set the optional `X-Part-Id` request header (the built-in clients set it from a UUID).                                                                                                                        |

<Warning>
  **Schema validation of `metadata` happens inside the agent, not at this endpoint.** A `kind: "message"` with bad or missing metadata returns `200 OK` here, but the agent rejects the turn at run time. From the wire the failure looks like a `turn-complete` control record with no preceding `text-delta` — i.e. an empty assistant response.

  **How to detect from the client:** treat "received `turn-complete` after sending a `submit-message` with no `text-delta`/`tool-input-*` chunks in between" as a schema-validation suspect, and surface a sensible error to your user. **How to confirm from the dashboard / Trigger MCP:** the run trace includes a `chat turn N [ERROR]` span followed by `waiting for next message (after error)`; the `[ERROR]` span carries the validation error message in its events. Use `mcp__trigger__get_run_details` (or open the run in the dashboard) on the run ID surfaced in the `runId` field of session-create.
</Warning>

### `ChatInputChunk`

```ts theme={"theme":"css-variables"}
type ChatInputChunk =
  | { kind: "message"; payload: ChatTaskWirePayload }
  | { kind: "stop"; message?: string };
```

The discriminator `kind` drives the agent's dispatch — `"message"` goes to the turn loop, `"stop"` fires the abort controller.

### `ChatTaskWirePayload`

```ts theme={"theme":"css-variables"}
type ChatTaskWirePayload<TMessage extends UIMessage = UIMessage, TMetadata = unknown> = {
  /**
   * The new message for this turn — at most ONE per record.
   *  - "submit-message": the new user message, OR a tool-approval-responded
   *    assistant message (with `state: "approval-responded"` tool parts).
   *  - "regenerate-message": omitted (the server trims its own tail).
   *  - "preload" / "close" / "action": omitted.
   *  - "handover-prepare": omitted (use `headStartMessages` instead — see below).
   */
  message?: TMessage;

  /**
   * Escape hatch for chat.headStart. Ships full UIMessage history on the
   * very first turn — before any snapshot exists. Used ONLY by
   * trigger: "handover-prepare" against the customer's own HTTP route
   * handler. The server ignores this field on any other trigger.
   */
  headStartMessages?: TMessage[];

  chatId: string;
  trigger:
    | "submit-message"
    | "regenerate-message"
    | "preload"
    | "close"
    | "action"
    | "handover-prepare";
  messageId?: string;
  /**
   * Wire envelope for the agent's typed `clientData` (declared via
   * `chat.withClientData({ schema })`). Whatever you put here is parsed
   * against that schema at the agent boundary. If the agent declares
   * `clientData: { userId: string }`, then `metadata.userId` is required.
   */
  metadata?: TMetadata;
  action?: unknown;
  /**
   * Informational — the server sets this automatically on continuation
   * runs (when the prior run is dead). Clients don't need to send it.
   * Read by the agent's boot gate to skip `onChatStart` and trigger
   * snapshot read + replay.
   */
  continuation?: boolean;
  /**
   * Informational — paired with `continuation: true`, set by the server
   * from the prior run's friendly ID. Surfaced to the agent in
   * `ctx.previousRunId`. Clients don't need to send it.
   */
  previousRunId?: string;
  idleTimeoutInSeconds?: number;
  sessionId?: string;
};
```

<Note>
  **`metadata` is the wire envelope for `clientData`.** The agent's `clientData` (typed via `chat.withClientData({ schema })`) is read from this field at run boot. If the agent declares e.g. `{ userId: string, model?: string }`, then every `kind: "message"` payload — and the `triggerConfig.basePayload` you sent at session create — must carry a matching `metadata.userId`. The agent rejects messages whose metadata fails schema validation.
</Note>

### Sending a message

```
POST /realtime/v1/sessions/{sessionId}/in/append
Authorization: Bearer <publicAccessToken>
Content-Type: application/json

{
  "kind": "message",
  "payload": {
    "message": {
      "id": "msg-2",
      "role": "user",
      "parts": [{ "type": "text", "text": "Tell me more" }]
    },
    "chatId": "conversation-123",
    "trigger": "submit-message",
    "metadata": { "userId": "user-456" }
  }
}
```

After sending, subscribe to `.out` (if you closed the stream after the previous turn's `turn-complete`) to receive the response.

<Note>
  Send only the **new** user message — never the full history. The agent rebuilds prior history from a durable S3 snapshot plus a `session.out` replay at run boot. See [How history is rebuilt](#how-history-is-rebuilt).
</Note>

### Sending a stop

```json theme={"theme":"css-variables"}
{ "kind": "stop" }
```

Interrupts the agent's current turn. `streamText` aborts, the agent emits a `turn-complete` control record, and the run returns to idle.

An optional `message` field surfaces in the agent's stop handler:

```json theme={"theme":"css-variables"}
{ "kind": "stop", "message": "user cancelled" }
```

### Sending an action

Custom actions (undo, rollback, edit) ride on the same `.in` channel using `kind: "message"` with `trigger: "action"` in the payload. Omit `message` — actions don't carry a UIMessage:

```json theme={"theme":"css-variables"}
{
  "kind": "message",
  "payload": {
    "chatId": "conversation-123",
    "trigger": "action",
    "action": { "type": "undo" },
    "metadata": { "userId": "user-456" }
  }
}
```

Actions wake the agent from suspension (same as messages) and fire the `onAction` hook — they are not turns, so `run()` and turn lifecycle hooks do not fire. If `onAction` returns a `StreamTextResult`, the response is auto-piped to the frontend (but still no `run()` or `onTurnComplete`). The `action` payload is validated against the agent's `actionSchema`. If the agent didn't register an `actionSchema` (or your `action` payload doesn't match it), validation fails the same way `metadata` does — `.in/append` returns `200 OK`, but the run trace shows `chat turn N [ERROR]` and the wire emits a `turn-complete` control record with no other chunks. See [Actions](/docs/ai-chat/actions) for the agent-side schema setup.

### Regenerating the last response

To regenerate the assistant's last response, send `trigger: "regenerate-message"` with no `message`:

```json theme={"theme":"css-variables"}
{
  "kind": "message",
  "payload": {
    "chatId": "conversation-123",
    "trigger": "regenerate-message",
    "metadata": { "userId": "user-456" }
  }
}
```

The agent trims trailing assistant messages from its accumulator and re-streams from the prior user turn. The frontend's `useChat()` already removed the trailing assistant locally — the wire signal tells the agent to do the same.

### Tool approval responses

When a tool requires approval (`needsApproval: true`), the agent streams the tool call with an `approval-requested` state and completes the turn. After the user approves or denies, send the **updated assistant message** back as a `kind: "message"` chunk — singular, not the full chain. The minimum shape the agent reads is just the resolved tool parts:

```json theme={"theme":"css-variables"}
{
  "kind": "message",
  "payload": {
    "message": {
      "id": "asst-msg-1",
      "role": "assistant",
      "parts": [
        {
          "type": "tool-sendEmail",
          "toolCallId": "call-1",
          "state": "approval-responded",
          "approval": { "id": "approval-1", "approved": true }
        }
      ]
    },
    "chatId": "conversation-123",
    "trigger": "submit-message",
    "metadata": { "userId": "user-456" }
  }
}
```

The agent matches the incoming message by `id` against the rebuilt accumulator (or hydrated chain) and **overlays the tool-state advance** onto the matching entry — `state` plus `output` / `errorText` / `approval`, depending on the new state. Hydrated `input`, text, reasoning, and provider metadata stay put. This is what makes the slim shape above sufficient: the agent rebuilds everything else from the snapshot or from your `hydrateMessages` hook.

The same shape applies to HITL `addToolOutput` answers — substitute `state: "output-available"` and `output: <result>` for the approval pair above. Single-tool HITL `addToolOutput` continuation payloads are typically \~1 KiB on the wire.

The built-in transports (`TriggerChatTransport`, `AgentChat`) ship the slim shape by default on `submit-message` continuations. Custom transports can ship a fuller `UIMessage` — the agent still only reads the resolved tool-part fields — but the slim shape is the most efficient and avoids brushing the per-record cap on reasoning-heavy turns.

<Note>
  The message `id` must match the one the agent assigned during streaming. `TriggerChatTransport` keeps IDs in sync automatically. Custom transports should use the `messageId` from the stream's `start` chunk.
</Note>

## How history is rebuilt

The agent rebuilds the full conversation accumulator on every fresh run boot. There are two reconstruction paths, and the agent picks based on what hooks the customer registered:

### Path A — `hydrateMessages` registered

If the agent declares a [`hydrateMessages`](/docs/ai-chat/lifecycle-hooks#hydratemessages) hook, the runtime trusts the customer to be the source of truth for history. Snapshot read and replay are **skipped entirely** at boot. The hook fires per turn — `incomingMessages` is 0-or-1-length consistently (since each record carries at most one new message) — and returns the canonical chain from the customer's database.

### Path B — Snapshot + replay (default)

When `hydrateMessages` is not registered, the runtime reconstructs history from durable infrastructure on every run boot:

<Steps>
  <Step title="Read the latest snapshot">
    The runtime fetches a per-session JSON snapshot from object storage (S3 or compatible). The snapshot stores `{ messages, lastOutEventId, lastOutTimestamp, savedAt }` — what was true at the moment the previous turn finished. A 404 (no snapshot yet) is fine — treated as empty.
  </Step>

  <Step title="Replay session.out tail">
    The runtime subscribes to `session.out` with `wait=0` starting from the snapshot's `lastOutEventId` (or seq 0 if there is no snapshot). Any chunks since that cursor are fed through the AI SDK's `processUIMessageStream` reducer to materialize fresh `UIMessage[]`. This catches turns whose snapshot write didn't make it before a crash.
  </Step>

  <Step title="Merge by id, replay wins">
    Snapshot messages and replayed messages are merged by `id`. On collision, replay wins — `session.out` is the freshest representation of any assistant message. Partial trailing assistant work from a crashed turn is cleaned up via `cleanupAbortedParts`.
  </Step>

  <Step title="Write a fresh snapshot after every turn">
    When `onTurnComplete` fires, the runtime serializes the accumulator and writes it back to object storage. The write is **awaited** — the run may suspend immediately after, and fire-and-forget would lose the snapshot.
  </Step>
</Steps>

Object-store configuration is the same as the rest of Trigger.dev — set `OBJECT_STORE_*` env vars. With no object store configured and no `hydrateMessages` hook, conversations don't survive run boundaries; the runtime logs a warning at registration time.

For a deeper walkthrough of the snapshot model, including OOM-retry interaction and crash semantics, see [Persistence and replay](/docs/ai-chat/patterns/persistence-and-replay).

## Head-start protocol caveat

The [`chat.headStart`](/docs/ai-chat/fast-starts#head-start) flow runs the first turn's LLM call inside the customer's own HTTP route handler, then hands the durable stream off to the agent for tool execution and step 2+. On that first-ever turn no snapshot exists yet — the agent boots empty.

To bridge that gap, the head-start route handler ships **full UIMessage history** through the dedicated `headStartMessages` field with `trigger: "handover-prepare"`. This is the **only** path where a wire-shipped UIMessage\[] still seeds the agent's accumulator:

```json theme={"theme":"css-variables"}
{
  "kind": "message",
  "payload": {
    "headStartMessages": [
      { "id": "u1", "role": "user", "parts": [/* ... */] },
      { "id": "a1", "role": "assistant", "parts": [/* ... */] }
    ],
    "chatId": "conversation-123",
    "trigger": "handover-prepare",
    "metadata": { "userId": "user-456" }
  }
}
```

Two reasons this exception is safe:

1. **The route handler runs against the customer's own HTTP endpoint**, not `/realtime/v1/sessions/{id}/in/append`. The per-record cap on the realtime route doesn't apply.
2. **`headStartMessages` is only honored on `trigger: "handover-prepare"`**. The runtime ignores the field on every other trigger — the one-message-per-record rule still holds for normal turns.

After turn 1 completes, the snapshot is written and turn 2+ run as a normal single-message-per-record chat.

## Pending and steering messages

You can send messages while the agent is still streaming a response. These are **pending messages** — the agent receives them mid-turn and can inject them between tool-call steps.

The wire format is identical to a normal `kind: "message"` send — same `.in` channel, single `message` field. The difference is timing. What happens depends on the agent's `pendingMessages` configuration:

* **With `pendingMessages.shouldInject`**: the message is injected into the model's context at the next `prepareStep` boundary. The agent sees it and can adjust its behavior mid-response.
* **Without `pendingMessages` config**: the message queues for the next turn.

See [Pending Messages](/docs/ai-chat/pending-messages) for how to configure the agent side.

<Note>
  Unlike a normal `sendMessage`, pending messages should **not** cancel the active stream subscription. Keep reading — the agent incorporates the message into the same turn or queues it for the next one.
</Note>

## Continuations

A run can end for several reasons: idle timeout, max turns reached, `chat.requestUpgrade()`, crash, or cancellation. When this happens, the session row stays alive — only the run is gone. The next message you append to `.in` automatically triggers a fresh run on the same session.

**Clients send the wire shape exactly as a normal `submit-message`** — the server detects the absent run and handles the continuation itself:

```json theme={"theme":"css-variables"}
{
  "kind": "message",
  "payload": {
    "message": {
      "id": "u-42",
      "role": "user",
      "parts": [{ "type": "text", "text": "Where were we?" }]
    },
    "chatId": "conversation-123",
    "trigger": "submit-message",
    "metadata": { "userId": "user-456" }
  }
}
```

POST to the same `/realtime/v1/sessions/{sessionId}/in/append` URL with the same `publicAccessToken` you've been using — both stay valid across runs. The server detects the absent run, triggers a new one on the session's `triggerConfig`, and the agent boots, reads the snapshot from the prior run's last turn, replays any tail, and continues. Only `runId` changes — the new run's id is encoded in the next refreshed `publicAccessToken`'s `read:runs:{runId}` scope.

<Note>
  **You don't need to track `runId` or set `continuation: true` / `previousRunId` yourself.** The server detects continuation when the prior run is in a terminal state and sets those fields on the new run's boot payload automatically. The `continuation` and `previousRunId` fields on `ChatTaskWirePayload` are informational — used internally by the agent's boot path, never required from the client.
</Note>

<Note>
  **`onChatStart` does NOT fire on continuation runs.** The hook is once-per-chat — it fires only on the chat's very first user message. Customers who want per-turn setup that also runs on continuation turns should use `onTurnStart` instead.
</Note>

<Tip>
  This is how [version upgrades](/docs/ai-chat/patterns/version-upgrades) work transparently — the agent calls `chat.requestUpgrade()`, the run exits, and the client's next message triggers a continuation on the new version. Same session, new run, same snapshot.
</Tip>

## Closing the conversation

When the user is done with the conversation, close the session:

```bash theme={"theme":"css-variables"}
POST /api/v1/sessions/{sessionId}/close
Authorization: Bearer <secret-key-or-jwt>
Content-Type: application/json

{ "reason": "user-ended" }
```

The body is optional — `{}` (or no body at all) closes the session with no reason set. If provided, `reason` is a free-form string up to 256 characters used for dashboard / audit display. Closing is **idempotent**: re-calling on an already-closed session returns the existing row without clobbering the original `closedAt` / `closedReason`.

A long-running chat that's just between turns is a **live** session, not a closed one — don't close it prematurely. Once closed, the session cannot be reopened; reuse a different `externalId` if the user wants to start fresh.

## Session state

A client needs to track per-conversation:

| Field               | Description                                                                                                                         |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `sessionId`         | Durable session ID (`session_*`). Stable for the life of the conversation.                                                          |
| `chatId`            | Your stable conversation ID (passed as `externalId` on create).                                                                     |
| `runId`             | Current run ID. Changes when a run ends and a continuation starts. Only needed if you want to display it.                           |
| `publicAccessToken` | JWT for session access. Stable across runs; refreshed via the `public-access-token` header on every `turn-complete` control record. |
| `lastEventId`       | Last `record.seq_num` received on `.out`. Use to resume mid-stream.                                                                 |

`sessionId`, `chatId`, and `publicAccessToken` are durable. `runId` is live-run state that refreshes on each new run. On reload, you only need `sessionId` + `publicAccessToken` + `lastEventId` to resume — `runId` is a hint that can be `null` when no run is active.

## Authentication

| Operation                                          | Auth                                                                                                    |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Create session (`POST /api/v1/sessions`)           | Secret API key, or JWT with `write:sessions` super-scope plus a matching `tasks:{taskIdentifier}` scope |
| Close session (`POST /api/v1/sessions/{id}/close`) | Secret API key, or JWT with `admin:sessions:{id}` / `admin:sessions` super-scope                        |
| `.in` append                                       | The session's `publicAccessToken` (carries `write:sessions:{id}`)                                       |
| `.out` subscribe                                   | The session's `publicAccessToken` (carries `read:sessions:{id}`)                                        |

The `publicAccessToken` returned in the body of `POST /api/v1/sessions` carries both `read:sessions:{externalId}` and `write:sessions:{externalId}` and is **the only token you need** for every `.in`/`.out` operation thereafter. A token minted on the externalId form authorizes both the externalId and the friendlyId URL forms on every read and write route, so use whichever URL form your client already has on hand.

<Warning>
  **Don't use the `x-trigger-jwt` header from `POST /api/v1/tasks/{taskId}/trigger`.** That header carries `read:runs:{runId}` + `write:inputStreams:{runId}` — run-scoped scopes, not session-scoped. It cannot subscribe to `.out` or append to `.in`. Always use the `publicAccessToken` from the session-create response body.
</Warning>

## FAQ

<Expandable title="After sending `kind: stop`, can I immediately send the next message?">
  Yes. `.in` records are processed in arrival order — the agent's stop handler aborts the in-flight `streamText`, emits a `turn-complete` control record, and reads the next record. You don't have to wait for `turn-complete` on the wire before posting the next `.in/append`. In practice you usually do anyway, because your UI is gated on the stream coming back to ready.
</Expandable>

<Expandable title="What's the format of the optional `X-Part-Id` header?">
  Any opaque ASCII string up to \~64 characters. The built-in clients generate a high-entropy id per logical send (a UUID in the browser, a `nanoid` server-side) and reuse it across auth retries of that send. The server uses it as a per-record idempotency key — re-POSTing the same body with the same `X-Part-Id` produces a single S2 record. If you don't send the header, the server generates one for you and idempotency is per-request only.
</Expandable>

<Expandable title="What happens on rate-limit (429)?">
  The `.in/append` route returns standard rate-limit response headers (`x-ratelimit-limit`, `x-ratelimit-remaining`, `x-ratelimit-reset` — Unix ms epoch when the bucket refills). On `429`, back off until `x-ratelimit-reset` and retry with the same `X-Part-Id` to remain idempotent. Default per-environment limits are generous (millions of requests/window); you'll typically only hit this with runaway client loops.
</Expandable>

<Expandable title="How do I tell from the `.out` stream that a run has ended (vs idled between turns)?">
  You don't need to. There's no `trigger:run-ended` chunk. The protocol is designed so the client doesn't track run lifecycle:

  * A `turn-complete` control record means **the turn finished**, not that the run is gone. The run may still be alive, idle-waiting for the next `.in` record, or it may have suspended / exited shortly after.
  * When you POST the next message to `.in/append`, the server figures out whether the existing run can pick it up or whether to spawn a continuation. Either way you get streamed responses on the same `.out` URL.

  If you genuinely need the live `runId` (for displaying the dashboard link, say), read it from the latest `turn-complete` control record's refreshed `public-access-token` header — the JWT's `read:runs:{runId}` scope encodes it. Or call `GET /api/v1/sessions/{sessionId}` (omitted from this page; see the Sessions API reference) to read `currentRunId`.
</Expandable>

<Expandable title="Does the `seq_num` reset across continuations or runs?">
  No. `seq_num` is monotonic across the entire session — turn 1 might emit seq 0–9, turn 2 picks up at seq 10+, and a continuation run on the same session continues numbering from where the prior run left off. A single `Last-Event-ID` cursor is sufficient to resume across turns and runs.
</Expandable>

<Expandable title="What's the maximum size of a single `.in/append` body?">
  The HTTP body is capped at 1 MiB as a DoS guard. The actual ceiling is at the storage layer: each `.in/append` becomes a single S2 record, metered as `8 + body_bytes_after_JSON_wrap`, capped at 1 MiB. So the practical limit on the raw HTTP body sits around \~1023 KiB for content with low JSON-escape overhead (ASCII, base64) and \~512 KiB for content that escapes heavily (all quotes / backslashes). A typical `kind: "message"` is a few KiB. If you're brushing the cap you're either shipping a single tool output that's itself oversized — see [Large payloads](/docs/ai-chat/patterns/large-payloads) — or you're shipping more than one message per record, which the protocol forbids. The 413 response carries CORS headers so browser fetches can read the status. The headStart path (`trigger: "handover-prepare"`) sends through the customer's own HTTP route handler, not `.in/append`, so the cap doesn't apply there.
</Expandable>

## See also

* [`TriggerChatTransport`](/docs/ai-chat/frontend) — Built-in browser transport (implements this protocol)
* [`AgentChat`](/docs/ai-chat/server-chat) — Built-in server-side client
* [Persistence and replay](/docs/ai-chat/patterns/persistence-and-replay) — How the snapshot + replay model works end-to-end
* [Lifecycle hooks](/docs/ai-chat/lifecycle-hooks) — What the agent does on each event
* [Version upgrades](/docs/ai-chat/patterns/version-upgrades) — How `chat.requestUpgrade()` uses continuations


# Compaction
Source: https://trigger.dev/docs/ai-chat/compaction

Automatic context compaction to keep long conversations within token limits.

## Overview

Long conversations accumulate tokens across turns. Eventually the context window fills up, causing errors or degraded responses. Compaction solves this by automatically summarizing the conversation when token usage exceeds a threshold, then using that summary as the context for future turns.

The `compaction` option on `chat.agent()` handles this in both paths:

* **Between tool-call steps** (inner loop) — via the AI SDK's `prepareStep`, compaction runs between tool calls within a single turn
* **Between turns** (outer loop) — for single-step responses with no tool calls, where `prepareStep` never fires

## Basic usage

Provide `shouldCompact` to decide when to compact and `summarize` to generate the summary:

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, generateText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

export const myChat = chat.agent({
  id: "my-chat",
  compaction: {
    shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000,
    summarize: async ({ messages }) => {
      const result = await generateText({
        model: anthropic("claude-haiku-4-5"),
        messages: [...messages, { role: "user", content: "Summarize this conversation concisely." }],
      });
      return result.text;
    },
  },
  run: async ({ messages, signal }) => {
    return streamText({
      ...chat.toStreamTextOptions({ registry }),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

<Note>
  The `prepareStep` for inner-loop compaction is automatically injected when you spread `chat.toStreamTextOptions()` into your `streamText` call. If you provide your own `prepareStep` after the spread, it overrides the auto-injected one.
</Note>

## How it works

After each turn completes:

1. `shouldCompact` is called with the current token usage
2. If it returns `true`, `summarize` generates a summary from the model messages
3. The **model messages** (sent to the LLM) are replaced with the summary
4. The **UI messages** (persisted and displayed) are preserved by default
5. The `onCompacted` hook fires if configured

On the next turn, the LLM receives the compact summary instead of the full history — dramatically reducing token usage while preserving context.

## Customizing what gets persisted

By default, compaction only affects model messages — UI messages stay intact so users see the full conversation after a page refresh. You can customize this with `compactUIMessages`:

### Summary + recent messages

Replace older messages with a summary but keep the last few exchanges visible:

```ts theme={"theme":"css-variables"}
import { generateId } from "ai";

export const myChat = chat.agent({
  id: "my-chat",
  compaction: {
    shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000,
    summarize: async ({ messages }) => {
      return generateText({
        model: anthropic("claude-haiku-4-5"),
        messages: [...messages, { role: "user", content: "Summarize." }],
      }).then((r) => r.text);
    },
    compactUIMessages: ({ uiMessages, summary }) => [
      {
        id: generateId(),
        role: "assistant",
        parts: [{ type: "text", text: `[Conversation summary]\n\n${summary}` }],
      },
      ...uiMessages.slice(-4), // Keep the last 4 messages
    ],
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

### Flatten to summary only

Replace all messages with just the summary (like the LLM sees):

```ts theme={"theme":"css-variables"}
compactUIMessages: ({ summary }) => [
  {
    id: generateId(),
    role: "assistant",
    parts: [{ type: "text", text: `[Conversation summary]\n\n${summary}` }],
  },
],
```

## Customizing model messages

By default, model messages are replaced with a single summary message. Use `compactModelMessages` to customize what the LLM sees after compaction:

### Summary + recent context

Keep the last few model messages so the LLM has recent detail alongside the summary:

```ts theme={"theme":"css-variables"}
compactModelMessages: ({ modelMessages, summary }) => [
  { role: "user", content: summary },
  ...modelMessages.slice(-2), // Keep last exchange for detail
],
```

### Keep tool results

Preserve tool-call results so the LLM remembers what tools returned:

```ts theme={"theme":"css-variables"}
compactModelMessages: ({ modelMessages, summary }) => [
  { role: "user", content: summary },
  ...modelMessages.filter((m) => m.role === "tool"),
],
```

## shouldCompact event

The `shouldCompact` callback receives context about the current state:

| Field          | Type                  | Description                                    |
| -------------- | --------------------- | ---------------------------------------------- |
| `messages`     | `ModelMessage[]`      | Current model messages                         |
| `totalTokens`  | `number \| undefined` | Total tokens from the triggering step/turn     |
| `inputTokens`  | `number \| undefined` | Input tokens                                   |
| `outputTokens` | `number \| undefined` | Output tokens                                  |
| `usage`        | `LanguageModelUsage`  | Full usage object                              |
| `totalUsage`   | `LanguageModelUsage`  | Cumulative usage across all turns              |
| `chatId`       | `string`              | Chat session ID                                |
| `turn`         | `number`              | Current turn (0-indexed)                       |
| `clientData`   | `unknown`             | Custom data from the frontend                  |
| `source`       | `"inner" \| "outer"`  | Whether this is between steps or between turns |
| `steps`        | `CompactionStep[]`    | Steps array (inner loop only)                  |
| `stepNumber`   | `number`              | Step index (inner loop only)                   |

## summarize event

The `summarize` callback receives similar context:

| Field        | Type                 | Description                         |
| ------------ | -------------------- | ----------------------------------- |
| `messages`   | `ModelMessage[]`     | Messages to summarize               |
| `usage`      | `LanguageModelUsage` | Usage from the triggering step/turn |
| `totalUsage` | `LanguageModelUsage` | Cumulative usage                    |
| `chatId`     | `string`             | Chat session ID                     |
| `turn`       | `number`             | Current turn                        |
| `clientData` | `unknown`            | Custom data from the frontend       |
| `source`     | `"inner" \| "outer"` | Where compaction is running         |
| `stepNumber` | `number`             | Step index (inner loop only)        |

## onCompacted hook

Track compaction events for logging, billing, or analytics:

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  compaction: { ... },
  onCompacted: async ({ summary, totalTokens, messageCount, chatId, turn }) => {
    logger.info("Compacted", { chatId, turn, totalTokens, messageCount });
    await db.compactionLog.create({
      data: { chatId, summary, totalTokens, messageCount },
    });
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

## User-initiated compaction

Sometimes you want the user to decide when to compact — a "Summarize conversation" button, a `/compact` slash command, or a settings toggle. Wire this up with [actions](/docs/ai-chat/actions): the frontend sends a typed action, `onAction` runs the summary, and `chat.history.set()` replaces the conversation.

### Backend

Define a `compact` action that reuses your existing `summarize` function:

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, generateText, generateId, convertToModelMessages } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

// Reusable summarize fn — also used by the automatic compaction config.
async function summarize(messages: ModelMessage[]) {
  const result = await generateText({
    model: anthropic("claude-haiku-4-5"),
    messages: [...messages, { role: "user", content: "Summarize this conversation concisely." }],
  });
  return result.text;
}

export const myChat = chat.agent({
  id: "my-chat",

  // Automatic compaction still runs on threshold.
  compaction: {
    shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000,
    summarize: async ({ messages }) => summarize(messages),
  },

  // User-initiated: the frontend sends { type: "compact" }.
  actionSchema: z.discriminatedUnion("type", [
    z.object({ type: z.literal("compact") }),
  ]),

  onAction: async ({ action, uiMessages }) => {
    if (action.type !== "compact") return;

    const summary = await summarize(convertToModelMessages(uiMessages));

    // Replace the full history with a single summary message.
    chat.history.set([
      {
        id: generateId(),
        role: "assistant",
        parts: [{ type: "text", text: `[Conversation summary]\n\n${summary}` }],
      },
    ]);
  },

  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

Actions fire `onAction` only (plus `hydrateMessages` if set) — `run()` and `onTurnComplete` do not fire for actions. Persist the compacted state directly inside `onAction` after the `chat.history.set` call. See [Actions](/docs/ai-chat/actions) for the full lifecycle.

### Frontend

Call `transport.sendAction()` from a button or slash command:

```tsx theme={"theme":"css-variables"}
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useChat } from "@ai-sdk/react";

function ChatView({ chatId }: { chatId: string }) {
  const transport = useTriggerChatTransport({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  });
  const { messages } = useChat({ id: chatId, transport });

  return (
    <>
      <button onClick={() => transport.sendAction(chatId, { type: "compact" })}>
        Summarize conversation
      </button>
      {messages.map(/* ... */)}
    </>
  );
}
```

The call returns as soon as the backend accepts the action. Because `onTurnComplete` replaces the `uiMessages` with the summary, `useChat` receives the new state via the normal turn-complete flow — the UI updates automatically.

### Indicating compaction in the UI

For "Compacting..." feedback while the summary generates, append a transient data part from `onAction` via `chat.stream.append()`:

```ts theme={"theme":"css-variables"}
onAction: async ({ action, uiMessages }) => {
  if (action.type !== "compact") return;

  chat.stream.append({ type: "data-compaction", data: { status: "compacting" } });
  const summary = await summarize(convertToModelMessages(uiMessages));
  chat.stream.append({ type: "data-compaction", data: { status: "complete" } });

  chat.history.set([ /* ... */ ]);
},
```

See [Raw streaming with `chat.stream`](/docs/ai-chat/backend#raw-streaming-with-chat-stream) for the full API.

## Using with chat.createSession()

Pass the same `compaction` config to `chat.createSession()`. The session handles outer-loop compaction automatically inside `turn.complete()`:

```ts theme={"theme":"css-variables"}
const session = chat.createSession(payload, {
  signal,
  idleTimeoutInSeconds: 60,
  timeout: "1h",
  compaction: {
    shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000,
    summarize: async ({ messages }) =>
      generateText({ model: anthropic("claude-haiku-4-5"), messages }).then((r) => r.text),
    compactUIMessages: ({ uiMessages, summary }) => [
      { id: generateId(), role: "assistant",
        parts: [{ type: "text", text: `[Summary]\n\n${summary}` }] },
      ...uiMessages.slice(-4),
    ],
  },
});

for await (const turn of session) {
  const result = streamText({
    model: anthropic("claude-sonnet-4-5"),
    messages: turn.messages,
    abortSignal: turn.signal,
    stopWhen: stepCountIs(15),
  });

  await turn.complete(result);
  // Outer-loop compaction runs automatically after complete()

  await db.chat.update({
    where: { id: turn.chatId },
    data: { messages: turn.uiMessages },
  });
}
```

## Using with raw tasks (MessageAccumulator)

Pass `compaction` to the `MessageAccumulator` constructor. Use `prepareStep()` for inner-loop compaction and `compactIfNeeded()` for the outer loop:

```ts theme={"theme":"css-variables"}
const conversation = new chat.MessageAccumulator({
  compaction: {
    shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000,
    summarize: async ({ messages }) =>
      generateText({ model: anthropic("claude-haiku-4-5"), messages }).then((r) => r.text),
    compactUIMessages: ({ summary }) => [
      { id: generateId(), role: "assistant",
        parts: [{ type: "text", text: `[Summary]\n\n${summary}` }] },
    ],
  },
});

for (let turn = 0; turn < 100; turn++) {
  const messages = await conversation.addIncoming(payload.messages, payload.trigger, turn);

  const result = streamText({
    model: anthropic("claude-sonnet-4-5"),
    messages,
    prepareStep: conversation.prepareStep(), // Inner-loop compaction
    stopWhen: stepCountIs(15),
  });

  const { message } = await chat.pipeAndCapture(result);
  if (message) await conversation.addResponse(message);

  // Outer-loop compaction
  const usage = await result.totalUsage;
  await conversation.compactIfNeeded(usage, { chatId: payload.chatId, turn });

  await db.chat.update({ data: { messages: conversation.uiMessages } });
  await chat.writeTurnComplete();
}
```

## Fully manual compaction

For maximum control, use `chat.compact()` directly inside a custom `prepareStep`:

```ts theme={"theme":"css-variables"}
prepareStep: async ({ messages: stepMessages, steps }) => {
  const result = await chat.compact(stepMessages, steps, {
    threshold: 80_000,
    summarize: async (msgs) =>
      generateText({ model: anthropic("claude-haiku-4-5"), messages: msgs }).then((r) => r.text),
  });
  return result.type === "skipped" ? undefined : result;
},
```

Or use the `chat.compactionStep()` factory:

```ts theme={"theme":"css-variables"}
prepareStep: chat.compactionStep({
  threshold: 80_000,
  summarize: async (msgs) =>
    generateText({ model: anthropic("claude-haiku-4-5"), messages: msgs }).then((r) => r.text),
}),
```

<Note>
  The fully manual APIs only handle inner-loop compaction (between tool-call steps). For outer-loop coverage, use the `compaction` option on `chat.agent()`, `chat.createSession()`, or `MessageAccumulator`.
</Note>


# Custom agents
Source: https://trigger.dev/docs/ai-chat/custom-agents

Build chat agents without chat.agent()'s managed lifecycle: register with chat.customAgent(), then drive turns with the createSession iterator or a hand-rolled loop.

**A custom agent is a task you register with `chat.customAgent()` and drive yourself — either with the managed turn iterator from `chat.createSession()`, or with a fully hand-rolled loop over the raw chat primitives.** You give up `chat.agent()`'s lifecycle hooks and automatic continuation recovery; you gain inline control over every turn, and (at the lowest level) full control over the stream conversion.

See the [comparison table](/docs/ai-chat/backend) before dropping down. The frontend is unchanged either way: all levels speak the same wire protocol, so [`useTriggerChatTransport`](/docs/ai-chat/frontend) points at a custom agent exactly like a `chat.agent()`.

## chat.customAgent()

`chat.customAgent()` is a thin wrapper around `task()` that does two things: it registers the task as an agent (so it appears in the agent dashboard, the playground, and the MCP server's `list_agents`), and it binds the run to its backing [Session](/docs/ai-chat/sessions) so the `chat.*` primitives resolve to the right `.in`/`.out` channels. There is no managed lifecycle — no turn loop, no hooks, no preload handling.

A plain `task()` works with the same primitives but stays invisible to the agent surfaces, so prefer `customAgent` unless you specifically don't want the task listed as an agent.

Inside the wrapper, pick one of two loop styles:

* **[Managed loop](#managed-loop-chatcreatesession)** — `chat.createSession()` yields turns; the SDK handles stop signals, accumulation, idle suspend/resume, and turn-complete signaling. You write the turn body.
* **[Hand-rolled loop](#hand-rolled-loop-with-primitives)** — you write the loop itself with `chat.messages`, `MessageAccumulator`, `pipeAndCapture`, and `writeTurnComplete`. The right choice when you need complete control over `.toUIMessageStream()` (e.g. `onFinish`, `originalMessages`) beyond what `chat.setUIMessageStreamOptions()` provides, or you're implementing a custom protocol.

## Managed loop: chat.createSession()

`chat.createSession()` gives you an async iterator of `ChatTurn` objects. Each turn arrives with the accumulated history, a combined stop+cancel signal, and helpers to finish the turn:

```ts trigger/my-chat.ts theme={"theme":"css-variables"}
import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

export const myChat = chat.customAgent({
  id: "my-chat",
  run: async (payload: ChatTaskWirePayload, { signal }) => {
    // One-time initialization — plain code, no hooks. Upsert, not create:
    // continuation runs boot with the row already in place.
    const clientData = payload.metadata as { userId: string };
    await db.chat.upsert({
      where: { id: payload.chatId },
      create: { id: payload.chatId, userId: clientData.userId },
      update: {},
    });

    const session = chat.createSession(payload, {
      signal,
      idleTimeoutInSeconds: 60,
      timeout: "1h",
    });

    for await (const turn of session) {
      // Persist the incoming user message BEFORE streaming — this is your
      // onTurnStart equivalent. Without it, a page reload mid-stream
      // restores the assistant text (replayed from the session) but loses
      // the user message that prompted it.
      await db.chat.update({
        where: { id: turn.chatId },
        data: { messages: turn.uiMessages },
      });

      const result = streamText({
        model: anthropic("claude-sonnet-4-5"),
        messages: turn.messages,
        abortSignal: turn.signal,
        stopWhen: stepCountIs(15),
      });

      // Pipe, capture, accumulate, and signal turn-complete — all in one call
      await turn.complete(result);

      // Persist the full exchange after the turn — your onTurnComplete equivalent
      await db.chat.update({
        where: { id: turn.chatId },
        data: { messages: turn.uiMessages },
      });
    }
  },
});
```

<Warning>
  If you pass `compaction` or `pendingMessages` to `chat.createSession()`, you must also pass `prepareStep: turn.prepareStep()` to `streamText` (or spread `chat.toStreamTextOptions()`, which wires it automatically). Without it, both features silently no-op.
</Warning>

### ChatSessionOptions

| Option                 | Type                         | Default     | Description                                                                                      |
| ---------------------- | ---------------------------- | ----------- | ------------------------------------------------------------------------------------------------ |
| `signal`               | `AbortSignal`                | required    | Run-level cancel signal (from task context)                                                      |
| `idleTimeoutInSeconds` | `number`                     | `30`        | Seconds to stay idle between turns before suspending                                             |
| `timeout`              | `string`                     | `"1h"`      | Duration string for suspend timeout                                                              |
| `maxTurns`             | `number`                     | `100`       | Max turns before ending                                                                          |
| `compaction`           | `ChatAgentCompactionOptions` | `undefined` | Automatic context [compaction](/docs/ai-chat/compaction) — same options as on `chat.agent()`          |
| `pendingMessages`      | `PendingMessagesOptions`     | `undefined` | Mid-execution [message injection](/docs/ai-chat/pending-messages) — same options as on `chat.agent()` |

Between turns the run idles on `waitWithIdleTimeout`: after `idleTimeoutInSeconds` with no message it suspends (compute is freed), and the next message restores it on the same run — the same warm/suspended pipeline `chat.agent()` uses.

### ChatTurn

Each turn yielded by the iterator provides:

| Field               | Type                              | Description                                                                                                                     |
| ------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `number`            | `number`                          | Turn number (0-indexed)                                                                                                         |
| `chatId`            | `string`                          | Chat session ID                                                                                                                 |
| `trigger`           | `string`                          | What triggered this turn                                                                                                        |
| `clientData`        | `unknown`                         | Client data from the transport                                                                                                  |
| `messages`          | `ModelMessage[]`                  | Full accumulated model messages — pass to `streamText`                                                                          |
| `uiMessages`        | `UIMessage[]`                     | Full accumulated UI messages — use for persistence                                                                              |
| `signal`            | `AbortSignal`                     | Combined stop+cancel signal (fresh each turn)                                                                                   |
| `stopped`           | `boolean`                         | Whether the user stopped generation this turn                                                                                   |
| `continuation`      | `boolean`                         | Whether this is a continuation run                                                                                              |
| `previousTurnUsage` | `LanguageModelUsage \| undefined` | Token usage from the previous turn (undefined on turn 0)                                                                        |
| `totalUsage`        | `LanguageModelUsage`              | Cumulative token usage across all completed turns                                                                               |
| `handover`          | `{ isFinal: boolean } \| null`    | The [`chat.headStart`](/docs/ai-chat/fast-starts#handover-with-custom-agents) handover for this turn (turn 0 only); `null` otherwise |

| Method                         | Description                                                                                                                                                                                              |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `turn.complete(source?)`       | Pipe stream, capture response, accumulate, and signal turn-complete. Call with no source on a final head-start handover (`turn.handover.isFinal`), where the warm step-1 partial is already the response |
| `turn.done()`                  | Signal turn-complete only (when you have piped manually)                                                                                                                                                 |
| `turn.addResponse(response)`   | Add a response to the accumulator manually                                                                                                                                                               |
| `turn.setMessages(uiMessages)` | Replace the accumulated messages — continuation seeding and on-demand compaction                                                                                                                         |
| `turn.prepareStep()`           | `prepareStep` callback wiring compaction + injection — pass to `streamText` when not spreading `chat.toStreamTextOptions()`                                                                              |

### Continuation runs and history seeding

`chat.agent()` rebuilds conversation history automatically when a chat continues on a fresh run (after a cancel, crash, version upgrade, or TTL expiry) — via its snapshot/replay boot or your `hydrateMessages` hook. Custom agents do none of that: a continuation run starts with an **empty accumulator**, and history restoration is your job.

With `createSession`, check `turn.continuation` on the first turn and seed from your store with `turn.setMessages()`:

```ts theme={"theme":"css-variables"}
for await (const turn of session) {
  if (turn.continuation && turn.number === 0) {
    const row = await db.chat.findUnique({ where: { id: turn.chatId } });
    const stored = (row?.messages ?? []) as UIMessage[];
    if (stored.length > 0) {
      // Keep any incoming message that isn't already persisted
      const incoming = turn.uiMessages.filter((m) => !stored.some((s) => s.id === m.id));
      await turn.setMessages([...stored, ...incoming]);
    }
  }

  // ... streamText + turn.complete as usual
}
```

Without this, a resumed chat silently loses its history: the model sees only the message that triggered the continuation. In a hand-rolled loop, seed by passing the stored history into the turn-0 `addIncoming` call — shown in the example below.

### turn.complete() vs manual control

`turn.complete(result)` is the one-call path — it handles piping, capturing the response, accumulating messages, cleaning up aborted parts on a stop, and writing the turn-complete chunk.

For more control, you can do each step manually:

```ts theme={"theme":"css-variables"}
for await (const turn of session) {
  const result = streamText({
    model: anthropic("claude-sonnet-4-5"),
    messages: turn.messages,
    abortSignal: turn.signal,
    stopWhen: stepCountIs(15),
  });

  // Manual: pipe and capture separately
  const { message } = await chat.pipeAndCapture(result, { signal: turn.signal });

  if (message) {
    // Custom processing before accumulating
    await turn.addResponse(message);
  }

  // Custom persistence, analytics, etc.
  await db.chat.update({ ... });

  // Must call done() when not using complete()
  await turn.done();
}
```

## Stopping generation

The frontend stops a turn with [`transport.stopGeneration(chatId)`](/docs/ai-chat/frontend#stop-generation), which writes a stop signal to the session's input stream. It aborts the current turn's generation but keeps the run alive, so the next message continues on the same session.

`turn.signal` is a combined stop-and-cancel `AbortSignal`, fresh each turn. Pass it to `streamText` so the stop reaches the model, then let `turn.complete()` finish the turn:

```ts trigger/my-chat.ts theme={"theme":"css-variables"}
for await (const turn of session) {
  const result = streamText({
    model: anthropic("claude-sonnet-4-5"),
    messages: turn.messages,
    abortSignal: turn.signal, // fires on a user stop OR a run cancel
    stopWhen: stepCountIs(15),
  });

  await turn.complete(result);

  if (turn.stopped) {
    // user stopped this turn — the partial response is already accumulated
  }
}
```

On a stop, `turn.complete()` cleans up the aborted parts of the partial response, accumulates it as its own assistant message, and writes turn-complete. The run does not end — the loop continues to the next turn.

Read `turn.stopped` to tell a user stop from a full run cancel:

* **User stop** (`transport.stopGeneration`): `turn.signal` aborts, `turn.stopped` is `true`, the partial response is accumulated, and the run stays alive for the next message.
* **Run cancel** (cancelled, expired, or `maxDuration` exceeded): `turn.signal` aborts, `turn.stopped` is `false`, and `turn.complete()` returns without accumulating because the run is ending.

A hand-rolled loop wires this itself with `chat.createStopSignal()` and `chat.cleanupAbortedParts()`. Two things `createSession` handles for you are easy to get wrong there — see the [hand-rolled loop checklist](#hand-rolled-loop-checklist).

## Hand-rolled loop with primitives

For full control, skip `createSession` and compose the primitives directly:

| Primitive                       | Description                                                                                 |
| ------------------------------- | ------------------------------------------------------------------------------------------- |
| `chat.messages`                 | Input stream for incoming messages — use `.waitWithIdleTimeout()` to wait for the next turn |
| `chat.createStopSignal()`       | Create a managed stop signal wired to the stop input stream                                 |
| `chat.pipeAndCapture(result)`   | Pipe a stream and capture the response; returns `{ message, status, error }`                |
| `chat.writeTurnComplete()`      | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors            |
| `chat.MessageAccumulator`       | Accumulates conversation messages across turns                                              |
| `chat.pipe(stream)`             | Pipe a stream to the frontend (no response capture)                                         |
| `chat.cleanupAbortedParts(msg)` | Clean up incomplete parts from a stopped response                                           |

A complete loop:

```ts trigger/my-chat-raw.ts theme={"theme":"css-variables"}
import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

export const myChat = chat.customAgent({
  id: "my-chat-raw",
  run: async (payload: ChatTaskWirePayload, { signal: runSignal }) => {
    let currentPayload = payload;

    // Handle preload — wait for the first real message
    if (currentPayload.trigger === "preload") {
      const result = await chat.messages.waitWithIdleTimeout({
        idleTimeoutInSeconds: 60,
        timeout: "1h",
        spanName: "waiting for first message",
      });
      if (!result.ok) return;
      currentPayload = result.output;
    }

    const stop = chat.createStopSignal();
    const conversation = new chat.MessageAccumulator();

    // Continuation runs (cancel, crash, upgrade) start with an empty
    // accumulator — fetch stored history so turn 0 can seed it.
    let continuationSeed: UIMessage[] = [];
    if (currentPayload.continuation) {
      const row = await db.chat.findUnique({ where: { id: currentPayload.chatId } });
      continuationSeed = (row?.messages ?? []) as UIMessage[];
    }

    for (let turn = 0; turn < 100; turn++) {
      stop.reset();

      // The wire payload carries at most one new message per turn. Turn 0
      // REPLACES the accumulator, so seed stored history through
      // addIncoming together with the incoming message — a setMessages
      // call before the loop would be wiped here.
      const incoming = currentPayload.message ? [currentPayload.message] : [];
      const turnInput =
        turn === 0 && continuationSeed.length > 0
          ? [...continuationSeed.filter((s) => !incoming.some((m) => m.id === s.id)), ...incoming]
          : incoming;
      const messages = await conversation.addIncoming(turnInput, currentPayload.trigger, turn);

      // Persist the incoming user message before streaming so a
      // mid-stream reload doesn't lose it.
      await db.chat.update({
        where: { id: currentPayload.chatId },
        data: { messages: conversation.uiMessages },
      });

      const combinedSignal = AbortSignal.any([runSignal, stop.signal]);

      const result = streamText({
        model: anthropic("claude-sonnet-4-5"),
        messages,
        abortSignal: combinedSignal,
        stopWhen: stepCountIs(15),
      });

      const { message, status, error } = await chat.pipeAndCapture(result, {
        signal: combinedSignal,
      });
      // pipeAndCapture never throws: a user stop returns status "aborted" with
      // the partial message, and a failure returns status "error" with `error`.
      if (status === "error") throw error;

      if (message) {
        const cleaned =
          stop.signal.aborted && !runSignal.aborted ? chat.cleanupAbortedParts(message) : message;
        await conversation.addResponse(cleaned);
      }

      if (runSignal.aborted) break;

      // Persist, analytics, etc.
      await db.chat.update({
        where: { id: currentPayload.chatId },
        data: { messages: conversation.uiMessages },
      });

      await chat.writeTurnComplete();

      // Wait for the next message
      const next = await chat.messages.waitWithIdleTimeout({
        idleTimeoutInSeconds: 60,
        timeout: "1h",
        spanName: "waiting for next message",
      });
      if (!next.ok) break;
      currentPayload = next.output;
    }

    stop.cleanup();
  },
});
```

### MessageAccumulator

`addIncoming(messages, trigger, turn)` has two modes:

* **Turn 0 or `trigger === "regenerate-message"`: replaces** the accumulator with exactly what you pass. This is why continuation seeding goes through `addIncoming` (above), and why a regenerate needs you to slice your own history — the wire omits the message on regenerate, so pass the stored history minus the last assistant message.
* **Every other turn: appends** what you pass (the wire carries at most the one new user message).

```ts theme={"theme":"css-variables"}
const conversation = new chat.MessageAccumulator();

// Returns full accumulated ModelMessage[] for streamText
const messages = await conversation.addIncoming(
  payload.message ? [payload.message] : [],
  payload.trigger,
  turn
);

// After piping, add the response
const { message } = await chat.pipeAndCapture(result);
if (message) await conversation.addResponse(message);

// Access accumulated messages for persistence
conversation.uiMessages; // UIMessage[]
conversation.modelMessages; // ModelMessage[]
```

The constructor also accepts `compaction` and `pendingMessages` options (same shapes as on `chat.agent()`); pass `prepareStep: conversation.prepareStep()` to `streamText` to activate them. See [pending messages](/docs/ai-chat/pending-messages#backend-messageaccumulator-raw-task) for the manual steering wiring.

### Hand-rolled loop checklist

Things the managed levels do for you that a raw loop has to get right:

* **Don't bare-await `result.totalUsage`.** On a stop-abort the AI SDK's `totalUsage` promise never settles, which wedges the loop forever. Race it with a timeout:

  ```ts theme={"theme":"css-variables"}
  const turnUsage = await Promise.race([
    result.totalUsage,
    new Promise((resolve) => setTimeout(() => resolve(undefined), 2000)),
  ]);
  ```

* **Persist the user message before streaming** (shown in the example above). The session replay restores the assistant's streamed text after a page reload, but nothing restores a user message you haven't written down.

* **Seed history on continuation runs through the turn-0 `addIncoming`** (shown above). `payload.continuation` is `true` when this run picked up an existing chat; the accumulator starts empty — and because turn 0 replaces the accumulator, a `setMessages` call before the loop gets wiped.

* **Clean up aborted parts on a stop** with `chat.cleanupAbortedParts()` before accumulating, or the partial response carries half-open tool calls into the next turn's prompt.

* **Read `payload.message` (singular).** The wire payload carries at most one new message per turn; there is no `messages` array on the payload.

## Next steps

<CardGroup>
  <Card title="Backend overview" icon="layer-group" href="/ai-chat/backend">
    The three abstraction levels compared, and everything chat.agent() adds on top.
  </Card>

  <Card title="Sessions" icon="wave-pulse" href="/ai-chat/sessions">
    The durable stream pair every agent — managed or custom — is built on.
  </Card>

  <Card title="Compaction" icon="compress" href="/ai-chat/compaction">
    Automatic context compression — works with createSession and MessageAccumulator.
  </Card>

  <Card title="Client protocol" icon="plug" href="/ai-chat/client-protocol">
    The wire format your loop is speaking, chunk by chunk.
  </Card>
</CardGroup>


# Error handling
Source: https://trigger.dev/docs/ai-chat/error-handling

How errors flow through chat.agent — stream errors, hook errors, run failures — and how to recover.

`chat.agent` errors fall into four layers, each with different recovery semantics. The default behavior is **conversation-preserving**: a thrown error in a hook or `run()` does not kill the chat. The current turn ends with an error chunk, and the agent waits for the user's next message.

## Error layers at a glance

| Layer           | Source                                                             | Default behavior                                                      | Recovery                                              |
| --------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------- | ----------------------------------------------------- |
| **Stream**      | `streamText` errors mid-response (rate limits, model API failures) | `onError` callback converts to error chunk                            | Sanitize message via `uiMessageStreamOptions.onError` |
| **Hook / turn** | Throws in `onValidateMessages`, `onTurnStart`, `run`, etc.         | Error chunk + turn-complete written to stream; conversation continues | Catch in your hook, or rely on default                |
| **Run**         | Unhandled exception escapes the run                                | Run fails. No retry by default. Standard task `onFailure` fires.      | `onFailure` task hook                                 |
| **Frontend**    | Stream delivers `{ type: "error", errorText }`                     | `useChat` exposes via `error` field and `onError` callback            | Show toast, retry button, etc.                        |

## Stream errors mid-turn

When the model API errors mid-response (rate limits, network failures, malformed output), the AI SDK's `streamText` calls the `onError` callback. Use `uiMessageStreamOptions.onError` to convert the error to a user-friendly string. The string is sent to the frontend as an error chunk.

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";

export const myChat = chat.agent({
  id: "my-chat",
  uiMessageStreamOptions: {
    onError: (error) => {
      console.error("Stream error:", error);
      if (error instanceof Error && error.message.includes("rate limit")) {
        return "Rate limited. Please wait a moment and try again.";
      }
      if (error instanceof Error && error.message.includes("context_length")) {
        return "This conversation is too long. Please start a new chat.";
      }
      return "Something went wrong while generating a response. Please try again.";
    },
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

<Note>
  Returning a string from `onError` is what gets shown to the user. Do not return raw error messages — they may leak internal details (API keys, stack traces, etc.).
</Note>

The frontend receives this as an error chunk that `useChat` exposes via its `error` field:

```tsx theme={"theme":"css-variables"}
const { messages, error } = useChat({ transport });

{error && <div className="text-red-600">{error.message}</div>}
```

## Hook and turn errors

If any lifecycle hook (`onValidateMessages`, `onChatStart`, `onTurnStart`, `hydrateMessages`, `onAction`, `prepareMessages`, `onBeforeTurnComplete`, `onTurnComplete`) or `run()` throws an unhandled exception, the turn loop catches it:

1. Writes `{ type: "error", errorText: error.message }` to the stream
2. Writes a turn-complete chunk to close the turn
3. Waits for the next user message

The conversation stays alive. The user can send another message and continue.

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  onTurnStart: async ({ chatId, uiMessages }) => {
    // If this throws, the turn ends with an error chunk
    // and the agent waits for the next message
    await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

### Catching errors in your own hooks

For granular control, wrap your hook code in try/catch and decide what to do. Common patterns:

```ts theme={"theme":"css-variables"}
onValidateMessages: async ({ messages }) => {
  try {
    return await validateUIMessages({ messages, tools: chatTools });
  } catch (err) {
    // Log to your error tracking service
    Sentry.captureException(err);
    // Throw a user-facing error message — this becomes the error chunk
    throw new Error("Your message contains invalid data and could not be sent.");
  }
},
```

<Tip>
  The `Error.message` you throw is sent verbatim to the frontend as the error chunk's `errorText`. Use messages safe for end users.
</Tip>

### Catching errors inside `run()`

`run()` is your code — wrap it in try/catch for full control. This is the right place to save partial state to your DB before the error chunk goes out:

```ts theme={"theme":"css-variables"}
run: async ({ messages, chatId, signal }) => {
  try {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  } catch (err) {
    // Save the failed turn for debugging / undo
    await db.failedTurn.create({
      data: {
        chatId,
        error: err instanceof Error ? err.message : String(err),
        messages,
      },
    });
    throw err; // Re-throw to trigger the error chunk
  }
},
```

## Saving error state to your DB

To persist errors for debugging or undo, use `onTurnComplete` (which fires even after errors) or the standard task `onComplete` hook.

### Using `onTurnComplete`

`onTurnComplete` fires after every turn — successful **or** errored. On an errored turn `responseMessage` is undefined or partial and `error` carries the thrown value (with `finishReason` set to `"error"`). Use this to mark the turn as failed:

```ts theme={"theme":"css-variables"}
onTurnComplete: async ({ chatId, uiMessages, responseMessage, stopped, error }) => {
  // Persist the messages regardless of error state
  await db.chat.update({
    where: { id: chatId },
    data: {
      messages: uiMessages,
      // `error` is set when the turn threw
      lastTurnStatus: error ? "errored" : stopped ? "stopped" : "ok",
    },
  });
},
```

### Using the standard `onFailure` task hook

For run-level failures (the entire run dies), use the standard task `onFailure` hook. This fires when the run terminates with an unhandled exception:

```ts theme={"theme":"css-variables"}
chat.agent({
  id: "my-chat",
  onFailure: async ({ error, ctx }) => {
    // Log run-level failure to your monitoring service
    await monitoring.recordRunFailure({
      runId: ctx.run.id,
      chatId: ctx.run.tags.find(t => t.startsWith("chat:"))?.slice(5),
      error: error.message,
    });
  },
  run: async ({ messages, signal }) => {
    return streamText({ ... });
  },
});
```

<Info>
  `chat.agent` uses `retry: { maxAttempts: 1 }` internally, so the run never retries on failure. To add run-level retries, wrap the agent in a parent task or implement your own retry logic in the frontend (re-send the message).
</Info>

## Recovery patterns

### Pattern 1: Undo to last successful response

A common pattern is to let the user "undo" the failed turn and try again. Combine `chat.history.rollbackTo` with a custom action:

```ts theme={"theme":"css-variables"}
chat.agent({
  id: "my-chat",
  actionSchema: z.discriminatedUnion("type", [
    z.object({ type: z.literal("undo") }),
  ]),
  onAction: async ({ action, uiMessages }) => {
    if (action.type === "undo") {
      // Find the last user message and roll back to it
      const lastUserIdx = [...uiMessages].reverse().findIndex(m => m.role === "user");
      if (lastUserIdx !== -1) {
        const targetIdx = uiMessages.length - 1 - lastUserIdx - 1;
        const target = uiMessages[targetIdx];
        if (target) chat.history.rollbackTo(target.id);
      }
    }
  },
  run: async ({ messages, signal }) => {
    return streamText({ ... });
  },
});
```

On the frontend, show an "Undo" button when an error occurs:

```tsx theme={"theme":"css-variables"}
{error && (
  <button onClick={() => transport.sendAction(chatId, { type: "undo" })}>
    Undo and try again
  </button>
)}
```

### Pattern 2: Retry the last message

For transient errors (network blips, rate limits), the simplest recovery is to re-send the last user message. The AI SDK's `useChat` provides `regenerate()`:

```tsx theme={"theme":"css-variables"}
const { messages, error, regenerate } = useChat({ transport });

{error && (
  <button onClick={() => regenerate()}>Retry</button>
)}
```

`regenerate()` removes the last assistant response and re-sends. Combined with `onValidateMessages` or `hydrateMessages`, you can reload the canonical state from your DB before retrying.

### Pattern 3: Save partial responses

When a stream errors mid-response, the `responseMessage` in `onBeforeTurnComplete` and `onTurnComplete` contains the partial output. Save it as a "draft" so the user can see what was generated before the error:

```ts theme={"theme":"css-variables"}
onBeforeTurnComplete: async ({ chatId, responseMessage, stopped }) => {
  if (responseMessage && responseMessage.parts.length > 0) {
    // Save partial response — user can manually accept or discard
    await db.partialResponse.create({
      data: {
        chatId,
        message: responseMessage,
        reason: stopped ? "stopped" : "errored",
      },
    });
  }
},
```

### Pattern 4: Fall back to a different model

If the primary model errors, try a fallback model in the same turn:

```ts theme={"theme":"css-variables"}
run: async ({ messages, signal }) => {
  try {
    return streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  } catch (err) {
    console.warn("Primary model failed, falling back:", err);
    return streamText({
      model: anthropic("claude-sonnet-4-6"),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  }
},
```

<Note>
  This only catches errors thrown synchronously by `streamText` setup. Errors that happen mid-stream go through `uiMessageStreamOptions.onError`, not your try/catch.
</Note>

## What gets written to the stream on error

When an error occurs at any layer, the frontend's `UIMessageChunk` stream surfaces an error chunk:

```json theme={"theme":"css-variables"}
{ "type": "error", "errorText": "Rate limited. Please wait a moment and try again." }
```

A `turn-complete` control record follows on `session.out` (header-form, not a data chunk — see [`turn-complete` control record](/docs/ai-chat/client-protocol#turn-complete-control-record) for the wire format) to mark the turn as done.

The AI SDK's `useChat` processes this and:

1. Sets `useChat`'s `error` field to an `Error` with `message = errorText`
2. Calls the user's `onError` callback (if set)
3. Marks the turn as complete (`status` returns to `"ready"`)

```tsx theme={"theme":"css-variables"}
const { messages, error, status } = useChat({
  transport,
  onError: (err) => {
    toast.error(err.message);
  },
});
```

## Frontend error handling

### Showing the error to the user

```tsx theme={"theme":"css-variables"}
function Chat() {
  const transport = useTriggerChatTransport({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  });
  const { messages, error, sendMessage } = useChat({ transport });

  return (
    <div>
      {messages.map(m => /* ... */)}
      {error && (
        <div className="rounded border border-red-300 bg-red-50 p-3">
          <p className="text-red-700">{error.message}</p>
        </div>
      )}
      <form onSubmit={(e) => { e.preventDefault(); sendMessage(/* ... */); }}>
        {/* ... */}
      </form>
    </div>
  );
}
```

### Distinguishing error types

The `errorText` is just a string, so distinguish error types via prefixes or codes:

```ts theme={"theme":"css-variables"}
// Backend
uiMessageStreamOptions: {
  onError: (error) => {
    if (error.message.includes("rate limit")) return "RATE_LIMIT: Please wait and try again.";
    if (error.message.includes("context_length")) return "CONTEXT_TOO_LONG: Start a new chat.";
    return "UNKNOWN: Something went wrong.";
  },
},
```

```tsx theme={"theme":"css-variables"}
// Frontend
{error?.message.startsWith("RATE_LIMIT") && <RateLimitNotice />}
{error?.message.startsWith("CONTEXT_TOO_LONG") && <NewChatPrompt />}
```

<Tip>
  For richer error structures, use [`chat.response.write()`](/docs/ai-chat/backend#custom-data-parts) with a custom `data-error` part type. This lets you ship structured error metadata (codes, retry hints, etc.) instead of stringly-typed messages.
</Tip>

### Errors from `accessToken` / `startSession`

If your `accessToken` or `startSession` callback throws (auth failure, DB write failure, network error), the rejection surfaces through `useChat`'s `error` state — same as a stream error. The transport doesn't retry the callback automatically; the customer is responsible for handling it.

```tsx theme={"theme":"css-variables"}
const transport = useTriggerChatTransport({
  task: "my-chat",
  accessToken: async ({ chatId }) => {
    try {
      return await mintChatAccessToken(chatId);
    } catch (err) {
      // Customer's server action failed (e.g. user lost auth).
      // Re-throw to surface as a useChat error, or return a sentinel
      // your UI can detect and prompt re-auth.
      throw new Error(`AUTH_REFRESH: ${err.message}`);
    }
  },
  startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
});
```

`startSession` failures most commonly mean the customer's authorization layer rejected the request (no plan, quota exceeded, user not allowed to chat with this agent). The customer's server should produce a meaningful error message; the transport propagates it verbatim to `useChat`'s `error` state.

## Run-level retries

`chat.agent` uses `retry: { maxAttempts: 1 }` — the run **never retries** on unhandled failure. This is intentional: each turn is conversation-preserving, so a true run failure is severe and shouldn't silently retry (which could send duplicate API calls or mutate state twice).

To add retry-like behavior:

* **Per-turn retries**: handle inside `run()` with try/catch and a fallback model
* **Per-message retries**: re-send from the frontend (call `sendMessage` or `regenerate` again)
* **Whole-run retries**: wrap `chat.agent` with a parent task that has `retry` configured, and call the agent's task internally

## Best practices

1. **Always set `uiMessageStreamOptions.onError`** to sanitize stream errors before they reach the user.
2. **Persist messages in `onTurnStart`** so a mid-stream failure still leaves the user's message visible.
3. **Use `onTurnComplete` to mark turn status** in your DB (`ok` / `errored` / `stopped`).
4. **Don't throw raw errors with internal details** in hooks — catch, log, then throw a sanitized user-facing message.
5. **Provide an undo or retry affordance** in the UI when errors occur.
6. **Use `onFailure` for run-level monitoring** (Sentry, monitoring dashboards).
7. **For known transient errors (rate limits, network)**, consider a fallback model inside `run()` instead of failing the turn.

## `ChatChunkTooLargeError`

A specific run-failing error worth flagging on its own. Anything written through the chat output is one record on the underlying realtime stream, capped at \~1 MiB per record. A single chunk over the cap throws `ChatChunkTooLargeError` (named export from `@trigger.dev/sdk`). The most common trigger is a tool whose result object is large enough to overflow as one `tool-output-available` chunk.

The error carries `chunkType`, `chunkSize`, and `maxSize`. Catch with the `isChatChunkTooLargeError` guard and route oversized values out-of-band.

See [Large payloads in chat.agent](/docs/ai-chat/patterns/large-payloads) for the ID-reference pattern that works around the cap, plus guidance on transient data parts and out-of-band logging.

## See also

* [`uiMessageStreamOptions.onError`](/docs/ai-chat/backend#error-handling-with-onerror) — stream error handler details
* [Custom actions](/docs/ai-chat/actions) — implement undo/retry actions
* [`chat.history`](/docs/ai-chat/backend#chat-history) — rollback to a previous message
* [Large payloads](/docs/ai-chat/patterns/large-payloads) — handling the \~1 MiB per-chunk cap
* [Database persistence](/docs/ai-chat/patterns/database-persistence) — saving conversation state
* [Standard task hooks](/docs/tasks/overview) — `onFailure`, `onComplete`, `onWait`, etc.


# Fast starts
Source: https://trigger.dev/docs/ai-chat/fast-starts

Two ways to cut first-turn TTFC: Preload eagerly triggers the run before the first message; Head Start runs step 1 in your warm server while the agent boots in parallel.

The first turn of a brand-new conversation pays for the chat.agent run's cold start: dequeue, process boot, `onPreload` / `onChatStart` hooks, and only then the LLM call. Two features address this from different angles.

## Picking an approach

|                                    | [Preload](#preload)                                | [Head Start](#head-start)                                                     |
| ---------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------- |
| **What it does**                   | Eagerly triggers the run before the first message  | Runs step 1's LLM call in your warm process while the agent boots in parallel |
| **First-turn TTFC win**            | Hides agent boot if the user *does* send a message | \~50% reduction (LLM TTFB floor); boot fully overlaps with TTFB               |
| **When to fire**                   | Page load / input focus — your call                | First message arrival — automatic                                             |
| **Cost when user never sends**     | Idle compute until the preload window times out    | Zero (no run was triggered)                                                   |
| **Requires a warm server process** | No — works for browser-only surfaces               | Yes — your route handler runs step 1                                          |
| **Requires LLM keys client-side?** | No                                                 | No — keys stay in your warm server                                            |
| **Bundle constraints**             | None                                               | Route handler must import schema-only tools (no heavy executes)               |

**Pick one, not both.** Running both for the same chat is wasted work — Head Start gates on a real first message, so adding Preload on top eats the idle-compute cost Head Start was avoiding.

**Use Preload** when the chat surface is browser-only, when you don't have a warm Node/Bun/Edge process serving the page, or when you can confidently predict the user *will* send a message (the run never goes idle).

**Use Head Start** when the chat lives behind a warm server (Next.js App Router, Hono, SvelteKit, Workers, etc.) and you want first-turn TTFC down at the LLM TTFB floor without any speculative run.

***

## Preload

Preload eagerly triggers a run for a chat before the first message is sent. Initialization (DB setup, context loading) happens while the user is still typing, reducing first-response latency.

### Frontend

Call `transport.preload(chatId)` to start a run early:

```tsx theme={"theme":"css-variables"}
import { useEffect } from "react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useChat } from "@ai-sdk/react";

export function Chat({ chatId }) {
  const transport = useTriggerChatTransport({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
    clientData: { userId: currentUser.id },
  });

  // Preload on mount: run starts before the user types anything.
  // Trigger config (idleTimeoutInSeconds, machine, tags) lives in the
  // server action that wraps `chat.createStartSessionAction`.
  useEffect(() => {
    transport.preload(chatId);
  }, [chatId]);

  const { messages, sendMessage } = useChat({ id: chatId, transport });
  // ...
}
```

Preload is a no-op if a session already exists for this chatId.

Your `accessToken` callback receives `{ chatId }` and is invoked the same way on preload as on any other refresh — no special branching by purpose. See [TriggerChatTransport options](/docs/ai-chat/reference#triggerchattransport-options).

### Backend

The `onPreload` hook fires immediately. The run then waits for the first message. When the user sends a message, `onChatStart` fires with `preloaded: true` so you can skip work that already ran:

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  onPreload: async ({ chatId, clientData }) => {
    // Eagerly initialize: runs before the first message
    userContext.init(await loadUser(clientData.userId));
    await db.chat.create({ data: { id: chatId } });
  },
  onChatStart: async ({ preloaded }) => {
    if (preloaded) return; // Already initialized in onPreload
    // ... fallback initialization for non-preloaded runs
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

With `chat.createSession()` or raw tasks, check `payload.trigger === "preload"` and wait for the first message:

```ts theme={"theme":"css-variables"}
if (payload.trigger === "preload") {
  // Initialize early...
  const result = await chat.messages.waitWithIdleTimeout({
    idleTimeoutInSeconds: 60,
    timeout: "1h",
  });
  if (!result.ok) return;
  currentPayload = result.output;
}
```

***

## Head Start

Head Start runs step 1's LLM call in your warm server process while the agent run boots in parallel. The user sees one continuous turn: text first from your server, then a clean handover to the agent for tool execution and any further steps. The agent you hand off to can be a `chat.agent`, a `chat.customAgent`, or a `chat.createSession` loop (see [Handover with custom agents](#handover-with-custom-agents)).

`chat.headStart` returns a standard [Web Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) handler — `(req: Request) => Promise<Response>` — so it slots into any runtime that speaks Web Fetch.

Drive it one of two ways: wire the handler into the transport's [`headStart` option](#the-transport-option) so the browser's first message POSTs to it (the setup below), or call [`chat.startHeadStart`](#detached-head-start) from a backend that creates the chat and triggers the run in one request, then resumes on a separate page.

**Verified runtimes:** Node 18+, Bun, Deno, Cloudflare Workers, Vercel (Node and Edge), Netlify (Functions and Edge). The handler uses only `fetch` and Web `ReadableStream` / `TransformStream` (no `node:*` imports), and the S2 streaming dependency picks the right transport for each runtime automatically (HTTP/2 on Node/Deno, HTTP/1.1 on Bun/Workers/browsers).

**Compatible frameworks (native Web Fetch):** Next.js App Router, Hono, SvelteKit, Remix, React Router v7, TanStack Start, Astro, Nitro/Nuxt, Elysia. Mount the handler directly.

**Node-only frameworks (Express, Fastify, Koa):** the handler still works, but the framework gives you a Node `IncomingMessage` instead of a Web `Request`. Use a small adapter — examples in [Mounting in your framework](#mounting-in-your-framework) below.

When the first turn is pure text (no tool calls), the agent run boots and exits without ever calling an LLM. You only pay for what the conversation actually needed.

### Measured TTFC

3 runs each, prompt `"say hi in five words"`, same model both sides (Anthropic Claude Sonnet 4):

|              | Without Head Start | With Head Start | Δ        |
| ------------ | ------------------ | --------------- | -------- |
| TTFT (avg)   | 2801 ms            | **1218 ms**     | **−57%** |
| TTFT (range) | 2351–3101 ms       | 1201–1252 ms    |          |
| Total turn   | 4180 ms            | 2345 ms         | −44%     |

With Head Start, time-to-first-text is essentially the LLM TTFB floor (50ms spread). Without it, agent boot + hooks stack before the LLM call, adding 750ms of variance.

### How it works

```mermaid theme={"theme":"css-variables"}
sequenceDiagram
    autonumber
    participant B as Browser
    participant H as Route handler<br/>(your warm server)
    participant T as chat.agent run<br/>(Trigger.dev)

    B->>H: POST first message<br/>(headStart URL)

    par Step 1 + agent boot in parallel
        H->>H: streamText step 1<br/>(your model, schema-only tools)
        H-->>B: SSE: step 1 chunks
    and
        H->>T: createSession + trigger run
        T->>T: boot → wait on session.in
    end

    alt finishReason: tool-calls
        H->>T: handover signal<br/>(partial assistant message)
        T->>T: execute tools, run step 2 LLM
        T-->>H: chunks via session.out
        H-->>B: SSE: step 2 chunks
        T-->>H: trigger:turn-complete
    else finishReason: stop (pure text)
        H->>T: handover-skip signal
        T->>T: exit (no LLM call)
    end

    H-->>B: SSE close
    Note over B,T: Subsequent turns bypass the handler:<br/>browser writes directly to session.in
```

<Steps>
  <Step title="Browser POSTs the first message to your route handler">
    The transport sees `headStart: "/api/chat"` is set and there's no session yet for this chat. It POSTs the wire payload (messages, chatId, metadata) to your route handler.
  </Step>

  <Step title="Your handler creates the session and triggers the agent run">
    A single `apiClient.createSession` round-trip both creates the chat session and triggers an agent run with `trigger: "handover-prepare"`. The agent run boots into a wait state on `session.in`.
  </Step>

  <Step title="Your handler runs streamText step 1">
    `streamText` runs in your warm process with `stopWhen: stepCountIs(1)`. The output is streamed to the browser as SSE while the agent run boots in parallel. Boot time (\~488ms) overlaps with LLM TTFB (\~389ms), fully hidden.
  </Step>

  <Step title="Mid-turn handover">
    On step 1's `tool-calls` finish, your handler signals the agent and the SDK splices the agent's step-2+ stream into the same SSE response. On pure-text finish, your handler signals `handover-skip` and the agent run exits clean — no LLM call from the trigger side.
  </Step>

  <Step title="Subsequent turns bypass the route handler">
    After turn 1, the transport hydrates the session PAT from response headers and writes turn 2 onward directly to `session.in`. Same direct-trigger path as a regular `chat.agent` setup.
  </Step>
</Steps>

### Setup

<Warning>
  **Bundle isolation is the load-bearing constraint.** Head Start only saves time because your route-handler bundle stays lightweight. Anything you import in that handler — and anything those modules import transitively — lands in the bundle. If your tool catalog with heavy `execute` fns (E2B, Puppeteer, native bindings, the trigger SDK runtime, Turndown, image processing, `node:child_process`) ends up in the bundle, you've put cold-start back into a different process.

  This is an **import-chain** problem, not a runtime one. A "we'll strip the executes at runtime" helper would not fix it — bundlers resolve imports at build time. The only correct shape is to keep schemas in their own module that imports `ai` and `zod` only.
</Warning>

<Steps>
  <Step title="Split your tool definitions into schemas + executes">
    Schemas in one module (light deps), executes in another (heavy deps). The agent task pulls in both; the route handler pulls in schemas only.

    ```ts lib/chat-tools/schemas.ts theme={"theme":"css-variables"}
    // ⚠️ This file MUST NOT import anything heavier than `ai` and `zod`.
    // Any import here lands in the route-handler bundle.
    import { tool } from "ai";
    import { z } from "zod";

    export const fetchPage = tool({
      description: "Fetch a URL and return text",
      inputSchema: z.object({ url: z.string().url() }),
      // No execute — agent task adds it elsewhere.
    });

    export const headStartTools = { fetchPage };
    ```

    ```ts trigger/chat-tools.ts theme={"theme":"css-variables"}
    // Heavy deps live here. Only the trigger task imports this module.
    import { tool } from "ai";
    import TurndownService from "turndown";
    import { fetchPage as fetchPageSchema } from "@/lib/chat-tools/schemas";

    const turndown = new TurndownService();

    export const fetchPage = tool({
      ...fetchPageSchema,
      execute: async ({ url }) => {
        const res = await fetch(url);
        return { body: turndown.turndown(await res.text()) };
      },
    });

    export const chatTools = { fetchPage };
    ```
  </Step>

  <Step title="Define your chat.agent (heavy executes)">
    The agent uses the full tool set — these are the executes that run when step 2+ needs them.

    ```ts trigger/chat.ts theme={"theme":"css-variables"}
    import { chat } from "@trigger.dev/sdk/ai";
    import { streamText, stepCountIs } from "ai";
    import { anthropic } from "@ai-sdk/anthropic";
    import { chatTools } from "./chat-tools";

    export const myChat = chat.agent({
      id: "my-chat",
      run: async ({ messages, signal }) =>
        streamText({
          ...chat.toStreamTextOptions({ tools: chatTools }),
          model: anthropic("claude-sonnet-4-6"),
          messages,
          stopWhen: stepCountIs(10),
          abortSignal: signal,
        }),
    });
    ```
  </Step>

  <Step title="Build the head-start handler">
    Call `chat.headStart({ agentId, run })`. It returns a standard Web Fetch handler: `(req: Request) => Promise<Response>`. Inside the `run` callback you call `streamText` yourself and spread `chat.toStreamTextOptions({ tools })` to inherit the SDK-owned wiring (messages, schema-only tools, `stopWhen: stepCountIs(1)`, abort signal). Add your own `model` and `system` on top.

    ```ts lib/chat-handler.ts theme={"theme":"css-variables"}
    import { chat } from "@trigger.dev/sdk/chat-server";
    import { streamText } 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 a helpful assistant.",
        }),
    });
    ```

    <Warning>
      Don't set `stopWhen` here. The spread pins it to `stepCountIs(1)`, and overriding it makes the handler run steps the agent is supposed to own — the handover then splices a stream that has already moved past step 1.
    </Warning>

    <Tip>
      Use the **same model** on both sides (route handler and `chat.agent`) to avoid a tone or style shift between step 1 and step 2+. Your LLM provider keys stay server-side in your warm process — Trigger.dev never holds them in this design.
    </Tip>

    Mount the handler in whatever framework you use — see [Mounting in your framework](#mounting-in-your-framework) below.
  </Step>

  <Step title="Opt in on the transport">
    Add `headStart: "/api/chat"` to `useTriggerChatTransport`. Subsequent turns bypass this URL automatically — `accessToken` and (optionally) `startSession` still run for the direct-trigger path on turn 2 onward.

    ```tsx components/chat.tsx theme={"theme":"css-variables"}
    const transport = useTriggerChatTransport<typeof myChat>({
      task: "my-chat",
      accessToken: ({ chatId }) => mintChatAccessToken(chatId),
      startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
      headStart: "/api/chat",
    });
    ```
  </Step>
</Steps>

### Mounting in your framework

`chat.headStart` returns a Web Fetch handler — `(req: Request) => Promise<Response>`. Frameworks that natively pass Web `Request` objects mount it as-is. Node-only frameworks (Express, Fastify, Koa) need a small adapter.

#### Web Fetch frameworks (recommended)

<CodeGroup>
  ```ts Next.js (App Router) theme={"theme":"css-variables"}
  // app/api/chat/route.ts
  import { chatHandler } from "@/lib/chat-handler";

  export const POST = chatHandler;
  // Default function timeout on Vercel is 10s. Bump if your turns
  // run long (multi-step tool use, slow models):
  // export const maxDuration = 60;
  ```

  ```ts Hono theme={"theme":"css-variables"}
  // src/index.ts
  import { Hono } from "hono";
  import { chatHandler } from "./chat-handler";

  const app = new Hono();

  app.post("/api/chat", (c) => chatHandler(c.req.raw));

  export default app;
  ```

  ```ts SvelteKit theme={"theme":"css-variables"}
  // src/routes/api/chat/+server.ts
  import type { RequestHandler } from "./$types";
  import { chatHandler } from "$lib/chat-handler";

  export const POST: RequestHandler = ({ request }) => chatHandler(request);
  ```

  ```ts Remix / React Router v7 theme={"theme":"css-variables"}
  // app/routes/api.chat.ts
  import type { ActionFunctionArgs } from "@remix-run/node";
  import { chatHandler } from "~/lib/chat-handler";

  export async function action({ request }: ActionFunctionArgs) {
    return chatHandler(request);
  }
  ```

  ```ts TanStack Start theme={"theme":"css-variables"}
  // app/routes/api/chat.ts
  import { createAPIFileRoute } from "@tanstack/start/api";
  import { chatHandler } from "~/lib/chat-handler";

  export const Route = createAPIFileRoute("/api/chat")({
    POST: ({ request }) => chatHandler(request),
  });
  ```

  ```ts Astro theme={"theme":"css-variables"}
  // src/pages/api/chat.ts
  import type { APIRoute } from "astro";
  import { chatHandler } from "../../lib/chat-handler";

  export const POST: APIRoute = ({ request }) => chatHandler(request);
  ```

  ```ts Nitro / Nuxt theme={"theme":"css-variables"}
  // server/api/chat.post.ts
  import { chatHandler } from "~/lib/chat-handler";

  export default defineEventHandler((event) => chatHandler(toWebRequest(event)));
  ```

  ```ts Elysia theme={"theme":"css-variables"}
  // src/index.ts
  import { Elysia } from "elysia";
  import { chatHandler } from "./chat-handler";

  new Elysia()
    .post("/api/chat", ({ request }) => chatHandler(request))
    .listen(3000);
  ```
</CodeGroup>

#### Edge / standalone runtimes

<CodeGroup>
  ```ts Cloudflare Workers theme={"theme":"css-variables"}
  // src/index.ts
  import { chatHandler } from "./chat-handler";

  export default {
    async fetch(req: Request): Promise<Response> {
      const url = new URL(req.url);
      if (req.method === "POST" && url.pathname === "/api/chat") {
        return chatHandler(req);
      }
      return new Response("Not found", { status: 404 });
    },
  };
  ```

  ```ts Bun (native server) theme={"theme":"css-variables"}
  // server.ts
  import { chatHandler } from "./chat-handler";

  Bun.serve({
    port: 3000,
    async fetch(req) {
      const url = new URL(req.url);
      if (req.method === "POST" && url.pathname === "/api/chat") {
        return chatHandler(req);
      }
      return new Response("Not found", { status: 404 });
    },
  });
  ```

  ```ts Deno (Deno.serve) theme={"theme":"css-variables"}
  // server.ts
  import { chatHandler } from "./chat-handler.ts";

  Deno.serve({ port: 3000 }, async (req) => {
    const url = new URL(req.url);
    if (req.method === "POST" && url.pathname === "/api/chat") {
      return chatHandler(req);
    }
    return new Response("Not found", { status: 404 });
  });
  ```
</CodeGroup>

#### Node-only frameworks

Express, Fastify, and Koa pass Node `IncomingMessage` / `ServerResponse` objects rather than Web `Request` / `Response`. The SDK ships `chat.toNodeListener` that wraps any Web Fetch handler as a Node `(req, res)` listener — body bytes are read upfront, headers translated, the response body streamed chunk-by-chunk, and client disconnect is propagated to the handler via `AbortSignal`.

<CodeGroup>
  ```ts Express theme={"theme":"css-variables"}
  import express from "express";
  import { chat } from "@trigger.dev/sdk/chat-server";
  import { chatHandler } from "./chat-handler";

  const app = express();
  app.post("/api/chat", chat.toNodeListener(chatHandler));
  app.listen(3000);
  ```

  ```ts Fastify theme={"theme":"css-variables"}
  import Fastify from "fastify";
  import { chat } from "@trigger.dev/sdk/chat-server";
  import { chatHandler } from "./chat-handler";

  const fastify = Fastify();
  const listener = chat.toNodeListener(chatHandler);

  fastify.post("/api/chat", (req, reply) => {
    // Hand the raw Node request/response to the adapter and tell
    // Fastify we'll handle the response ourselves (no auto-reply).
    reply.hijack();
    return listener(req.raw, reply.raw);
  });

  fastify.listen({ port: 3000 });
  ```

  ```ts Koa theme={"theme":"css-variables"}
  import Koa from "koa";
  import Router from "@koa/router";
  import { chat } from "@trigger.dev/sdk/chat-server";
  import { chatHandler } from "./chat-handler";

  const app = new Koa();
  const router = new Router();
  const listener = chat.toNodeListener(chatHandler);

  router.post("/api/chat", async (ctx) => {
    ctx.respond = false; // Tell Koa not to send the response itself.
    await listener(ctx.req, ctx.res);
  });

  app.use(router.routes()).listen(3000);
  ```

  ```ts Raw node:http theme={"theme":"css-variables"}
  import http from "node:http";
  import { chat } from "@trigger.dev/sdk/chat-server";
  import { chatHandler } from "./chat-handler";

  const listener = chat.toNodeListener(chatHandler);

  http
    .createServer((req, res) => {
      if (req.method === "POST" && req.url === "/api/chat") {
        return listener(req, res);
      }
      res.statusCode = 404;
      res.end();
    })
    .listen(3000);
  ```
</CodeGroup>

<Warning>
  Don't run `express.json()` (or any body-parsing middleware) before the head-start route — it consumes the request body before `chat.toNodeListener` can read the raw bytes. Either skip the parser for this route, or scope it to other routes.
</Warning>

#### Streaming response timeouts

The handler keeps the SSE response open until the agent run signals turn-complete (or skip, on a pure-text turn). Make sure your framework / serverless function timeout accommodates that:

* **Pure-text first turns**: \~LLM TTFB (1–3 s typically).
* **Tool-calling first turns**: LLM step 1 + agent boot + tool execution + step 2 LLM call. Usually 5–15 s; longer for multi-step tool use.
* **Vercel**: default function timeout is 10 s on Hobby, 60 s on Pro. Set `export const maxDuration = N;` on the route segment.
* **Cloudflare Workers**: default 30 s CPU time (paid plans up to 5 min). Streaming wall time is generally not the bottleneck.
* **AWS Lambda behind API Gateway**: 29 s API Gateway hard limit; Lambda Function URL allows up to 15 min.

### What gets routed where

|                                         | First turn (handover)                                             | Subsequent turns             |
| --------------------------------------- | ----------------------------------------------------------------- | ---------------------------- |
| Browser sends message via               | POST to `headStart` URL                                           | Direct write to `session.in` |
| Step 1 LLM call runs in                 | Your warm process                                                 | Trigger.dev agent run        |
| Tool execution runs in                  | Trigger.dev agent run                                             | Trigger.dev agent run        |
| Step 2+ LLM call runs in                | Trigger.dev agent run                                             | Trigger.dev agent run        |
| `onChatStart` / `onTurnStart` fire      | After handover signal arrives                                     | Normally                     |
| `hydrateMessages` fires (if registered) | After handover, with the first-turn history as `incomingMessages` | Normally                     |
| `onTurnComplete` fires                  | After turn finishes (handover) or skipped (handover-skip)         | Normally                     |

### Persistence and the handover contract

A head-start turn persists exactly like a normal turn — the handover machinery is invisible to your hooks. The guarantees:

* **One stable assistant `messageId` across the whole turn.** The route handler generates the id, the handover signal carries it to the agent, and the agent's step 2+ stream reuses it — so the browser merges step 1 and step 2+ into a single assistant message, and you can merge-by-id when persisting.
* **`onTurnComplete` is the canonical persistence point**, same as any turn. It carries the full assistant message under that one id: step-1 text, reasoning, and tool calls plus step-2+ tool results and text. The [database persistence](/docs/ai-chat/patterns/database-persistence) patterns apply unchanged.
* **Reasoning parts survive the handover.** When step 1 runs on an extended-thinking model, the reasoning streamed by your route handler lands in the durable session history (and `onTurnComplete`) under the same `messageId`, with provider metadata intact — Anthropic thinking signatures survive a replay back to the model. Step-2 reasoning appends to the same message rather than replacing it.

#### With `hydrateMessages`

Head Start composes with [`hydrateMessages`](/docs/ai-chat/lifecycle-hooks#hydratemessages). On the first turn, the hook receives the route handler's first-turn history as `incomingMessages` — the canonical upsert-and-return pattern persists the user message exactly as it would on a direct-trigger turn. The runtime splices the warm handler's partial assistant onto your hydrated chain after the hook returns, deduplicated by the assistant `messageId`, so your hook never needs to include the in-flight partial.

<Warning>
  **Hydrate hooks must upsert their conversation row, not update it.** Head-start turns skip preload entirely, so row-creating hooks (`onPreload`, or an `onChatStart` create) have not run when `hydrateMessages` first fires. A bare `UPDATE` against a missing row throws and errors the turn.
</Warning>

Your hydrate hook shapes **model context**, not the transcript — dropping reasoning-only entries or unresolved tool rows from the returned chain is fine and does not affect what `onTurnComplete` persists or what the UI renders.

#### With `prepareMessages`

When the first turn's handover carries a pending tool call, the runtime reshapes it into a tool-approval round: the partial assistant gets a `tool-approval-request` and the chain ends with a `tool` message holding the matching `tool-approval-response`. The agent's `streamText` reads that trailing row to execute the handed-over call before step 2. `chat.agent` keeps that tail intact across your [`prepareMessages`](/docs/ai-chat/reference#chatagentoptions) hook, so the common [prompt-caching](/docs/ai-chat/prompt-caching) pattern of rolling a cache breakpoint onto the last message is safe on a resume turn (the breakpoint lands on the next user or assistant message instead). If you hand-roll a backend with [`chat.customAgent`](#chatcustomagent) or [`chat.createSession`](#chatcreatesession), preserve that trailing approval row yourself.

### Handover with custom agents

The route handler is backend-agnostic: `agentId` can point at a `chat.agent`, a [`chat.customAgent`](/docs/ai-chat/custom-agents), or a [`chat.createSession`](/docs/ai-chat/custom-agents#managed-loop-chatcreatesession) loop. With `chat.agent` the handover is consumed for you (the steps above). The two hand-rolled backends consume it explicitly on turn 0.

#### chat.createSession

The turn iterator surfaces the handover as `turn.handover`. On a final (pure-text) handover, call `turn.complete()` with no source to finalize the warm partial without streaming; otherwise stream as usual. The iterator threads the spliced partial as `originalMessages` for you, so a resumed tool round merges into the handed-over assistant.

```ts trigger/chat.ts theme={"theme":"css-variables"}
for await (const turn of session) {
  // Pure-text handover (isFinal): step 1 already IS the response.
  const result = turn.handover?.isFinal
    ? undefined
    : streamText({
        model: anthropic("claude-sonnet-4-6"),
        messages: turn.messages,
        abortSignal: turn.signal,
        stopWhen: stepCountIs(10),
      });

  await turn.complete(result); // no source on a final handover
}
```

#### chat.customAgent

In a hand-rolled loop, call `conversation.consumeHandover({ payload })` at the top of turn 0. It waits for the handover signal, seeds prior history from `payload.headStartMessages`, splices the warm step-1 partial into the accumulator, and returns `{ isFinal, skipped }`.

```ts trigger/chat.ts theme={"theme":"css-variables"}
// Turn 0, gated on a head-start run:
if (turn === 0 && payload.trigger === "handover-prepare") {
  const { isFinal, skipped } = await conversation.consumeHandover({ payload });
  if (skipped) return; // not a head-start run, or the warm handler aborted — exit
  if (!isFinal) {
    // The partial carries a pending tool call. Run step 2 to execute it,
    // passing originalMessages so the tool output merges into the
    // handed-over assistant instead of starting a new message.
    const result = streamText({
      model: anthropic("claude-sonnet-4-6"),
      messages: conversation.modelMessages,
      stopWhen: stepCountIs(10),
    });
    const { message } = await chat.pipeAndCapture(result, {
      originalMessages: conversation.uiMessages,
    });
    if (message) await conversation.addResponse(message);
  }
  await chat.writeTurnComplete(); // on isFinal the warm partial is already the response
  return;
}
```

Gate the call on `trigger === "handover-prepare"` — `consumeHandover` consumes the warm handover, not a normal first message. See [Custom agents](/docs/ai-chat/custom-agents) for the full loop (continuation seeding, stop handling, persistence). The lower-level `chat.waitForHandover({ payload })` and `accumulator.applyHandover(signal)` are exported if you need to wait and splice in separate steps.

<Note>
  Always pass `originalMessages: conversation.uiMessages` to `pipeAndCapture` in a custom loop. It keeps assistant message IDs stable across turns and lets a tool-approval or handover resume merge into the trailing assistant — the same threading `chat.agent` does internally.
</Note>

### The `chat.headStart` API

```ts theme={"theme":"css-variables"}
chat.headStart<TTools>({
  agentId: string,                       // The chat.agent / chat.customAgent id you're handing off to
  run: (args: HeadStartRunArgs<TTools>) => Promise<StreamTextResult<any, any>>,
  idleTimeoutInSeconds?: number,         // How long the agent waits for the handover signal. Default: 60
  triggerConfig?: Partial<SessionTriggerConfig>, // Run options for the handover-prepare run
}): (req: Request) => Promise<Response>
```

`triggerConfig` sets run options on the auto-triggered handover-prepare run: `tags`, `queue`, `machine`, `maxAttempts`, `maxDuration`, `region`, and `lockToVersion`. The `chat:{chatId}` tag is prepended automatically. Because the session is created once on the first head-start turn (idempotent on the chat id), this is the only place to set those options for a head-start chat's lifetime, mirroring what [`chat.createStartSessionAction`](/docs/ai-chat/sessions) sets for the direct-trigger path.

```ts lib/chat-handler.ts theme={"theme":"css-variables"}
export const chatHandler = chat.headStart({
  agentId: "my-chat",
  triggerConfig: { tags: ["org:acme"], queue: "chat", machine: "small-2x" },
  run: async ({ chat: helper }) =>
    streamText({ ...helper.toStreamTextOptions({ tools: headStartTools }), model, system }),
});
```

The `run` callback receives:

* `messages: UIMessage[]` — user messages parsed from the request body.
* `signal: AbortSignal` — fires when the request closes or the SDK times out the handover.
* `chat: HeadStartChatHelper<TTools>` — exposes `chat.toStreamTextOptions({ tools })` and a `chat.session` escape hatch for power users.

`chat.toStreamTextOptions({ tools })` returns options to spread into `streamText`. The SDK owns these keys — overriding them will break the protocol:

| Key           | What the SDK sets                    | Why                                  |
| ------------- | ------------------------------------ | ------------------------------------ |
| `messages`    | `convertToModelMessages(uiMessages)` | First-turn user history              |
| `tools`       | What you pass                        | Schema-only tools for step 1         |
| `stopWhen`    | `stepCountIs(1)`                     | Step 1 only — agent picks up step 2+ |
| `abortSignal` | Combined request + idle timeout      | Safe cleanup on disconnect           |

You bring `model`, `system`, `providerOptions`, `prepareStep`, anything else `streamText` accepts.

#### The transport option

```ts theme={"theme":"css-variables"}
useTriggerChatTransport({
  // ... task, accessToken, startSession, ...
  headStart?: string,  // URL of your chat.headStart route handler
});
```

Optional. When set, the FIRST message of a brand-new chat (no existing session state) routes through this URL. Subsequent turns bypass it and use the direct-trigger path.

This is **not** a stock `useChat` `endpoint` — it's not the canonical request URL for every turn, just the first-turn shortcut.

### Detached head start

`chat.startHeadStart` runs the same head start as the [`chat.headStart` handler above](#setup); the difference is how step 1 reaches the browser. `chat.headStart` streams step 1 back over the live connection the browser opens when it sends the first message. `chat.startHeadStart` has no open browser connection to stream to, so it drains step 1 into the durable session stream and the browser **resumes** it when the chat page loads.

Use it when there's no open connection at first-turn time, because the first message is captured outside the chat UI and the conversation renders on a separate page. A typical flow: a "new chat" form posts the prompt to your backend, which creates the chat row, starts the run with `chat.startHeadStart`, and returns a `chatId`; the browser then navigates to `/chats/{chatId}` and resumes. You still get the first-turn TTFC win; the browser picks step 1 up on resume instead of live.

**1. Start the head start in your create endpoint.** Call `chat.startHeadStart`, keep `completion` alive past the response (`waitUntil` / Next.js `after`), and return the `chatId`.

```ts app/api/chat/create/route.ts (your backend) theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/chat-server";
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { after } from "next/server";
// Schema-only tools; same bundle-isolation rule as chat.headStart.
import { headStartTools } from "@/lib/chat-tools/schemas";

export async function POST(req: Request) {
  const { chatId, messages } = await req.json();
  // Persist your own chat row + the first user message here.

  const { completion } = await chat.startHeadStart({
    agentId: "my-chat",
    chatId, // session externalId; reuse it on the destination page
    messages, // first-turn user history
    run: async ({ chat: helper }) =>
      streamText({
        ...helper.toStreamTextOptions({ tools: headStartTools }),
        model: anthropic("claude-sonnet-4-6"),
        system: "You are a helpful assistant.",
      }),
  });

  // Keep the function warm until step 1 drains and the handover dispatches.
  after(completion);

  return Response.json({ chatId });
}
```

**2. Resume the chat on the destination page.** Set no `headStart` and no `startSession`: the run is already in flight, so the transport resumes `session.out` and replays step 1 (warm) then step 2+ (agent) under one assistant message.

```tsx app/chats/[chatId]/page.tsx theme={"theme":"css-variables"}
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useChat } from "@ai-sdk/react";

const transport = useTriggerChatTransport({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
});

const { messages } = useChat({ id: chatId, transport, resume: true });
```

<Warning>
  `completion` must run to completion, or step 1 never finishes draining and the turn stalls. On serverless, hand it to the platform's run-after-response primitive (`waitUntil`, Next.js `after`). On a long-lived server you can ignore it; it runs in the background.
</Warning>

<Warning>
  If you hydrate the transport's session state yourself (the `sessions` option, e.g. so the same page can also resume already-stored chats from your session store), a head-started session is still mid-turn, so mark it `isStreaming: true`. A session hydrated as not streaming is treated as settled: the transport skips reconnecting to `session.out`, so the browser never sees the turn even though the run completed. The minimal example above sidesteps this by not hydrating a session at all (`resume: true` alone reconnects).
</Warning>

The `run` callback and bundle-isolation rule are the same as `chat.headStart`. Pass `metadata` to attach auth tokens or context to the run; it never reaches the browser. The cost over the transport-routed handler is one extra round trip: the create request, then the resume.

#### The `chat.startHeadStart` API

```ts theme={"theme":"css-variables"}
chat.startHeadStart<TTools>({
  agentId: string,                               // chat.agent / chat.customAgent / chat.createSession id
  chatId: string,                                // session externalId; reuse on the destination page
  messages: UIMessage[],                         // first-turn user history
  run: (args: HeadStartRunArgs<TTools>) => Promise<StreamTextResult<any, any>>,
  idleTimeoutInSeconds?: number,                 // how long the agent waits for the handover signal. Default: 60
  triggerConfig?: Partial<SessionTriggerConfig>, // tags, queue, machine, …
  apiClient?: ApiClientConfiguration,            // when the agent lives in another project/env
  metadata?: Record<string, unknown>,            // merged into the run payload; never sent to the browser
}): Promise<{ chatId: string; completion: Promise<void> }>
```

`completion` resolves once the head start finishes; `await` it or hand it to `waitUntil`. It rejects if the warm step or the dispatch fails.

### Limitations

* **First turn only.** Step 2+ and turn 2+ run on the trigger side. There's no per-turn "head start every turn" mode — the win comes from amortizing agent boot across the LLM call once.
* **Single step on the warm-server side.** The handler runs `stopWhen: stepCountIs(1)`. Multi-step handover (handler does step 1 + step 2 + ...) is out of scope.
* **Your server needs an LLM provider key.** The first-turn LLM call runs in your warm process, so that environment needs whatever keys the model requires. The agent's executes still run on the Trigger.dev side with whatever environment variables they need there.
* **Browser-only chat surfaces don't apply.** Without a warm server process, there's nowhere to run step 1 ahead of the agent run. Use [Preload](#preload) or eat the cold-start tax.
* **Streaming-capable runtime required.** Your framework / runtime has to support streaming HTTP responses (Web Fetch `Response` body or equivalent). Most modern hosts do — Next.js, Hono, SvelteKit, Workers, Bun, Deno, Vercel, etc. Some legacy platforms that buffer full responses won't deliver chunks until the turn is over, which negates the TTFC benefit (correctness still holds).
* **Non-`useChat` chat surfaces** (Slack bots, Discord bots, custom protocols) don't fit the `chat.headStart` shape — the API expects the AI SDK transport's wire payload on input. For those, trigger the chat.agent directly from your bot handler.

## Reference

* [`chat.headStart` factory and types](/docs/ai-chat/reference) — full signatures for `HeadStartRunArgs`, `HeadStartChatHelper`, `HeadStartSession`, `HeadStartHandlerOptions`.
* [`headStart` transport option](/docs/ai-chat/reference#triggerchattransport-options) — alongside `accessToken`, `startSession`, etc.
* [`onPreload` hook](/docs/ai-chat/lifecycle-hooks#onpreload) — the backend hook that fires when a run is preloaded.
* [Custom agents](/docs/ai-chat/custom-agents) — the `chat.customAgent` and `chat.createSession` loops that `consumeHandover` / `turn.handover` plug into.


# Frontend
Source: https://trigger.dev/docs/ai-chat/frontend

Transport setup, session management, client data, and frontend patterns for AI Chat.

## How the transport works

Vanilla `useChat` expects an `api` URL — it POSTs the conversation to your own Next.js route handler, which terminates the stream. `useTriggerChatTransport` replaces that round-trip: instead of an `api` URL, you pass a custom [`ChatTransport`](https://ai-sdk.dev/docs/ai-sdk-ui/transport) that talks directly to the Trigger.dev cloud (or your self-hosted webapp) on behalf of `useChat`.

There's no API route to maintain. The browser uses a short-lived session-scoped PAT (minted by your `accessToken` server action) to:

* **Create the session** via your `startSession` action on the first message (or `transport.preload(chatId)`).
* **Append the new user message** to the session's durable `.in` stream.
* **Subscribe to the `.out` SSE stream** for the agent's response chunks (text, tool calls, reasoning, custom `data-*` parts).

The transport handles the auth refresh, reconnect, `Last-Event-ID` resume, and stop-signal plumbing transparently. `useChat` sees the result as `UIMessageChunk`s and renders them unchanged.

## Transport setup

Use the `useTriggerChatTransport` hook from `@trigger.dev/sdk/chat/react` to create a memoized transport instance, then pass it to `useChat`:

```tsx theme={"theme":"css-variables"}
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useChat } from "@ai-sdk/react";
import type { myChat } from "@/trigger/chat";
import { mintChatAccessToken, startChatSession } from "@/app/actions";

export function Chat() {
  const transport = useTriggerChatTransport<typeof myChat>({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  });

  const { messages, sendMessage, stop, status } = useChat({ transport });
  // ... render UI
}
```

The transport is created once on first render and reused across re-renders. Pass a type parameter for compile-time validation of the task ID.

The two callbacks have distinct responsibilities:

* **`accessToken`** is a *pure* PAT mint — the transport invokes it on a 401/403 to refresh the session-scoped token. Customer wraps `auth.createPublicToken({ scopes: { read: { sessions: chatId }, write: { sessions: chatId } } })`, which resolves to a `Promise<string>` (the JWT). Return that string from your `accessToken` callback.
* **`startSession`** wraps `chat.createStartSessionAction(taskId)` and is called when the transport needs to *create* the session (`transport.preload(chatId)`, or lazily on the first `sendMessage` for a chatId without a cached PAT). The customer's server controls authorization here, alongside any DB writes paired with session creation.

See [Quick start](/docs/ai-chat/quick-start) for the matching server actions.

<Tip>
  The hook keeps `onSessionChange` and `clientData` up to date via internal refs, so you don't need
  to memoize callbacks or worry about stale closures when those options change between renders.
</Tip>

## Typed messages (`chat.withUIMessage`)

If your chat agent is defined with [`chat.withUIMessage<YourUIMessage>()`](/docs/ai-chat/types) (custom `data-*` parts, typed tools, etc.), pass the same message type through `useChat` so `messages` and `message.parts` are narrowed on the client:

```tsx theme={"theme":"css-variables"}
import { useChat } from "@ai-sdk/react";
import { useTriggerChatTransport, type InferChatUIMessage } from "@trigger.dev/sdk/chat/react";
import type { myChat } from "./myChat";

type Msg = InferChatUIMessage<typeof myChat>;

const transport = useTriggerChatTransport<typeof myChat>({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
  startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
});
const { messages } = useChat<Msg>({ transport });
```

See the [Types](/docs/ai-chat/types) guide for defining `YourUIMessage`, default stream options, and backend examples.

### Calling a fetch endpoint instead of a server action

If you want to mint tokens via a REST endpoint instead of a Next.js server action, the same callbacks accept any async function. Import `AccessTokenParams` and `StartSessionParams` from `@trigger.dev/sdk/chat` to type your fetch handler.

```ts theme={"theme":"css-variables"}
import type { AccessTokenParams, StartSessionParams } from "@trigger.dev/sdk/chat";

const transport = useTriggerChatTransport({
  task: "my-chat",
  accessToken: async ({ chatId }: AccessTokenParams) => {
    const res = await fetch(`/api/chat/${chatId}/access-token`, { method: "POST" });
    return res.text();
  },
  startSession: async ({ chatId, taskId, clientData }: StartSessionParams) => {
    const res = await fetch(`/api/chat/${chatId}/start`, {
      method: "POST",
      body: JSON.stringify({ taskId, clientData }),
    });
    return res.json(); // { publicAccessToken: string }
  },
});
```

The fetch handlers on the server side wrap the same SDK helpers as the server-action variant: `auth.createPublicToken({ scopes: { read: { sessions: chatId }, write: { sessions: chatId } } })` for refresh and `chat.createStartSessionAction(taskId)` for create.

## Session management

Every chat is backed by a durable Session — the row that owns the chat's runs, persists across run lifecycles, and orchestrates handoffs. The transport manages the session for you; what you persist on your side is a small piece of state per chat that lets a fresh tab resume without a round-trip to create a new session.

### What the transport persists per chat

| Field               | Type                   | Notes                                                                                                                                                                                                                                                                                                                                                                                                                  |
| ------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `publicAccessToken` | `string`               | Session-scoped JWT (`read:sessions:{chatId} + write:sessions:{chatId}`). Refreshed automatically on 401/403 via `accessToken`.                                                                                                                                                                                                                                                                                         |
| `lastEventId`       | `string \| undefined`  | Last SSE event received on `.out`. **Valid for the lifetime of the Session** — keep it across `endRun` / `requestUpgrade` / continuation-run boundaries; only clear when the Session itself closes. The cursor lets the next subscription open past the prior turn's stale `turn-complete` record.                                                                                                                     |
| `isStreaming`       | `boolean \| undefined` | **Optional.** The transport sets it internally, but you don't have to persist it — the server decides "nothing is streaming" via the session's [`X-Session-Settled`](/docs/ai-chat/client-protocol#x-session-settled-fast-close-on-idle-reconnects) signal on reconnect. If you do persist it, the transport keeps the fast-path short-circuit. If you drop it, reconnects open the SSE and close fast on settled sessions. |

### Session cleanup (frontend)

Since session creation and updates are handled server-side, the frontend only needs to handle session deletion when a run ends:

```tsx theme={"theme":"css-variables"}
const transport = useTriggerChatTransport<typeof myChat>({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
  startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  sessions: loadedSessions, // Restored from DB on page load
  onSessionChange: (chatId, session) => {
    if (!session) {
      deleteSession(chatId); // Server action — run ended
    }
  },
});
```

### Restoring on page load

On page load, fetch both the messages and the session state from your database, then pass them to `useChat` and the transport. Pass `resume: true` to `useChat` when there's an existing conversation — this tells the AI SDK to reconnect to the stream via the transport.

Because the underlying Session row outlives individual runs, a chat you were in yesterday resumes against the same chat — even if the original run has long since exited. The transport hydrates from the persisted state and uses `lastEventId` to resubscribe; if the client tries to send a new message and no run is alive, the server triggers a fresh continuation run on the same session before the message is appended.

```tsx app/chat/[chatId]/ChatPage.tsx theme={"theme":"css-variables"}
"use client";

import { useEffect, useState } from "react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useChat } from "@ai-sdk/react";
import {
  mintChatAccessToken,
  startChatSession,
  getChatMessages,
  getSession,
  deleteSession,
} from "@/app/actions";

// Rendered from `app/chat/[chatId]/page.tsx`, which awaits `params`
// and forwards `chatId` into this client component:
//
//   export default async function Page({ params }: { params: Promise<{ chatId: string }> }) {
//     const { chatId } = await params;
//     return <ChatPage chatId={chatId} />;
//   }
export default function ChatPage({ chatId }: { chatId: string }) {
  const [initialMessages, setInitialMessages] = useState([]);
  const [initialSession, setInitialSession] = useState(undefined);
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    async function load() {
      const [messages, session] = await Promise.all([getChatMessages(chatId), getSession(chatId)]);
      setInitialMessages(messages);
      setInitialSession(session ? { [chatId]: session } : undefined);
      setLoaded(true);
    }
    load();
  }, [chatId]);

  if (!loaded) return null;

  return (
    <ChatClient
      chatId={chatId}
      initialMessages={initialMessages}
      initialSessions={initialSession}
    />
  );
}

function ChatClient({ chatId, initialMessages, initialSessions }) {
  const transport = useTriggerChatTransport({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
    sessions: initialSessions,
    onSessionChange: (id, session) => {
      if (!session) deleteSession(id);
    },
  });

  const { messages, sendMessage, stop, status } = useChat({
    id: chatId,
    messages: initialMessages,
    transport,
    resume: initialMessages.length > 0, // Resume if there's an existing conversation
  });

  // ... render UI
}
```

<Info>
  `resume: true` causes `useChat` to call `reconnectToStream` on the transport when the component
  mounts. The transport uses the session's `lastEventId` to skip past already-seen stream events, so
  the frontend only receives new data. Only enable `resume` when there are existing messages — for
  brand new chats, there's nothing to reconnect to.
</Info>

<Note>
  After resuming, `useChat`'s built-in `stop()` won't send the stop signal to the backend. The
  transport accepts an abort signal on `reconnectToStream` but doesn't treat it as owning the turn,
  so aborting a resumed subscription only closes your local stream while the run keeps generating.
  Use `transport.stopGeneration(chatId)` for reliable stop behavior after resume — see
  [Stop generation](#stop-generation) for the recommended pattern. Pass `stopOnAbort: true` to
  `reconnectToStream` only when that subscriber owns the turn.
</Note>

<Warning>
  In React strict mode (enabled by default in Next.js dev), you may see a `TypeError: Cannot read
      properties of undefined (reading 'state')` in the console when using `resume`. This is a [known
  bug in the AI SDK](https://github.com/vercel/ai/issues/8477) caused by React strict mode
  double-firing the resume effect. The error is caught internally and **does not affect
  functionality** — streaming and message display work correctly. It only appears in development and
  will not occur in production builds.
</Warning>

### Network resilience

You don't need to handle network drops, mobile background-kills, or Safari bfcache restores. The transport retries indefinitely with bounded backoff, reconnects on `online` / tab refocus / `pageshow` with `event.persisted`, and uses `Last-Event-ID` to resume without dropping chunks. See the [changelog entry](/docs/ai-chat/changelog) for the gory details.

## Client data and metadata

### Transport-level client data

Set default client data on the transport that's included in every request. When the task uses `clientDataSchema`, this is type-checked to match:

```ts theme={"theme":"css-variables"}
const transport = useTriggerChatTransport<typeof myChat>({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
  startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  clientData: { userId: currentUser.id },
});
```

The transport threads `clientData` through three places automatically: into `startSession`'s `params.clientData` for the first run's `payload.metadata`, into per-turn `metadata` on every `.in/append` chunk, and live-updates if the option value changes between renders (so React-driven values like the current user work without reconstructing the transport).

### Per-message metadata

Pass metadata with individual messages via `sendMessage`. Per-message values are merged with transport-level client data (per-message wins on conflicts):

```ts theme={"theme":"css-variables"}
sendMessage({ text: "Hello" }, { metadata: { model: "gpt-4o", priority: "high" } });
```

### Typed client data with clientDataSchema

Instead of manually parsing `clientData` with Zod in every hook, pass a `clientDataSchema` to `chat.agent`. The schema validates the data once per turn, and `clientData` is typed in all hooks and `run`:

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

export const myChat = chat.agent({
  id: "my-chat",
  clientDataSchema: z.object({
    model: z.string().optional(),
    userId: z.string(),
  }),
  onChatStart: async ({ chatId, clientData }) => {
    // clientData is typed as { model?: string; userId: string }
    await db.chat.create({
      data: { id: chatId, userId: clientData.userId },
    });
  },
  run: async ({ messages, clientData, signal }) => {
    // Same typed clientData — no manual parsing needed
    return streamText({
      model: openai(clientData?.model ?? "gpt-4o"),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

The schema also types the `clientData` option on the frontend transport:

```ts theme={"theme":"css-variables"}
// TypeScript enforces that clientData matches the schema
const transport = useTriggerChatTransport<typeof myChat>({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
  startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  clientData: { userId: currentUser.id },
});
```

Supports Zod, ArkType, Valibot, and other schema libraries supported by the SDK.

## Stop generation

Use `transport.stopGeneration(chatId)` to stop the current generation. This sends a stop signal to the running task via input streams, aborting the current `streamText` call while keeping the run alive for the next message.

`stopGeneration` works in all scenarios — including after a page refresh when the stream was reconnected via `resume`. Call it alongside `useChat`'s `stop()` to also update the frontend state:

```tsx theme={"theme":"css-variables"}
const { messages, sendMessage, stop: aiStop, status } = useChat({ transport });

// Wrap both calls in a single stop handler
const stop = useCallback(() => {
  transport.stopGeneration(chatId);
  aiStop();
}, [transport, chatId, aiStop]);

{
  status === "streaming" && (
    <button type="button" onClick={stop}>
      Stop
    </button>
  );
}
```

<Info>
  `transport.stopGeneration(chatId)` handles the backend stop signal and closes
  the SSE connection, while `aiStop()` (from `useChat`) updates the frontend
  status to `"ready"` and fires the `onFinish` callback.
</Info>

<Tip>
  Aborting a resumed stream never stops the run by itself, so `useChat`'s
  built-in `stop()` isn't enough after a page refresh. Use the pattern above, or
  pass `stopOnAbort: true` to `reconnectToStream` when that subscriber owns the
  turn.
</Tip>

See [Stop generation](/docs/ai-chat/backend#stop-generation) in the backend docs for how to handle stop signals in your task.

## Tool approvals

The AI SDK supports tools that require human approval before execution. To use this with `chat.agent`, define a tool with `needsApproval: true` on the backend, then handle the approval UI and configure `sendAutomaticallyWhen` on the frontend.

### Backend: define an approval-required tool

```ts theme={"theme":"css-variables"}
import { tool } from "ai";
import { z } from "zod";

const sendEmail = tool({
  description: "Send an email. Requires human approval before sending.",
  inputSchema: z.object({
    to: z.string(),
    subject: z.string(),
    body: z.string(),
  }),
  needsApproval: true,
  execute: async ({ to, subject, body }) => {
    await emailService.send({ to, subject, body });
    return { sent: true, to, subject };
  },
});
```

Pass the tool to `streamText` in your `run` function as usual. When the model calls the tool, `chat.agent` streams a `tool-approval-request` chunk. The turn completes and the run waits for the next message.

### Frontend: approval UI

Import `lastAssistantMessageIsCompleteWithApprovalResponses` from the AI SDK and pass it to `sendAutomaticallyWhen`. This tells `useChat` to automatically re-send messages once all approvals have been responded to.

Destructure `addToolApprovalResponse` from `useChat` and wire it to your approval buttons:

```tsx theme={"theme":"css-variables"}
import { useChat } from "@ai-sdk/react";
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";

function Chat({ chatId, transport }) {
  const { messages, sendMessage, addToolApprovalResponse, status } = useChat({
    id: chatId,
    transport,
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
  });

  const handleApprove = (approvalId: string) => {
    addToolApprovalResponse({ id: approvalId, approved: true });
  };

  const handleDeny = (approvalId: string) => {
    addToolApprovalResponse({ id: approvalId, approved: false, reason: "User denied" });
  };

  return (
    <div>
      {messages.map((msg) =>
        msg.parts.map((part, i) => {
          if (part.state === "approval-requested") {
            return (
              <div key={i}>
                <p>Tool "{part.type}" wants to run with input:</p>
                <pre>{JSON.stringify(part.input, null, 2)}</pre>
                <button onClick={() => handleApprove(part.approval.id)}>Approve</button>
                <button onClick={() => handleDeny(part.approval.id)}>Deny</button>
              </div>
            );
          }
          // ... render other parts
        })
      )}
    </div>
  );
}
```

### How it works

1. Model calls a tool with `needsApproval: true` — the turn completes with the tool in `approval-requested` state
2. Frontend shows Approve/Deny buttons
3. User clicks Approve — `addToolApprovalResponse` updates the tool part to `approval-responded`
4. `sendAutomaticallyWhen` returns `true` — `useChat` re-sends the updated assistant message
5. The transport sends the message via input streams — the backend matches it by ID and replaces the existing assistant message in the accumulator
6. `streamText` sees the approved tool, executes it, and streams the result

<Info>
  Message IDs are kept in sync between frontend and backend automatically. The backend always
  includes a `generateMessageId` function when streaming responses, ensuring the `start` chunk
  carries a `messageId` that the frontend uses. This makes the ID-based matching reliable
  for tool approval updates.
</Info>

## Sending actions

Send custom actions (undo, rollback, edit) to the agent via `transport.sendAction()`. Actions wake the agent and fire only `hydrateMessages` (if configured) and `onAction` — they're not turns, so `onTurnStart` / `prepareMessages` / `onBeforeTurnComplete` / `onTurnComplete` and `run()` do not fire.

For optimistic UI, mirror the action's effect on the `useChat` state via `setMessages` while the request is in flight:

```tsx theme={"theme":"css-variables"}
function ChatControls({ chatId }: { chatId: string }) {
  const transport = useTriggerChatTransport({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  });

  const { setMessages } = useChat({ transport });

  return (
    <div>
      <button
        onClick={() => {
          void transport.sendAction(chatId, { type: "undo" });
          setMessages((prev) => prev.slice(0, -2));
        }}
      >
        Undo last exchange
      </button>
      <button
        onClick={() => transport.sendAction(chatId, { type: "rollback", targetMessageId: "msg-5" })}
      >
        Rollback to message
      </button>
    </div>
  );
}
```

The action payload is validated against the agent's `actionSchema` on the backend — invalid actions are rejected. See [Actions](/docs/ai-chat/actions) for the backend setup.

<Note>
  `sendAction` returns a `ReadableStream<UIMessageChunk>`. For side-effect-only actions (where `onAction` returns `void`), the stream completes immediately with `trigger:turn-complete`. For actions where `onAction` returns a `StreamTextResult`, the stream carries the assistant chunks the same way `sendMessages` does — `useChat` consumes them automatically.
</Note>

For server-to-server usage, `AgentChat` has the same method:

```ts theme={"theme":"css-variables"}
const stream = await agentChat.sendAction({ type: "undo" });
for await (const chunk of stream) {
  if (chunk.type === "text-delta") process.stdout.write(chunk.delta);
}
```

## Multi-tab coordination

When the same chat is open in multiple browser tabs, `multiTab: true` prevents duplicate messages and syncs conversation state across tabs. Only one tab can send at a time. Other tabs enter read-only mode with real-time message updates.

```tsx theme={"theme":"css-variables"}
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useMultiTabChat } from "@trigger.dev/sdk/chat/react";
import { useChat } from "@ai-sdk/react";

function Chat({ chatId }: { chatId: string }) {
  const transport = useTriggerChatTransport({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
    multiTab: true,
  });

  const { messages, setMessages, sendMessage } = useChat({
    id: chatId,
    transport,
  });

  const { isReadOnly } = useMultiTabChat(transport, chatId, messages, setMessages);

  return (
    <div>
      {isReadOnly && (
        <div className="bg-amber-50 text-amber-700 p-2 text-sm">
          This chat is active in another tab. Messages are read-only.
        </div>
      )}
      {/* message list */}
      <input
        disabled={isReadOnly}
        placeholder={isReadOnly ? "Active in another tab" : "Type a message..."}
      />
    </div>
  );
}
```

### How it works

1. When a tab sends a message, the transport "claims" the chatId via `BroadcastChannel`
2. Other tabs detect the claim and enter read-only mode (`isReadOnly: true`)
3. The active tab broadcasts its messages so read-only tabs see updates in real-time
4. When the turn completes, the claim is released. Any tab can send next.
5. Heartbeats detect crashed tabs (10s timeout clears stale claims)

### What `useMultiTabChat` does

* Returns `{ isReadOnly }` for disabling the input UI
* Broadcasts `messages` from the active tab to other tabs
* Calls `setMessages` on read-only tabs when messages arrive from the active tab
* Tracks read-only state via the transport's `BroadcastChannel` coordinator

<Note>
  Multi-tab coordination is same-browser only (`BroadcastChannel` is a browser API). It gracefully degrades to a no-op in Node.js, SSR, or browsers without `BroadcastChannel` support. Cross-device coordination requires server-side involvement.
</Note>

## Self-hosting

If you're self-hosting Trigger.dev, pass the `baseURL` option:

```ts theme={"theme":"css-variables"}
const transport = useTriggerChatTransport({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
  startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  baseURL: "https://your-trigger-instance.com",
});
```

`baseURL` also accepts a function so you can route per endpoint — useful when fronting `.in/append` with an edge proxy (e.g. to inject server-trusted signal into the wire) while keeping `.out` SSE direct:

```ts theme={"theme":"css-variables"}
baseURL: ({ endpoint }) =>
  endpoint === "out" ? "https://api.trigger.dev" : "https://chat-proxy.example.com",
```

For per-request control beyond URL routing (header injection, custom retries, tracing), pass a `fetch` override. See [Trusted edge signals](/docs/ai-chat/patterns/trusted-edge-signals) for a full proxy walkthrough.

## Monitoring message delivery

`sendMessage` from `useChat` gives no feedback about whether the message actually reached the backend. The transport's `onEvent` callback closes that gap with typed lifecycle events (see the [event catalog](/docs/ai-chat/reference#transport-events)) so you can record real metrics:

```ts theme={"theme":"css-variables"}
const transport = useTriggerChatTransport({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
  onEvent: (event) => {
    switch (event.type) {
      case "message-sent":
        metrics.increment("chat.message_sent");
        metrics.timing("chat.send_duration_ms", event.durationMs);
        break;
      case "message-send-failed":
        metrics.increment("chat.message_send_failed", { status: event.status });
        break;
    }
  },
});
```

A `message-sent` event means the message is durably written to the session's input stream (the stream the agent consumes from), so it's a true "sent successfully" signal. Because send and response events share the same callback, "sent but never answered" becomes a small client-side watchdog:

```ts theme={"theme":"css-variables"}
// Module scope (or a ref) so re-renders don't recreate it. Keyed by chatId
// on purpose: a new send on the same chat supersedes the in-flight turn.
const pending = new Map<string, ReturnType<typeof setTimeout>>();

onEvent: (event) => {
  if (event.type === "message-sent" && event.source === "submit-message") {
    pending.set(event.chatId, setTimeout(() => {
      // Log the chatId; don't tag the metric with it (unbounded cardinality).
      metrics.increment("chat.sent_but_unanswered");
      console.warn("sent but unanswered", event.chatId);
    }, 30_000));
  }
  if (event.type === "first-chunk" || event.type === "turn-completed" || event.type === "stream-error") {
    clearTimeout(pending.get(event.chatId));
    pending.delete(event.chatId);
  }
};
```

Time to first token is `first-chunk`'s `sinceSendMs` (the transport tracks the last turn-producing send per chat, so no bookkeeping is needed), and `turn-completed`'s `sinceSendMs` is the full turn latency. Exceptions thrown inside `onEvent` are swallowed, so a failing metrics pipeline can never break the chat.


# How the AI Agents SDK works
Source: https://trigger.dev/docs/ai-chat/how-it-works

End-to-end mechanics of a chat.agent turn: the two durable channels per session, the long-lived task that reads and writes them, and how a chat survives refreshes, deploys, and idle gaps.

This page explains how `chat.agent` is put together, what each piece does on a single turn, and how a chat survives across turns. It is not an API tour — for that, see [Backend](/docs/ai-chat/backend), [Frontend](/docs/ai-chat/frontend), and the [Reference](/docs/ai-chat/reference). For the byte-level wire format, see [Client Protocol](/docs/ai-chat/client-protocol).

<Note>
  **What you don't have to think about**: SSE reconnects, WebSocket backpressure, container cold starts, whether a worker is currently running, or how to re-deliver chunks the client missed during a reload. The platform handles those. **What you do have to think about**: idempotency in your `run()` function, and how much state you keep in memory between turns versus persist in your own database.
</Note>

## The primary noun: the chat session

A **chat session** is a stateful execution of an agent: two-way streaming plus durable compute, able to span multiple runs. It is the unit chat.agent owns, and it is three things bound together:

* An **inbox** channel called `.in` — every user message lands here as a record.
* An **outbox** channel called `.out` — every assistant chunk leaves through here.
* A long-lived **agent task** that reads from `.in` and writes to `.out`.

Both channels are S2 ([s2.dev](https://s2.dev)) durable append-only streams, keyed by the session. Think of them as a pair of per-session topics on a tiny Kafka: records have monotonically increasing sequence numbers, readers resume from a cursor, writers append to the tail. We chose S2 because reads are resumable from an offset — so a browser reload can replay the response stream without re-running the LLM, and a crashed run can rejoin mid-conversation by reading from where it left off.

A chat ID identifies the session for the lifetime of the conversation. The same session can be served by **many runs**: one run handles a turn (or several), goes idle, eventually exits, and the next user message triggers a fresh continuation run on the same session. Sessions are the durable identity; runs are the ephemeral compute.

## The lifecycle states

A run moves through a small state machine over its lifetime. Each state is named below, with the trigger that moves it to the next.

### Cold start

There is no run yet for this session. The frontend's first `sendMessage` posts to the session's `.in` channel; the server sees no live `currentRunId` and triggers a fresh `chat.agent` run with `continuation: false`. Moves to **Streaming** as soon as the task wakes and begins consuming `.in`.

### Streaming

The agent task is running. It reads the new message off `.in`, fires `onTurnStart`, runs your `run()` function, and pipes `streamText()` chunks onto `.out`. The browser is SSE-subscribed to `.out` and renders chunks as they land. When `streamText()` ends, the task writes a `trigger:turn-complete` control record (an S2 record with an empty body and a special header) and immediately trims `.out` back to the *previous* turn's completion marker — keeping the outbox bounded to roughly one turn of chunks at steady state. Moves to **Idle** after `onTurnComplete` runs and the post-turn snapshot is written.

### Idle (awaiting next message)

The turn is over. The task is alive but not doing work — it is parked in a waitpoint on `.in`, waiting for the next user message. If one arrives, it goes back to **Streaming** for the next turn. If `idleTimeoutInSeconds` (30 seconds by default) passes with no new message, it moves to **Suspended**.

### Suspended

The task fires `onChatSuspend`, then the engine **checkpoints** the run's whole process state and frees the compute. The session is still live (the row exists, the `.out` stream is still readable, the chat ID still works), but no machine is dedicated to it. This is the same Checkpoint-Resume System that powers every Trigger.dev task — covered in detail at [How it works → Checkpoint-Resume](/docs/how-it-works#the-checkpoint-resume-system). Moves to **Resuming** when the next message lands in `.in`.

### Resuming

The engine restores the suspended run from its checkpoint. The same JS process picks up exactly where it parked — `chat.local` values, the accumulator, in-flight promises, in-memory caches all preserved as they were. `onChatResume` fires immediately after the restore, then the task transitions to **Streaming**. No boot work, no snapshot read, no SDK reinitialization. This is the cheap path.

### Continuation (after exit)

If the run has fully exited (because it hit `maxTurns`, the customer called `chat.endRun()` or `chat.requestUpgrade()`, or it was cancelled or crashed), the next user message can't resume it — there is nothing to resume. Instead, the server triggers a brand-new run with `continuation: true`. The new run does a cold boot, reads the prior conversation's S3 snapshot, replays any `.out` chunks after the snapshot cursor, AND replays any `.in` records past the last `turn-complete` cursor (the user messages a dead run never acknowledged). If the predecessor died mid-stream and left a partial assistant response in `.out`, the smart default splices `[firstInFlightUser, partialAssistant]` onto the chain so any follow-up has full context — see [Recovery boot](/docs/ai-chat/patterns/recovery-boot). The new run then enters **Streaming** with `turn === 0` of the new run but `messageCount > 0`.

### Closed

`POST /api/v1/sessions/:id/close` flips `closedAt` on the session row. Future appends are rejected. Reads still work for transcript viewing. The session is terminal.

## One turn, end to end

Here is a typical cold turn — user opens the page, types "What's the weather?", reads the response — traced through every component.

<Steps>
  <Step title="Browser: useChat calls transport.sendMessages">
    The Vercel AI SDK's `useChat` hook serializes the user's message into the slim wire format: `{ chatId, trigger: "submit-message", message, metadata }`. Only the new message goes on the wire, not the full history.
  </Step>

  <Step title="Browser: transport posts to /append">
    The transport calls `POST /realtime/v1/sessions/:chatId/in/append`, authenticated with the session's public access token. The body is one S2 record.
  </Step>

  <Step title="Server: route ensures a run exists">
    The append route resolves the session, then calls `ensureRunForSession()`. The session's `currentRunId` is null (cold start), so it triggers a new `chat.agent` run on the project's dev/prod environment and atomically claims the slot via an optimistic version counter.
  </Step>

  <Step title="Server: route appends the record to S2 .in">
    The route writes the message to `s2://sessions/:chatId/in` as a single record. S2 assigns a sequence number. Any waitpoints registered on this channel fire, which would wake an existing run — but there is no run waiting yet, so this is a no-op for now.
  </Step>

  <Step title="Browser: transport opens an SSE subscription to .out">
    In parallel with the send, the transport opens `GET /realtime/v1/sessions/:chatId/out` (server-sent events). It passes its `lastEventId` if it has one cached; on a brand-new chat it does not. Any chunks the agent writes from now on will be delivered to this stream.
  </Step>

  <Step title="Task: agent run boots">
    The newly-triggered run starts. `onBoot` fires once per worker process. Because this is a fresh chat, no snapshot is read.
  </Step>

  <Step title="Task: enters the turn loop, reads the message from .in">
    The agent reads the pending record off `.in` via a waitpoint. `onChatStart` fires (once per chat lifetime). `onTurnStart` fires (every turn).
  </Step>

  <Step title="Task: runs your run() function, streams chunks to .out">
    Your code calls `streamText({ model, messages })`. Each `UIMessageChunk` it produces is appended to `s2://sessions/:chatId/out` as a record. The browser sees them arrive on the SSE stream and the AI SDK renders them.
  </Step>

  <Step title="Task: writes the turn-complete control record">
    When `streamText()` finishes, the agent writes a record with header `trigger:turn-complete` and an empty body. The browser transport sees this header and closes the per-turn readable stream.
  </Step>

  <Step title="Task: trims .out back to the previous turn-complete">
    Immediately after writing the new turn-complete marker, the agent issues an S2 trim command targeting the *previous* turn-complete's sequence number. This bounds the stream's storage to roughly one turn of chunks plus the latest control record.
  </Step>

  <Step title="Task: fires onTurnComplete, writes snapshot to S3">
    `onTurnComplete` runs (your hook for persistence). Then the agent writes `ChatSnapshotV1` — `{ version: 1, messages, lastOutEventId, lastOutTimestamp }` — to S3 at `sessions/:chatId/snapshot.json`. This write is awaited, not fire-and-forget, so the next run is guaranteed to find it.
  </Step>

  <Step title="Task: goes idle, then suspends">
    The agent re-enters the waitpoint on `.in`. After `idleTimeoutInSeconds` of nothing arriving, `onChatSuspend` fires and the engine snapshots the run. Compute is freed.
  </Step>
</Steps>

## Three layers of persistence

chat.agent survives idle gaps, deploys, refreshes, and crashes because three separate persistence mechanisms work at three different layers of the stack. They're orthogonal — each protects against a different failure mode, and conflating them is a common source of bugs.

### Layer 1: the engine checkpoint (compute)

When a run enters the Suspended state, the engine **checkpoints** the running process — its memory, CPU registers, and open file descriptors — and frees the compute. Today this is done via [CRIU](https://criu.org/) (Checkpoint/Restore in Userspace), the same mechanism that powers every Trigger.dev task's suspend/resume. On the new microVM compute runtime (currently in [private beta](/docs/compute-private-beta)), it becomes a full Firecracker VM snapshot: every byte of memory plus filesystem state plus every kernel object inside the VM.

When the next message arrives, the engine **restores** the checkpoint. The same JS process picks up at the exact instruction it parked on. From your code's perspective, the line right after the `messagesInput.wait()` waitpoint just continues executing. Anything in process memory survives: `chat.local`, the message accumulator, in-flight Promises, in-memory caches, open DB connections. The runId is unchanged.

This is what lets you write `run()` as a single long-lived function with stateful closures, even though the underlying compute actually goes through checkpoint/restore cycles between turns. `onChatSuspend` fires immediately before the checkpoint; `onChatResume` fires immediately after the restore.

### Layer 2: the chat snapshot (S3)

After every turn the agent writes a `ChatSnapshotV1` blob to S3 — full accumulated `UIMessage[]` plus the current `lastOutEventId` cursor. This is chat-specific and lives one layer above the engine. It has nothing to do with CRIU or Firecracker.

The chat snapshot bridges run *boundaries*. If a run exits cleanly — because it hit `maxTurns`, called `chat.endRun()` or `chat.requestUpgrade()`, was cancelled, crashed, or got bumped to a new version after a deploy — the engine checkpoint is gone with it. When the next user message arrives, the server triggers a fresh run with `continuation: true`. That new run reads the S3 snapshot, replays any post-snapshot chunks from `.out`, merges by message ID, and starts its first turn with the full conversation history already in memory.

The chat snapshot carries only message history — not process memory. `chat.local`, in-memory caches, open connections all need to be reinitialized on a continuation. This is why `onBoot` (every fresh worker) is the right place to initialize `chat.local`, not `onChatStart` (only the very first turn of the chat). See [Persistence and replay](/docs/ai-chat/patterns/persistence-and-replay) for the full snapshot model.

If your task registers a `hydrateMessages` hook, the chat snapshot is skipped entirely — your hook is the single source of truth for history.

### Layer 3: the `lastEventId` cursor (browser)

The transport stores `lastEventId` — the S2 sequence number of the most recent chunk it processed — in its session state. On page reload, it reopens the SSE stream with `Last-Event-ID: <cursor>` as a header. S2 resumes from that cursor; chunks the browser already saw are not redelivered. If the agent was mid-turn when the browser reloaded, the rest of the turn streams in. If the turn had already completed, the stream closes immediately via an `X-Session-Settled` header so the client doesn't long-poll for nothing.

Unlike the other two layers, this one is client-side. The server doesn't even need to know the browser refreshed — the agent run keeps running (or stays suspended) regardless.

### Which layer covers which failure mode

| What happened                                               | Recovery layer                           | Same run?                   | In-memory state preserved? |
| ----------------------------------------------------------- | ---------------------------------------- | --------------------------- | -------------------------- |
| Idle gap mid-conversation (suspend → resume)                | Engine checkpoint                        | Yes                         | Yes                        |
| Run exited cleanly (`endRun`, `requestUpgrade`, `maxTurns`) | Chat snapshot                            | No (fresh continuation run) | No                         |
| Run crashed mid-turn (OOM, exception)                       | Chat snapshot + `.out` tail replay       | (retried as a new attempt)  | No                         |
| Browser tab reloaded mid-stream                             | `lastEventId` cursor on `.out`           | (run unaffected)            | (n/a)                      |
| Deploy rolled out a new version mid-chat                    | Chat snapshot, via `requestUpgrade` flow | No                          | No                         |

No single layer covers every case. The engine checkpoint alone can't survive a run exit (there's nothing to restore). The chat snapshot alone can't survive a tab refresh mid-turn (chunks already streamed would be lost). The `lastEventId` cursor alone can't bridge run boundaries (the new run wouldn't know the history). Together they cover every realistic failure.

## Warm vs cold: same chat, three different timings

Take the same conversation — "What's the weather?" then "What about tomorrow?" — and look at how each second turn lands.

**Warm second turn (within a few seconds).** The first turn finished, the agent is parked on the `.in` waitpoint, status is **Idle**. The new message hits `/append`, the waitpoint fires, the agent wakes inside the same run with all memory intact, runs `onTurnStart` for turn 2, streams the response. No checkpoint involved — the process never went to sleep. Latency to first chunk: dominated by the LLM, not the platform.

**Resumed second turn (a few minutes later).** The first turn finished and the agent suspended — the engine checkpoint is stored, compute is freed. The new message hits `/append`. The engine restores the checkpoint, fires `onChatResume`, and the task picks up exactly where it parked — all in-memory state preserved (`chat.local`, the accumulator, the lot). Latency to first chunk: the engine's restore overhead, then the LLM.

**Continuation second turn (an hour later, or after a deploy).** The first turn finished and the run eventually exited. The new message hits `/append`, the server triggers a fresh run with `continuation: true`. The new run boots cold, `onBoot` fires, the agent reads the S3 chat snapshot, replays the `.out` tail, then enters the turn loop with the full conversation already accumulated. The previous run's in-memory state is gone — anything in `chat.local` has to be re-initialized in `onBoot`. Latency to first chunk: cold start plus snapshot read, then the LLM.

All three look identical to the browser. Only the agent task knows which path it took, via `payload.continuation` and `ctx.attempt.number`.

## Lifecycle hooks: where you plug in

| Hook                             | When it fires                                                                   | Typical use                                     |
| -------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------- |
| `onBoot`                         | Once per worker process, before any chat work                                   | Initialize `chat.local` resources               |
| `onPreload`                      | Once per chat lifetime, if the chat was preloaded before the first message      | Warm caches, fetch the user's profile           |
| `onChatStart`                    | Once per chat lifetime, on the first turn of a fresh chat (not on continuation) | First-message persistence, system-prompt setup  |
| `onValidateMessages`             | Every turn, before merging the incoming message                                 | Reject or transform user input                  |
| `hydrateMessages`                | Every turn, instead of snapshot+replay                                          | Use your DB as the source of truth              |
| `onTurnStart`                    | Every turn, before `run()`                                                      | Compact history, persist the user message       |
| `onBeforeTurnComplete`           | Every turn, after streaming, before the turn-complete record                    | Emit a final custom chunk                       |
| `onTurnComplete`                 | Every turn, after the turn-complete record is written                           | Persist the assistant message and `lastEventId` |
| `onChatSuspend` / `onChatResume` | At the idle → suspend / suspend → wake transitions                              | Release/reacquire expensive resources           |

See [Lifecycle hooks](/docs/ai-chat/lifecycle-hooks) for the full signatures and firing order.

## When chat.agent is the right primitive

**Good fit**:

* Multi-turn conversational agents where the user is expected to come back later.
* Long-running agent loops with tool calls, where a single turn can take a minute or more.
* Cases where you want page reloads to resume the in-flight response without re-running the model.
* Cases where you can't predict idle gaps — humans go to lunch.

**Not a good fit**:

* Single-shot completions where you don't need durability or resume. Call your model directly.
* Workflows where you control both ends and want a custom protocol. Use a [raw `task()` with chat primitives](/docs/ai-chat/custom-agents) directly without the `chat.agent` wrapper.
* High-fanout broadcasting (one source, many subscribers). Use Trigger.dev realtime streams against a regular task instead.

## Putting it together

```mermaid theme={"theme":"css-variables"}
sequenceDiagram
    participant Browser
    participant API as Trigger.dev API
    participant S2_in as S2 .in
    participant S2_out as S2 .out
    participant Agent as chat.agent task
    participant S3 as S3 snapshot

    Note over Agent: Cold start
    Browser->>API: POST /sessions/:id/in/append
    API->>S2_in: append(message)
    API->>Agent: trigger run (continuation: false)
    Browser->>API: GET /sessions/:id/out (SSE)
    API->>S2_out: read stream
    Agent->>S2_in: read message (waitpoint)
    Agent->>S2_out: append chunk(s)
    S2_out-->>Browser: SSE chunks
    Agent->>S2_out: append turn-complete (control)
    Agent->>S2_out: trim < previous turn-complete
    Agent->>S3: write snapshot
    Note over Agent: Idle on waitpoint

    Note over Agent: ...time passes...
    Note over Agent: Suspended

    Browser->>API: POST /sessions/:id/in/append
    API->>S2_in: append(message)
    API->>Agent: restore from suspend
    Agent->>S2_in: read message
    Agent->>S2_out: append chunk(s)
    S2_out-->>Browser: SSE chunks
    Agent->>S2_out: append turn-complete
    Agent->>S3: write snapshot
    Note over Agent: Idle again
```

## Where to go next

* [Quick start](/docs/ai-chat/quick-start) — get a chat running in a few minutes.
* [Backend](/docs/ai-chat/backend) — the `chat.agent()` API in detail.
* [Lifecycle hooks](/docs/ai-chat/lifecycle-hooks) — every hook, what fires when.
* [Persistence and replay](/docs/ai-chat/patterns/persistence-and-replay) — deeper on the snapshot model.
* [Client protocol](/docs/ai-chat/client-protocol) — wire format if you're writing a custom transport.


# Lifecycle hooks
Source: https://trigger.dev/docs/ai-chat/lifecycle-hooks

Hook into every stage of a chat agent's run: preload, turn start, turn complete, suspend, resume, and more.

`chat.agent({ ... })` accepts a set of lifecycle hooks for persisting state, validating input, transforming messages, and reacting to suspension and resumption. They fire at well-defined points in the chat agent's lifetime.

**Once per worker process (every fresh run boot):** `onBoot` → `onPreload` (preloaded runs only).

**Once per chat (first message of the chat's lifetime):** `onChatStart`.

**Per-turn order:** `onValidateMessages` → `hydrateMessages` → `onChatStart` (chat's first message only) → `onTurnStart` → `run()` → `onBeforeTurnComplete` → `onTurnComplete`.

**Suspend / resume:** `onChatSuspend` fires when the run transitions from idle to suspended (waiting on the next message); `onChatResume` fires on wake.

**Four scopes to keep straight:**

| Scope                                                                               | Fires when                                                                                                     | Use for                                                                                             |
| ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| **Process** ([`onBoot`](#onboot))                                                   | Every fresh worker boots — initial, preloaded, and reactive continuation (post-cancel/crash/`endRun`/upgrade). | Initialize `chat.local`, open per-process resources, re-hydrate state from your DB on continuation. |
| **Recovery** ([`onRecoveryBoot`](#onrecoveryboot))                                  | Continuation boot where the dead run was mid-stream — a partial assistant survives on `session.out`.           | Override the smart default — drop the partial, synthesize tool results, emit a recovery banner.     |
| **Chat** ([`onChatStart`](#onchatstart))                                            | First message of a chat's lifetime. Does NOT fire on continuation runs or OOM retries.                         | One-time DB rows for the chat, resources tied to the chat's lifetime.                               |
| **Turn** ([`onTurnStart`](#onturnstart), [`onTurnComplete`](#onturncomplete), etc.) | Every turn.                                                                                                    | Persist messages, post-process responses.                                                           |

## Task context (`ctx`)

Every chat lifecycle callback and the `run` payload include `ctx`: the same run context object as `task({ run: (payload, { ctx }) => ... })`. Import the type with `import type { TaskRunContext } from "@trigger.dev/sdk"` (the `Context` export is the same type). Use `ctx` for tags, metadata, or any API that needs the full run record. The string `runId` on chat events is always `ctx.run.id` (both are provided for convenience). See [Task context (`ctx`)](/docs/ai-chat/reference#task-context-ctx) in the API reference.

Standard [task lifecycle hooks](/docs/tasks/overview) such as `onWait`, `onResume`, `onComplete`, and `onFailure` are also available on `chat.agent()` with the same shapes as on a normal `task()` — but prefer the chat-specific [`onChatSuspend` / `onChatResume`](#onchatsuspend--onchatresume) for any chat-related work. The generic hooks fire on every wait/resume (including ones the runtime uses internally for non-chat reasons); the chat-specific ones fire only at the idle-to-suspended transition you actually care about and carry full chat context.

## onBoot

Fires **once per worker process picking up the chat** — for the initial run, for preloaded runs, AND for reactive continuation runs (post-cancel, crash, `endRun`, `requestUpgrade`, OOM retry). Does NOT fire when the same run resumes from snapshot via the idle-window suspend/resume path — use [`onChatResume`](#onchatsuspend--onchatresume) for that.

This is the right place to initialize anything that lives in the JS process for the lifetime of the run: [`chat.local`](/docs/ai-chat/chat-local) state, [DB connections](/docs/database-connections), sandboxes, in-memory caches. It runs before `onPreload`, `onChatStart`, the continuation-wait branch, and any turn — so anything you set up here is available everywhere downstream.

<Warning>
  If you initialize `chat.local` only in `onChatStart`, your `run()` will crash on continuation runs with `chat.local can only be modified after initialization`. `onChatStart` is once-per-chat by contract; `chat.local` is per-process and needs `onBoot`.
</Warning>

Branch on `continuation` to decide whether to load existing state from your DB or start fresh:

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  clientDataSchema: z.object({ userId: z.string() }),
  onBoot: async ({ chatId, clientData, continuation, previousRunId }) => {
    const user = await db.user.findUnique({ where: { id: clientData.userId } });
    userContext.init({ name: user.name, plan: user.plan });

    if (continuation) {
      // Re-hydrate per-chat in-memory state from your DB.
      // `previousRunId` is the public id of the prior run (use it for
      // logging or to look up persisted state keyed on run id).
      const saved = await db.chatState.findUnique({ where: { chatId } });
      if (saved) {
        // Re-apply your saved per-chat state into wherever your
        // run() reads it from (a chat.local slot, an in-memory map, etc.).
        userContext.applySaved(saved);
      }
    }
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

| Field             | Type                        | Description                                                                  |
| ----------------- | --------------------------- | ---------------------------------------------------------------------------- |
| `ctx`             | `TaskRunContext`            | Full task run context. See [reference](/docs/ai-chat/reference#task-context-ctx). |
| `chatId`          | `string`                    | Chat session ID                                                              |
| `runId`           | `string`                    | The Trigger.dev run ID for this run boot                                     |
| `chatAccessToken` | `string`                    | Scoped access token for this run                                             |
| `clientData`      | Typed by `clientDataSchema` | Custom data from the frontend                                                |
| `continuation`    | `boolean`                   | `true` when this run is taking over from a prior dead run                    |
| `previousRunId`   | `string \| undefined`       | Public id of the prior run when `continuation` is true                       |
| `preloaded`       | `boolean`                   | Whether this run was triggered as a preload                                  |

<Tip>
  `onBoot` and `onChatStart` are complementary — keep DB-row creation in `onChatStart` (it only needs to happen once per chat) and put process-level setup (`chat.local`, connections, caches) in `onBoot` (it needs to happen on every fresh worker).
</Tip>

## onRecoveryBoot

Fires once on a continuation boot when the dead predecessor was mid-stream — a partial assistant survives on `session.out`. The runtime reconstructs context automatically via a smart default; this hook is the override path for policies that need something different.

The hook does NOT fire when there's no partial — clean continuations after `chat.endRun()` or `chat.requestUpgrade()`, fresh chats, OOM retries on top of a complete snapshot. Those paths dispatch any in-flight user message as a normal turn on the new run without involving the hook. It also does NOT fire when [`hydrateMessages`](#hydratemessages) is registered (the customer owns persistence).

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  onRecoveryBoot: async ({ partialAssistant, inFlightUsers, writer, cause, previousRunId }) => {
    writer.write({
      type: "data-chat-recovery",
      data: { cause, previousRunId, partialPresent: partialAssistant !== undefined },
      transient: true,
    });
    // Return nothing → fall through to the smart default
    // (splice partial + first user into chain, dispatch the rest).
  },
  run: async ({ messages, signal }) =>
    streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal }),
});
```

| Field              | Type                                                | Description                                                                                       |
| ------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `ctx`              | `TaskRunContext`                                    | Full task run context                                                                             |
| `chatId`           | `string`                                            | Chat session ID                                                                                   |
| `runId`            | `string`                                            | The Trigger.dev run ID for this run boot                                                          |
| `previousRunId`    | `string`                                            | Public id of the prior run that died                                                              |
| `cause`            | `"cancelled" \| "crashed" \| "unknown"`             | Best-effort cause. Currently always `"unknown"` — don't branch on it                              |
| `settledMessages`  | `TUIMessage[]`                                      | The chain persisted by the predecessor's last `onTurnComplete`                                    |
| `inFlightUsers`    | `TUIMessage[]`                                      | User messages on `session.in` past the cursor — the message(s) the predecessor never acknowledged |
| `partialAssistant` | `TUIMessage \| undefined`                           | The trailing assistant message whose stream never received `finish`                               |
| `pendingToolCalls` | `Array<{ toolCallId, toolName, input, partIndex }>` | Tool calls in `input-available` state extracted from `partialAssistant`                           |
| `writer`           | `ChatWriter`                                        | Lazy session.out writer — write a recovery banner / signal here                                   |

Returns `{ chain?, recoveredTurns?, beforeBoot? }` — every field optional. Omitted fields fall through to the smart default. See [Recovery boot](/docs/ai-chat/patterns/recovery-boot) for the full guide, examples (drop partial, synthesize tool results, persist before boot), and interaction notes.

<Tip>
  Don't put `chat.local` initialization in `onRecoveryBoot` — use [`onBoot`](#onboot). `onRecoveryBoot` is for recovery decisions, not per-process setup. `onBoot` fires first.
</Tip>

## onPreload

Fires when a **preloaded run** starts, before any messages arrive. Use it to eagerly create chat-scoped DB rows (the Chat row, the ChatSession row) while the user is still typing — so the very first message lands fast.

Preloaded runs are triggered by calling `transport.preload(chatId)` on the frontend. See [Preload](/docs/ai-chat/fast-starts#preload) for details.

Per-process state (anything in [`chat.local`](/docs/ai-chat/chat-local), DB connections, etc.) belongs in [`onBoot`](#onboot) — `onBoot` fires before `onPreload` on every fresh worker, including on continuation runs where `onPreload` never fires.

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  clientDataSchema: z.object({ userId: z.string() }),
  onBoot: async ({ clientData }) => {
    // Per-process state — runs on every fresh worker (initial,
    // preloaded, continuation). See onBoot above.
    const user = await db.user.findUnique({ where: { id: clientData.userId } });
    userContext.init({ name: user.name, plan: user.plan });
  },
  onPreload: async ({ chatId, clientData, runId, chatAccessToken }) => {
    // Chat-scoped DB rows — only matters on preload (and onChatStart as
    // a fallback when not preloaded).
    await db.chat.create({ data: { id: chatId, userId: clientData.userId } });
    await db.chatSession.upsert({
      where: { id: chatId },
      create: { id: chatId, runId, publicAccessToken: chatAccessToken },
      update: { runId, publicAccessToken: chatAccessToken },
    });
  },
  onChatStart: async ({ preloaded }) => {
    if (preloaded) return; // Already initialized in onPreload
    // ... non-preloaded chat-row initialization
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

| Field             | Type                                          | Description                                                                  |
| ----------------- | --------------------------------------------- | ---------------------------------------------------------------------------- |
| `ctx`             | `TaskRunContext`                              | Full task run context. See [reference](/docs/ai-chat/reference#task-context-ctx). |
| `chatId`          | `string`                                      | Chat session ID                                                              |
| `runId`           | `string`                                      | The Trigger.dev run ID                                                       |
| `chatAccessToken` | `string`                                      | Scoped access token for this run                                             |
| `clientData`      | Typed by `clientDataSchema`                   | Custom data from the frontend                                                |
| `writer`          | [`ChatWriter`](/docs/ai-chat/reference#chatwriter) | Stream writer for custom chunks                                              |

Every lifecycle callback receives a `writer`, a lazy stream writer that lets you send custom `UIMessageChunk` parts (like `data-*` parts) to the frontend. Non-transient `data-*` chunks written via the `writer` are automatically added to the response message and available in `onTurnComplete`. Add `transient: true` for ephemeral chunks (progress indicators, etc.) that should not persist. See [Custom data parts](/docs/ai-chat/backend#custom-data-parts).

## onChatStart

Fires **exactly once per chat**, on the very first user message of the chat's lifetime, before `run()` executes. Use it for one-time chat-scoped setup — create the Chat DB row, mint resources tied to the chat's lifetime.

`onChatStart` does **not** fire on:

* **Continuation runs** — a new run picking up an existing session after the prior run ended (`chat.endRun`, waitpoint timeout, `chat.requestUpgrade`, cancel, crash). The chat already started.
* **OOM-retry attempts** — same chat, same conversation, just on a larger machine.

For per-process state that has to be initialized on every fresh worker (including continuation runs), use [`onBoot`](#onboot). For per-turn setup, use [`onTurnStart`](#onturnstart).

<Warning>
  Do not initialize [`chat.local`](/docs/ai-chat/chat-local) here. `chat.local` is per-process state that must survive continuation runs, but `onChatStart` only fires on the chat's very first message. Use [`onBoot`](#onboot) instead.
</Warning>

The `preloaded` field tells you whether [`onPreload`](#onpreload) already ran for this chat — useful for skipping setup work that's already done.

<Note>
  Because `onChatStart` fires only on the chat's first ever message, `messages` is either empty (when no message exists yet — e.g. a preloaded run that hasn't received its first turn) or contains just the first user message. There's no prior history to load here.
</Note>

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  onChatStart: async ({ chatId, clientData, preloaded }) => {
    if (preloaded) return; // Already set up in onPreload

    const { userId } = clientData as { userId: string };
    await db.chat.create({
      data: { id: chatId, userId, title: "New chat" },
    });
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

<Tip>
  `clientData` contains custom data from the frontend: either the `clientData` option on the
  transport constructor (sent with every message) or the `metadata` option on `sendMessage()`
  (per-message). See [Client data and metadata](/docs/ai-chat/frontend#client-data-and-metadata).
</Tip>

## onValidateMessages

Validate or transform incoming `UIMessage[]` before they are converted to model messages. Fires on turns that carry incoming messages, with the raw messages from the wire payload (after cleanup of aborted tool parts), **before** accumulation and `toModelMessages()`. Turns with no incoming messages — preload, close, and regenerate with nothing re-sent — skip it.

Return the validated messages array. Throw to abort the turn with an error.

This is the right place to call the AI SDK's [`validateUIMessages`](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence#validating-messages-on-the-server) to catch malformed messages from storage or untrusted input before they reach the model, especially useful when persisting conversations to a database where tool schemas may drift between deploys.

| Field      | Type                                                               | Description                        |
| ---------- | ------------------------------------------------------------------ | ---------------------------------- |
| `messages` | `UIMessage[]`                                                      | Incoming UI messages for this turn |
| `chatId`   | `string`                                                           | Chat session ID                    |
| `turn`     | `number`                                                           | Turn number (0-indexed)            |
| `trigger`  | `"submit-message" \| "regenerate-message" \| "preload" \| "close"` | The trigger type for this turn     |

```ts theme={"theme":"css-variables"}
import { validateUIMessages } from "ai";

export const myChat = chat.agent({
  id: "my-chat",
  onValidateMessages: async ({ messages }) => {
    const userMessages = messages.filter((m) => m.role === "user");
    if (userMessages.length > 0) {
      await validateUIMessages({ messages: userMessages, tools: chatTools });
    }
    return messages;
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, tools: chatTools, abortSignal: signal });
  },
});
```

<Warning>
  On HITL continuations (`addToolOutput` / `addToolApproveResponse`) the assistant entry in `messages` is **slim** — `state` + `output` / `errorText` / `approval` only, no `input` or other parts. `validateUIMessages` against the AI SDK schema rejects that shape (the schema requires `input` on resolved tool parts), so filter to user messages first (or skip validation entirely on those turns). The example above does the filter.
</Warning>

<Note>
  `onValidateMessages` fires **before** `onTurnStart` and message accumulation. If you need to validate messages loaded from a database, do the loading in `onChatStart` or `onPreload` and let `onValidateMessages` validate the full incoming set each turn.
</Note>

## hydrateMessages

Load the full message history from your backend on every turn, replacing the built-in linear accumulator. When set, the hook's return value becomes the accumulated state; the normal accumulation logic (append for submit, replace for regenerate) is skipped entirely.

Use this when the backend should be the source of truth for message history: abuse prevention, branching conversations (DAGs), or rollback/undo support.

| Field              | Type                                                   | Description                                                                                                                                                                                                                                                                                                           |
| ------------------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chatId`           | `string`                                               | Chat session ID                                                                                                                                                                                                                                                                                                       |
| `turn`             | `number`                                               | Turn number (0-indexed)                                                                                                                                                                                                                                                                                               |
| `trigger`          | `"submit-message" \| "regenerate-message" \| "action"` | The trigger type for this turn                                                                                                                                                                                                                                                                                        |
| `incomingMessages` | `UIMessage[]`                                          | Validated incoming messages for this turn. Usually 0-or-1 (empty for actions, regenerates, and continuations; one element for normal `submit-message` and tool-approval responses). On a [Head Start](/docs/ai-chat/fast-starts#with-hydratemessages) first turn, this can contain the route handler's first-turn history. |
| `previousMessages` | `UIMessage[]`                                          | Accumulated UI messages before this turn (`[]` on turn 0)                                                                                                                                                                                                                                                             |
| `clientData`       | Typed by `clientDataSchema`                            | Custom data from the frontend                                                                                                                                                                                                                                                                                         |
| `continuation`     | `boolean`                                              | Whether this run is continuing an existing chat                                                                                                                                                                                                                                                                       |
| `previousRunId`    | `string \| undefined`                                  | The previous run ID (if continuation)                                                                                                                                                                                                                                                                                 |

```ts theme={"theme":"css-variables"}
import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";

export const myChat = chat.agent({
  id: "my-chat",
  hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
    const record = await db.chat.findUnique({ where: { id: chatId } });
    const stored = record?.messages ?? [];

    if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
      // Upsert, not update: on a head-start first turn no preload ran,
      // so the row may not exist yet when this hook fires.
      await db.chat.upsert({
        where: { id: chatId },
        create: { id: chatId, messages: stored },
        update: { messages: stored },
      });
    }

    return stored;
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

`upsertIncomingMessage` (exported from `@trigger.dev/sdk/ai`) handles the three cases that matter — fresh user messages get pushed, HITL continuations (`addToolOutput` / `addToolApproveResponse`) no-op because the incoming wire shares the existing assistant's id and the runtime overlays the new tool-state advance onto that entry, and non-`submit-message` triggers (`regenerate-message` / `action`) skip persistence. It returns `true` when it mutated `stored`, so the caller knows whether to persist.

If you need branching, rollback, or other custom hydrate logic, you can still write the upsert by hand — `upsertIncomingMessage` is a convenience for the common case, not the only supported shape.

**Lifecycle position:** `onValidateMessages` → **`hydrateMessages`** → `onChatStart` (chat's first message only) → `onTurnStart` → `run()`

After the hook returns, the runtime overlays the wire's tool-state advances (`output-available` / `output-error` / `approval-responded` / `output-denied`) onto matching hydrated entries by id. Everything else on the hydrated entry — text, reasoning, tool `input`, providerMetadata — stays put. This makes [tool approvals](/docs/ai-chat/frontend#tool-approvals) and HITL `addToolOutput` continuations work transparently: ship a slim resolution on the wire, the agent merges the new state onto your DB-backed copy.

<Note>
  `hydrateMessages` also fires for [action](/docs/ai-chat/actions) turns (`trigger: "action"`) with empty `incomingMessages`. This lets the action handler work with the latest DB state.
</Note>

<Tip>
  Registering `hydrateMessages` short-circuits the runtime's [snapshot + replay](/docs/ai-chat/patterns/persistence-and-replay) reconstruction at run boot — your hook is the single source of truth for history, so the runtime skips reading or writing the snapshot entirely. No object storage traffic, no replay cost. The trade-off is that you own persistence end-to-end.
</Tip>

<Note>
  `incomingMessages` is **usually 0-or-1-length**. `submit-message` and tool-approval responses ship a single message; `regenerate-message`, continuations, and actions ship none. The exception is a [Head Start](/docs/ai-chat/fast-starts#with-hydratemessages) first turn, where it carries the route handler's first-turn history. Patterns like [tool-result auditing](/docs/ai-chat/patterns/tool-result-auditing) work the same regardless — iterate the array rather than assuming a single element.
</Note>

## onTurnStart

Fires at the start of **every turn** — including the first turn of a continuation run, where `onChatStart` doesn't fire. Runs after message accumulation and (when applicable) `onChatStart`, but **before** `run()` executes. Use it to persist messages before streaming begins so a mid-stream page refresh still shows the user's message.

| Field             | Type                                          | Description                                                                  |
| ----------------- | --------------------------------------------- | ---------------------------------------------------------------------------- |
| `ctx`             | `TaskRunContext`                              | Full task run context. See [reference](/docs/ai-chat/reference#task-context-ctx). |
| `chatId`          | `string`                                      | Chat session ID                                                              |
| `messages`        | `ModelMessage[]`                              | Full accumulated conversation (model format)                                 |
| `uiMessages`      | `UIMessage[]`                                 | Full accumulated conversation (UI format)                                    |
| `turn`            | `number`                                      | Turn number (0-indexed)                                                      |
| `runId`           | `string`                                      | The Trigger.dev run ID                                                       |
| `chatAccessToken` | `string`                                      | Scoped access token for this run                                             |
| `continuation`    | `boolean`                                     | Whether this run is continuing an existing chat                              |
| `preloaded`       | `boolean`                                     | Whether this run was preloaded                                               |
| `clientData`      | Typed by `clientDataSchema`                   | Custom data from the frontend                                                |
| `writer`          | [`ChatWriter`](/docs/ai-chat/reference#chatwriter) | Stream writer for custom chunks                                              |

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  onTurnStart: async ({ chatId, uiMessages, runId, chatAccessToken }) => {
    await db.chat.update({
      where: { id: chatId },
      data: { messages: uiMessages },
    });
    await db.chatSession.upsert({
      where: { id: chatId },
      create: { id: chatId, runId, publicAccessToken: chatAccessToken },
      update: { runId, publicAccessToken: chatAccessToken },
    });
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

<Tip>
  By persisting in `onTurnStart`, the user's message is saved to your database before the AI starts
  streaming. If the user refreshes mid-stream, the message is already there.
</Tip>

## onBeforeTurnComplete

Fires after the response is captured but **before** the stream closes. The `writer` can send custom chunks that appear in the current turn. Use this for post-processing indicators, compaction progress, or any data the user should see before the turn ends.

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  onBeforeTurnComplete: async ({ writer, usage, uiMessages }) => {
    // Write a custom data part while the stream is still open
    writer.write({
      type: "data-usage-summary",
      data: {
        tokens: usage?.totalTokens,
        messageCount: uiMessages.length,
      },
    });

    // You can also compact messages here and write progress
    if (usage?.totalTokens && usage.totalTokens > 50_000) {
      writer.write({ type: "data-compaction", data: { status: "compacting" } });
      chat.setMessages(compactedMessages);
      writer.write({ type: "data-compaction", data: { status: "complete" } });
    }
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

Receives the same fields as [`TurnCompleteEvent`](/docs/ai-chat/reference#turncompleteevent), plus a [`writer`](/docs/ai-chat/reference#chatwriter).

## onTurnComplete

Fires after each turn completes, after the response is captured and the stream is closed. This is the primary hook for persisting the assistant's response. Does not include a `writer` since the stream is already closed.

| Field                | Type                     | Description                                                                                  |
| -------------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
| `ctx`                | `TaskRunContext`         | Full task run context. See [reference](/docs/ai-chat/reference#task-context-ctx).                 |
| `chatId`             | `string`                 | Chat session ID                                                                              |
| `messages`           | `ModelMessage[]`         | Full accumulated conversation (model format)                                                 |
| `uiMessages`         | `UIMessage[]`            | Full accumulated conversation (UI format)                                                    |
| `newMessages`        | `ModelMessage[]`         | Only this turn's messages (model format)                                                     |
| `newUIMessages`      | `UIMessage[]`            | Only this turn's messages (UI format)                                                        |
| `responseMessage`    | `UIMessage \| undefined` | The assistant's response for this turn                                                       |
| `turn`               | `number`                 | Turn number (0-indexed)                                                                      |
| `runId`              | `string`                 | The Trigger.dev run ID                                                                       |
| `chatAccessToken`    | `string`                 | Scoped access token for this run                                                             |
| `lastEventId`        | `string \| undefined`    | Stream position for resumption. Persist this with the session.                               |
| `stopped`            | `boolean`                | Whether the user stopped generation during this turn                                         |
| `continuation`       | `boolean`                | Whether this run is continuing an existing chat                                              |
| `rawResponseMessage` | `UIMessage \| undefined` | The raw assistant response before abort cleanup (same as `responseMessage` when not stopped) |

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  onTurnComplete: async ({ chatId, uiMessages, runId, chatAccessToken, lastEventId }) => {
    // Atomic write — see Database persistence for the race-condition rationale
    await db.$transaction([
      db.chat.update({
        where: { id: chatId },
        data: { messages: uiMessages },
      }),
      db.chatSession.upsert({
        where: { id: chatId },
        create: { id: chatId, runId, publicAccessToken: chatAccessToken, lastEventId },
        update: { runId, publicAccessToken: chatAccessToken, lastEventId },
      }),
    ]);
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

<Tip>
  Use `uiMessages` to overwrite the full conversation each turn (simplest). Use `newUIMessages` if
  you prefer to store messages individually, e.g. one database row per message.
</Tip>

<Tip>
  Persist `lastEventId` alongside the session. When the transport reconnects after a page refresh,
  it uses this to skip past already-seen events, preventing duplicate messages.
</Tip>

<Tip>
  For a full **conversation + session** persistence pattern (including preload, continuation, and token renewal), see [Database persistence](/docs/ai-chat/patterns/database-persistence).
</Tip>

## onChatSuspend / onChatResume

Chat-specific hooks that fire at the **idle-to-suspended** transition: the moment the run stops using compute and waits for the next message. These replace the need for the generic `onWait` / `onResume` task hooks for chat-specific work.

The `phase` discriminator tells you **when** the suspend/resume happened:

* `"preload"`: after `onPreload`, waiting for the first message
* `"turn"`: after `onTurnComplete`, waiting for the next message

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  onChatSuspend: async (event) => {
    // Tear down expensive resources before suspending
    await disposeCodeSandbox(event.ctx.run.id);
    if (event.phase === "turn") {
      logger.info("Suspending after turn", { turn: event.turn });
    }
  },
  onChatResume: async (event) => {
    // Re-initialize after waking up
    logger.info("Resumed", { phase: event.phase });
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

| Field        | Type                        | Description                                          |
| ------------ | --------------------------- | ---------------------------------------------------- |
| `phase`      | `"preload" \| "turn"`       | Whether this is a preload or post-turn suspension    |
| `ctx`        | `TaskRunContext`            | Full task run context                                |
| `chatId`     | `string`                    | Chat session ID                                      |
| `runId`      | `string`                    | The Trigger.dev run ID                               |
| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend                        |
| `turn`       | `number`                    | Turn number (**`"turn"` phase only**)                |
| `messages`   | `ModelMessage[]`            | Accumulated model messages (**`"turn"` phase only**) |
| `uiMessages` | `UIMessage[]`               | Accumulated UI messages (**`"turn"` phase only**)    |

<Tip>
  Unlike `onWait` (which fires for all wait types: duration, task, batch, token), `onChatSuspend` fires only at chat suspension points with full chat context. No need to filter on `wait.type`.
</Tip>

## exitAfterPreloadIdle

When set to `true`, a preloaded run completes successfully after the idle timeout elapses instead of suspending. Use this for "fire and forget" preloads. If the user doesn't send a message during the idle window, the run ends cleanly.

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  preloadIdleTimeoutInSeconds: 10,
  exitAfterPreloadIdle: true,
  onPreload: async ({ chatId, clientData }) => {
    // Eagerly set up state. If no message comes, the run just ends.
    await initializeChat(chatId, clientData);
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

## See also

* [Reference](/docs/ai-chat/reference) for full event-type definitions
* [Database persistence](/docs/ai-chat/patterns/database-persistence) for the canonical persistence pattern
* [Code execution sandbox](/docs/ai-chat/patterns/code-sandbox) for an `onChatSuspend` use case
* [Backend](/docs/ai-chat/backend) for `chat.agent({ ... })` itself, prompts, stop signals, persistence overview, and runtime configuration


# MCP Server
Source: https://trigger.dev/docs/ai-chat/mcp

Chat with your agents from any AI coding tool using the Trigger.dev MCP server.

The Trigger.dev MCP server includes tools for having conversations with your chat agents directly from AI coding tools like Claude Code, Cursor, Windsurf, and others. This lets your AI assistant interact with your agents without writing any code.

## Available tools

| Tool                 | Description                           |
| -------------------- | ------------------------------------- |
| `list_agents`        | List all agents in the current worker |
| `start_agent_chat`   | Start a conversation with an agent    |
| `send_agent_message` | Send a message and get the response   |
| `close_agent_chat`   | Close a conversation                  |

See the [MCP Tools Reference](/docs/mcp-tools#agent-chat-tools) for full details on each tool.

## Typical workflow

<Steps>
  <Step title="List available agents">
    Ask your AI assistant to list agents in your project. This calls `list_agents` which returns all tasks created with [`chat.agent()`](/docs/ai-chat/backend#chat-agent) or [`chat.customAgent()`](/docs/ai-chat/custom-agents).
  </Step>

  <Step title="Start a chat">
    Start a conversation with an agent using `start_agent_chat`. This triggers a run and optionally preloads the agent so it's ready to respond immediately.

    If the agent uses `withClientData`, pass the required fields via `clientData`.
  </Step>

  <Step title="Send messages">
    Send messages with `send_agent_message`. The agent processes each message and returns the full response text, including which tools were used. The agent retains full context across messages in the same chat.
  </Step>

  <Step title="Close the chat">
    When done, close the conversation with `close_agent_chat`. This signals the agent to exit gracefully. If you skip this step, the agent will close on its own when its idle timeout expires.
  </Step>
</Steps>

## Example conversation

Here's what a typical MCP conversation with an agent looks like:

```
You: List my agents

AI: Found 3 agents in worker 20250402.12 (dev):
    - support-agent (src/trigger/support.ts)
    - pr-review (src/trigger/pr-review.ts)
    - research (src/trigger/research.ts)

You: Start a chat with the support agent

AI: Agent chat started and preloaded.
    - Chat ID: abc-123
    - Agent: support-agent
    - Run ID: run_xyz

You: Ask it to look up order #4821

AI: I found order #4821. It was placed on March 15 and shipped via
    FedEx (tracking: 7891234). Current status: In Transit, estimated
    delivery March 19.

    Tools used: lookupOrder, getShippingStatus

You: Close the chat

AI: Chat abc-123 closed.
```

## Setup

If you haven't set up the MCP server yet, see the [MCP Server introduction](/docs/mcp-introduction) for installation and client configuration.

Agent chat tools require:

* A running dev server (`trigger dev`) or a deployed worker
* At least one agent defined with [`chat.agent()`](/docs/ai-chat/backend#chat-agent) or [`chat.customAgent()`](/docs/ai-chat/custom-agents)

## How it works

Under the hood, the MCP tools use the same protocol as the [frontend transport](/docs/ai-chat/frontend) and [AgentChat SDK](/docs/ai-chat/server-chat):

1. **`start_agent_chat`** triggers a task run with the `preload` trigger and stores the session (run ID, chat ID) in memory.
2. **`send_agent_message`** sends the message via the run's input stream and subscribes to the output SSE stream to collect the agent's full response.
3. **`close_agent_chat`** sends a close signal via the input stream and removes the session.

Sessions are held in-memory within the MCP server process. If the MCP server restarts, active sessions are lost — but the underlying agent runs continue until their idle timeout.

<Note>
  The `get_current_worker` tool also labels agents with `[agent]` in its output, making it easy to identify which tasks are agents even when listing all tasks.
</Note>

## See also

* [AgentChat SDK](/docs/ai-chat/server-chat) — programmatic server-side access to agents
* [Sub-Agents](/docs/ai-chat/patterns/sub-agents) — agents calling other agents
* [MCP Tools Reference](/docs/mcp-tools#agent-chat-tools) — full tool parameter reference


# Migrate from an AI SDK route handler
Source: https://trigger.dev/docs/ai-chat/migrating-from-a-route-handler

Move an existing useChat + streamText chat app to chat.agent — what stays identical, what you delete, and a prompt you can hand to a coding agent.

Your `streamText` call, model config, tool definitions, `useChat` hook, and message rendering all survive this migration unchanged. What goes away is the plumbing around them: the route handler, the persistence glue you wired into it, and any resumable-stream setup.

This guide assumes a Next.js App Router app with `useChat` on the client and an `app/api/chat/route.ts` that calls `streamText`. [Hono, SvelteKit, and Express follow the same shape](#other-frameworks).

## What changes

|           | Before                                                           | After                                                                                 |
| --------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| **Stays** | `streamText` call, model, `system`, `stopWhen`, provider options | Same call, inside `run()`                                                             |
| **Stays** | Tool definitions (`inputSchema`, `execute`, `toModelOutput`)     | Same tools, also declared on the agent config                                         |
| **Stays** | `useChat`, `messages`, `message.parts`, your UI                  | Unchanged                                                                             |
| **Goes**  | `app/api/chat/route.ts`                                          | Deleted, or kept as a [Head Start](#keep-the-first-turn-fast-with-head-start) handler |
| **Goes**  | `convertToModelMessages`, `toUIMessageStreamResponse`            | The runtime does both                                                                 |
| **Goes**  | `resumable-stream` / Redis, the stream-resume `GET` route        | The transport resumes from `lastEventId`                                              |
| **New**   | —                                                                | A `chat.agent` task in `trigger/chat.ts`                                              |
| **New**   | —                                                                | Two server actions: mint a token, start a session                                     |
| **New**   | —                                                                | `useTriggerChatTransport` in place of the `api` URL                                   |

<Note>
  One thing gets slower, and it's the thing you'll notice first: the opening response of a brand-new chat. Your route handler answered out of an already-warm process; the agent run has to boot before it reaches the model. [Head Start](#keep-the-first-turn-fast-with-head-start) gives that back — get the migration working first, then add it.
</Note>

Before you start, make sure the project has the SDK installed and the CLI authenticated — [Manual setup](/docs/manual-setup), or `npx trigger.dev@latest init` in an existing project.

## Hand it to a coding agent

Paste this into Claude Code, Cursor, or any coding agent that can fetch URLs. It reads the live docs first and is explicit about not rewriting your model config or tools.

```text Migration prompt theme={"theme":"css-variables"}
Migrate this app's AI chat from a Vercel AI SDK route handler to Trigger.dev `chat.agent`.

Before you edit anything:

1. Run `npx trigger.dev@latest skills`. That installs the `trigger-authoring-chat-agent`
   and `trigger-chat-agent-advanced` skills, which are version-pinned to the
   `@trigger.dev/sdk` in this project, so they describe the exact API surface we have.
   Load both before you plan the change.
2. Read these pages in full:
   - https://trigger.dev/docs/ai-chat/quick-start.md
   - https://trigger.dev/docs/ai-chat/frontend.md
   - https://trigger.dev/docs/ai-chat/reference.md
   Use https://trigger.dev/docs/llms.txt as the index if you need anything else
   (persistence, tools, lifecycle hooks). Do not fetch llms-full.txt.
3. Find and read the current chat code: the `useChat` component, the chat route handler
   (`app/api/chat/route.ts` or equivalent), the tool definitions, and anything that
   persists messages or resumes streams.

Then make these changes:

- Create a `chat.agent` task in `trigger/chat.ts`. Move the existing `streamText` call
  into its `run` function UNCHANGED — same model, same `system`, same `temperature`,
  same `stopWhen`, same provider options. Do not rewrite the prompt or swap the model.
- Spread `...chat.toStreamTextOptions({ tools })` as the FIRST property of that
  `streamText` call, so the explicit options after it still win.
- `run` receives `ModelMessage[]` already. Delete the `convertToModelMessages` call.
- Forward the `signal` from `run` as `abortSignal` so Stop works.
- Move the existing tool set onto `chat.agent({ tools })` as well, and read it back from
  the `run` payload. Keep every tool's schema, `execute`, and `toModelOutput` as-is.
- Add two server actions: `chat.createStartSessionAction("<task-id>")` to start the
  session, and an access-token mint using `auth.createPublicToken` scoped to
  `read: { sessions: chatId }` and `write: { sessions: chatId }`. Move the route
  handler's auth check into them.
- Replace the client's `DefaultChatTransport` / `api` URL with `useTriggerChatTransport`
  from `@trigger.dev/sdk/chat/react`, wired to those two actions. Leave the rest of the
  `useChat` usage and all message rendering alone.
- Move message persistence out of the route handler. If this app's database should stay
  the source of truth for history, use a `hydrateMessages` hook; otherwise persist from
  `onTurnStart` and `onTurnComplete`. Write `lastEventId` alongside the messages in the
  same transaction.
- Delete the route handler, any resumable-stream / Redis stream-resumption plumbing, and
  the separate stream-resume GET route. The transport resumes from `lastEventId`.

Constraints:

- Import from `@trigger.dev/sdk`, `@trigger.dev/sdk/ai`, and `@trigger.dev/sdk/chat/react`.
  Never `@trigger.dev/sdk/v3`.
- Import the agent into client components with `import type` only.
- Never mint a token in the browser or expose `TRIGGER_SECRET_KEY` client-side.
- Do not change the model, prompt, tool schemas, or UI components beyond what the
  transport swap requires.

Do NOT attempt this unless I ask for it separately:

- Head Start (`chat.headStart`), which keeps a route handler around to run the first
  turn's opening model call in the warm server process. It's a follow-on change with its
  own constraint — tool schemas have to be split away from tool executes so the route
  handler's bundle stays light. Read https://trigger.dev/docs/ai-chat/fast-starts.md
  before touching it.

When you're done, list what you deleted and show the diff for the agent task, the server
actions, and the client component.
```

The rest of this page is the same migration by hand.

## Move `streamText` into a `chat.agent` task

Here's a representative route handler — auth check, persistence, `streamText`, stream response:

```ts app/api/chat/route.ts theme={"theme":"css-variables"}
import { anthropic } from "@ai-sdk/anthropic";
import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from "ai";
import { auth } from "@/lib/auth";
import { saveChat } from "@/lib/chat-store";
import { tools } from "@/lib/tools";

export const maxDuration = 60;

export async function POST(req: Request) {
  const { id, messages }: { id: string; messages: UIMessage[] } = await req.json();

  const session = await auth();
  if (!session) return new Response("Unauthorized", { status: 401 });

  const result = streamText({
    model: anthropic("claude-sonnet-4-5"),
    system: "You are a helpful assistant.",
    messages: convertToModelMessages(messages),
    tools,
    stopWhen: stepCountIs(15),
    abortSignal: req.signal,
  });

  return result.toUIMessageStreamResponse({
    originalMessages: messages,
    onFinish: ({ messages }) => saveChat({ id, userId: session.user.id, messages }),
  });
}
```

The agent task keeps the middle of that function and drops the HTTP shell:

```ts trigger/chat.ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { anthropic } from "@ai-sdk/anthropic";
import { stepCountIs, streamText } from "ai";
import { tools } from "@/lib/tools";

export const myChat = chat.agent({
  id: "my-chat",
  tools,
  run: async ({ messages, tools, signal }) =>
    streamText({
      // Spread first, so every option below still wins.
      ...chat.toStreamTextOptions({ tools }),
      model: anthropic("claude-sonnet-4-5"),
      system: "You are a helpful assistant.",
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    }),
});
```

Four things changed inside the `streamText` call, and `tools` moved onto the agent config (the [next section](#move-tools-onto-the-agent-config) covers why). Everything else is byte-for-byte the same:

* **`messages` arrives as `ModelMessage[]`.** The runtime converts the frontend's `UIMessage[]` for you, so `convertToModelMessages` is gone.
* **`abortSignal` comes from `signal` on the payload**, not `req.signal`. It fires on stop and on cancel.
* **Return the `StreamTextResult`.** It's piped to the frontend automatically — no `toUIMessageStreamResponse`. If `streamText` is buried in a helper, call `await chat.pipe(result)` from anywhere in the task instead and let `run` resolve `void`.
* **`...chat.toStreamTextOptions()` is spread first.** It wires up the `prepareStep` callback behind [compaction](/docs/ai-chat/compaction), [mid-turn steering](/docs/ai-chat/pending-messages), and [background injection](/docs/ai-chat/background-injection), plus the system prompt set via [`chat.prompt()`](/docs/ai-chat/backend#using-prompts) and telemetry.

<Warning>
  Omitting `...chat.toStreamTextOptions()` throws no error — compaction, steering, and background injection just silently never run. Spread it first so any explicit override you write after it takes precedence.
</Warning>

There's no `maxDuration` equivalent to set. A turn isn't bounded by a function timeout; a run stays alive across turns and suspends when nothing is happening.

## Move tools onto the agent config

Your tool definitions don't change. Declare the same set in two places: on `chat.agent({ tools })`, and — via the `run` payload — on `chat.toStreamTextOptions({ tools })`.

```ts lib/tools.ts theme={"theme":"css-variables"}
import { tool } from "ai";
import { z } from "zod";
import { renderToPng } from "@/lib/charts";

export const tools = {
  renderChart: tool({
    description: "Render a chart and return it as an image.",
    inputSchema: z.object({ spec: z.string() }),
    execute: async ({ spec }) => renderToPng(spec),
    toModelOutput: ({ output }) => ({
      type: "content",
      value: [{ type: "media", mediaType: "image/png", data: output.base64 }],
    }),
  }),
};
```

Declaring them on the config is what keeps `toModelOutput` working across turns. After each turn the conversation is persisted as `UIMessage[]` and re-converted to model messages at the start of the next one, and that conversion needs your tools to find each `toModelOutput`. Pass tools only to `streamText` and the transform runs on turn 1, then gets skipped from turn 2 on — the model silently starts seeing raw stringified output instead of the image.

Reading `tools` back off the `run` payload gives you the resolved set, typed, so you don't re-import the map. See [Tools](/docs/ai-chat/tools) for per-turn tool resolution and typing messages from your tool set.

## Replace the route handler with two server actions

The route handler was doing two jobs: authorizing the request, and terminating the stream. The stream job disappears. The authorization job moves into two server actions that the transport calls.

```ts app/actions.ts theme={"theme":"css-variables"}
"use server";

import { auth as triggerAuth } from "@trigger.dev/sdk";
import { chat, type ChatStartSessionParams } from "@trigger.dev/sdk/ai";
import { auth } from "@/lib/auth";
import { assertChatOwner, claimChat } from "@/lib/chat-access";
import type { myChat } from "@/trigger/chat";

const start = chat.createStartSessionAction<typeof myChat>("my-chat");

// Creates the session and triggers its first run, then returns a
// session-scoped token. Idempotent on (environment, chatId).
export async function startChatSession(params: ChatStartSessionParams<typeof myChat>) {
  const session = await auth();
  if (!session) throw new Error("Unauthorized");

  await claimChat(params.chatId, session.user.id);

  return start(params);
}

// Pure mint. The transport calls this on a 401/403 to refresh.
export async function mintChatAccessToken(chatId: string) {
  const session = await auth();
  if (!session) throw new Error("Unauthorized");

  await assertChatOwner(chatId, session.user.id);

  return triggerAuth.createPublicToken({
    scopes: {
      read: { sessions: chatId },
      write: { sessions: chatId },
    },
    expirationTime: "1h",
  });
}
```

Both run on your server, so the browser never sees `TRIGGER_SECRET_KEY`. This is where per-user and per-plan authorization belongs, alongside any database writes you want paired with session creation.

<Warning>
  Signed in is not the same as entitled to this chat, and `chatId` arrives from the browser. Bind the two yourself: `claimChat` records the owner the first time a chat id is seen and rejects it if someone else already holds it, and `assertChatOwner` requires a row the caller owns. Check only that a session exists and any signed-in user can mint a read/write token for someone else's conversation.
</Warning>

<Note>
  If you'd rather keep REST endpoints than use server actions, both callbacks accept any async function — see [calling a fetch endpoint instead](/docs/ai-chat/frontend#calling-a-fetch-endpoint-instead-of-a-server-action).
</Note>

## Swap the transport on the client

`useChat` stays. Only the transport changes.

<CodeGroup>
  ```tsx Before: app/components/chat.tsx theme={"theme":"css-variables"}
  "use client";

  import { useChat } from "@ai-sdk/react";
  import { DefaultChatTransport } from "ai";

  export function Chat({ chatId, initialMessages }) {
    const { messages, sendMessage, stop, status } = useChat({
      id: chatId,
      messages: initialMessages,
      transport: new DefaultChatTransport({ api: "/api/chat" }),
    });

    // ... render messages, form, stop button
  }
  ```

  ```tsx After: app/components/chat.tsx theme={"theme":"css-variables"}
  "use client";

  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({ chatId, initialMessages, initialSessions }) {
    const transport = useTriggerChatTransport<typeof myChat>({
      task: "my-chat",
      accessToken: ({ chatId }) => mintChatAccessToken(chatId),
      startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }),
      sessions: initialSessions,
    });

    const { messages, sendMessage, stop, status } = useChat({
      id: chatId,
      messages: initialMessages,
      transport,
      resume: initialMessages.length > 0,
    });

    // ... render messages, form, stop button — unchanged
  }
  ```
</CodeGroup>

Everything downstream of `useChat` is untouched: `messages`, `message.parts`, tool parts, reasoning parts, custom `data-*` parts, `status`, `stop`.

Three things to note in the new version:

* **`import type`, not a value import.** The agent module pulls in your tools' `execute` dependencies; `typeof myChat` gives you compile-time validation of the task id without any of that reaching the browser bundle.
* **`sessions`** hydrates the transport from what you persisted (the session token and `lastEventId`), so a fresh tab reconnects without a round-trip to create a session.
* **`resume`** reconnects to an in-flight stream on mount. Gate it on there being existing messages, as the snippet does — a brand-new chat has nothing to reconnect to.

<Note>
  After a resume, `useChat`'s built-in `stop()` doesn't reach the backend, because the AI SDK doesn't thread its abort signal through `reconnectToStream`. Call `transport.stopGeneration(chatId)` instead — see [Stop generation](/docs/ai-chat/frontend#stop-generation).
</Note>

## Move persistence into hooks

Your existing tables and queries stay. Only the call sites move — out of the route handler, into lifecycle hooks that fire inside the agent.

If your database should remain the source of truth for history (you support editing, branching, or rollback, or you don't want to trust client-accumulated state), use `hydrateMessages`. It loads history from your database on every turn and ignores the frontend's copy, except for the new user message which arrives in `incomingMessages`:

```ts trigger/chat.ts theme={"theme":"css-variables"}
import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";
import { anthropic } from "@ai-sdk/anthropic";
import { stepCountIs, streamText } from "ai";
import { db } from "@/lib/db";
import { tools } from "@/lib/tools";

export const myChat = chat.agent({
  id: "my-chat",
  tools,
  hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
    const record = await db.chat.findUnique({ where: { id: chatId } });
    const stored = record?.messages ?? [];

    // Pushes a fresh user message; no-ops on tool-approval continuations.
    if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
      await db.chat.upsert({
        where: { id: chatId },
        create: { id: chatId, messages: stored },
        update: { messages: stored },
      });
    }

    return stored;
  },
  onTurnComplete: async ({ chatId, uiMessages, chatAccessToken, lastEventId }) => {
    // One transaction: a refresh between these two writes would resume
    // from a stale cursor and re-render the assistant message twice.
    await db.$transaction([
      db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }),
      db.chatSession.upsert({
        where: { id: chatId },
        create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId },
        update: { publicAccessToken: chatAccessToken, lastEventId },
      }),
    ]);
  },
  run: async ({ messages, tools, signal }) =>
    streamText({
      ...chat.toStreamTextOptions({ tools }),
      model: anthropic("claude-sonnet-4-5"),
      system: "You are a helpful assistant.",
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    }),
});
```

Without `hydrateMessages`, persist from `onTurnStart` (the user message, awaited before streaming begins) and `onTurnComplete` (the assistant reply plus `lastEventId`). Read the `chatSession` row back on page load and pass it as the transport's `sessions` option.

The `lastEventId` write is what replaces your stream-resumption setup, so it isn't optional polish. [Database persistence](/docs/ai-chat/patterns/database-persistence) has the full per-hook breakdown, the race conditions to avoid, and an end-to-end three-file example.

<Note>
  Per-process state — `chat.local`, database pools, sandboxes — initializes in [`onBoot`](/docs/ai-chat/lifecycle-hooks#onboot), which fires on every fresh worker. `onChatStart` fires only on a chat's very first message, so a later run that picks the conversation back up would skip it.
</Note>

## Delete the stream-resumption plumbing

If you wired up `resumable-stream` with a Redis publisher and a separate `GET /api/chat/[id]/stream` route to survive mid-stream refreshes, remove all of it: the package, the Redis client, the stream context, the route, and the `activeStreamId` column that tracked it.

Response chunks are written to a durable append-only stream keyed on the chat, and the browser's `lastEventId` is a cursor into it. On reload the transport reopens the subscription from that cursor, so chunks it already rendered aren't redelivered and the remainder of an in-flight turn streams in. There's no Redis to run and no TTL to tune.

<Warning>
  Don't clear `lastEventId` when a run ends. The cursor is keyed to the session, not the run, and stays valid across run boundaries. Clearing it forces the next subscription to start from the beginning of the stream, where it can hit the previous turn's stale completion marker and close empty.
</Warning>

## Verify it

Run the agent locally and send a message through your existing UI.

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest dev
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest dev
  ```

  ```bash bun theme={"theme":"css-variables"}
  bunx trigger.dev@latest dev
  ```
</CodeGroup>

The turn shows up in the dashboard as a run, with a span per model call and per tool call. Three checks worth doing deliberately, because they're what the migration bought you:

1. **Refresh mid-stream.** The response keeps streaming into the reloaded page instead of restarting.
2. **Press Stop.** Generation halts server-side, not just in the UI. If it doesn't, `signal` isn't reaching `streamText`.
3. **Send a follow-up after a few minutes idle.** The conversation continues with full history.

## Keep the first turn fast with Head Start

Do this once the migration above works, because it's the regression you're about to notice. Opening a brand-new chat now waits on the agent run being dequeued and booted before anything reaches the model, where your route handler started streaming out of a process that was already warm. [Measured on a trivial prompt](/docs/ai-chat/fast-starts#measured-ttfc), that's 2.8s to the first chunk against 1.2s once a warm first-turn call is back in front of it. Only the opening turn pays it — the run stays alive between messages, and a suspended run resumes without booting again.

Head Start brings the route handler back for exactly that first turn. It runs step 1 in your warm process while the agent boots alongside it, so boot time hides inside the model's own time-to-first-byte instead of stacking in front of it. When step 1 finishes as plain text the agent exits without ever calling a model; when it ends in tool calls the agent executes them and step 2 streams into the same assistant message. The user sees one continuous response.

<Steps>
  <Step title="Split your tools into schemas and executes">
    This is the constraint the whole feature rests on. Everything your route handler imports, and everything those modules import, ends up in its bundle — so a tool catalog with Puppeteer or native bindings behind its `execute` puts the cold start straight back, just in a different process. Bundlers resolve this at build time, so stripping executes at runtime doesn't help. Schemas need their own module that imports nothing heavier than `ai` and `zod`.

    ```ts lib/chat-tools/schemas.ts theme={"theme":"css-variables"}
    import { tool } from "ai";
    import { z } from "zod";

    export const headStartTools = {
      renderChart: tool({
        description: "Render a chart and return it as an image.",
        inputSchema: z.object({ spec: z.string() }),
        // No execute — the agent's copy carries it.
      }),
    };
    ```

    Your existing `lib/tools.ts` then builds the real tools on top of those schemas, so the two can't drift apart:

    ```ts lib/tools.ts theme={"theme":"css-variables"}
    import { tool } from "ai";
    import { headStartTools } from "@/lib/chat-tools/schemas";
    import { renderToPng } from "@/lib/charts";

    export const tools = {
      renderChart: tool({
        ...headStartTools.renderChart,
        execute: async ({ spec }) => renderToPng(spec),
        toModelOutput: ({ output }) => ({
          type: "content",
          value: [{ type: "media", mediaType: "image/png", data: output.base64 }],
        }),
      }),
    };
    ```

    The agent task is unchanged — it still imports the full `tools`.
  </Step>

  <Step title="Build the head-start handler">
    `chat.headStart` returns a plain Web Fetch handler, `(req: Request) => Promise<Response>`. You call `streamText` inside it much as you did in the original route handler, with the same model and the same system prompt as the agent so there's no tone shift when step 2 takes over.

    ```ts lib/chat-handler.ts theme={"theme":"css-variables"}
    import { chat } from "@trigger.dev/sdk/chat-server";
    import { anthropic } from "@ai-sdk/anthropic";
    import { streamText } from "ai";
    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-5"),
          system: "You are a helpful assistant.",
        }),
    });
    ```

    <Warning>
      Spread `toStreamTextOptions()` first and add only your own keys after it. It owns `messages`, `tools`, `abortSignal`, and `stopWhen` — and unlike the agent-side spread, re-setting any of those breaks the handover rather than degrading it. `stopWhen` in particular is pinned to `stepCountIs(1)`: the agent, not the handler, runs step 2 onward.
    </Warning>

    Your provider keys never leave your server — the first-turn model call runs in your process, so that environment needs whatever the model requires.
  </Step>

  <Step title="Mount it where the old handler was, auth check and all">
    The authorization check you moved into the server actions belongs here too, in the same place it always was, ownership check included. Wrap the handler rather than exporting it directly:

    ```ts app/api/chat/route.ts theme={"theme":"css-variables"}
    import { auth } from "@/lib/auth";
    import { claimChat } from "@/lib/chat-access";
    import { chatHandler } from "@/lib/chat-handler";

    // The handler holds the SSE response open until the agent signals
    // turn-complete, so this covers the whole first turn, not just step 1.
    export const maxDuration = 60;

    export async function POST(req: Request) {
      const session = await auth();
      if (!session) return new Response("Unauthorized", { status: 401 });

      // Clone so the handler still gets an unread body.
      const { chatId } = await req.clone().json();
      await claimChat(chatId, session.user.id);

      return chatHandler(req);
    }
    ```

    Any framework that hands you a Web `Request` mounts it the same way — Hono, SvelteKit, Remix, TanStack Start, Astro, Nitro, Elysia, Workers, Bun, Deno. Express, Fastify, and Koa need the `chat.toNodeListener` adapter. [Mounting in your framework](/docs/ai-chat/fast-starts#mounting-in-your-framework) has one for each.
  </Step>

  <Step title="Point the transport at it">
    One option on the transport you already wired up. Keep both server actions: Head Start only covers the first turn of a chat that has no session yet, and turns 2 onward go down the direct path that needs `accessToken`.

    ```tsx app/components/chat.tsx theme={"theme":"css-variables"}
    const transport = useTriggerChatTransport<typeof myChat>({
      task: "my-chat",
      accessToken: ({ chatId }) => mintChatAccessToken(chatId),
      startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }),
      headStart: "/api/chat",
      sessions: initialSessions,
    });
    ```

    This isn't a `useChat` `api` URL under a different name. It's the first-turn shortcut only; the transport stops POSTing to it as soon as a session exists.
  </Step>
</Steps>

Persistence doesn't change. The handover carries one stable assistant message id across both halves of the turn, so `onTurnComplete` still fires once with the whole message, and `hydrateMessages` still receives the first-turn history as `incomingMessages` — with one caveat: a head-start turn skips preload entirely, so a hydrate hook that assumes its conversation row already exists has to upsert rather than update.

If the first message gets captured somewhere other than the chat page — a "new chat" prompt box that navigates to `/chats/{id}` — there's no open connection to stream step 1 into. Use [`chat.startHeadStart`](/docs/ai-chat/fast-starts#detached-head-start) instead: it drains step 1 into the durable session stream and the destination page resumes it.

<Note>
  Head Start and [Preload](/docs/ai-chat/fast-starts#preload) solve the same problem from opposite ends, and running both for one chat is wasted work. Preload is the answer when there's no warm server to run step 1 in — a browser-only chat surface, say. [Picking an approach](/docs/ai-chat/fast-starts#picking-an-approach) compares them.
</Note>

## What you get once you're moved over

* **Turns aren't bounded by a function timeout.** A tool-heavy turn can run for minutes without a platform deadline to work around.
* **Mid-stream refreshes resume**, with no Redis and no resumable-stream package.
* **Idle gaps are cheap.** After 30 seconds of quiet the run is suspended and its compute freed; the next message restores the process — memory, registers, open file descriptors — and execution continues from the line it parked on. In-memory caches and `chat.local` are still there.
* **Crashes are survivable.** A crash or OOM doesn't lose the conversation: the next message gets a fresh run with the history restored. That's a different mechanism from suspend/resume — a fresh run boots cold, so nothing that was on the heap comes back. Anything you need after a crash belongs in `onBoot` or your database. See [OOM resilience](/docs/ai-chat/patterns/oom-resilience).
* **Production primitives are built in**: [stop](/docs/ai-chat/frontend#stop-generation), [mid-turn steering](/docs/ai-chat/pending-messages), [human-in-the-loop approvals](/docs/ai-chat/patterns/human-in-the-loop), [sub-agents](/docs/ai-chat/patterns/sub-agents), [branching](/docs/ai-chat/patterns/branching-conversations), [compaction](/docs/ai-chat/compaction).
* **Every turn is observable** in the dashboard, and conversations are queryable via `sessions.list` for inbox-style UIs.

## Other frameworks

The shape is identical outside Next.js. The agent task and the React component don't change at all; only where the two server-side helpers live does.

* **Hono, SvelteKit, Express, Remix** — expose the token mint and the session start as two small POST endpoints instead of server actions, and point the transport's `accessToken` and `startSession` callbacks at them with `fetch`. Type the handlers with `AccessTokenParams` and `StartSessionParams` from `@trigger.dev/sdk/chat`. See [calling a fetch endpoint instead of a server action](/docs/ai-chat/frontend#calling-a-fetch-endpoint-instead-of-a-server-action).
* **Non-React clients** implement the same wire protocol directly — see [Client protocol](/docs/ai-chat/client-protocol).

## Gotchas

**The first response of a new chat is slower than the old route handler.** That's agent boot, and only the opening turn pays it. [Head Start](#keep-the-first-turn-fast-with-head-start) overlaps boot with the first model call and puts you back at the model's own TTFB.

**Head Start is on, and nothing got faster.** The route-handler bundle is pulling in the heavy side of your tools. Check what `lib/chat-tools/schemas.ts` imports transitively — `ai` and `zod` and nothing else.

**The head-start route dies mid-turn on Vercel.** The handler holds the SSE response open until the agent signals turn-complete, so the function timeout has to cover the whole turn, not just step 1. Set `maxDuration` on that route segment.

**Compaction and steering do nothing.** The `...chat.toStreamTextOptions()` spread is missing, or something before it in the object is overwriting `prepareStep`. Spread it as the first property.

**`toModelOutput` works on the first turn, then stops.** Tools are declared only on `streamText`. Declare the same set on `chat.agent({ tools })` too, and read it back off the `run` payload.

**Messages come out mangled or double-converted.** `run()` receives `ModelMessage[]`, not `UIMessage[]`. Delete the `convertToModelMessages` call you moved over from the route handler.

**Stop updates the UI but the model keeps going.** `signal` isn't being forwarded as `abortSignal`. After a resume, use `transport.stopGeneration(chatId)` rather than `useChat`'s `stop()`.

**The client bundle blows up, or the build fails on a Node import.** The agent module was imported as a value into a client component. Use `import type { myChat } from "@/trigger/chat"` — the type is all `useTriggerChatTransport<typeof myChat>` needs.

**The assistant message renders twice after a refresh.** The messages and `lastEventId` were written in two separate awaits, and a reload landed between them. Write both in one transaction.

**`chat.local can only be modified after initialization`.** It's being initialized in `onChatStart`, which only fires on a chat's first message. Move it to `onBoot`.

**A deploy went out and an open chat is still running the old code.** A run is pinned to the deploy version it started on, by design — a mid-conversation code swap would be a worse default. To opt in, call [`chat.requestUpgrade()`](/docs/ai-chat/patterns/version-upgrades) in `onTurnStart`: the current run exits without handling the turn, and the transport re-sends the message to a run on the latest version.

## Next steps

<CardGroup>
  <Card title="Backend" icon="server" href="/ai-chat/backend">
    Every `chat.agent` option, `chat.pipe`, custom data parts, and runtime config.
  </Card>

  <Card title="Lifecycle hooks" icon="list-check" href="/ai-chat/lifecycle-hooks">
    Every hook, its payload, and the exact per-turn firing order.
  </Card>

  <Card title="Database persistence" icon="database" href="/ai-chat/patterns/database-persistence">
    The full per-hook persistence mapping and the race conditions to avoid.
  </Card>

  <Card title="How it works" icon="diagram-project" href="/ai-chat/how-it-works">
    Sessions, runs, the durable channels, and what survives which failure.
  </Card>
</CardGroup>


# AI Agents
Source: https://trigger.dev/docs/ai-chat/overview

Durable multi-turn AI chats — one Trigger.dev task per conversation, surviving refreshes, deploys, and crashes.

An AI chat isn't a request — it's a session. `chat.agent` runs every conversation as a single long-lived Trigger.dev task: you write the loop, it wakes up when a message arrives, freezes when none do, and the same in-memory state and on-disk workspace survive across page refreshes, deploys, idle gaps, and crashes. The substrate handles the parts most teams stitch together by hand — turn lifecycle, mid-stream resume, recovery from cancel/crash/OOM, HITL approvals, deploy upgrades — so your code is the loop you'd write anyway: messages in, `streamText` out.

## A minimal example

A `chat.agent` task takes `messages`, calls `streamText`, and returns the result. The frontend wires the [Vercel AI SDK's `useChat`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) to a `TriggerChatTransport`. No API routes.

```ts trigger/chat.ts theme={"theme":"css-variables"}
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({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    }),
});
```

```tsx app/components/Chat.tsx theme={"theme":"css-variables"}
import { useChat } from "@ai-sdk/react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";

export function Chat() {
  const transport = useTriggerChatTransport<typeof myChat>({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  });
  const { messages, sendMessage } = useChat({ transport });
  // ... render UI
}
```

See [Quick Start](/docs/ai-chat/quick-start) for the matching server actions and a runnable project.

## Why use AI Agents on Trigger.dev

* **Resume across refreshes, deploys, and crashes.** A chat in progress keeps streaming through a redeploy, pinned to the version it started on. Move it onto new code when you choose with a [version upgrade](/docs/ai-chat/patterns/version-upgrades). Mid-stream refreshes pick up where they left off.
* **Native AI SDK support.** Text, tool calls, reasoning, and custom `data-*` parts all flow through `useChat` over a custom `ChatTransport`. No custom protocol to maintain.
* **Multi-turn for free.** Each turn is a step inside the same durable task; conversation history accumulates server-side, so clients only ship the new message.
* **Fast cold starts.** Opt-in [Head Start](/docs/ai-chat/fast-starts#head-start) runs the first `streamText` step in your warm Next.js / Hono / SvelteKit server while the agent boots in parallel — cuts time-to-first-chunk roughly in half.
* **Production primitives ship in the box.** Stop generation, steering, edits, branching, sub-agents, HITL tool approvals, version upgrades, recovery from cancel/crash/OOM — all first-class.
* **Observable.** Every turn is a span in the Trigger.dev dashboard. Sessions are queryable via `sessions.list` for inbox-style UIs.

## How it fits together

Three primitives, related but distinct:

* **Chat agents** — the SDK surface you define with [`chat.agent()`](/docs/ai-chat/backend#chat-agent). Owns the turn loop, lifecycle hooks, and the response stream.
* **Sessions** — the durable, bi-directional channel keyed on `chatId` that holds the conversation across run boundaries. A chat agent runs *on top of* a [Session](/docs/ai-chat/sessions).
* **Sub-agents** — Delegate work from one agent to another via [`AgentChat`](/docs/ai-chat/patterns/sub-agents). The sub-agent runs as its own durable agent on its own session; its response streams back through the parent as preliminary tool results, so the frontend sees the sub-agent working inside the parent's tool card.

## Next steps

<CardGroup>
  <Card title="Quick Start" icon="rocket" href="/ai-chat/quick-start">
    Get a working chat in three steps — agent, token, frontend.
  </Card>

  <Card title="How it works" icon="diagram-project" href="/ai-chat/how-it-works">
    Sessions, the turn loop, durable streams, and what survives a refresh.
  </Card>

  <Card title="Backend" icon="server" href="/ai-chat/backend">
    `chat.agent` options, lifecycle hooks, and the raw-task primitives.
  </Card>

  <Card title="Tools" icon="wrench" href="/ai-chat/tools">
    Declare tools so `toModelOutput` survives across turns, typed in `run()`.
  </Card>

  <Card title="Patterns" icon="puzzle-piece" href="/ai-chat/patterns/sub-agents">
    HITL approvals, branching, sub-agents, OOM/crash recovery.
  </Card>

  <Card title="Database connections" icon="database" href="/database-connections">
    Size and release connection pools so agents don't exhaust your database.
  </Card>
</CardGroup>


# Branching conversations
Source: https://trigger.dev/docs/ai-chat/patterns/branching-conversations

Build ChatGPT-style conversation trees with edit, regenerate, undo, and branch switching using hydrateMessages, chat.history, and actions.

Most chat UIs treat conversations as linear sequences. But real conversations branch — users edit previous messages, regenerate responses, undo exchanges, and explore alternative paths. This pattern shows how to build a branching conversation system using `hydrateMessages`, `chat.history`, and custom actions.

## Data model

The standard approach (used by ChatGPT, Open WebUI, LibreChat, and others) stores messages as a tree with parent pointers:

```ts theme={"theme":"css-variables"}
// Each message is a node in the tree
type ChatNode = {
  id: string;
  chatId: string;
  parentId: string | null; // null for root
  role: "user" | "assistant";
  message: UIMessage; // the full AI SDK message
  createdAt: Date;
};
```

A conversation is a tree of nodes. The **active branch** is resolved by walking from a leaf node up through `parentId` pointers to the root, then reversing:

```
root
├── user: "Hello"
│   └── assistant: "Hi there!"
│       ├── user: "What's the weather?" ← branch A
│       │   └── assistant: "It's sunny!"
│       └── user: "Tell me a joke" ← branch B (active)
│           └── assistant: "Why did the..."
```

Switching branches means changing which leaf is "active" — the same tree, different path.

## Backend setup

### Store: tree operations

Define helpers that read and write the node tree. Adapt to your database:

```ts theme={"theme":"css-variables"}
// Resolve the active path: walk from leaf to root, reverse
async function getActiveBranch(chatId: string): Promise<UIMessage[]> {
  const nodes = await db.chatNode.findMany({ where: { chatId } });
  const byId = new Map(nodes.map((n) => [n.id, n]));

  // Find active leaf (most recently created leaf node)
  const childIds = new Set(nodes.map((n) => n.parentId).filter(Boolean));
  const leaves = nodes.filter((n) => !childIds.has(n.id));
  const activeLeaf = leaves.sort((a, b) => b.createdAt - a.createdAt)[0];
  if (!activeLeaf) return [];

  // Walk to root
  const path: UIMessage[] = [];
  let current: ChatNode | undefined = activeLeaf;
  while (current) {
    path.unshift(current.message);
    current = current.parentId ? byId.get(current.parentId) : undefined;
  }
  return path;
}

// Append a message as a child of the current leaf
async function appendMessage(chatId: string, message: UIMessage): Promise<void> {
  const branch = await getActiveBranch(chatId);
  const parentId = branch.length > 0 ? branch[branch.length - 1]!.id : null;

  await db.chatNode.create({
    data: { id: message.id, chatId, parentId, role: message.role, message, createdAt: new Date() },
  });
}
```

### Agent: hydration + actions

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

export const myChat = chat.agent({
  id: "branching-chat",

  // Load the active branch from the DB on every turn.
  // The frontend's message array is ignored — the tree is the source of truth.
  hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
    if (trigger === "submit-message" && incomingMessages.length > 0) {
      await appendMessage(chatId, incomingMessages[incomingMessages.length - 1]!);
    }
    return getActiveBranch(chatId);
  },

  actionSchema: z.discriminatedUnion("type", [
    // Edit a previous user message — creates a sibling node in the tree
    z.object({ type: z.literal("edit"), messageId: z.string(), text: z.string() }),
    // Switch to a different branch by selecting a leaf node
    z.object({ type: z.literal("switch-branch"), leafId: z.string() }),
    // Undo the last user + assistant exchange
    z.object({ type: z.literal("undo") }),
  ]),

  onAction: async ({ action, chatId }) => {
    switch (action.type) {
      case "edit": {
        // Find the original message's parent, create a sibling with new content
        const original = await db.chatNode.findUnique({ where: { id: action.messageId } });
        if (!original) break;

        const newId = generateId();
        await db.chatNode.create({
          data: {
            id: newId,
            chatId,
            parentId: original.parentId, // same parent = sibling
            role: "user",
            message: { id: newId, role: "user", parts: [{ type: "text", text: action.text }] },
            createdAt: new Date(),
          },
        });
        // Active branch now resolves through the new sibling (most recent leaf)
        break;
      }

      case "switch-branch": {
        // Mark this leaf as the most recently accessed so getActiveBranch picks it
        await db.chatNode.update({
          where: { id: action.leafId },
          data: { createdAt: new Date() },
        });
        break;
      }

      case "undo": {
        // Remove the last two nodes (user + assistant) from the active branch
        const branch = await getActiveBranch(chatId);
        if (branch.length >= 2) {
          const lastTwo = branch.slice(-2);
          await db.chatNode.deleteMany({
            where: { id: { in: lastTwo.map((m) => m.id) } },
          });
        }
        break;
      }
    }

    // Reload the (now modified) active branch into the accumulator
    const updated = await getActiveBranch(chatId);
    chat.history.set(updated);
  },

  onTurnComplete: async ({ chatId, responseMessage }) => {
    // Persist the assistant's response as a new node
    if (responseMessage) {
      await appendMessage(chatId, responseMessage);
    }
  },

  run: async ({ messages, signal }) => {
    return streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

## Frontend

### Sending actions

Wire up edit, undo, and branch switching to the transport:

```tsx theme={"theme":"css-variables"}
function MessageActions({ message, chatId }: { message: UIMessage; chatId: string }) {
  const transport = useTransport();
  const [editing, setEditing] = useState(false);
  const [editText, setEditText] = useState("");

  if (message.role !== "user") return null;

  return (
    <div>
      {editing ? (
        <form onSubmit={() => {
          transport.sendAction(chatId, { type: "edit", messageId: message.id, text: editText });
          setEditing(false);
        }}>
          <input value={editText} onChange={(e) => setEditText(e.target.value)} />
          <button type="submit">Save</button>
        </form>
      ) : (
        <button onClick={() => { setEditText(getMessageText(message)); setEditing(true); }}>
          Edit
        </button>
      )}
    </div>
  );
}
```

### Branch navigation

To show the `< 2/3 >` sibling switcher, query the tree for siblings at each fork point. This is a frontend concern — the backend exposes the data, the UI navigates it.

```tsx theme={"theme":"css-variables"}
function BranchSwitcher({ message, chatId, siblings }: {
  message: UIMessage;
  chatId: string;
  siblings: { id: string; createdAt: string }[];
}) {
  const transport = useTransport();
  if (siblings.length <= 1) return null;

  const currentIndex = siblings.findIndex((s) => s.id === message.id);

  return (
    <div>
      <button
        disabled={currentIndex === 0}
        onClick={() => {
          // Find the leaf of the previous sibling's subtree
          transport.sendAction(chatId, {
            type: "switch-branch",
            leafId: siblings[currentIndex - 1]!.id,
          });
        }}
      >
        &lt;
      </button>
      <span>{currentIndex + 1}/{siblings.length}</span>
      <button
        disabled={currentIndex === siblings.length - 1}
        onClick={() => {
          transport.sendAction(chatId, {
            type: "switch-branch",
            leafId: siblings[currentIndex + 1]!.id,
          });
        }}
      >
        &gt;
      </button>
    </div>
  );
}
```

<Note>
  The sibling data (which messages share the same parent) needs to come from your database — query it when loading the chat or include it as client data. The agent only returns the active branch via `hydrateMessages`.
</Note>

## How it works

| Operation         | What happens                                                                                                                                                               |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Send message**  | `hydrateMessages` appends the new message as a child of the current leaf, returns the active path                                                                          |
| **Edit message**  | `onAction` creates a sibling node with the same parent. The new node becomes the latest leaf, so `hydrateMessages` resolves through it. LLM responds to the edited history |
| **Regenerate**    | Same as edit — create a new assistant sibling. The AI SDK's `regenerate()` handles this via `trigger: "regenerate-message"`                                                |
| **Undo**          | `onAction` removes the last two nodes. `chat.history.set()` updates the accumulator. LLM responds to the earlier state                                                     |
| **Switch branch** | `onAction` updates which leaf is "active". `hydrateMessages` loads the new path. LLM responds to the switched context                                                      |

## Design notes

* **Messages are immutable** — edits create siblings, not mutations. This preserves full history for analytics and auditing.
* **The tree lives in your database** — the agent loads a linear path from it via `hydrateMessages`. The agent itself doesn't know about the tree structure.
* **`hydrateMessages` + `onAction` + `chat.history`** are the three primitives. Hydration loads the active path, actions modify the tree, and `chat.history.set()` syncs the accumulator after tree modifications.
* **Frontend owns navigation** — the `< 2/3 >` UI, sibling queries, and branch switching triggers are client-side concerns. The backend just processes actions and returns responses.

## See also

* [`hydrateMessages`](/docs/ai-chat/lifecycle-hooks#hydratemessages) — backend-controlled message history
* [Actions](/docs/ai-chat/actions) — custom actions with `actionSchema` and `onAction`
* [`chat.history`](/docs/ai-chat/backend#chat-history) — imperative history mutations
* [Database persistence](/docs/ai-chat/patterns/database-persistence) — basic persistence pattern (linear)


# Code execution sandbox
Source: https://trigger.dev/docs/ai-chat/patterns/code-sandbox

Warm an isolated sandbox on each chat turn, run an AI SDK executeCode tool, and tear down right before the run suspends — using chat.agent hooks and chat.local.

Use a **hosted code sandbox** (for example [E2B](https://e2b.dev)) when the model should run short scripts to analyze tool output (PostHog queries, CSV-like data, math) without executing arbitrary code on the Trigger worker host.

This page describes a **durable chat** pattern that fits `chat.agent()`:

* **Warm** the sandbox at the start of each turn (**non-blocking**).
* **Reuse** it for every `executeCode` tool call during that turn (and across turns in the same run if you keep the handle).
* **Dispose** it **right before the run suspends** waiting for the next user message — using the **`onChatSuspend`** hook, not `onTurnComplete`.

## Why not tear down in `onTurnComplete`?

After a turn finishes, the chat runtime still goes through an **idle** window and only then suspends. During that window the run is still executing — useful for `chat.defer()` work — and the run hasn't suspended yet.

The boundary you want for “turn done, about to sleep” is **`onChatSuspend`**, which fires right before the run transitions from idle to suspended. It provides the `phase` (`”preload”` or `”turn”`) and full chat context. See [onChatSuspend / onChatResume](/docs/ai-chat/lifecycle-hooks#onchatsuspend--onchatresume).

```mermaid theme={"theme":"css-variables"}
sequenceDiagram
  participant TurnStart as onTurnStart
  participant Run as run / streamText
  participant TurnDone as onTurnComplete
  participant Idle as Idle window
  participant Suspend as onChatSuspend
  participant Sleep as suspended

  TurnStart->>Run: warm sandbox (async)
  Run->>TurnDone: persist / inject / etc.
  TurnDone->>Idle: still running
  Idle->>Suspend: dispose sandbox
  Suspend->>Sleep: waiting for next message
```

## Recommended provider: E2B

* **API key** auth — works from any Trigger.dev worker; no Vercel-only OIDC.
* **Code Interpreter** SDK (`@e2b/code-interpreter`): long-lived sandbox, `runCode()`, `kill()`.

Alternatives (Modal, Daytona, raw Docker) are fine but more DIY. Vercel’s sandbox + AI SDK helpers are a better fit when execution stays **on Vercel**, not on the Trigger worker.

## Implementation sketch

### 1. Run-scoped sandbox map

Keep a `Map<runId, Promise<Sandbox>>` (or similar) in a **task-only module** so your Next.js app never imports it.

### 2. `onTurnStart` — warm without blocking

```ts theme={"theme":"css-variables"}
onTurnStart: async ({ runId, ctx, ...rest }) => {
  warmCodeSandbox(runId); // fire-and-forget Sandbox.create()
  // ...persist messages, writer, etc.
},
```

### 3. `chat.local` — run id for tools

Tool `execute` functions do not receive hook payloads. Use [`chat.local()`](/docs/ai-chat/chat-local) to store the current run id for the sandbox key, **initialized from `onTurnStart`** (same `runId` as the map):

```ts theme={"theme":"css-variables"}
// In the same task module as your tools
import { chat } from "@trigger.dev/sdk/ai";

export const codeSandboxRun = chat.local<{ runId: string }>({ id: "codeSandboxRun" });

export function warmCodeSandbox(runId: string) {
  codeSandboxRun.init({ runId });
  // ...start Sandbox.create(), store promise in Map by runId
}
```

The **`executeCode`** tool reads `codeSandboxRun.runId` and awaits the sandbox promise before `runCode`.

### 4. `onChatSuspend` / `onComplete` — teardown

Use **`onChatSuspend`** to dispose the sandbox right before the run suspends, and **`onComplete`** as a safety net when the run ends entirely.

```ts theme={"theme":"css-variables"}
export const aiChat = chat.agent({
  id: "ai-chat",
  // ...
  onChatSuspend: async ({ phase, ctx }) => {
    await disposeCodeSandboxForRun(ctx.run.id);
  },
  onComplete: async ({ ctx }) => {
    await disposeCodeSandboxForRun(ctx.run.id);
  },
});
```

Unlike `onWait` (which fires for all wait types), `onChatSuspend` only fires at chat suspension points — no need to filter on `wait.type`. The `phase` discriminator tells you if this is a preload or post-turn suspension.

Optional **`onChatResume`**: log or reset flags; a fresh sandbox can be warmed again on the next **`onTurnStart`**.

### 5. AI SDK tool

Wrap the provider in a normal AI SDK `tool({ inputSchema, execute })` (same pattern as `webFetch`). Keep tool definitions in **task code**, not in the Next.js server bundle.

### 6. Environment

Set **`E2B_API_KEY`** (or your provider’s secret) on the **Trigger environment** for the worker — not in public client env.

## Typing `ctx`

Every `chat.agent` lifecycle event and the `run` payload include **`ctx`**: the same **[`TaskRunContext`](/docs/ai-chat/reference#task-context-ctx)** shape as `task({ run: (payload, { ctx }) => ... })`.

```ts theme={"theme":"css-variables"}
import type { TaskRunContext } from "@trigger.dev/sdk";
```

The alias **`Context`** is also exported from `@trigger.dev/sdk` and is the same type.

## See also

* [Database persistence for chat](/docs/ai-chat/patterns/database-persistence) — conversation + session rows, hooks, token renewal
* [Lifecycle hooks](/docs/ai-chat/lifecycle-hooks)
* [API Reference — `ctx` on events](/docs/ai-chat/reference#task-context-ctx)
* [Per-run data with `chat.local`](/docs/ai-chat/chat-local)


# Database persistence for chat
Source: https://trigger.dev/docs/ai-chat/patterns/database-persistence

Split conversation state and live session metadata across hooks — preload, turn start, turn complete — without tying the pattern to a specific ORM or schema.

Durable chat runs can span **hours** and **many turns**. You usually want:

1. **Conversation state** — full **`UIMessage[]`** (or equivalent) keyed by **`chatId`**, so reloads and history views work.
2. **Live session state** — a **scoped access token** for the session and optionally **`lastEventId`** for stream resume.

This page describes a **hook mapping** that works with any database. Adapt table and column names to your stack.

## Conceptual data model

You can use one table or two; the important split is **semantic**:

| Concept            | Purpose                               | Typical fields                                                                                                |
| ------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Conversation**   | Durable transcript + display metadata | Stable id (same as **`chatId`**), serialized **`uiMessages`**, title, model choice, owner/user id, timestamps |
| **Active session** | Hydrate the transport on page reload  | Same **`chatId`** as key (or FK), **`publicAccessToken`**, optional **`lastEventId`**                         |

The **conversation** row is what your UI lists as "chats." The **session** row is what the **transport** needs after a refresh: a session-scoped PAT (so the transport doesn't have to re-mint on first paint) and the SSE resume cursor.

Storing the current **`runId`** is optional — useful for telemetry / dashboard linking ("View this run") but not required for resume. The Session row owns its current run server-side; the transport reads from `session.out` keyed on `chatId`, so a run swap (continuation, upgrade) is invisible to your DB schema.

<Note>
  Store **`UIMessage[]`** in a JSON-compatible column, or normalize to a messages table — the pattern is *when* you read/write, not *how* you encode rows.
</Note>

## Where each hook writes

This pattern covers **durable DB rows** (the conversation and the active session). Per-process in-memory state ([`chat.local`](/docs/ai-chat/chat-local), [DB connection pools](/docs/database-connections), sandboxes, etc.) belongs in [`onBoot`](/docs/ai-chat/lifecycle-hooks#onboot) — it fires on every fresh worker including continuation runs, where `onPreload` and `onChatStart` do not.

### `onPreload` (optional)

When the user triggers [preload](/docs/ai-chat/fast-starts#preload), the run starts **before** the first user message.

* Ensure the **conversation** row exists (create or no-op).
* **Upsert session**: **`chatAccessToken`** from the event (a session-scoped PAT covering both `read:sessions:{chatId}` and `write:sessions:{chatId}`).
* Load any **user / tenant context** you need for prompts (`clientData`).

If you skip preload, do the equivalent in **`onChatStart`** when **`preloaded`** is false.

### `onChatStart` (chat's first message, non-preloaded path)

* Fires **once per chat**, on the very first user message. Does NOT fire on continuation runs (post-`endRun`, post-waitpoint-timeout, post-`chat.requestUpgrade`) or on OOM-retry attempts.
* If **`preloaded`** is true, return early — **`onPreload`** already ran.
* Otherwise mirror preload: user/context, conversation create, session upsert.
* No need to gate the conversation create on `continuation` — it's always a brand-new chat at this point.
* For continuation runs that need to refresh per-run state (new PAT, new `lastEventId`), do it in **`onTurnStart`** / **`onTurnComplete`** — both fire on every turn including the first turn of a continuation run.

### `onTurnStart`

* **`await`** persist **`uiMessages`** (full accumulated history including the new user turn) **before** the hook returns — `chat.agent` does not begin streaming until `onTurnStart` resolves, so this is what bounds "user message is durable before the stream".

<Warning>
  **Don't use [`chat.defer()`](/docs/ai-chat/background-injection#chat-defer-standalone) for the message write here.** `chat.defer` is fire-and-forget — the hook resolves before the write lands and the stream starts immediately. If the user refreshes mid-stream, the next page load reads `[]` from your DB, the resumed SSE stream pushes the assistant into an empty array, and the user's message disappears from the rendered conversation forever.

  ```ts theme={"theme":"css-variables"}
  // ❌ Bad — non-blocking write, mid-stream refresh drops the user message.
  onTurnStart: async ({ chatId, uiMessages }) => {
    chat.defer(db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }));
  },

  // ✅ Good — awaited, durable before the model starts.
  onTurnStart: async ({ chatId, uiMessages }) => {
    await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
  },
  ```

  `chat.defer` is for writes whose timing doesn't matter for resume — analytics, audit logs, search-index updates, etc. Anything the next page load reads needs to land before the stream begins.
</Warning>

### `onTurnComplete`

* Persist **`uiMessages`** again with the **assistant** reply finalized.
* **Upsert session** with the fresh **`chatAccessToken`** and **`lastEventId`** from the event.

**`lastEventId`** lets the frontend [resume](/docs/ai-chat/frontend) without replaying SSE events it already applied. Treat it as part of session state, not optional polish, if you care about duplicate chunks after refresh.

<Warning>
  **Write the messages and `lastEventId` in a single transaction.** Both values are read in parallel on the next page load (one fetches the conversation, the other fetches the session). If a refresh races between the two writes, the page can see the assistant message persisted (full history) but a stale `lastEventId` from the previous turn. The transport then resumes from that stale cursor and replays this turn's chunks on top of the already-persisted assistant message, producing a duplicated render.

  ```ts theme={"theme":"css-variables"}
  // ✅ Atomic — refresh on the next page load reads both writes consistently.
  await db.$transaction([
    db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }),
    db.chatSession.upsert({
      where: { id: chatId },
      create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId },
      update: { publicAccessToken: chatAccessToken, lastEventId },
    }),
  ]);

  // ❌ Two awaits — narrow race window where messages are post-write but
  // lastEventId is still pre-write. A page refresh that lands here will
  // duplicate the assistant message on resume.
  await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
  await db.chatSession.upsert({ /* ... */ });
  ```
</Warning>

## Token renewal (app server)

The persisted PAT has a TTL (see **`chatAccessTokenTTL`** on **`chat.agent`**, default 1h). When the transport gets a **401** on a session-PAT-authed request, it calls your **`accessToken`** callback to mint a fresh PAT — no DB lookup required, since the session is keyed on `chatId` (which the transport already has).

Your `accessToken` callback typically just wraps `auth.createPublicToken`:

```ts theme={"theme":"css-variables"}
"use server";
import { auth } from "@trigger.dev/sdk";

export async function mintChatAccessToken(chatId: string) {
  return auth.createPublicToken({
    scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
    expirationTime: "1h",
  });
}
```

If you want to keep your DB session row in sync, the transport's **`onSessionChange`** callback fires every time the cached PAT changes — persist the new value there.

No Trigger task code needs to run for renewal.

## Minimal pseudocode

```typescript theme={"theme":"css-variables"}
// Pseudocode — replace saveConversation / saveSession with your DB layer.

chat.agent({
  id: "my-chat",
  clientDataSchema: z.object({ userId: z.string() }),

  onPreload: async ({ chatId, chatAccessToken, clientData }) => {
    if (!clientData) return;
    await ensureUser(clientData.userId);
    await upsertConversation({ id: chatId, userId: clientData.userId /* ... */ });
    await upsertSession({ chatId, publicAccessToken: chatAccessToken });
  },

  onChatStart: async ({ chatId, chatAccessToken, clientData, preloaded }) => {
    if (preloaded) return;
    // Fires once per chat — no continuation gate needed.
    await ensureUser(clientData.userId);
    await upsertConversation({ id: chatId, userId: clientData.userId /* ... */ });
    await upsertSession({ chatId, publicAccessToken: chatAccessToken });
  },

  onTurnStart: async ({ chatId, uiMessages }) => {
    // Awaited, not chat.defer — see the warning in `onTurnStart` above.
    await saveConversationMessages(chatId, uiMessages);
  },

  onTurnComplete: async ({ chatId, uiMessages, chatAccessToken, lastEventId }) => {
    // Atomic: messages + lastEventId must be readable consistently on resume.
    // See the warning above for why a non-atomic write causes duplicate renders.
    await db.$transaction([
      saveConversationMessagesQuery(chatId, uiMessages),
      upsertSessionQuery({ chatId, publicAccessToken: chatAccessToken, lastEventId }),
    ]);
  },

  run: async ({ messages, signal }) => {
    /* streamText, etc. */
  },
});
```

## Alternative: `hydrateMessages`

For apps that need the backend to be the single source of truth for message history — abuse prevention, branching conversations, or rollback support — use [`hydrateMessages`](/docs/ai-chat/lifecycle-hooks#hydratemessages) instead of relying on the frontend's accumulated state.

With hydration, the hook loads messages from your database on every turn. The frontend's messages are ignored (except for the new user message, which arrives in `incomingMessages`):

```ts theme={"theme":"css-variables"}
import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";

export const myChat = chat.agent({
  id: "my-chat",
  hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
    const record = await db.chat.findUnique({ where: { id: chatId } });
    const stored = record?.messages ?? [];

    // `upsertIncomingMessage` pushes a fresh user message and no-ops
    // on HITL continuations (the runtime overlays the new tool-state
    // advance onto the existing entry). See lifecycle hooks for the
    // full pattern: /ai-chat/lifecycle-hooks#hydratemessages
    if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
      // Upsert, not update: on a head-start first turn no preload ran,
      // so the row may not exist yet when this hook fires.
      await db.chat.upsert({
        where: { id: chatId },
        create: { id: chatId, messages: stored },
        update: { messages: stored },
      });
    }

    return stored;
  },
  onTurnComplete: async ({ chatId, uiMessages, chatAccessToken, lastEventId }) => {
    // Persist the response and refresh session state atomically — see the
    // warning in the previous section for why these two writes have to be
    // in the same transaction.
    await db.$transaction([
      db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }),
      db.chatSession.upsert({
        where: { id: chatId },
        create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId },
        update: { publicAccessToken: chatAccessToken, lastEventId },
      }),
    ]);
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

This replaces the `onTurnStart` persistence pattern — the hook handles both loading and persisting the new message in one place.

Hydration composes with [Head Start](/docs/ai-chat/fast-starts#with-hydratemessages): on a head-start first turn the route handler's history arrives as `incomingMessages`, and the write path must be an upsert because no preload ran to create the row.

## Design notes

* **`chatId`** is stable for the life of a thread and is the only identifier the transport persists. Runs come and go (idle continuation, upgrade, cancel/restart) but the chat keeps its identity.
* **`continuation: true`** means "same logical chat, new run" — refresh the persisted PAT, don't assume an empty conversation.
* The current `runId` is available on every hook event for telemetry / dashboard linking ("View this run"), but you don't need to persist it for resume to work — the transport addresses by `chatId`.
* Keep **task modules** that perform writes **out of** browser bundles; the pattern assumes persistence runs **in the worker** (or your BFF that the task calls).

## Complete example

End-to-end implementation across the three files involved: agent task, server actions, and React component.

<Warning>
  The example below trusts raw `chatId` and returns rows without filtering by user. In a real multi-user app, **scope every query by the authenticated user** — read the user from your auth/session in each server action and add `where: { userId }` to all `db.chat.*` and `db.chatSession.*` queries. Without that, one client could read or delete another user's chat state, and `getAllSessions()` would leak other users' `publicAccessToken`s. The snippet keeps auth out of the way to focus on the persistence shape.
</Warning>

<CodeGroup>
  ```ts trigger/chat.ts theme={"theme":"css-variables"}
  import { chat } from "@trigger.dev/sdk/ai";
  import { streamText, stepCountIs } from "ai";
  import { anthropic } from "@ai-sdk/anthropic";
  import { z } from "zod";
  import { db } from "@/lib/db";

  export const myChat = chat.agent({
    id: "my-chat",
    clientDataSchema: z.object({
      userId: z.string(),
    }),
    onChatStart: async ({ chatId, clientData }) => {
      await db.chat.create({
        data: { id: chatId, userId: clientData.userId, title: "New chat", messages: [] },
      });
    },
    onTurnStart: async ({ chatId, uiMessages, runId, chatAccessToken }) => {
      // Persist messages + session before streaming
      await db.chat.update({
        where: { id: chatId },
        data: { messages: uiMessages },
      });
      await db.chatSession.upsert({
        where: { id: chatId },
        create: { id: chatId, runId, publicAccessToken: chatAccessToken },
        update: { runId, publicAccessToken: chatAccessToken },
      });
    },
    onTurnComplete: async ({ chatId, uiMessages, runId, chatAccessToken, lastEventId }) => {
      // Persist assistant response + stream position atomically — see the
      // race-condition warning earlier on this page.
      await db.$transaction([
        db.chat.update({
          where: { id: chatId },
          data: { messages: uiMessages },
        }),
        db.chatSession.upsert({
          where: { id: chatId },
          create: { id: chatId, runId, publicAccessToken: chatAccessToken, lastEventId },
          update: { runId, publicAccessToken: chatAccessToken, lastEventId },
        }),
      ]);
    },
    run: async ({ messages, signal }) => {
      return streamText({
        model: anthropic("claude-sonnet-4-5"),
        messages,
        abortSignal: signal,
        stopWhen: stepCountIs(15),
      });
    },
  });
  ```

  ```ts app/actions.ts theme={"theme":"css-variables"}
  "use server";

  import { auth } from "@trigger.dev/sdk";
  import { chat } from "@trigger.dev/sdk/ai";
  import { db } from "@/lib/db";

  export const startChatSession = chat.createStartSessionAction("my-chat");

  export async function mintChatAccessToken(chatId: string) {
    return auth.createPublicToken({
      scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
      expirationTime: "1h",
    });
  }

  export async function getChatMessages(chatId: string) {
    const found = await db.chat.findUnique({ where: { id: chatId } });
    return found?.messages ?? [];
  }

  export async function getAllSessions() {
    const sessions = await db.chatSession.findMany();
    const result: Record<
      string,
      {
        publicAccessToken: string;
        lastEventId?: string;
      }
    > = {};
    for (const s of sessions) {
      result[s.id] = {
        publicAccessToken: s.publicAccessToken,
        lastEventId: s.lastEventId ?? undefined,
      };
    }
    return result;
  }

  export async function deleteSession(chatId: string) {
    await db.chatSession.delete({ where: { id: chatId } }).catch(() => {});
  }
  ```

  ```tsx app/components/chat.tsx theme={"theme":"css-variables"}
  "use client";

  import { useChat } from "@ai-sdk/react";
  import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
  import type { myChat } from "@/trigger/chat";
  import { mintChatAccessToken, startChatSession, deleteSession } from "@/app/actions";

  export function Chat({ chatId, initialMessages, initialSessions }) {
    const transport = useTriggerChatTransport<typeof myChat>({
      task: "my-chat",
      accessToken: ({ chatId }) => mintChatAccessToken(chatId),
      startSession: ({ chatId, clientData }) =>
        startChatSession({ chatId, clientData }),
      clientData: { userId: currentUser.id }, // Type-checked against clientDataSchema
      sessions: initialSessions,
      onSessionChange: (id, session) => {
        if (!session) deleteSession(id);
      },
    });

    const { messages, sendMessage, stop, status } = useChat({
      id: chatId,
      messages: initialMessages,
      transport,
      resume: initialMessages.length > 0,
    });

    return (
      <div>
        {messages.map((m) => (
          <div key={m.id}>
            <strong>{m.role}:</strong>
            {m.parts.map((part, i) =>
              part.type === "text" ? <span key={i}>{part.text}</span> : null
            )}
          </div>
        ))}

        <form
          onSubmit={(e) => {
            e.preventDefault();
            const input = e.currentTarget.querySelector("input");
            if (input?.value) {
              sendMessage({ text: input.value });
              input.value = "";
            }
          }}
        >
          <input placeholder="Type a message..." />
          <button type="submit" disabled={status === "streaming"}>
            Send
          </button>
          {status === "streaming" && (
            <button type="button" onClick={stop}>
              Stop
            </button>
          )}
        </form>
      </div>
    );
  }
  ```
</CodeGroup>

## See also

* [Lifecycle hooks](/docs/ai-chat/lifecycle-hooks)
* [Session management](/docs/ai-chat/frontend#session-management) — `resume`, `lastEventId`, transport
* [`chat.defer()`](/docs/ai-chat/background-injection#chat-defer-standalone) — non-blocking writes during a turn
* [Code execution sandbox](/docs/ai-chat/patterns/code-sandbox) — combines **`onWait`** / **`onComplete`** with this persistence model


# Human-in-the-loop
Source: https://trigger.dev/docs/ai-chat/patterns/human-in-the-loop

Pause the agent mid-response to ask the user a clarifying question, then resume with their answer.

Some turns need to stop and ask the user something before they can finish — picking between options, confirming a destructive action, or clarifying an ambiguous request. The AI SDK calls this **human-in-the-loop** (HITL), and the building block is a tool with no `execute` function.

When the LLM calls a tool that has no `execute`, `streamText` ends with the tool call still pending. The turn completes cleanly, the frontend renders UI to collect the answer, and when the user responds, a new turn resumes with the answer merged into the same assistant message.

## How it works

```
Turn N:
  User message → run()
  LLM streams text → calls askUser tool (no execute)
  streamText ends with tool-call in `input-available` state
  onTurnComplete fires (finishReason = "tool-calls")
  Agent suspends (compute freed) — maxDuration does not tick while paused

Frontend:
  Renders question + option buttons from tool input
  User clicks → addToolOutput({ tool, toolCallId, output })
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls
  → sendMessage() fires next turn

Turn N+1:
  hydrateMessages / accumulator sees the updated assistant message
  run() is called, LLM continues from the tool result
  onTurnComplete fires (finishReason = "stop", responseMessage is the FULL merged message)
```

The AI SDK's `toUIMessageStream` automatically reuses the assistant message ID across the pause (we pass `originalMessages` internally), so `responseMessage` in the post-resume `onTurnComplete` is the **full merged message** — the original text, the completed tool call, and any follow-up content — not just the new parts.

## Duration and cost while paused

A pause doesn't hold compute. After the model calls a no-execute tool, the turn finishes and the run stays warm for `idleTimeoutInSeconds` (default 30s), then **suspends** and frees its compute, the same way [`wait.for`](/docs/wait-for) does. The user's `addToolOutput` wakes it back up.

Because the run is suspended while it waits, the human's thinking time is not billed and does **not** count against [`maxDuration`](/docs/runs/max-duration). `maxDuration` measures active CPU time and excludes suspended waitpoint time, exactly like `wait.for`, so a user can take minutes, hours, or days to answer without the run hitting `maxDuration`. The only time that counts is each turn's actual compute plus the short warm window before each suspend.

You don't need to raise `maxDuration` or end the run to support long human waits. How long a single suspended pause stays open is governed by the run's suspend timeout, not `maxDuration`; if a wait outlives it the run ends, and the next `addToolOutput` boots a fresh continuation that picks up the resolved tool result.

## Backend: define the tool

A HITL tool has an `inputSchema` describing what the model can ask, but **no `execute` function**. When the LLM calls it, `streamText` returns control to your agent.

```ts trigger/my-chat.ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, tool, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

const askUser = tool({
  description:
    "Ask the user a clarifying question when you need their input. " +
    "Present 2-4 options for them to pick from.",
  inputSchema: z.object({
    question: z.string(),
    options: z
      .array(
        z.object({
          id: z.string(),
          label: z.string(),
          description: z.string().optional(),
        })
      )
      .min(2)
      .max(4),
  }),
  // No execute function — streamText ends, the frontend supplies the output
  // via addToolOutput, and the next turn continues from the result.
});

export const myChat = chat.agent({
  id: "my-chat",
  tools: { askUser },
  run: async ({ messages, tools, signal }) => {
    return streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      tools,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

Declaring `tools` on the config (and reading them back from the payload) is the recommended shape for any agent with tools. See [Tools](/docs/ai-chat/tools).

## Frontend: render the question and collect the answer

Two pieces on the client:

1. **UI for the pending tool call** — render when the tool part is in `input-available` state, i.e. the LLM has called the tool but there's no output yet.
2. **Auto-send on resolution** — use `sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls` so answering kicks off the next turn without the user having to hit "send."

```tsx theme={"theme":"css-variables"}
import { useChat, lastAssistantMessageIsCompleteWithToolCalls } from "@ai-sdk/react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";

function ChatView({ chatId }: { chatId: string }) {
  const transport = useTriggerChatTransport({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  });
  const { messages, sendMessage, addToolOutput } = useChat({
    id: chatId,
    transport,
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
  });

  return (
    <>
      {messages.map((m) =>
        m.parts.map((part, i) => {
          if (part.type === "tool-askUser" && part.state === "input-available") {
            return (
              <AskUserCard
                key={i}
                question={part.input.question}
                options={part.input.options}
                onAnswer={(opt) =>
                  addToolOutput({
                    tool: "askUser",
                    toolCallId: part.toolCallId,
                    output: { optionId: opt.id, label: opt.label },
                  })
                }
              />
            );
          }
          if (part.type === "text") return <Markdown key={i}>{part.text}</Markdown>;
          return null;
        })
      )}
    </>
  );
}
```

`addToolOutput` patches the assistant message locally with `state: "output-available"` and fills in `output`. `lastAssistantMessageIsCompleteWithToolCalls` detects that every pending tool call now has a result, and `useChat` fires a new `sendMessage` — the backend picks it up as the next turn.

## Detecting a paused turn in `onTurnComplete`

Two ways to detect "this turn paused for user input" vs "this turn finished normally":

### Via `finishReason` (recommended)

The AI SDK's finish reason is surfaced on every `onTurnComplete` event. If the model stopped on tool calls, it's `"tool-calls"`:

```ts theme={"theme":"css-variables"}
onTurnComplete: async ({ finishReason, responseMessage }) => {
  if (finishReason === "tool-calls") {
    // Turn paused — assistant message has pending tool call(s)
    const pending = responseMessage?.parts.filter(
      (p) => p.type.startsWith("tool-") && p.state === "input-available"
    );
    // Persist as a checkpoint / partial turn
  } else {
    // finishReason === "stop" — normal completion
    // Persist as a completed turn
  }
};
```

<Note>
  `finishReason` is only undefined for manual `chat.pipe()` flows or aborted streams. For the common `run() → return streamText(...)` pattern it's always populated.
</Note>

### Via response parts

If you need more nuance (e.g. which specific tool is pending), use `chat.history.getPendingToolCalls()`:

```ts theme={"theme":"css-variables"}
const pending = chat.history.getPendingToolCalls();
// [{ toolCallId, toolName, messageId }]
```

The result reflects the most recent assistant message: the one waiting on `addToolOutput`. Use it from `onAction` to gate fresh user turns ("can't send a new message while a HITL is open"), or from `onTurnComplete` to decide what to persist.

Both `finishReason === "tool-calls"` and `chat.history.getPendingToolCalls().length > 0` are equivalent in practice. Use `finishReason` for dispatch, the helper for detail.

### Acting once per net-new tool result

When the user's `addToolOutput` round-trips a tool answer back to the agent, the wire message carries the resolved tool part. If you want to fire side-effects (audit log, billing, notifications) exactly once per resolved tool call, do it in `hydrateMessages` before the runtime merges. `chat.history.extractNewToolResults(message)` returns only the parts whose `toolCallId` isn't already resolved on the chain:

```ts theme={"theme":"css-variables"}
hydrateMessages: async ({ incomingMessages }) => {
  for (const msg of incomingMessages) {
    if (msg.role !== "assistant") continue;
    for (const r of chat.history.extractNewToolResults(msg)) {
      await auditLog.record({
        toolCallId: r.toolCallId,
        toolName: r.toolName,
        output: r.output,
        errorText: r.errorText, // set only for output-error parts
      });
    }
  }
  return incomingMessages;
},
```

`extractNewToolResults` compares against the current `chat.history`. By the time `onTurnComplete` fires, the chain already contains `responseMessage`, so the helper returns `[]` there. Use it where the message is from outside the accumulator: `hydrateMessages`, `onAction` if the action carries a message, or any custom pre-merge code path.

## Persistence: one message vs one record per pause

Because the AI SDK reuses the assistant message ID across the pause, the "same turn" from the user's perspective maps to **two `onTurnComplete` firings** on the server — but both receive a `responseMessage` with the **same `id`**, and the second firing's `responseMessage` contains the fully merged content.

Two common persistence patterns:

### Overwrite on every turn (simplest)

Just store the latest `uiMessages` array on every `onTurnComplete`. The paused-turn write is overwritten by the resume-turn write; the final DB state has the full merged message.

```ts theme={"theme":"css-variables"}
onTurnComplete: async ({ chatId, uiMessages }) => {
  await db.chat.update({
    where: { id: chatId },
    data: { messages: uiMessages },
  });
},
```

Use this unless you specifically need an audit trail.

### Checkpoint nodes (immutable history)

For apps that want every pause point recorded as its own immutable snapshot (branching, replay, diff review), save a checkpoint when paused and a sibling when complete:

```ts theme={"theme":"css-variables"}
onTurnComplete: async ({ chatId, responseMessage, finishReason, uiMessages }) => {
  if (!responseMessage) return;

  if (finishReason === "tool-calls") {
    // Paused — save a checkpoint
    await db.turnCheckpoint.create({
      data: {
        chatId,
        messageId: responseMessage.id,
        parts: responseMessage.parts,
        kind: "partial",
      },
    });
  } else {
    // Completed — save a sibling with the merged full message
    await db.turnCheckpoint.create({
      data: {
        chatId,
        messageId: responseMessage.id,
        parts: responseMessage.parts,
        kind: "final",
      },
    });
  }

  // Always update the canonical chat record for `hydrateMessages` to load
  await db.chat.update({
    where: { id: chatId },
    data: { messages: uiMessages },
  });
};
```

Both writes see `responseMessage.id` as the same value — they're checkpoints of the same logical message. Grouping by `messageId` + ordering by `createdAt` gives you the progression.

## Multi-pause turns

A single logical turn can pause more than once — the LLM asks question A, gets the answer, thinks, then asks question B before finishing. Each pause fires its own `onTurnComplete` with `finishReason === "tool-calls"`; only the last firing has `finishReason === "stop"`. The checkpoint pattern above handles this naturally — each pause adds a new checkpoint sharing the same `responseMessage.id`.

## Gotchas

* **Don't set an `execute` function on the HITL tool.** If it has one, `streamText` will call it immediately instead of handing control back.
* **The frontend must use `sendAutomaticallyWhen`.** Without it, the user has to press Enter after answering — `addToolOutput` updates local state but doesn't fire a new turn by itself.
* **Don't mutate `responseMessage` in `onTurnComplete`.** It's the captured snapshot. To add custom parts, use `chat.response.append()` in `onBeforeTurnComplete` (while the stream is open).
* **Stop handling.** If the user stops the run while a pause is active (`chat.stop()` on the transport), `onTurnComplete` fires with `stopped: true` and `finishReason` reflecting the last successful step. Treat stopped paused turns the same as stopped normal turns.


# Large payloads in chat.agent
Source: https://trigger.dev/docs/ai-chat/patterns/large-payloads

Why a single chunk on the chat stream is capped at ~1 MiB, what error you'll see, and how to work around it with ID references.

The realtime stream that backs `chat.agent` enforces a **per-record cap of \~1 MiB** (`1048576` bytes minus a small envelope reserve). Anything written through the chat output — auto-piped LLM chunks, `chat.response.write`, custom `writer.write` parts — counts as one record per chunk and is rejected if it crosses the cap.

This is a platform-level limit and cannot be raised per project or per stream.

## What you'll see

When a chunk crosses the cap, the run fails with a typed [`ChatChunkTooLargeError`](/docs/ai-chat/error-handling):

```
ChatChunkTooLargeError: chat.agent chunk of type "tool-output-available" is 2000126 bytes,
over the realtime stream's per-record cap of 1047552 bytes. For oversized payloads
(e.g. large tool outputs), write the value to your own store and emit only an id/url
through the chat stream — see https://trigger.dev/docs/ai-chat/patterns/large-payloads.
```

The error includes:

* `chunkType` — discriminant on the chunk that failed (e.g. `tool-output-available`, `data-handover`, `text-delta`).
* `chunkSize` — UTF-8 byte count of the JSON-serialized record.
* `maxSize` — the effective cap.

You can catch and re-throw / log it explicitly:

```ts theme={"theme":"css-variables"}
import { ChatChunkTooLargeError, isChatChunkTooLargeError } from "@trigger.dev/sdk";

try {
  await someWrite();
} catch (err) {
  if (isChatChunkTooLargeError(err)) {
    logger.error("Oversized chunk", { type: err.chunkType, size: err.chunkSize });
  }
  throw err;
}
```

## Most common cause: large tool outputs

If you return a `streamText` result from `run()`, the AI SDK auto-pipes its `UIMessageStream` into the chat output. A tool whose result object is large (a fetched HTML body, a CSV blob, an image as base64, a deep DB row dump) gets emitted as one `tool-output-available` chunk — and that's the chunk that overruns.

**Diagnose first**: log tool sizes during development.

```ts theme={"theme":"css-variables"}
const fetchPage = tool({
  inputSchema: z.object({ url: z.string().url() }),
  execute: async ({ url }) => {
    const html = await (await fetch(url)).text();
    if (html.length > 500_000) {
      logger.warn("Large tool output", { tool: "fetchPage", bytes: html.length });
    }
    return { html };
  },
});
```

If the size is unbounded by input, fix the tool — not the stream.

## ID-reference pattern

Store the large value in your own database (or object store) and emit only an identifier through the chat stream. The frontend fetches the full payload separately on demand.

This keeps the chat stream small, predictable, and resumable, and lets you reuse the value across turns or sessions without re-streaming it.

<CodeGroup>
  ```ts task.ts theme={"theme":"css-variables"}
  import { chat } from "@trigger.dev/sdk/ai";
  import { tool } from "ai";
  import { z } from "zod";

  const fetchPage = tool({
    description: "Fetch a URL and store the HTML for later inspection.",
    inputSchema: z.object({ url: z.string().url() }),
    execute: async ({ url }) => {
      const html = await (await fetch(url)).text();
      const docId = await db.documents.create({
        data: { url, html, byteSize: html.length },
      });

      // Tool result is small — just an id and metadata.
      // The model and the UI both work with this lightweight handle.
      return {
        docId,
        url,
        byteSize: html.length,
        preview: html.slice(0, 500),
      };
    },
  });
  ```

  ```ts api/document/[id]/route.ts theme={"theme":"css-variables"}
  // Frontend fetches the full document on demand.
  import { auth, currentUser } from "@/lib/auth";

  export async function GET(_req: Request, { params }: { params: { id: string } }) {
    const user = await currentUser();
    const doc = await db.documents.findUniqueOrThrow({
      where: { id: params.id, userId: user.id },
    });
    return new Response(doc.html, { headers: { "content-type": "text/html" } });
  }
  ```

  ```tsx component.tsx theme={"theme":"css-variables"}
  function ToolResultCard({ part }: { part: ToolUIPart<"fetchPage"> }) {
    const { docId, url, byteSize, preview } = part.output;
    return (
      <div>
        <p>{url} — {(byteSize / 1024).toFixed(0)} KB</p>
        <pre>{preview}…</pre>
        <a href={`/api/document/${docId}`}>Open full HTML</a>
      </div>
    );
  }
  ```
</CodeGroup>

The same pattern works for `chat.response.write` — push the heavy value to your DB, then emit a small data part with the id:

```ts theme={"theme":"css-variables"}
const id = await db.attachments.create({ data: { content: hugeReport } });
chat.response.write({ type: "data-report", data: { id, summary: shortSummary } });
```

<Tip>
  Persist the large value **before** you emit the id chunk. If the chunk reaches the UI before the row is written, the frontend gets a 404 on the follow-up fetch.
</Tip>

## Transient UI parts

For progress indicators or status data that should stream to the UI but not persist into the response message, use `chat.response.write` with `transient: true`. The chunk still travels on the chat stream (so the 1 MiB per-record cap still applies), but it never lands in `responseMessage` or `uiMessages`:

```ts theme={"theme":"css-variables"}
chat.response.write({
  type: "data-progress",
  data: { percent: 50 },
  transient: true,
});
```

For genuinely high-volume diagnostic data (per-token traces, large debug dumps), don't try to ship it through the realtime stream at all. Log to your own store (DB, object storage, OTel logger) and surface it through a separate UI route that isn't tied to the chat session.

## What does **not** trigger the cap

These calls don't go through the realtime stream and have no per-record cap:

* [`chat.history.set` / `slice` / `replace` / `remove`](/docs/ai-chat/backend#chat-history) — locals-only mutations on the in-memory message list.
* [`chat.inject`](/docs/ai-chat/background-injection#chat-inject) — appends to the run's pending message queue, not the stream.
* [`chat.defer`](/docs/ai-chat/background-injection#chat-defer-standalone) — promise registry; awaited at turn boundaries, never serialized to the stream.

The control markers `chat.agent` emits internally (`trigger:turn-complete`, `trigger:upgrade-required`) are tiny by construction.

## See also

* [Error handling](/docs/ai-chat/error-handling) — how `ChatChunkTooLargeError` flows through the layers.
* [Database persistence](/docs/ai-chat/patterns/database-persistence) — your own store as the durable backing for ID references.
* [Client protocol](/docs/ai-chat/client-protocol) — chunk shapes that travel on the chat stream.


# OOM resilience
Source: https://trigger.dev/docs/ai-chat/patterns/oom-resilience

Recover from out-of-memory errors mid-turn by automatically retrying the failed turn on a larger machine — without losing the in-flight user message or re-processing completed turns.

When a `chat.agent` turn runs out of memory, the worker process dies and everything in it is gone: the in-flight LLM call, the accumulator, any tool execution mid-flight. By default, Trigger.dev surfaces the OOM as a run failure.

Setting `oomMachine` opts the agent into automatic recovery: the failed turn re-runs on a larger machine, picks up the user message that triggered the OOM (without re-processing earlier completed turns), and produces a normal response.

## Setup

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";

export const myChat = chat.agent({
  id: "my-chat",
  machine: "small-1x",         // default machine
  oomMachine: "medium-2x",     // fallback on OOM
  run: async ({ messages, signal }) =>
    streamText({ model, messages, abortSignal: signal }),
});
```

That's the entire opt-in. With `oomMachine` set, the agent gets:

* **`retry.maxAttempts: 2`** internally — one retry for OOM only; non-OOM errors don't retry.
* **`retry.outOfMemory.machine: oomMachine`** — the fresh attempt boots on the larger machine.
* **`session.in` cursor recovery** — the new attempt skips records belonging to turns that already completed on the prior attempt and only re-runs the OOM'd turn.

`chat.agent` does not expose generic `retry` options. OOM recovery is the only retry path because retrying an LLM-driven loop on non-OOM errors tends to be expensive and side-effecting. Drop down to a [raw `task()` with chat primitives](/docs/ai-chat/custom-agents) if you need richer retry semantics.

## How recovery works

The recovery doesn't need any customer-side persistence to avoid duplicate processing. It uses two pieces of durable state Trigger already maintains for every chat:

* **`session.out`** — the durable response stream. Every successful turn writes a `trigger:turn-complete` chunk here.
* **`session.in`** — the durable input stream. Every user message after the first turn lands here as a record with a server-assigned timestamp.

On retry boot, the SDK:

1. Scans `session.out` for the latest `trigger:turn-complete` chunk and reads its timestamp. Call this `T_last_complete`.
2. Sets a per-stream filter on `session.in` so any record with `timestamp <= T_last_complete` is dropped before it reaches the turn loop.
3. Begins normal processing. The first record that passes the filter is the message that triggered the OOM (or any newer message that arrived during the retry window).

Result: turns 1..N-1 are not re-processed, turn N runs on the larger machine, and the conversation continues.

```mermaid theme={"theme":"css-variables"}
sequenceDiagram
  participant User
  participant Run as chat.agent run
  participant SessionIn as session.in
  participant SessionOut as session.out

  User->>SessionIn: u2 (turn 2)
  Run->>SessionIn: read u2
  Run->>SessionOut: turn-complete (T1)
  User->>SessionIn: u3 (turn 3)
  Run->>SessionIn: read u3
  Run->>SessionOut: turn-complete (T2)
  User->>SessionIn: u4 (turn 4)
  Run->>SessionIn: read u4
  Note over Run: OOM mid-turn
  Run->>Run: ⚠️ killed
  Note over Run: Attempt 2 boots on oomMachine
  Run->>SessionOut: scan → T_last_complete = T2
  Run->>SessionIn: read with filter (ts > T2)
  SessionIn-->>Run: u2 (filtered, ts < T2)
  SessionIn-->>Run: u3 (filtered, ts < T2)
  SessionIn-->>Run: u4 (passes — the OOM'd turn)
  Run->>SessionOut: turn 4 complete
```

The scan on `session.out` is streaming and bounded in memory: each chunk is inspected and discarded one at a time, so a long-running chat doesn't bloat the retry-boot worker. Bandwidth scales linearly with `session.out` size, but only on the OOM-retry path — a rare event.

## With `hydrateMessages`

If your agent uses [`hydrateMessages`](/docs/ai-chat/lifecycle-hooks#hydratemessages) to load the durable conversation history per turn, the OOM'd turn re-runs against the full prior accumulator: the model sees `[u1, a1, u2, a2, ..., u_N]` and responds in context. This is the recommended pattern for production chats.

## Without `hydrateMessages`

Recovery boot reconstructs context automatically. The boot reads both the durable `session.out` snapshot (settled turns) and the `session.out` tail past the snapshot cursor (the partial assistant chunks the OOM'd turn streamed before dying). When the new attempt processes the OOM'd user message, the model sees the full prior conversation **plus** the partial assistant that was cut off — so a "keep going" follow-up continues naturally, and any other follow-up has the same context the original turn had.

`hydrateMessages` is still the right choice if you want a single source of truth in your own database (branching conversations, message-level access control, etc.). It's no longer required for OOM continuity.

For full control over recovery — drop the partial, synthesize tool results for an interrupted tool call, emit a recovery banner to the UI — register [`onRecoveryBoot`](/docs/ai-chat/patterns/recovery-boot).

## Tool execute idempotency

If an OOM hits mid-tool-execution, the new attempt re-runs the entire turn — including the tool call. Make tool `execute` functions idempotent or checkpoint their progress externally. Trigger doesn't roll back side effects automatically.

```ts theme={"theme":"css-variables"}
import { tool } from "ai";

export const sendEmail = tool({
  description: "Send an email",
  inputSchema: z.object({ to: z.string(), idempotencyKey: z.string() }),
  execute: async ({ to, idempotencyKey }) => {
    // Stripe-style: dedupe at the side-effect layer with a customer-supplied key.
    return await mailer.send({ to, idempotencyKey });
  },
});
```

## Limitations

* **One OOM retry per run.** `chat.agent` sets `maxAttempts: 2`. If attempt 2 also OOMs, the run fails. Use a sufficiently large `oomMachine` to avoid this.
* **Single fallback tier.** Only one `oomMachine`. There's no "tiered retry" (small → medium → large). If you need that, drop down to a [raw `task()` with chat primitives](/docs/ai-chat/custom-agents) and configure `retry` directly.
* **Non-OOM errors don't retry.** Schema errors, model-call rejections, tool throws, etc. fail the run as before. Out-of-memory is the only retry trigger.
* **Tools mid-execution are not checkpointed.** A partially-run tool re-runs from scratch on the new attempt. Make them idempotent.

## See also

* [Recovery boot](/docs/ai-chat/patterns/recovery-boot) — the underlying hook + smart default that gives OOM recovery its full-context behavior
* [Lifecycle hooks](/docs/ai-chat/lifecycle-hooks) — `onChatResume` fires on every retry attempt with `phase: "preload"` or `"turn"`
* [Database persistence](/docs/ai-chat/patterns/database-persistence) — the `hydrateMessages` pattern for branching, ACL, and DB-as-source-of-truth scenarios


# Persistence and replay
Source: https://trigger.dev/docs/ai-chat/patterns/persistence-and-replay

How chat.agent rebuilds conversation history at run boot — durable JSON snapshot in object storage plus session.out replay, with a hydrateMessages short-circuit for backend-owned history.

`chat.agent` runs are processes — they boot, stream a turn, and either suspend (waiting for the next message) or exit. When the next message arrives at a session whose previous run already exited, a **fresh** run boots with no in-memory state. Something has to rebuild the conversation history before that turn can produce a coherent response.

This page walks through the **snapshot + replay** model the runtime uses by default, and the [`hydrateMessages`](/docs/ai-chat/lifecycle-hooks#hydratemessages) short-circuit that turns the whole thing off when the customer owns history.

## Why a snapshot at all

The wire is delta-only: each `.in/append` carries at most one new `UIMessage` (see [Client Protocol](/docs/ai-chat/client-protocol#chattaskwirepayload)). A long conversation might be 50 turns deep with megabytes of tool results — the wire never carries that. So when run #2 boots to handle turn 51, the wire alone tells it almost nothing about turns 1–50.

Two existing pieces of durable state already capture everything that happened:

* **`session.in`** — every user message and tool-approval response ever sent.
* **`session.out`** — every assistant token, tool call, and tool result the agent emitted, ordered.

Replaying `session.out` from the beginning is correct but expensive — bandwidth scales with chat length, and parsing N megabytes of streamed chunks at every boot adds latency. So the runtime writes a **snapshot** after every turn and reads it on the next boot. Replay only covers the gap between the snapshot's cursor and now.

## The model end-to-end

```mermaid theme={"theme":"css-variables"}
sequenceDiagram
  participant User
  participant Run1 as Run 1 (turn 1)
  participant Snapshot as Object storage
  participant SessionOut as session.out
  participant Run2 as Run 2 (turn 2+)

  User->>Run1: u1
  Run1->>SessionOut: assistant chunks for a1
  Run1->>Run1: onTurnComplete
  Run1->>Snapshot: write { messages: [u1, a1], lastOutEventId, lastOutTimestamp }
  Note over Run1: idle suspend (or exit)

  User->>Run2: u2 (delta only)
  Run2->>Snapshot: read snapshot
  Run2->>SessionOut: subscribe(lastEventId, wait=0)
  SessionOut-->>Run2: (empty — nothing since snapshot)
  Note over Run2: accumulator = [u1, a1]
  Run2->>Run2: append u2 from wire
  Run2->>SessionOut: assistant chunks for a2
  Run2->>Run2: onTurnComplete
  Run2->>Snapshot: write { messages: [u1, a1, u2, a2], ... }
```

### Run 1 — first turn

The accumulator starts empty. The wire delivers `u1`. After the model finishes, `onTurnComplete` fires, then the runtime serializes the full accumulator and writes:

```json theme={"theme":"css-variables"}
{
  "version": 1,
  "savedAt": 1715180400000,
  "messages": [u1, a1],
  "lastOutEventId": "42",
  "lastOutTimestamp": 1715180399000
}
```

The key is `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json` — overwritten every turn, never appended. The write is **awaited**, not fire-and-forget — if the run idle-suspends immediately after, in-flight promises don't reliably complete and the snapshot would be lost.

### Run 2 — boot

A new run boots when the user sends `u2`. Run 1 has long since exited. Run 2 has no in-memory state. The boot sequence:

<Steps>
  <Step title="Read the snapshot">
    GET the JSON blob. On 404 (no snapshot yet — first-ever turn) or read error or version mismatch, treat as empty and continue. Snapshot misses are non-fatal — replay alone may still be sufficient.
  </Step>

  <Step title="Replay session.out tail">
    Subscribe to `session.out` with `wait=0` starting from `snapshot.lastOutEventId`. Drain whatever's there and close. Returns:

    * **Settled messages** — closed assistant turns past the snapshot cursor (the chunks of a turn that completed after the snapshot was written but before the run exited cleanly).
    * **A partial assistant** — the trailing message if its stream never received a `finish` chunk. The dead run was mid-response when it died. `cleanupAbortedParts` has already stripped streaming-in-progress fragments.

    In the steady state this returns empty. In recovery, it returns whatever the dead run was in the middle of.
  </Step>

  <Step title="Replay session.in tail">
    GET `session.in` records past the last `turn-complete`'s `session-in-event-id` cursor. Returns the user messages the dead run hadn't acknowledged — typically the message that triggered the cancelled / crashed turn, plus anything the customer typed after.
  </Step>

  <Step title="Reconstruct the chain (smart default)">
    Snapshot messages merge with the settled replay (replay wins on `id` collision). Then:

    * If there's a partial assistant **and** at least one in-flight user message, splice `[firstInFlightUser, partialAssistant]` onto the end of the chain. The model sees the prior turn's incomplete attempt and can continue, abandon, or pivot based on the next user message.
    * Remaining in-flight users dispatch as fresh turns after the recovered first one.
    * If there's no partial OR no in-flight users, the chain is just the settled chain and any in-flight users dispatch normally.

    Customers can override this entirely via [`onRecoveryBoot`](/docs/ai-chat/patterns/recovery-boot).
  </Step>

  <Step title="Append the new wire message">
    Append `u2` from the wire payload, exactly as on turn 1.
  </Step>
</Steps>

The model now sees `[u1, a1, u2]` and produces `a2`. After `onTurnComplete`, the runtime overwrites the snapshot with `[u1, a1, u2, a2]` and the cycle repeats.

### Crash mid-turn — replay carries the load

Suppose Run 1's turn 1 streams partial assistant chunks to `session.out` and then crashes (OOM, exception, server-side cancel) before `onTurnComplete` fires. No snapshot was written. The next run boots and:

1. Snapshot read returns 404 → empty.
2. `session.out` tail replay picks up the partial assistant chunks emitted before the crash. `cleanupAbortedParts` strips streaming-in-progress fragments but keeps the cleaned trailing message as the `partialAssistant`.
3. `session.in` tail replay finds the user message the dead run was answering (no `turn-complete` was written, so the cursor never advanced past it).
4. Smart default splices `[firstInFlightUser, partialAssistant]` onto the chain. Any later user messages (including the customer's follow-up) dispatch as fresh turns.
5. The model sees full prior context and responds in kind — continuing a cut-off essay on "keep going", answering a fresh question on "actually, what's 7+8?", abandoning the prior work on "scrap that, do X instead".

Replay carries the conversation across the crash boundary with zero customer code. For policies different from "preserve context" — drop the partial entirely, synthesize tool results for an interrupted tool call, write a recovery banner to the UI — register [`onRecoveryBoot`](/docs/ai-chat/patterns/recovery-boot).

## OOM-retry interaction

The runtime already had an OOM-retry path that scans `session.out` for the latest `trigger:turn-complete` timestamp to use as a cutoff for `session.in` (so the retry doesn't re-process completed turns — see [OOM resilience](/docs/ai-chat/patterns/oom-resilience)). The snapshot includes a `lastOutTimestamp` field that is exactly that high-water mark.

When a snapshot exists, the OOM-retry path reads `lastOutTimestamp` directly instead of scanning `session.out`. One fewer stream subscription per retry. Free win.

If no snapshot exists (first turn, or `hydrateMessages` registered), the path falls back to the scan.

## Action turns — no snapshot write

[Action turns](/docs/ai-chat/actions) (`trigger: "action"`) don't fire `onTurnComplete` — they fire `onAction` only. The snapshot write site is gated on `onTurnComplete`, so action turns don't snapshot.

If `onAction` mutates `chat.history.*` and then the run crashes before the next regular turn, the mutation is lost. The user re-fires the action. This matches `chat.history` semantics in general — mutations are persisted at turn boundaries, not action boundaries.

## The `hydrateMessages` short-circuit

When the customer registers a [`hydrateMessages`](/docs/ai-chat/lifecycle-hooks#hydratemessages) hook, the runtime trusts the hook to be the source of truth for history. Snapshot read and replay are **skipped entirely** at boot. The hook fires per turn, returns the canonical chain from the customer's database, and the accumulator is set to whatever the hook returned.

```ts theme={"theme":"css-variables"}
import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";
import { db } from "@/lib/db";

export const myChat = chat.agent({
  id: "my-chat",
  hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
    const stored = (await db.chat.findUnique({ where: { id: chatId } }))?.messages ?? [];

    // See lifecycle-hooks for the full upsert pattern + rationale:
    // /ai-chat/lifecycle-hooks#hydratemessages
    if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
      // Upsert, not update: head-start first turns run without a preload
      // to create the row.
      await db.chat.upsert({
        where: { id: chatId },
        create: { id: chatId, messages: stored },
        update: { messages: stored },
      });
    }

    return stored;
  },
  onTurnComplete: async ({ chatId, uiMessages }) => {
    await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

What you gain:

* **Zero object-store traffic per turn.** No snapshot read, no snapshot write, no replay subscription. `OBJECT_STORE_*` env vars don't have to be set.
* **Branching, undo, edit, abuse prevention** — patterns that need a backend-side single source of truth work naturally because the customer mediates every read.

What you give up:

* **You own persistence end-to-end.** A bug in `hydrateMessages` that returns the wrong chain corrupts the conversation visible to the model.
* **OOM-retry needs a `session.out` scan again** because there's no snapshot to short-circuit it. (Same as the pre-snapshot baseline — not a regression, just a missed optimization.)

The runtime's snapshot+replay is the safer default. `hydrateMessages` is the right choice when you already have authoritative storage for messages and want one consistent persistence path.

## When neither is configured

If `hydrateMessages` is not registered **and** no object store is configured, conversations don't survive run boundaries. A continuation boots empty. The runtime logs a warning at agent registration time so you see this at deploy time, not at user-traffic time.

For local development this is sometimes fine — you're not testing continuations. For production it isn't. Configure one of:

* **Object store** (`OBJECT_STORE_*` env vars on your webapp) — easiest, default behavior.
* **`hydrateMessages` + your own database** — stronger control, suits multi-tenant apps with audit needs.

## Snapshot key & lifecycle

| Field      | Value                                                               |
| ---------- | ------------------------------------------------------------------- |
| Bucket     | Whatever `OBJECT_STORE_BASE_URL` points to                          |
| Key prefix | `packets/{projectRef}/{envSlug}/` (server-prefixed)                 |
| Key suffix | `sessions/{sessionId}/snapshot.json`                                |
| Final key  | `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json` |
| Size       | Tens of KB typical, capped only by object-store limits              |
| Cadence    | Overwritten after every successful `onTurnComplete`                 |

Snapshots accumulate per-session forever unless you set a lifecycle policy on the bucket. A 90-day expiry on `packets/*/sessions/*/snapshot.json` is a reasonable default if your chats don't typically resume after that window. Closed sessions are not auto-cleaned today.

### MinIO and S3-compatible stores

Snapshot read/write reuses the same object-store layer as Trigger.dev's existing large-payload routes. Anything that already works for large payloads — AWS S3, MinIO (self-host or local development), Cloudflare R2, Tigris, Backblaze B2 — works for snapshots too. `OBJECT_STORE_DEFAULT_PROTOCOL` controls the routing (`s3`, `minio`, etc.) and the SDK picks the right driver automatically. No snapshot-specific config.

For local development against `pnpm run docker`, the bundled MinIO container is enough — set `OBJECT_STORE_DEFAULT_PROTOCOL=minio` and the standard MinIO env vars on the webapp, and continuations work end-to-end against a local stack.

## See also

* [Client Protocol](/docs/ai-chat/client-protocol#how-history-is-rebuilt) — the wire-level view of the same model
* [`hydrateMessages`](/docs/ai-chat/lifecycle-hooks#hydratemessages) — the short-circuit hook
* [OOM resilience](/docs/ai-chat/patterns/oom-resilience) — how `session.in` cutoffs interact with snapshots
* [Database persistence](/docs/ai-chat/patterns/database-persistence) — the canonical persistence pattern using `onTurnComplete`
* [v4.5 upgrade guide](/docs/ai-chat/upgrade-guide#v45-wire-format-change) — when this model landed and what changed


# Recovery boot
Source: https://trigger.dev/docs/ai-chat/patterns/recovery-boot

Recover from cancel-mid-stream, crashes, and OOM kills with full conversational context. The smart default Just Works; the onRecoveryBoot hook is the override path for advanced policies.

When a `chat.agent` run dies in the middle of streaming a response — the user cancels, the worker OOMs, or an unhandled exception kills the process — the durable streams hold what was in flight. The next run boots as a continuation, reads both stream tails, and reconstructs a chain that preserves the partial response so any follow-up (`keep going`, `actually do X instead`, a new question) has full context.

The behavior is automatic. The `onRecoveryBoot` hook is opt-in for policies that need something different.

## The scenario

```ts theme={"theme":"css-variables"}
// Turn 1 is mid-essay when the user clicks Cancel.
window.__chat.send("Write me a long essay about espresso");
// ... assistant has written 3000 characters ...
window.__chat.stop();                              // OR: server-side cancel_run

// User decides what they want next.
window.__chat.send("keep going");                  // OR: "what's 7+8?", or anything
```

The cancelled run never wrote `onTurnComplete`. The snapshot is stale or absent. `session.out` has a half-written assistant message. `session.in` has the original user message (the run consumed it but never marked the turn complete) plus the new follow-up.

A naive continuation would either re-run the cancelled essay (the user already chose to stop) or drop everything (no context for the follow-up). Recovery boot handles this without either failure mode.

## The smart default

On a continuation boot, the runtime reads:

* **Snapshot** — settled turns persisted by the last successful `onTurnComplete`.
* **`session.out` tail past the snapshot cursor** — closed assistant turns plus, optionally, a `partialAssistant` (the trailing message whose stream never received a `finish` chunk). `cleanupAbortedParts` has already stripped streaming-in-progress fragments.
* **`session.in` tail past the last `turn-complete` cursor** — user messages the dead run hadn't acknowledged.

If both `partialAssistant` and `inFlightUsers` are non-empty, the runtime splices `[firstInFlightUser, partialAssistant]` onto the chain. The remaining in-flight users dispatch as fresh turns. The model sees:

```
[ ...settledMessages,  // chain through the last completed turn
  firstInFlightUser,   // the question the dead run was answering
  partialAssistant,    // the dead run's incomplete response
  followUpUser ]       // the new turn the customer just sent
```

Modern instruction-following models prioritize the latest user message. The follow-up determines the response:

| Follow-up                          | Model behavior                                             |
| ---------------------------------- | ---------------------------------------------------------- |
| "keep going" / "continue" / "more" | Continues the partial essay from where it stopped.         |
| "actually, what's 7+8?"            | Answers the new question. Prior context doesn't derail it. |
| "scrap that, do something else"    | Abandons the partial work and follows the new direction.   |

No customer code needed for any of these.

## When to register `onRecoveryBoot`

The hook fires when recovery state is non-empty (either `partialAssistant` is defined or there's at least one in-flight user). Register it when you need a policy different from "preserve context":

* **Drop the partial entirely.** Your UX means "cancel discards the work — start fresh from the follow-up."
* **Synthesize tool results.** The partial has tool calls in `input-available` state (HITL was mid-call when the run died). Return a chain that has fabricated `output-available` results so the model can continue.
* **Emit a recovery banner.** Write a `data-chat-recovery` UIMessage chunk via `ctx.writer` so the frontend can render "Recovering interrupted response..." before the model speaks.
* **Persist recovered state.** Use `beforeBoot` to flush the partial to your own database before the next turn starts.

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";

export const myChat = chat.agent({
  id: "my-chat",
  onRecoveryBoot: async ({ partialAssistant, inFlightUsers, writer, cause, previousRunId }) => {
    writer.write({
      type: "data-chat-recovery",
      data: { cause, previousRunId, partialPresent: partialAssistant !== undefined },
      transient: true,
    });
    // Return nothing → fall through to smart default.
  },
  run: async ({ messages, signal }) =>
    streamText({ model, messages, abortSignal: signal }),
});
```

## Hook reference

### Fires when

The hook fires once on a continuation boot, AFTER both stream tails have been read, AND only when there's a partial assistant — the mid-stream-died signal:

```ts theme={"theme":"css-variables"}
const shouldFire = partialAssistant !== undefined;
```

In-flight users alone don't fire the hook. Graceful exits like `chat.requestUpgrade()` and `chat.endRun()` may leave an unacknowledged user on `session.in` (the message that triggered the upgrade, the next message after endRun), but no partial — that's a normal continuation, not recovery. The next message just dispatches as turn 1 on the new run via the normal session.in pump.

Skipped scenarios (where the hook does NOT fire):

* A clean continuation after `chat.endRun()` with no buffered follow-up.
* A fresh chat (no continuation, attempt 1).
* An OOM retry that booted onto a complete snapshot (no partial on the tail).
* `chat.requestUpgrade()` graceful exit — predecessor ended cleanly before processing, no partial.
* An agent with [`hydrateMessages`](/docs/ai-chat/lifecycle-hooks#hydratemessages) registered. Customers using `hydrateMessages` own persistence — recovery decisions live in their own DB query.

### Event shape

```ts theme={"theme":"css-variables"}
type RecoveryBootEvent<TUIM extends UIMessage = UIMessage> = {
  ctx: TaskRunContext;
  chatId: string;
  runId: string;
  previousRunId: string;
  cause: "cancelled" | "crashed" | "unknown";
  settledMessages: TUIM[];
  inFlightUsers: TUIM[];
  partialAssistant: TUIM | undefined;
  pendingToolCalls: Array<{
    toolCallId: string;
    toolName: string;
    input: unknown;
    partIndex: number;
  }>;
  writer: ChatWriter;
};
```

<Note>
  `cause` is currently always `"unknown"` — the run engine doesn't yet plumb the
  real reason into the continuation payload. The enum is forward-looking; don't
  branch behavior on it for now.
</Note>

### Return shape

Every field is optional. Returning `undefined` (or nothing) accepts the smart default for every field.

```ts theme={"theme":"css-variables"}
type RecoveryBootResult<TUIM extends UIMessage = UIMessage> = {
  chain?: TUIM[];
  recoveredTurns?: TUIM[];
  beforeBoot?: () => Promise<void>;
};
```

* **`chain`** — replaces the seed chain. Defaults to `[...settledMessages, firstInFlightUser, partialAssistant]` when both partial and in-flight users exist, otherwise `settledMessages` alone.
* **`recoveredTurns`** — user messages to dispatch as fresh turns after the chain is restored. Defaults to `inFlightUsers.slice(1)` when the smart default consumed the first user, otherwise `inFlightUsers`.
* **`beforeBoot`** — runs after the writer flushes and before the first recovered turn fires. Use for blocking persistence (write the partial to your DB so a later turn can reference it). Errors bubble — wrap your own try/catch if you want to soft-fail.

## Examples

### Drop the partial — strict "cancel means discard"

The customer's UX treats cancel as "throw the work away":

```ts theme={"theme":"css-variables"}
onRecoveryBoot: async ({ inFlightUsers, partialAssistant }) => {
  if (!partialAssistant) return;          // No partial → nothing to drop
  return {
    chain: undefined,                      // Use settledMessages, don't splice partial
    recoveredTurns: inFlightUsers.slice(1) // Still skip the first user (the dead run was answering it)
  };
}
```

### Synthesize tool results for a mid-call interruption

The dead run was processing a tool call when it died. The partial has tool parts in `input-available` state with no `output-available`. Synthesize a result so the model can keep going:

```ts theme={"theme":"css-variables"}
onRecoveryBoot: async ({ partialAssistant, pendingToolCalls, settledMessages, inFlightUsers }) => {
  if (pendingToolCalls.length === 0) return;

  // Rebuild the partial with synthetic outputs for any input-available tool call.
  const repaired = {
    ...partialAssistant!,
    parts: partialAssistant!.parts!.map((part, i) => {
      const pending = pendingToolCalls.find(p => p.partIndex === i);
      if (!pending) return part;
      return {
        ...part,
        state: "output-available" as const,
        output: { interrupted: true, reason: "previous run was cancelled" },
      };
    }),
  };

  return {
    chain: [...settledMessages, inFlightUsers[0]!, repaired],
    recoveredTurns: inFlightUsers.slice(1),
  };
}
```

### Persist the partial before the next turn fires

```ts theme={"theme":"css-variables"}
onRecoveryBoot: async ({ chatId, partialAssistant }) => {
  return {
    beforeBoot: async () => {
      if (partialAssistant) {
        await db.partial.create({
          data: { chatId, partialJson: JSON.stringify(partialAssistant) },
        });
      }
    },
  };
}
```

## Interaction with other features

### `hydrateMessages`

If your agent registers [`hydrateMessages`](/docs/ai-chat/lifecycle-hooks#hydratemessages), the runtime skips snapshot read, `session.out` replay, `session.in` replay, AND `onRecoveryBoot`. Your DB is the source of truth — recovery decisions live in your own query. To detect a cancel-recovery scenario yourself, persist a `runState: "in-progress"` flag in `onTurnStart` and check for it in `hydrateMessages`.

### `chat.requestUpgrade()`

[`chat.requestUpgrade()`](/docs/ai-chat/patterns/version-upgrades) is a graceful exit — the old run doesn't crash, it returns cleanly. The new continuation run boots with a clean `session.out` tail (`partialAssistant` is undefined) and the upgrade-trigger message on `session.in` (one in-flight user). The smart default doesn't splice (it requires both partial AND in-flight users), so the chain is just `settledMessages` and the in-flight user dispatches as a fresh turn. `onRecoveryBoot` still fires (there's an in-flight user) — use it to emit an "upgraded" signal to the UI if you want.

### Hooks throwing

If the body of `onRecoveryBoot` throws (or rejects), the runtime logs a warning and falls back to the smart default — the run does not fail. Wrap your own try/catch if you want stricter handling.

`beforeBoot` is the exception: it's the contract you opted into for blocking persistence, so errors thrown there **bubble** and fail the run rather than dispatch recovered turns against half-persisted state. Wrap it yourself if you want to soft-fail.

## See also

* [OOM resilience](/docs/ai-chat/patterns/oom-resilience) — `oomMachine` opt-in for automatic memory-driven recovery; uses the same recovery boot path.
* [Persistence and replay](/docs/ai-chat/patterns/persistence-and-replay) — the snapshot + dual-tail replay model that recovery boot sits on top of.
* [Lifecycle hooks](/docs/ai-chat/lifecycle-hooks) — where `onRecoveryBoot` sits in the broader hook taxonomy.


# Agent Skills
Source: https://trigger.dev/docs/ai-chat/patterns/skills

Ship reusable capabilities (folders with SKILL.md + scripts) that a chat agent discovers and invokes on demand.

Agent skills are reusable capabilities you ship as folders — a `SKILL.md` describing when and how to use them, plus optional scripts, references, and assets. The chat agent sees a short description of each skill in its system prompt, loads the full instructions on demand via a `loadSkill` tool, and invokes the bundled scripts via `bash` — all without you wiring anything up manually.

Built on the [AI SDK cookbook pattern](https://ai-sdk.dev/cookbook/guides/agent-skills). Works with any provider (OpenAI, Anthropic, Gemini, etc.) — not tied to Anthropic's server-side skills.

## Why skills?

Compared to regular AI SDK tools:

* **Tools** are typed functions you pre-declare. Great when you know up-front exactly what capability the agent needs.
* **Skills** are folders the model discovers and reads on demand. Great when the capability is a bundle of instructions + helper scripts that would be awkward to encode as a single tool.

PDFs are the canonical example: you don't want to ask the LLM to parse PDF bytes inline. You want it to `bash scripts/extract.py report.pdf` using a bundled `pdfplumber` wrapper. A skill ships the script, the instructions, and any reference notes together.

Dashboard-editable `SKILL.md` is on the roadmap so a platform team can tighten a skill's description or "when to use" text without a redeploy. Today, skills are SDK-only — defined in your task code and shipped with each deploy.

## Trust model

Skills are **developer-authored code**, not end-user-supplied. The same developer who writes the `chat.agent()` writes the skill bundle. The trust boundary is identical to any `tool.execute` handler the developer writes — scripts run directly in the Trigger.dev worker container, no sandboxing required.

This makes skills different from the Claude Code / end-user model where arbitrary user-provided skills need isolation. Don't accept skill paths from untrusted input.

## Skill folder layout

A skill is a directory under your project (conventionally `trigger/skills/{id}/`):

```
trigger/skills/time-utils/
├── SKILL.md              # Required — frontmatter + instructions
├── scripts/
│   ├── now.sh
│   └── add.sh
├── references/
│   └── timezones.txt
└── assets/               # Optional — templates, data files, etc.
```

### SKILL.md

Frontmatter is YAML-subset — only `name` and `description` are required:

```md theme={"theme":"css-variables"}
---
name: time-utils
description: Compute and format dates/times in arbitrary timezones. Use when the user asks "what time is it", timezone conversions, or date math.
---

# Time utilities

## When to use

- The user asks for the current time in a timezone
- The user wants date math ("3 days from now")

## Scripts

### `scripts/now.sh [TZ]`
Prints the current time in the given IANA timezone (default `UTC`).

### `scripts/add.sh DAYS [TZ]`
Prints a date `DAYS` days from now.

## Tips
- IANA timezone names only (`America/New_York`, not `EST`).
- See `references/timezones.txt` for a cheat-sheet.
```

The **description** is what the model sees in its system prompt — write it like you're explaining to the agent when to reach for the skill.

The **body** is loaded on demand via the `loadSkill` tool when the agent decides to use the skill. Write it like documentation for the agent.

## Defining and using a skill

```ts trigger/chat.ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { skills } from "@trigger.dev/sdk";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

const timeUtilsSkill = skills.define({
  id: "time-utils",
  path: "./skills/time-utils",
});

export const agent = chat.agent({
  id: "docs-chat",
  onChatStart: async () => {
    chat.skills.set([await timeUtilsSkill.local()]);
  },
  run: async ({ messages, signal }) => {
    return streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      ...chat.toStreamTextOptions(),
      stopWhen: stepCountIs(15),
    });
  },
});
```

`skills.define({ id, path })` does two things:

1. Registers the skill with the Trigger.dev build system so the CLI **automatically bundles the folder** into your deploy image at `/app/.trigger/skills/{id}/`. No `trigger.config.ts` changes, no build extension — it just works.
2. Returns a `SkillHandle` you use at runtime.

`skill.local()` reads the bundled `SKILL.md` from disk and returns a `ResolvedSkill` with the parsed frontmatter + body + on-disk path.

`chat.skills.set([...])` stores the resolved skills for the current run. `chat.toStreamTextOptions()` spreads them into `streamText` automatically:

* The frontmatter `description` lands in the system prompt under "Available skills:".
* Three tools are added: `loadSkill`, `readFile`, `bash` — scoped per skill.

## What gets auto-injected

When you spread `chat.toStreamTextOptions()` with skills set, the AI SDK call receives three tools:

### `loadSkill({ name })`

Returns the full `SKILL.md` body for the named skill. The model calls this first when it decides a skill is relevant, to load the full instructions.

### `readFile({ skill, path })`

Reads a file inside the skill's bundled folder. Paths are relative to the skill's root and are rejected if they attempt to escape via `..` or absolute paths. Output is capped at 1 MB per call.

Use for reference files and templates that the model should read literally:

```
readFile({ skill: "time-utils", path: "references/timezones.txt" })
```

### `bash({ skill, command })`

Runs a bash command with `cwd` set to the skill's root. Stdout and stderr are captured and returned (each capped at 64 KB per call, with tail truncation). The turn's abort signal propagates — cancelling the run kills the child process.

Use to invoke the skill's bundled scripts:

```
bash({ skill: "time-utils", command: "bash scripts/now.sh America/Los_Angeles" })
```

Script runtime expectations are yours to manage. If your skill uses `extract.py`, your deploy image needs Python — add it via your build config the same way you would for any other task dependency.

## How discovery works in the model

The model sees a short preamble appended to your system prompt:

```
Available skills (call `loadSkill` to read the full instructions before using one):
- time-utils: Compute and format dates/times in arbitrary timezones...
- pdf-processing: Extract text from PDFs, fill forms...
```

When the user asks something that matches a description, the model calls `loadSkill({ name: "time-utils" })` to load the body, then follows the body's instructions — typically by calling `bash` or `readFile` on the bundled scripts.

This is **progressive disclosure**: each skill costs \~100 tokens up front (its one-line description), and only the ones the model actually uses pay the full context cost.

## Mixing skills with custom tools

If you also define your own AI SDK tools, pass them through `chat.toStreamTextOptions()` so the merge is explicit:

```ts theme={"theme":"css-variables"}
return streamText({
  model: anthropic("claude-sonnet-4-5"),
  messages,
  abortSignal: signal,
  ...chat.toStreamTextOptions({
    tools: {
      webFetch,       // your tool
      deepResearch,   // your tool
    },
  }),
  stopWhen: stepCountIs(15),
});
```

Your tools win on name conflicts. (Pick names that don't collide with `loadSkill` / `readFile` / `bash` to keep things predictable.)

Also declare those same tools on the agent's [`tools`](/docs/ai-chat/tools) config. `toStreamTextOptions` merges them with the skill tools for the model call, while the config option threads them into history re-conversion so any `toModelOutput` survives across turns. The auto-injected skill tools (`loadSkill` / `readFile` / `bash`) don't define `toModelOutput`, so they don't need to be on the config.

## Bundling

Bundling is **built-in to the CLI** — there's no extension to import. When you run `trigger deploy` or `trigger dev`:

1. esbuild bundles your task code as usual.
2. The CLI forks the indexer locally against the bundled output, collects every `skills.define({ path })` registration.
3. Each skill's folder is copied to `{outputPath}/.trigger/skills/{id}/` via a recursive copy.
4. The existing Dockerfile `COPY` picks up `.trigger/skills/` along with the rest of the bundle — no Dockerfile changes.

If you're running `trigger dev`, the same layout appears in the local dev output directory, so `skill.local()` works the same way.

## Path scoping rules

* `skill.path` always resolves to `${process.cwd()}/.trigger/skills/{id}/` at runtime. Don't hardcode paths elsewhere.
* `readFile` rejects `..` segments and absolute paths — the tool only exposes files inside the skill's own directory.
* `bash` runs with `cwd` set to the skill's root. Inside the script, relative paths resolve against the skill directory.
* Cross-skill access isn't provided — each skill is isolated by design. If two skills need to share data, either duplicate the shared file or consolidate the skills.

## Current limitations

* `skill.resolve()` (backend-managed overrides) is not available yet — use `.local()` for now. Dashboard-editable `SKILL.md` is on the roadmap.
* No per-skill metrics in the dashboard yet.
* No Anthropic `/v1/skills` integration — use the portable path today; we're tracking the Anthropic optimization separately.

## Full example

See [`projects/ai-chat/src/trigger/skills/time-utils/`](https://github.com/triggerdotdev/references/tree/main/projects/ai-chat/src/trigger/skills/time-utils) in the [references repo](https://github.com/triggerdotdev/references) for a working skill that bundles two bash scripts and a reference cheat-sheet, wired into a `chat.agent` that answers timezone questions.

## Related

* [AI SDK cookbook — Agent Skills](https://ai-sdk.dev/cookbook/guides/agent-skills) — the userland pattern we build on
* [Anthropic Agent Skills](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) — Anthropic's codified version (server-side, optional future integration)


# Sub-Agents
Source: https://trigger.dev/docs/ai-chat/patterns/sub-agents

Delegate work to durable sub-agents from within a parent agent's tool calls, with streaming preliminary results.

Sub-agents let a parent agent delegate work to other agents running as durable Trigger.dev tasks. The sub-agent's response streams back through the parent as preliminary tool results, so the frontend sees the sub-agent working inside the parent's tool call card.

This builds on the AI SDK's [async generator tool pattern](https://ai-sdk.dev/docs/agents/subagents) and Trigger.dev's [AgentChat](/docs/ai-chat/server-chat) for server-side agent interaction.

## How it works

1. The parent LLM calls a tool (e.g., `researchAgent`)
2. The tool's `execute` is an `async function*` (async generator)
3. Inside, it creates an `AgentChat` and sends a message to the sub-agent
4. `yield* stream.messages()` streams each accumulated `UIMessage` snapshot as a preliminary tool result
5. The frontend renders the sub-agent's response building up inside the parent's tool card
6. `toModelOutput` compresses the full output into a summary for the parent LLM

```
Parent LLM
  │
  ├─ calls researchAgent tool
  │    │
  │    ├─ AgentChat triggers sub-agent run
  │    ├─ sub-agent streams response (text, tool calls, etc.)
  │    ├─ yield* sends UIMessage snapshots as preliminary results
  │    └─ toModelOutput compresses for parent LLM
  │
  └─ parent LLM reads compressed summary, continues reasoning
```

## Single-turn sub-agent

The simplest pattern: one tool call, one sub-agent turn, conversation closes.

```ts theme={"theme":"css-variables"}
import { tool, stepCountIs } from "ai";
import { AgentChat } from "@trigger.dev/sdk/chat";
import { z } from "zod";
import type { prReviewAgent } from "./trigger/pr-review";

const prReviewTool = tool({
  description: "Delegate a PR review to the PR review agent.",
  inputSchema: z.object({
    prNumber: z.number().describe("The PR number to review"),
    repo: z.string().describe("The GitHub repo URL"),
  }),
  execute: async function* ({ prNumber, repo }, { abortSignal }) {
    const chat = new AgentChat<typeof prReviewAgent>({
      agent: "pr-review",
      id: `review-${prNumber}`,
      clientData: { userId: "parent-agent", githubUrl: repo },
    });

    const stream = await chat.sendMessage(`Review PR #${prNumber}`, { abortSignal });

    // Each yield sends a UIMessage snapshot to the frontend
    yield* stream.messages();

    await chat.close();
  },
  // The parent LLM only sees this compressed summary
  toModelOutput: ({ output: message }) => {
    const lastText = message?.parts?.findLast(
      (p: { type: string }) => p.type === "text"
    ) as { text?: string } | undefined;
    return { type: "text", value: lastText?.text ?? "Review complete." };
  },
});
```

Use this tool in a parent agent's `streamText` call:

```ts theme={"theme":"css-variables"}
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

const result = streamText({
  model: anthropic("claude-sonnet-4-6"),
  tools: { prReview: prReviewTool },
  prompt: "Review PR #42 on triggerdotdev/trigger.dev",
  stopWhen: stepCountIs(15),
});
```

## Multi-turn sub-agent (LLM-driven)

The parent LLM drives a persistent conversation with a sub-agent across multiple tool calls. Each call with the same `conversationId` hits the same durable agent run.

```ts theme={"theme":"css-variables"}
import { tool } from "ai";
import { AgentChat } from "@trigger.dev/sdk/chat";
import { z } from "zod";

// Track active sub-agent conversations
const subAgents = new Map<string, AgentChat>();

const researchTool = tool({
  description:
    "Talk to a research agent. Use the same conversationId to continue " +
    "an existing conversation — the agent remembers full context.",
  inputSchema: z.object({
    conversationId: z
      .string()
      .describe("Unique ID for this research thread. Reuse to continue."),
    message: z.string().describe("Your message to the research agent"),
  }),
  execute: async function* ({ conversationId, message }, { abortSignal }) {
    let agent = subAgents.get(conversationId);
    if (!agent) {
      agent = new AgentChat({
        agent: "research-agent",
        id: conversationId,
      });
      subAgents.set(conversationId, agent);
    }

    const stream = await agent.sendMessage(message, { abortSignal });
    yield* stream.messages();
  },
  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." };
  },
});
```

The parent LLM naturally calls this tool multiple times:

1. `researchAgent({ conversationId: "competitors", message: "Research competitors in AI agents" })` — first call triggers a new sub-agent run
2. `researchAgent({ conversationId: "competitors", message: "Go deeper on pricing" })` — same run, sub-agent has full context
3. `researchAgent({ conversationId: "new-topic", message: "..." })` — different ID = different sub-agent

### Cross-turn persistence

Sub-agent conversations persist across **parent turns** because the `Map` lives in the parent's process heap. When the parent suspends and restores via snapshot, the heap is preserved — the Map still has the conversations, the sessions still have the run IDs.

```ts theme={"theme":"css-variables"}
export const orchestrator = chat
  .withClientData({ schema: z.object({ userId: z.string() }) })
  .customAgent({
    id: "orchestrator",
    run: async (payload, { signal: runSignal }) => {
      // These survive across parent turns via snapshot/restore
      const subAgents = new Map<string, AgentChat>();

      const researchTool = tool({
        // ... closes over subAgents Map
      });

      // Turn loop — subAgents persist across all turns
      for (let turn = 0; turn < 50; turn++) {
        // ... streamText with researchTool
      }

      // Cleanup when parent exits
      await Promise.all(
        Array.from(subAgents.values()).map((a) => a.close().catch(() => {}))
      );
    },
  });
```

## How sub-agents clean up

Sub-agents clean up through three mechanisms:

1. **Explicit close**: Call `chat.close()` or `agent.close()` when done
2. **Idle timeout**: The sub-agent's idle timeout expires, it suspends
3. **Suspend timeout**: The sub-agent's suspend timeout expires, the run ends

For the multi-turn pattern, the parent should clean up sub-agents when it exits (in `onComplete` for managed agents, or at the end of the loop for custom agents). Without explicit cleanup, sub-agents close on their own via timeouts — no leaked resources or cost while suspended.

## What the frontend sees

Each `yield` from `stream.messages()` sends a complete `UIMessage` containing all the sub-agent's parts accumulated so far. The AI SDK delivers these as `tool-output-available` chunks with `preliminary: true`.

The frontend renders the tool part with:

* `state: "output-available"` and `preliminary: true` while streaming
* `state: "output-available"` and `preliminary: false` (or absent) when done

The tool output contains the full `UIMessage` with nested parts — text, the sub-agent's own tool calls and results, reasoning, etc.

### Controlling what the parent LLM sees

`toModelOutput` transforms the tool's output before it enters the parent LLM's context. The full UIMessage streams to the frontend, but the model only sees the compressed version:

```ts theme={"theme":"css-variables"}
toModelOutput: ({ output: message }) => {
  // Extract just the final text — the model doesn't need
  // to see all the sub-agent's tool calls and intermediate work
  const lastText = message?.parts?.findLast(
    (p: { type: string }) => p.type === "text"
  ) as { text?: string } | undefined;
  return { type: "text", value: lastText?.text ?? "Done." };
},
```

This is important for token efficiency: the sub-agent might use 100K tokens exploring and reasoning, but the parent LLM only consumes the summary.

<Warning>
  `toModelOutput` only runs when the SDK has your tools at conversion time. On a multi-turn parent, the SDK re-converts the persisted history at the start of each turn, so you must declare the sub-agent tool on the agent config (`chat.agent({ tools })`) for the compression to survive. Without it, the summary holds on turn 1 but turn 2 onward re-ingests the full sub-agent output. In a `chat.customAgent` loop you own the conversion, so pass the tools to `convertToModelMessages(uiMessages, { tools })` yourself. See [Tools: toModelOutput across turns](/docs/ai-chat/tools#tomodeloutput-across-turns).
</Warning>

## ChatStream.messages()

The `messages()` method on `ChatStream` wraps the AI SDK's `readUIMessageStream`. It reads the raw `UIMessageChunk` stream and yields complete `UIMessage` snapshots — each containing all parts received so far.

```ts theme={"theme":"css-variables"}
const stream = await chat.sendMessage("Research this topic");

// Each yield is a complete UIMessage with all accumulated parts
for await (const message of stream.messages()) {
  console.log(message.parts.length, "parts so far");
}
```

For the sub-agent pattern, use `yield*` to delegate all yields to the parent tool's generator:

```ts theme={"theme":"css-variables"}
execute: async function* ({ topic }, { abortSignal }) {
  const stream = await chat.sendMessage(topic, { abortSignal });
  yield* stream.messages();
},
```

<Tip>
  `stream.messages()` consumes the stream. You can't also call `stream.text()` or iterate over chunks on the same stream. Pick one consumption mode.
</Tip>

## Combining with chat.agent()

Sub-agent tools work inside both `chat.agent()` (managed) and `chat.customAgent()` (manual lifecycle):

```ts theme={"theme":"css-variables"}
// Managed agent with sub-agent tool
const tools = { research: researchTool };

export const myAgent = chat.agent({
  id: "orchestrator",
  tools, // declare here so toModelOutput survives across turns
  run: async ({ messages, tools, stopSignal }) => {
    return streamText({
      model: anthropic("claude-sonnet-4-6"),
      messages,
      tools,
      abortSignal: stopSignal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

For `chat.customAgent()`, define the tool and sub-agent Map inside the `run` closure so they survive across turns. Since you own the turn loop there, convert history with your tools in scope so `toModelOutput` is re-applied each turn: `convertToModelMessages(uiMessages, { tools })`. See [Tools: manual turn loops](/docs/ai-chat/tools#manual-turn-loops-chatcustomagent).

## Streaming progress from a subtask to the parent chat

When a tool invokes a subtask via `triggerAndWait`, the subtask can stream custom data parts directly to the parent chat using `chat.stream.writer({ target: "root" })`. The frontend receives these as `DataUIPart` objects in `message.parts` on the **parent's** message stream:

```ts theme={"theme":"css-variables"}
import { chat, ai } from "@trigger.dev/sdk/ai";
import { schemaTask } from "@trigger.dev/sdk";
import { streamText, tool, generateId } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

export const researchTask = schemaTask({
  id: "research",
  schema: z.object({ query: z.string() }),
  run: async ({ query }) => {
    const partId = generateId();

    // Stream a data-* chunk to the root run's chat stream.
    const { waitUntilComplete } = chat.stream.writer({
      target: "root",
      execute: ({ write }) => {
        write({
          type: "data-research-status",
          id: partId,
          data: { query, status: "in-progress" },
        });
      },
    });
    await waitUntilComplete();

    const result = await doResearch(query);

    // Update the same part with the final status — same type + id replaces it.
    const { waitUntilComplete: waitDone } = chat.stream.writer({
      target: "root",
      execute: ({ write }) => {
        write({
          type: "data-research-status",
          id: partId,
          data: { query, status: "done", resultCount: result.length },
        });
      },
    });
    await waitDone();

    return result;
  },
});

const research = tool({
  description: researchTask.description ?? "",
  inputSchema: researchTask.schema!,
  execute: ai.toolExecute(researchTask),
});
```

On the frontend, render the custom data part:

```tsx theme={"theme":"css-variables"}
{message.parts.map((part, i) => {
  if (part.type === "data-research-status") {
    const { query, status, resultCount } = part.data;
    return (
      <div key={i}>
        {status === "done" ? `Found ${resultCount} results` : `Researching "${query}"...`}
      </div>
    );
  }
  // ...other part types
})}
```

The `target` option accepts:

* `"self"` — current run (default)
* `"parent"` — parent task's run
* `"root"` — root task's run (the chat agent)
* A specific run ID string

## Inside `ai.toolExecute`: accessing tool + chat context

When a subtask runs via `execute: ai.toolExecute(task)`, it can read the parent's tool call ID and chat context from inside the subtask body:

```ts theme={"theme":"css-variables"}
import { ai, chat } from "@trigger.dev/sdk/ai";
import type { myChat } from "./chat";

export const mySubtask = schemaTask({
  id: "my-subtask",
  schema: z.object({ query: z.string() }),
  run: async ({ query }) => {
    // The AI SDK tool call ID — useful as a stable `data-*` chunk id
    const toolCallId = ai.toolCallId();

    // Typed chat context — `clientData` is typed off your chat's `clientDataSchema`
    const { chatId, clientData } = ai.chatContextOrThrow<typeof myChat>();

    const { waitUntilComplete } = chat.stream.writer({
      target: "root",
      execute: ({ write }) => {
        write({
          type: "data-progress",
          id: toolCallId,
          data: { status: "working", query, userId: clientData?.userId },
        });
      },
    });
    await waitUntilComplete();

    return { result: "done" };
  },
});
```

| Helper                                   | Returns                                                   | Description                                                                         |
| ---------------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `ai.toolCallId()`                        | `string \| undefined`                                     | The AI SDK tool call ID                                                             |
| `ai.chatContext<typeof myChat>()`        | `{ chatId, turn, continuation, clientData } \| undefined` | Chat context with typed `clientData`. Returns `undefined` if not in a chat context. |
| `ai.chatContextOrThrow<typeof myChat>()` | `{ chatId, turn, continuation, clientData }`              | Same as above but throws if not in a chat context                                   |
| `ai.currentToolOptions()`                | `ToolCallExecutionOptions \| undefined`                   | Full tool execution options                                                         |

The subtask body also has read-only access to any [`chat.local`](/docs/ai-chat/chat-local) values initialized in the parent — auto-hydrated from the parent's metadata on first access.


# Tool result auditing
Source: https://trigger.dev/docs/ai-chat/patterns/tool-result-auditing

Fire side effects exactly once per resolved tool call — audit logs, billing, notifications — using extractNewToolResults inside hydrateMessages or onTurnComplete.

When a chat agent uses [tools](/docs/ai-chat/tools) (especially [human-in-the-loop](/docs/ai-chat/patterns/human-in-the-loop) tools that wait on `addToolOutput` from the frontend), you often need to fire side effects exactly once per resolved tool call:

* **Audit logs** — record every tool result for compliance.
* **Billing** — charge per tool invocation.
* **Notifications** — alert downstream systems when a specific tool resolves.
* **Search-index updates** — reflect tool outputs into a derived store.

The naive approach — "log every tool part you see" — over-counts. The same assistant message gets re-shown across re-renders, replays, and retries. You want a function of the form **"is this tool result one I haven't already logged?"** That's exactly what [`chat.history.extractNewToolResults`](/docs/ai-chat/backend#chat-history) returns.

## The pattern

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { auditLog } from "@/lib/audit";

export const myChat = chat.agent({
  id: "my-chat",
  hydrateMessages: async ({ chatId, incomingMessages }) => {
    for (const msg of incomingMessages) {
      for (const r of chat.history.extractNewToolResults(msg)) {
        await auditLog.record({
          chatId,
          toolCallId: r.toolCallId,
          toolName: r.toolName,
          output: r.output,
          errorText: r.errorText,
        });
      }
    }
    return await db.getMessages(chatId);
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

The hook fires per turn. `incomingMessages` is the new wire message (0-or-1-length, see [v4.5 wire format change](/docs/ai-chat/upgrade-guide#v45-wire-format-change)). For each new tool result on that message, write one audit row. Then return the canonical chain from your DB.

`extractNewToolResults` compares the message against the current `chat.history` chain and returns only tool parts whose `toolCallId` is **not** already resolved. That's what makes the call exactly-once:

* A re-emitted message (same id, same toolCallId) returns `[]` — no duplicate log.
* A genuinely new tool result on a known assistant message returns just the new ones.
* A first-time tool result returns the full set.

## Why `hydrateMessages` is the right hook

The pattern works in any pre-merge callback, but `hydrateMessages` is the canonical spot for two reasons:

1. **It fires before the runtime merges** the incoming message into the accumulator. Once merged, the tool results are already on the chain, and `extractNewToolResults` returns `[]` for them.
2. **It always fires per turn** — including HITL turns where the user resolved a tool with `addToolOutput`, which is the highest-volume audit event in most apps.

By the time `onTurnComplete` fires, the chain already contains `responseMessage`, so calling `extractNewToolResults(responseMessage)` there returns `[]`. Don't put audit logging there for the resolution path.

## Without `hydrateMessages` — `onTurnComplete` for self-emitted tool calls

If you don't use `hydrateMessages`, the runtime's snapshot+replay path handles persistence. You can still audit the agent's **own** tool executions in `onTurnComplete` — but compare against the prior message rather than the just-emitted one:

```ts theme={"theme":"css-variables"}
onTurnComplete: async ({ chatId, newUIMessages }) => {
  // The assistant message from this turn is in newUIMessages.
  for (const msg of newUIMessages) {
    if (msg.role !== "assistant") continue;
    for (const part of msg.parts) {
      if (
        typeof part.type === "string" &&
        part.type.startsWith("tool-") &&
        ((part as any).state === "output-available" ||
         (part as any).state === "output-error")
      ) {
        await auditLog.record({
          chatId,
          toolCallId: (part as any).toolCallId,
          toolName: (part as any).type.slice("tool-".length),
          output: (part as any).output,
          errorText: (part as any).errorText,
        });
      }
    }
  }
},
```

`newUIMessages` is just the messages this turn produced — no prior-chain noise. Each tool part shows up exactly once.

This works for tools the agent itself calls (no HITL pause). For HITL flows where the user resolves a tool with `addToolOutput`, the resolution arrives on the **next** turn's wire message, not in `newUIMessages` of the resolving turn — use `hydrateMessages` for those.

## Idempotency at the storage layer

Even with `extractNewToolResults`, transient failures (e.g. an audit-log POST that times out and is retried) can produce duplicates. Make the audit-log writer idempotent on `toolCallId`:

```ts theme={"theme":"css-variables"}
await auditLog.upsert({
  where: { toolCallId: r.toolCallId },
  create: { /* ... */ },
  update: { /* timestamp, retry count, etc. */ },
});
```

`toolCallId` is unique per tool invocation (assigned by the AI SDK when the model emits the tool call) and stable across retries — perfect for an idempotency key.

## What `extractNewToolResults` returns

```ts theme={"theme":"css-variables"}
type ChatNewToolResult = {
  toolCallId: string;
  toolName: string;
  output: unknown;      // The tool's return value (carries the resolved value; in output-error state see errorText)
  errorText?: string;   // Set iff the part is in output-error state
};
```

Tool parts in `input-available` state (the model called the tool but it hasn't resolved yet) are not returned — only **resolved** results count.

## Combining with HITL

[Human-in-the-loop](/docs/ai-chat/patterns/human-in-the-loop) tools pause the turn waiting for `addToolOutput` from the frontend. When the user submits, the wire message carries an updated assistant message with the tool now in `output-available` state. `extractNewToolResults` against that message returns the just-resolved tool — exactly one audit row per user resolution:

```ts theme={"theme":"css-variables"}
hydrateMessages: async ({ chatId, incomingMessages }) => {
  for (const msg of incomingMessages) {
    for (const r of chat.history.extractNewToolResults(msg)) {
      // Fires once per ask_user / approval / similar resolution
      await auditLog.record({ chatId, /* ... */ });
    }
  }
  return await db.getMessages(chatId);
}
```

This is the original motivator for the helper — see the [HITL pattern's net-new-tool-result section](/docs/ai-chat/patterns/human-in-the-loop#acting-once-per-net-new-tool-result).

## See also

* [`chat.history`](/docs/ai-chat/backend#chat-history) — full reference for `extractNewToolResults`, `getPendingToolCalls`, `getResolvedToolCalls`
* [Human-in-the-loop](/docs/ai-chat/patterns/human-in-the-loop) — the pattern this auditing hook complements
* [`hydrateMessages`](/docs/ai-chat/lifecycle-hooks#hydratemessages) — where pre-merge auditing lives
* [Persistence and replay](/docs/ai-chat/patterns/persistence-and-replay) — how the runtime rebuilds chains, and why `extractNewToolResults` works against them


# Trusted edge signals
Source: https://trigger.dev/docs/ai-chat/patterns/trusted-edge-signals

How to safely deliver server-trusted signals (bot scores, JA4, ASN, ReCAPTCHA verdicts) to a chat.agent run via an edge proxy.

A common need for chat-style endpoints is to drive agent behavior from **server-trusted signals** that the browser cannot be allowed to declare itself — bot management scores, JA4 fingerprints, ASN, ReCAPTCHA verdicts, or any other anti-abuse data only the edge can see. The agent's [`clientData`](/docs/ai-chat/reference#withclientdata) channel is the right delivery mechanism, but `clientData` set in the browser is by definition spoofable. The fix is to move the value population out of the browser and into a trusted edge proxy.

This page documents the pattern using Cloudflare Workers as the proxy. The same shape applies to any edge layer (custom reverse proxy, Vercel Edge Middleware, AWS Lambda\@Edge) — the trust comes from the deployment topology, not from Trigger.dev validating the source.

## Why headers don't work

It's tempting to ask whether `POST /realtime/v1/sessions/{id}/in/append` could carry the signal as an HTTP header. It cannot. The realtime route reads only `Authorization` and `X-Part-Id`; the remaining headers are dropped at the route boundary and the body is persisted to the durable stream as opaque bytes. There is no `headers → run payload` channel.

The trigger.dev wire payload, on the other hand, has a typed per-turn metadata channel ([`ChatTaskWirePayload.metadata`](/docs/ai-chat/client-protocol#chattaskwirepayload)). It already flows from the wire into [`clientData`](/docs/ai-chat/reference#withclientdata) on every hook (`onBoot`, `onChatStart`, `onTurnStart`, `run`, `onTurnComplete`). That field is where signals must land.

## The trust boundary

The pattern has one architectural requirement and one wire-shape convention.

**Topology**: the browser must not be able to reach `trigger.dev` directly. All four chat-related requests (`POST /api/v1/sessions`, `GET /realtime/v1/sessions/{id}/out`, `POST /realtime/v1/sessions/{id}/in/append`, `POST /api/v1/auth/jwt/claims`) flow through your edge proxy. The proxy holds the trust; trigger.dev simply persists whatever the proxy writes.

**Namespace**: pick a key your edge proxy owns exclusively — e.g. `__cf`, `__edge`, `__trust`. The proxy **strips** anything in that key on the way in and **injects** its own value on every request. Nothing else in your system should write that key. This is the convention that converts deployment topology into a guarantee the agent can rely on.

```mermaid theme={"theme":"css-variables"}
sequenceDiagram
  participant Browser
  participant Edge as Edge Proxy (CF Worker)
  participant Trigger as trigger.dev API
  participant Agent as chat.agent run

  Browser->>Edge: POST /api/v1/sessions { triggerConfig.basePayload.metadata: {...} }
  Edge->>Edge: strip body.triggerConfig.basePayload.metadata.__cf<br/>inject body.triggerConfig.basePayload.metadata.__cf = { botScore, ja4, asn }
  Edge->>Trigger: POST /api/v1/sessions (rewritten body)
  Trigger-->>Agent: run boots with payload.metadata.__cf
  Browser->>Edge: POST /realtime/v1/sessions/{id}/in/append { kind: "message", payload: {...} }
  Edge->>Edge: strip payload.metadata.__cf<br/>inject payload.metadata.__cf
  Edge->>Trigger: POST /in/append (rewritten body)
  Trigger-->>Agent: chat.messages.wait() resolves with payload.metadata.__cf
```

## Wire payload — the two endpoints to rewrite

The signal needs to land in **two** places. Both bodies are JSON; the edge proxy parses, mutates the namespaced key, and re-serializes.

### `POST /api/v1/sessions` — session create

The browser's session-create call carries the first-turn metadata under `triggerConfig.basePayload.metadata`. The proxy mutates that:

```ts theme={"theme":"css-variables"}
// Before
{
  "type": "chat.agent",
  "externalId": "conv-123",
  "taskIdentifier": "my-agent",
  "triggerConfig": {
    "basePayload": {
      "chatId": "conv-123",
      "trigger": "preload",
      "metadata": { "userId": "user-456" }
    }
  }
}

// After
{
  "type": "chat.agent",
  "externalId": "conv-123",
  "taskIdentifier": "my-agent",
  "triggerConfig": {
    "basePayload": {
      "chatId": "conv-123",
      "trigger": "preload",
      "metadata": {
        "userId": "user-456",
        "__cf": { "botScore": 95, "ja4": "...", "asn": 13335, "country": "US" }
      }
    }
  }
}
```

### `POST /realtime/v1/sessions/{id}/in/append` — every follow-up turn

The body is a JSON-serialized `ChatInputChunk`. The proxy parses it, checks `kind === "message"`, and mutates `payload.metadata`:

```ts theme={"theme":"css-variables"}
// Before
{
  "kind": "message",
  "payload": {
    "message": { "id": "u-2", "role": "user", "parts": [{ "type": "text", "text": "..." }] },
    "chatId": "conv-123",
    "trigger": "submit-message",
    "metadata": { "userId": "user-456" }
  }
}

// After
{
  "kind": "message",
  "payload": {
    "message": { ... },
    "chatId": "conv-123",
    "trigger": "submit-message",
    "metadata": {
      "userId": "user-456",
      "__cf": { "botScore": 95, "ja4": "...", "asn": 13335, "country": "US" }
    }
  }
}
```

Both bodies stay well under the [per-record cap on `/in/append`](/docs/ai-chat/client-protocol#step-3-send-messages-stops-and-actions) — a typical trust object is \~200 bytes.

Other paths — `.out` SSE, `/api/v1/auth/jwt/claims`, anything else — pass through the proxy untouched. The SSE stream in particular must not be buffered; preserve the response body as-is.

## Cloudflare Worker reference implementation

A complete worker that proxies all paths to `TRIGGER_API_UPSTREAM` and injects `__cf` on the two body-write endpoints:

```ts theme={"theme":"css-variables"}
export interface Env {
  TRIGGER_API_UPSTREAM: string; // e.g. "https://api.trigger.dev"
}

type CfTrustData = {
  botScore: number;
  ja4: string;
  asn: number;
  country: string;
};

function readCfTrustData(request: Request): CfTrustData {
  const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
  const bm = cf?.botManagement as Record<string, unknown> | undefined;
  return {
    botScore: (bm?.score as number) ?? 0,
    ja4: (bm?.ja4 as string) ?? "",
    asn: (cf?.asn as number) ?? 0,
    country: (cf?.country as string) ?? "",
  };
}

function injectCf(metadata: Record<string, unknown> | undefined, cf: CfTrustData) {
  // Strip anything the client tried to send under our namespace,
  // then inject the edge-trusted value. Topology + convention =
  // trust.
  const stripped = { ...(metadata ?? {}) };
  delete stripped.__cf;
  return { ...stripped, __cf: cf };
}

function rewriteSessionsCreate(body: string, cf: CfTrustData) {
  const parsed = JSON.parse(body) as Record<string, unknown>;
  const tc = (parsed.triggerConfig as Record<string, unknown>) ?? {};
  const bp = (tc.basePayload as Record<string, unknown>) ?? {};
  parsed.triggerConfig = {
    ...tc,
    basePayload: { ...bp, metadata: injectCf(bp.metadata as Record<string, unknown>, cf) },
  };
  return JSON.stringify(parsed);
}

function rewriteAppend(body: string, cf: CfTrustData) {
  let parsed: Record<string, unknown>;
  try {
    parsed = JSON.parse(body);
  } catch {
    return body;
  }
  if (parsed.kind !== "message") return body;
  const payload = (parsed.payload as Record<string, unknown>) ?? {};
  parsed.payload = { ...payload, metadata: injectCf(payload.metadata as Record<string, unknown>, cf) };
  return JSON.stringify(parsed);
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const incoming = new URL(request.url);
    const target = new URL(incoming.pathname + incoming.search, env.TRIGGER_API_UPSTREAM);
    const cf = readCfTrustData(request);

    const isSessionsCreate =
      request.method === "POST" && incoming.pathname === "/api/v1/sessions";
    const isAppend =
      request.method === "POST" &&
      /^\/realtime\/v1\/sessions\/[^/]+\/in\/append$/.test(incoming.pathname);

    let body: BodyInit | null = null;
    if (request.method !== "GET" && request.method !== "HEAD") {
      const raw = await request.text();
      if (isSessionsCreate && raw) body = rewriteSessionsCreate(raw, cf);
      else if (isAppend && raw) body = rewriteAppend(raw, cf);
      else body = raw;
    }

    const headers = new Headers(request.headers);
    headers.delete("host");
    headers.delete("content-length");

    return fetch(target.toString(), {
      method: request.method,
      headers,
      body,
      redirect: "manual",
    });
  },
};
```

Browser-only deployments also need CORS on the worker — echo `Access-Control-Request-Headers` on preflight and set `Access-Control-Allow-Origin` to your frontend origin. The trigger.dev route itself allows all origins, but the worker becomes the visible cross-origin endpoint to the browser.

### Streaming and latency

The SDK's `baseURL` accepts a function (see [Browser transport configuration](#browser-transport-configuration)), so the recommended setup routes `.in/append` and session-create through the worker but lets `.out` SSE go direct to `api.trigger.dev`. Body-mutation only happens on the POST paths; the SSE stream is read-only, doesn't need rewriting, and routing it direct saves an edge hop on every reconnect.

If you do route `.out` through the proxy (e.g. you want a single origin in front of `api.trigger.dev` and don't care about the extra hop), the template above handles it correctly because the worker returns `response.body` as a `ReadableStream`. **Do not replace that with `await response.text()`** anywhere in your fork; doing so converts the streaming SSE response into a buffered read and breaks per-chunk delivery.

[Cloudflare Workers HTTP requests](https://developers.cloudflare.com/workers/platform/limits/) have no wall-clock duration limit while the client stays connected — the 60-second long-poll runs to completion on every plan, including Free. CPU-time limits (10 ms on Free, 30 s default on Paid) only apply to active computation; relaying bytes through `fetch` doesn't burn CPU. The two body-rewrite paths use sub-millisecond CPU for typical message sizes, well under either ceiling.

Network-wise the proxy adds one edge hop: roughly 10–50 ms per request round trip versus talking to `api.trigger.dev` directly. Routing SSE direct via the function-form `baseURL` eliminates that hop on the long-lived path.

## Agent side — declare the namespace in `clientDataSchema`

Mirror the namespace in the agent so every turn lands typed:

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { z } from "zod";

export const myAgent = chat
  .withClientData({
    schema: z.object({
      userId: z.string(),
      __cf: z.object({
        botScore: z.number(),
        ja4: z.string(),
        asn: z.number(),
        country: z.string(),
      }),
    }),
  })
  .agent({
    id: "my-agent",
    run: async ({ messages, clientData, signal }) => {
      // Score-based routing. The values arrive from the edge proxy.
      if (clientData.__cf.botScore < 30) {
        return streamText({
          model: anthropic("claude-haiku-4-5"),
          messages: [{ role: "system", content: "Reject politely; do not engage." }],
          abortSignal: signal,
          stopWhen: stepCountIs(15),
        });
      }

      return streamText({
        model: anthropic("claude-sonnet-4-5"),
        messages,
        abortSignal: signal,
        // ...
        stopWhen: stepCountIs(15),
      });
    },
  });
```

Because the schema requires `__cf` on every turn, any request that *doesn't* go through the proxy fails at the agent boundary — the turn produces a `[ERROR]` span on the trace and an empty `turn-complete` on the wire (see [the client protocol error-detection note](/docs/ai-chat/client-protocol#step-3-send-messages-stops-and-actions)). That gives you a server-side enforcement check for "did this request actually come through the trusted path?"

## Browser transport configuration

Point the `TriggerChatTransport` at the worker, not at `api.trigger.dev`:

`baseURL` accepts a function so you can route `.in/append` through the worker while keeping `.out` SSE direct to `api.trigger.dev`. The append path is where the body-mutation matters; the SSE stream is a read-only one-way channel that doesn't need to be proxied. Routing it direct saves an edge hop on every long-poll.

```tsx theme={"theme":"css-variables"}
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";

const WORKER = "https://worker.your-domain.com";
const DIRECT = "https://api.trigger.dev";

const transport = useTriggerChatTransport({
  task: "my-agent",
  baseURL: ({ endpoint }) => (endpoint === "out" ? DIRECT : WORKER),
  // ... accessToken, startSession, etc.
  // NOTE: do not set __cf in clientData here. The browser cannot be
  // trusted to populate it — the worker is the source of truth.
  clientData: { userId: currentUserId },
});
```

If you'd rather route everything through the worker, pass a single string:

```tsx theme={"theme":"css-variables"}
baseURL: "https://worker.your-domain.com",
```

`baseURL` accepts the same string-or-function shape on `chat.createStartSessionAction`, so the Next.js server action that creates the session also flows through the worker — that's how the very first run's `basePayload.metadata.__cf` gets injected before reaching `api.trigger.dev`:

```ts theme={"theme":"css-variables"}
// actions.ts — server-only
import { chat } from "@trigger.dev/sdk/ai";

export const startSession = chat.createStartSessionAction("my-agent", {
  tokenTTL: "1h",
  baseURL: ({ endpoint }) =>
    endpoint === "sessions" ? WORKER : DIRECT,
});
```

The session-create endpoint discriminator is `"sessions"` (POST `/api/v1/sessions`) or `"auth"` (POST `/api/v1/auth/jwt/claims`) — distinct from the chat transport's `"in"` / `"out"`. If you want everything proxied, pass a string.

## Threat model

Two important invariants follow from this design:

1. **Direct browser-to-trigger.dev requests cannot succeed**. As long as your agent's `clientDataSchema` requires the namespaced field, any request that doesn't go through the proxy fails schema validation and produces an empty turn. This is your gate.
2. **Anything inside the namespaced key is trusted only as far as the proxy is the sole writer**. If a client could obtain the public access token and bypass the proxy, they could send arbitrary values under `__cf`. The schema would still validate (it only checks shape, not provenance). The mitigation is operational: the public access token must only be served to clients that reach trigger.dev through the proxy. In practice this means your Next.js server actions and your browser are both behind the same edge layer, and the worker is the only fetch destination for `trigger.dev` baked into either of them.

You can harden further with a shared-secret header the worker injects (e.g. `X-Edge-Signature`) and an agent-side check, but in most CDN deployments the deployment topology is already sufficient.

## Recipe summary

1. Pick a namespaced key the edge proxy owns (`__cf`, `__edge`, `__trust`).
2. Deploy a proxy in front of `trigger.dev` that rewrites POST `/api/v1/sessions` and POST `/realtime/v1/sessions/{id}/in/append` to inject your trusted values under that key.
3. Declare the namespace in the agent's `clientDataSchema` so missing or malformed signals fail at the agent boundary.
4. Point your transport's `baseURL` at the proxy. Never expose `api.trigger.dev` directly to the browser.

## See also

* [Client Protocol](/docs/ai-chat/client-protocol) — the full wire shape the proxy is rewriting.
* [`withClientData`](/docs/ai-chat/reference#withclientdata) — agent-side typed metadata channel.
* [Large payloads](/docs/ai-chat/patterns/large-payloads) — for when injected signals or hooks need to ship more than the 1 MiB stream cap allows.


# Version upgrades
Source: https://trigger.dev/docs/ai-chat/patterns/version-upgrades

Gracefully migrate suspended chat agents to a new deployment using chat.requestUpgrade() and the continuation mechanism.

Chat agent runs are pinned to the worker version they started on. When you deploy a new version, suspended runs resume on the **old** code. If your deploy includes breaking changes (new tools, changed schemas, updated API contracts), this can cause issues.

`chat.requestUpgrade()` lets the agent opt out of the current run so the transport triggers a new one on the latest version.

## How it works

When `chat.requestUpgrade()` is called in `onTurnStart` or `onValidateMessages`:

1. `run()` is **skipped** — no response is generated on old code
2. The agent calls the server-side `endAndContinueSession` endpoint, which atomically swaps the Session's `currentRunId` to a freshly-triggered run on the latest deployment (optimistic-claim against `currentRunVersion`)
3. The new run picks up the conversation and produces the response
4. The transport's existing SSE subscription to `session.out` keeps receiving chunks across the swap — no client-side reconnect

The new run lives on the **same Session** as the old one. `chatId` is the durable identity; only the underlying `currentRunId` rotates. The audit log records the new run with `reason: "upgrade"`.

When called from inside `run()` or `chat.defer()`, the current turn completes normally first and the run exits afterward. The next message triggers the continuation on the same session.

```mermaid theme={"theme":"css-variables"}
sequenceDiagram
  participant User
  participant Transport
  participant RunV1 as Run (v1)
  participant RunV2 as Run (v2)

  User->>Transport: send message
  Transport->>RunV1: input stream
  RunV1->>RunV1: onTurnStart → requestUpgrade()
  RunV1-->>Transport: trigger:upgrade-required
  RunV1->>RunV1: exit (run() never called)
  Transport->>RunV2: trigger new run (continuation, same message)
  RunV2-->>Transport: response stream
  Transport-->>User: response (seamless)
```

## Contract versioning

Define an explicit version for the contract between your frontend and agent. The frontend sends a `protocolVersion` via `clientData`, and the agent declares which versions it supports. When a breaking change ships (new tools, changed data parts, updated response format), bump the version.

This gives you full control — the frontend can be backwards-compatible across multiple agent versions, and the agent only upgrades when it sees a version it doesn't support.

```tsx title="app/components/Chat.tsx" theme={"theme":"css-variables"}
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useChat } from "@ai-sdk/react";

export function Chat() {
  const transport = useTriggerChatTransport({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
    // Bump this when you ship a breaking change to the chat UI or tools
    clientData: { userId: user.id, protocolVersion: "v2" },
  });

  const { messages, sendMessage } = useChat({ transport });
  // ...
}
```

On the agent side, declare which versions the current code supports:

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

// The set of frontend protocol versions this agent code supports.
// When you deploy a breaking change, remove old versions from this set.
const SUPPORTED_VERSIONS = new Set(["v2", "v3"]);

export const myChat = chat
  .withClientData({
    schema: z.object({
      userId: z.string(),
      protocolVersion: z.string(),
    }),
  })
  .agent({
    id: "my-chat",
    onTurnStart: async ({ clientData }) => {
      if (clientData?.protocolVersion && !SUPPORTED_VERSIONS.has(clientData.protocolVersion)) {
        chat.requestUpgrade();
      }
    },
    run: async ({ messages, signal }) => {
      return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
    },
  });
```

The transport includes `clientData` in every payload — both the initial trigger and subsequent records on the session's `.in` channel — so the agent always has the current value.

This pattern is useful when:

* Your frontend is backwards-compatible across several agent versions, but occasionally ships breaking changes
* You want explicit control over when upgrades happen rather than upgrading on every deploy
* Multiple frontend versions may be active at the same time (e.g., users with cached tabs)

## Auto-detect from build ID (Next.js / Vercel)

For automatic upgrade on every deploy, pass your platform's build ID via `clientData` instead of a manual version. The agent stores the ID from the first message and upgrades when it changes:

```tsx title="app/components/Chat.tsx" theme={"theme":"css-variables"}
// Vercel sets this at build time, or use your own build ID
const APP_VERSION = process.env.NEXT_PUBLIC_VERCEL_DEPLOYMENT_ID
  ?? process.env.NEXT_PUBLIC_BUILD_ID
  ?? "dev";

export function Chat() {
  const transport = useTriggerChatTransport({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
    clientData: { userId: user.id, appVersion: APP_VERSION },
  });
  // ...
}
```

```ts title="trigger/chat.ts" theme={"theme":"css-variables"}
const initialAppVersion = chat.local<{ version: string }>({ id: "appVersion" });

export const myChat = chat
  .withClientData({
    schema: z.object({
      userId: z.string(),
      appVersion: z.string(),
    }),
  })
  .agent({
    id: "my-chat",
    onBoot: async ({ clientData }) => {
      initialAppVersion.init({ version: clientData.appVersion });
    },
    onTurnStart: async ({ clientData }) => {
      if (clientData?.appVersion && clientData.appVersion !== initialAppVersion.version) {
        chat.requestUpgrade();
      }
    },
    run: async ({ messages, signal }) => {
      return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
    },
  });
```

This upgrades on **every** deploy, not just breaking changes. Good for fast-moving projects where you always want the latest code.

## Other agent types

* **`chat.agent()`** and **`chat.createSession()`** — use `chat.requestUpgrade()` as shown above
* **`chat.customAgent()`** — you control the turn loop, so just `return` from `run()` when you want to exit

## Interaction with recovery boot

`chat.requestUpgrade()` is a graceful exit — the old run returns cleanly, never writing a partial assistant. The new continuation run boots with an empty `session.out` tail and the upgrade-trigger message on `session.in`. The trigger message dispatches as turn 1 on the new version via the normal continuation-wait path. [`onRecoveryBoot`](/docs/ai-chat/patterns/recovery-boot) does NOT fire on this path — the hook is reserved for mid-stream interruptions (cancel / crash / OOM) where a partial assistant exists on the tail.

## See also

* [Lifecycle hooks](/docs/ai-chat/lifecycle-hooks) — where `onTurnStart` and `onChatResume` fit in the turn cycle
* [Recovery boot](/docs/ai-chat/patterns/recovery-boot) — the sibling hook for mid-stream interruptions (does NOT fire on `requestUpgrade`)
* [Database persistence](/docs/ai-chat/patterns/database-persistence) — how continuations interact with session state
* [Client Protocol](/docs/ai-chat/client-protocol#step-4-handle-continuations) — how clients handle continuations at the wire level


# Pending Messages
Source: https://trigger.dev/docs/ai-chat/pending-messages

Inject user messages mid-execution to steer agents between tool-call steps.

## Overview

When an AI agent is executing tool calls, users may want to send a message that **steers the agent mid-execution** — adding context, correcting course, or refining the request without waiting for the response to finish.

By default (without `pendingMessages`), a message sent while the agent is responding never interrupts the in-flight response: it's buffered and processed as its own turn once the current turn completes, with multiple messages running sequentially in arrival order.

The `pendingMessages` option enables steering instead, injecting user messages between tool-call steps via the AI SDK's `prepareStep`. Messages that arrive during streaming are queued and injected at the next step boundary. If there are no more step boundaries (single-step response or final text generation), the message becomes the next turn automatically.

## How it works

1. User sends a message while the agent is streaming
2. The message is sent to the backend via input stream (`transport.sendPendingMessage`)
3. The backend queues it in the steering queue
4. At the next `prepareStep` boundary (between tool-call steps), `shouldInject` is called
5. If it returns `true`, the message is injected into the LLM's context
6. A `data-pending-message-injected` stream chunk confirms injection to the frontend
7. If `prepareStep` never fires (no tool calls), the message becomes the next turn

## Backend: chat.agent

Add `pendingMessages` to your `chat.agent` configuration:

```ts theme={"theme":"css-variables"}
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",
  pendingMessages: {
    // Only inject when there are completed steps (tool calls happened)
    shouldInject: ({ steps }) => steps.length > 0,
  },
  run: async ({ messages, signal }) => {
    return streamText({
      ...chat.toStreamTextOptions({ registry }),
      messages,
      tools: { /* ... */ },
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

The `prepareStep` for injection is automatically included when you spread `chat.toStreamTextOptions()`. If you provide your own `prepareStep` after the spread, it overrides the auto-injected one.

### Options

| Option         | Type                                                   | Description                                                                                         |
| -------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| `shouldInject` | `(event: PendingMessagesBatchEvent) => boolean`        | Decide whether to inject the batch. Called once per step boundary. If absent, no injection happens. |
| `prepare`      | `(event: PendingMessagesBatchEvent) => ModelMessage[]` | Transform the batch before injection. Default: convert each message via `convertToModelMessages`.   |
| `onReceived`   | `(event) => void`                                      | Called when a message arrives during streaming (per-message).                                       |
| `onInjected`   | `(event) => void`                                      | Called after a batch is injected.                                                                   |

### shouldInject

Called once per step boundary with the full batch of pending messages. Return `true` to inject all of them, `false` to skip (they'll be available at the next boundary or become the next turn).

```ts theme={"theme":"css-variables"}
pendingMessages: {
  // Always inject
  shouldInject: () => true,

  // Only inject after tool calls
  shouldInject: ({ steps }) => steps.length > 0,

  // Only inject if there's one message
  shouldInject: ({ messages }) => messages.length === 1,
},
```

The event includes:

| Field           | Type               | Description                  |
| --------------- | ------------------ | ---------------------------- |
| `messages`      | `UIMessage[]`      | All pending messages (batch) |
| `modelMessages` | `ModelMessage[]`   | Current conversation         |
| `steps`         | `CompactionStep[]` | Completed steps              |
| `stepNumber`    | `number`           | Current step (0-indexed)     |
| `chatId`        | `string`           | Chat session ID              |
| `turn`          | `number`           | Current turn                 |
| `clientData`    | `unknown`          | Frontend metadata            |

### prepare

Transform the batch of pending messages before they're injected into the LLM's context. By default, each UIMessage is converted to ModelMessages individually. Use `prepare` to combine multiple messages or add context:

```ts theme={"theme":"css-variables"}
pendingMessages: {
  shouldInject: ({ steps }) => steps.length > 0,
  prepare: ({ messages }) => [{
    role: "user",
    content: messages.length === 1
      ? messages[0].parts[0]?.text ?? ""
      : `The user sent ${messages.length} messages:\n${
          messages.map((m, i) => `${i + 1}. ${m.parts[0]?.text}`).join("\n")
        }`,
  }],
},
```

### Stream chunk

When messages are injected, the SDK automatically writes a `data-pending-message-injected` stream chunk containing the message IDs and text. The frontend uses this to:

* Confirm which messages were injected
* Remove them from the pending overlay
* Render them inline at the injection point in the assistant response

A "pending message injected" span also appears in the run trace.

## Backend: chat.createSession

Pass `pendingMessages` to the session options:

```ts theme={"theme":"css-variables"}
const session = chat.createSession(payload, {
  signal,
  idleTimeoutInSeconds: 60,
  pendingMessages: {
    shouldInject: () => true,
  },
});

for await (const turn of session) {
  const result = streamText({
    model: anthropic("claude-sonnet-4-5"),
    messages: turn.messages,
    abortSignal: turn.signal,
    prepareStep: turn.prepareStep(), // Handles injection + compaction
    stopWhen: stepCountIs(15),
  });

  await turn.complete(result);
}
```

Use `turn.prepareStep()` to get a prepareStep function that handles both injection and compaction. Users who spread `chat.toStreamTextOptions()` get it automatically.

## Backend: MessageAccumulator (raw task)

Pass `pendingMessages` to the constructor and wire up the message listener manually:

```ts theme={"theme":"css-variables"}
const conversation = new chat.MessageAccumulator({
  pendingMessages: {
    shouldInject: () => true,
    prepare: ({ messages }) => [{
      role: "user",
      content: `[Steering]: ${messages.map(m => m.parts[0]?.text).join(", ")}`,
    }],
  },
});

for (let turn = 0; turn < 100; turn++) {
  // The wire payload carries at most one new message per turn.
  const messages = await conversation.addIncoming(
    payload.message ? [payload.message] : [],
    payload.trigger,
    turn
  );

  // Listen for steering messages during streaming
  const sub = chat.messages.on(async (msg) => {
    if (msg.message) await conversation.steerAsync(msg.message);
  });

  const result = streamText({
    model: anthropic("claude-sonnet-4-5"),
    messages,
    prepareStep: conversation.prepareStep(), // Handles injection + compaction
    stopWhen: stepCountIs(15),
  });

  const { message, status, error } = await chat.pipeAndCapture(result);
  sub.off();

  // Keep any partial output, then branch on the outcome: pipeAndCapture no
  // longer throws, so rethrow a real failure and don't complete a failed turn.
  if (message) await conversation.addResponse(message);
  if (status === "error") throw error;

  await chat.writeTurnComplete();
}
```

### MessageAccumulator methods

| Method                           | Description                                                    |
| -------------------------------- | -------------------------------------------------------------- |
| `steer(message, modelMessages?)` | Queue a UIMessage for injection (sync)                         |
| `steerAsync(message)`            | Queue a UIMessage, converting to model messages automatically  |
| `drainSteering()`                | Get and clear unconsumed steering messages                     |
| `prepareStep()`                  | Returns a prepareStep function handling injection + compaction |

## Frontend: usePendingMessages hook

The `usePendingMessages` hook manages all the frontend complexity — tracking pending messages, detecting injections, and handling the turn lifecycle.

```tsx theme={"theme":"css-variables"}
import { useChat } from "@ai-sdk/react";
import { useTriggerChatTransport, usePendingMessages } from "@trigger.dev/sdk/chat/react";

function Chat({ chatId }: { chatId: string }) {
  const transport = useTriggerChatTransport({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  });

  const { messages, setMessages, sendMessage, stop, status } = useChat({
    id: chatId,
    transport,
  });

  const pending = usePendingMessages({
    transport,
    chatId,
    status,
    messages,
    setMessages,
    sendMessage,
    metadata: { model: "gpt-4o" },
  });

  return (
    <div>
      {/* Render messages */}
      {messages.map((msg) => (
        <div key={msg.id}>
          {msg.role === "assistant" ? (
            msg.parts.map((part, i) =>
              pending.isInjectionPoint(part) ? (
                // Render injected messages inline at the injection point
                <div key={i}>
                  {pending.getInjectedMessages(part).map((m) => (
                    <div key={m.id} className="injected-message">{m.text}</div>
                  ))}
                </div>
              ) : (
                <Part key={i} part={part} />
              )
            )
          ) : (
            <UserMessage msg={msg} />
          )}
        </div>
      ))}

      {/* Render pending messages */}
      {pending.pending.map((msg) => (
        <div key={msg.id}>
          <span>{msg.text}</span>
          <span>{msg.mode === "steering" ? "Steering" : "Queued"}</span>
          {msg.mode === "queued" && status === "streaming" && (
            <button onClick={() => pending.promoteToSteering(msg.id)}>
              Steer instead
            </button>
          )}
        </div>
      ))}

      {/* Send form */}
      <form onSubmit={(e) => {
        e.preventDefault();
        pending.steer(input); // Steers during streaming, sends normally when ready
        setInput("");
      }}>
        <input value={input} onChange={(e) => setInput(e.target.value)} />
        <button type="submit">Send</button>
        {status === "streaming" && (
          <button type="button" onClick={() => { pending.queue(input); setInput(""); }}>
            Queue
          </button>
        )}
      </form>
    </div>
  );
}
```

### Hook API

| Property/Method               | Type                                   | Description                                                               |
| ----------------------------- | -------------------------------------- | ------------------------------------------------------------------------- |
| `pending`                     | `PendingMessage[]`                     | Current pending messages with `id`, `text`, `mode`, and `injected` status |
| `steer(text)`                 | `(text: string) => void`               | Send a steering message during streaming, or normal message when ready    |
| `queue(text)`                 | `(text: string) => void`               | Queue for next turn during streaming, or send normally when ready         |
| `promoteToSteering(id)`       | `(id: string) => void`                 | Convert a queued message to steering (sends via input stream immediately) |
| `isInjectionPoint(part)`      | `(part: unknown) => boolean`           | Check if an assistant message part is an injection confirmation           |
| `getInjectedMessageIds(part)` | `(part: unknown) => string[]`          | Get message IDs from an injection point                                   |
| `getInjectedMessages(part)`   | `(part: unknown) => InjectedMessage[]` | Get messages (id + text) from an injection point                          |

### PendingMessage

| Field      | Type                     | Description                             |
| ---------- | ------------------------ | --------------------------------------- |
| `id`       | `string`                 | Unique message ID                       |
| `text`     | `string`                 | Message text                            |
| `mode`     | `"steering" \| "queued"` | How the message is being handled        |
| `injected` | `boolean`                | Whether the backend confirmed injection |

### Message lifecycle

* **Steering messages** are sent via `transport.sendPendingMessage()` immediately. They appear as purple pending bubbles. If injected, they disappear from the overlay and render inline at the injection point. If not injected (no more step boundaries), they auto-send as the next turn when the response finishes.

* **Queued messages** stay client-side until the turn completes, then auto-send as the next turn via `sendMessage()`. They can be promoted to steering mid-stream by clicking "Steer instead".

* **Promoted messages** are queued messages that were converted to steering. They get sent via input stream immediately and follow the steering lifecycle from that point.

## Transport: sendPendingMessage

The `TriggerChatTransport` exposes a `sendPendingMessage` method for sending messages via input stream without disrupting the active stream subscription:

```ts theme={"theme":"css-variables"}
const sent = await transport.sendPendingMessage(chatId, {
  id: crypto.randomUUID(),
  role: "user",
  parts: [{ type: "text", text: "and compare to vercel" }],
}, { model: "gpt-4o" });
```

Unlike `sendMessage()` from useChat, this does NOT:

* Add the message to useChat's local state
* Cancel the active stream subscription
* Start a new response stream

The `usePendingMessages` hook calls this internally — you typically don't need to use it directly.

## Coexistence with compaction

Pending message injection and compaction both use `prepareStep`. When both are configured, the auto-injected `prepareStep` handles them in order:

1. **Compaction** runs first — checks threshold, generates summary if needed
2. **Injection** runs second — pending messages are appended to either the compacted or original messages

This means injected messages are always included after compaction, ensuring the LLM sees both the compressed history and the new steering input.


# Prompt caching
Source: https://trigger.dev/docs/ai-chat/prompt-caching

Cache the stable prefix of your agent's prompt with Anthropic prompt caching to cut token cost and latency on every turn.

**Prompt caching lets a provider reuse the unchanged prefix of your prompt across requests, billing it at a fraction of the input price and skipping re-processing.** With Anthropic, cache reads cost \~10% of base input tokens, so a long, stable system prompt or a growing conversation history pays full price once and reads cheaply on every turn after.

Caching is a **byte-exact prefix match**: any change in the prefix invalidates everything after it. A multi-turn agent is the ideal case — the system prompt, tools, and earlier turns are identical turn over turn, so the cacheable prefix only grows. `chat.agent` is built to keep that prefix stable across turns, suspends, and resumes; this page shows how to place the cache breakpoints and verify they're hitting.

Caching is provider-specific. This guide covers Anthropic (`@ai-sdk/anthropic`), where you opt in per breakpoint with `providerOptions.anthropic.cacheControl`. Other providers cache differently, and most cache automatically — see [Other providers](#other-providers).

## What you cache, and where

A request renders as `tools` → `system` → `messages`. There are three prefix regions worth caching, in order:

| Region                  | How to cache it                                                                                                       | Stability                                                   |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| System prompt (+ tools) | `cacheControl` / `systemProviderOptions` on `chat.toStreamTextOptions()`, or `providerOptions` on `chat.prompt.set()` | Set once, never changes — the highest-value target          |
| Conversation history    | `prepareMessages` adds a breakpoint to the last message                                                               | Grows append-only across turns                              |
| Tool definitions        | Stable as long as your tool set doesn't change between turns                                                          | Render at position 0 — changing them invalidates everything |

`chat.agent` preserves `providerOptions` through message persistence and rehydration, so a breakpoint you place survives a suspend/resume or a page refresh. The recommended way to place message breakpoints is `prepareMessages` (below) rather than baking `cacheControl` into stored messages — `prepareMessages` runs on every prompt-assembly path, including after compaction, so the breakpoint is always in the right place.

## Cache the system prompt

The system prompt (your `chat.prompt` text plus any skills preamble) is usually the largest stable block, so it's the first thing to cache. `chat.toStreamTextOptions()` returns `system` as a plain string by default; opt into caching and it returns a structured system message carrying the cache breakpoint instead.

<Note>
  System-prompt caching needs AI SDK v6 or later, where the `system` parameter accepts a structured message. On AI SDK v5 `system` is a plain string, so these options won't apply a breakpoint to the system block — cache the conversation via `prepareMessages` instead.
</Note>

Three ways to opt in, depending on where you'd rather express it.

**`cacheControl` at the `streamText` call site** — the Anthropic-flavored one-liner:

```ts /trigger/chat.ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

export const myChat = chat.agent({
  id: "my-chat",
  onChatStart: async () => {
    chat.prompt.set(SYSTEM_PROMPT); // a large, stable instruction block
  },
  run: async ({ messages, signal }) => {
    return streamText({
      model: anthropic("claude-sonnet-4-6"),
      // Caches the system block with a 5-minute breakpoint.
      ...chat.toStreamTextOptions({ cacheControl: { type: "ephemeral" } }),
      messages,
      abortSignal: signal,
    });
  },
});
```

**`systemProviderOptions`** is the provider-agnostic form — pass the raw `providerOptions` so it composes with any provider:

```ts /trigger/chat.ts theme={"theme":"css-variables"}
return streamText({
  model: anthropic("claude-sonnet-4-6"),
  ...chat.toStreamTextOptions({
    systemProviderOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
  }),
  messages,
  abortSignal: signal,
});
```

**`providerOptions` on `chat.prompt.set()`** co-locates the intent with where the prompt is defined. It carries through to `toStreamTextOptions()` with no call-site change:

```ts /trigger/chat.ts theme={"theme":"css-variables"}
onChatStart: async () => {
  chat.prompt.set(SYSTEM_PROMPT, {
    providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
  });
},
run: async ({ messages, signal }) => {
  return streamText({
    model: anthropic("claude-sonnet-4-6"),
    ...chat.toStreamTextOptions(), // already cached
    messages,
    abortSignal: signal,
  });
},
```

If more than one is set, the call-site option wins: `systemProviderOptions` overrides `cacheControl`, and both override `chat.prompt.set`'s `providerOptions`. There's no deep merge — the most specific option replaces the rest.

<Note>
  Use the 1-hour cache for prefixes that sit idle longer than 5 minutes between turns: `cacheControl: { type: "ephemeral", ttl: "1h" }`. Writes cost more (2× vs 1.25×), so it pays off only when reads span the longer window.
</Note>

## Cache the conversation history

Place a breakpoint on the last message and the entire conversation prefix up to that point is cached, so the next turn reads it back instead of re-processing it. Do this in [`prepareMessages`](/docs/ai-chat/reference#chatagentoptions) — it transforms model messages once, and `chat.agent` applies it on every path that builds a prompt (each turn, and both compaction rebuild paths), so the breakpoint always lands on the real last message.

```ts /trigger/chat.ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  prepareMessages: async ({ messages }) => {
    if (messages.length === 0) return messages;
    const last = messages[messages.length - 1];
    return [
      ...messages.slice(0, -1),
      {
        ...last,
        providerOptions: {
          ...last.providerOptions,
          anthropic: { cacheControl: { type: "ephemeral" } },
        },
      },
    ];
  },
  run: async ({ messages, signal }) => {
    return streamText({
      model: anthropic("claude-sonnet-4-6"),
      ...chat.toStreamTextOptions({ cacheControl: { type: "ephemeral" } }),
      messages,
      abortSignal: signal,
    });
  },
});
```

The system breakpoint and the conversation breakpoint compose: the system block is cached once for the life of the chat, and each turn extends the cached message prefix.

<Note>
  Anthropic allows **at most 4** cache breakpoints per request, and a prefix must be at least \~1024 tokens (model-dependent) to cache at all — shorter prefixes silently don't cache. One system breakpoint plus one rolling message breakpoint is the typical setup and leaves headroom.
</Note>

<Note>
  This rolling-breakpoint pattern composes with [Head Start](/docs/ai-chat/fast-starts#head-start). On a head-start handover, the first turn's pending tool call is handed to the agent as a tool-approval round whose trailing `tool` message must reach `streamText` untouched for that call to execute. `chat.agent` preserves that tail across `prepareMessages` automatically, so rewriting the last message here is safe: on a resume turn the breakpoint just lands on the next user or assistant message instead of the transient approval row.
</Note>

## Caching and compaction

Compaction rewrites the conversation prefix — it replaces earlier turns with a summary — so it necessarily invalidates the cached message prefix at that point. That's a one-time reset, not a regression: because `prepareMessages` also runs on the compaction rebuild and result paths, the new (shorter) prefix gets a fresh breakpoint and re-warms on the next turn. Your system-prompt cache is unaffected — compaction never touches the system block. See [Compaction](/docs/ai-chat/compaction) for how the summary is produced.

## Other providers

Caching is provider-specific, and most providers don't use per-block breakpoints at all:

* **OpenAI** and **Google Gemini** cache automatically. OpenAI caches any prompt prefix over 1024 tokens; Gemini 2.5 caches implicitly (1024 tokens on Flash, 2048 on Pro). Neither needs a breakpoint, so the system-caching options above are a no-op for them — `chat.agent` already gives automatic caching exactly what it needs: a byte-stable prefix that only grows across turns. Keep the system prompt frozen and the prefix over the model's minimum and reads happen on their own. (OpenAI's optional `providerOptions.openai.promptCacheKey` improves hit-routing across requests; it's a top-level option, not a system-block breakpoint.)

* **Anthropic** and **Amazon Bedrock** take an explicit breakpoint on the system block — Anthropic via `cacheControl`, Bedrock via `cachePoint`. Both go through the provider-agnostic `systemProviderOptions`:

```ts /trigger/chat.ts theme={"theme":"css-variables"}
// Amazon Bedrock
return streamText({
  ...chat.toStreamTextOptions({
    systemProviderOptions: { bedrock: { cachePoint: { type: "default" } } },
  }),
  messages,
});
```

The `cacheControl` shorthand is Anthropic-only; `systemProviderOptions` (and `chat.prompt.set`'s `providerOptions`) is the form to reach for on any other breakpoint-based provider.

Usage reporting is normalized. Each provider reports cache tokens under its own provider-specific field, but the AI SDK maps them into the same `inputTokenDetails.cacheReadTokens` / `cacheWriteTokens` that `previousTurnUsage` and `totalUsage` carry and the dashboard shows — so the [verify step](#verify-caching-is-working) is the same regardless of provider.

## Verify caching is working

The turn's usage carries cache token counts. `chat.agent` accumulates them across turns and hands them to `run` as `previousTurnUsage` (last turn) and `totalUsage` (whole chat), both `LanguageModelUsage`:

```ts /trigger/chat.ts theme={"theme":"css-variables"}
run: async ({ messages, signal, previousTurnUsage }) => {
  // After turn 1, cacheReadTokens should be > 0 on a stable prefix.
  console.log("cache read", previousTurnUsage?.inputTokenDetails?.cacheReadTokens);
  console.log("cache write", previousTurnUsage?.inputTokenDetails?.cacheWriteTokens);

  return streamText({
    model: anthropic("claude-sonnet-4-6"),
    ...chat.toStreamTextOptions({ cacheControl: { type: "ephemeral" } }),
    messages,
    abortSignal: signal,
  });
},
```

The first turn writes the cache (`cacheWriteTokens > 0`, `cacheReadTokens` is 0). Every turn after, on an unchanged prefix, reads it (`cacheReadTokens > 0`). The dashboard surfaces the same numbers on the AI span as **Cache write** and **Cache read**, so you can confirm hits per run without logging.

If `cacheReadTokens` stays 0 across turns with an identical prefix, a silent invalidator is shifting the bytes — see below.

<Warning>
  Anything that changes the prefix between turns silently kills the cache. Keep the system prompt **byte-stable** — never interpolate a timestamp, request ID, or per-turn value into `chat.prompt`. Don't change the **model** or the **tool set** mid-conversation (tools render at position 0, so adding one invalidates everything after). Inject dynamic per-turn context as a late message via [pending messages](/docs/ai-chat/pending-messages) or [background injection](/docs/ai-chat/background-injection), not into the cached prefix.
</Warning>

## Next steps

<CardGroup>
  <Card title="Compaction" icon="compress" href="/ai-chat/compaction">
    Keep long conversations within token limits — and re-warm the cache after.
  </Card>

  <Card title="Fast starts" icon="bolt" href="/ai-chat/fast-starts">
    Cut cold-start latency so a cached prefix is the only thing between a message and a reply.
  </Card>

  <Card title="chat.agent reference" icon="book" href="/ai-chat/reference#chatagentoptions">
    Full option surface, including `prepareMessages` and `toStreamTextOptions`.
  </Card>

  <Card title="Building agents: backend" icon="server" href="/ai-chat/backend">
    The three ways to build a chat backend and when to reach for each.
  </Card>
</CardGroup>


# Quick Start
Source: https://trigger.dev/docs/ai-chat/quick-start

Get a working AI agent in 3 steps — define an agent, generate a token, and wire up the frontend.

These steps assume you already have a Trigger.dev project with the SDK installed and the CLI authenticated — if you don't, follow [Manual setup](/docs/manual-setup) (or `npx trigger.dev@latest init` in an existing project) first. You should be able to run `pnpm exec trigger dev` from your project root before continuing.

The chat surface works with Vercel AI SDK **v5, v6, or v7**; install whichever major you want. On **v7**, also install `@ai-sdk/otel` so your model calls are traced (the SDK registers it for you). See [compatibility](/docs/ai-chat/reference#compatibility) for the full matrix.

<Steps>
  <Step title="Define a chat agent">
    Use `chat.agent` from `@trigger.dev/sdk/ai` to define an agent that handles chat messages. The `run` function receives `ModelMessage[]` (already converted from the frontend's `UIMessage[]`) — pass them directly to `streamText`.

    If you return a `StreamTextResult`, it's **automatically piped** to the frontend.

    ```ts trigger/chat.ts theme={"theme":"css-variables"}
    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 }) => {
        return streamText({
          // Spread chat.toStreamTextOptions() FIRST — it wires up
          // prepareStep (compaction, steering, background injection),
          // the system prompt set via chat.prompt(), and telemetry.
          // Skipping this is the single most common cause of subtle
          // bugs (silent broken compaction, missing steering, etc.).
          ...chat.toStreamTextOptions(),
          model: anthropic("claude-sonnet-4-5"),
          messages,
          abortSignal: signal,
          stopWhen: stepCountIs(15),
        });
      },
    });
    ```

    <Warning>
      **Always spread `chat.toStreamTextOptions()` into your `streamText` call.** It wires up the `prepareStep` callback that drives compaction, mid-turn steering, and background injection — features that silently no-op if the spread is missing. Spread it **first** so any explicit overrides (e.g. a custom `prepareStep`) win.
    </Warning>

    <Tip>
      For a **custom** [`UIMessage`](https://sdk.vercel.ai/docs/reference/ai-sdk-core/ui-message) subtype (typed `data-*` parts, tool map, etc.), define the agent with [`chat.withUIMessage<...>().agent({...})`](/docs/ai-chat/types) instead of `chat.agent`.
    </Tip>
  </Step>

  <Step title="Add two server actions">
    On your server (e.g. as Next.js server actions), expose two helpers the transport will call: one that creates the chat session, and one that mints a fresh session-scoped access token for refresh.

    ```ts app/actions.ts theme={"theme":"css-variables"}
    "use server";

    import { auth } from "@trigger.dev/sdk";
    import { chat } from "@trigger.dev/sdk/ai";

    // Creates the Session row + triggers the first run, returns the
    // session PAT. Idempotent on (env, chatId) so concurrent calls
    // converge to the same session.
    export const startChatSession = chat.createStartSessionAction("my-chat");

    // Pure mint — fresh session-scoped PAT for an existing session.
    // The transport calls this on 401/403 to refresh.
    export async function mintChatAccessToken(chatId: string) {
      return auth.createPublicToken({
        scopes: {
          read: { sessions: chatId },
          write: { sessions: chatId },
        },
        expirationTime: "1h",
      });
    }
    ```

    The browser never holds your environment's secret key — both helpers run on your server, where customer-side authorization (per-user, per-plan, etc.) lives alongside any DB writes you want to pair with session creation.
  </Step>

  <Step title="Use in the frontend">
    Use the `useTriggerChatTransport` hook from `@trigger.dev/sdk/chat/react` to create a memoized transport instance, then pass it to `useChat`. Wire both server actions into the transport's `accessToken` and `startSession` callbacks.

    The example below uses the Next.js `@/*` path alias for imports from `@/trigger/chat` and `@/app/actions`. If you're not using Next.js (or haven't configured the alias), swap them for relative imports.

    ```tsx app/components/chat.tsx theme={"theme":"css-variables"}
    "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",
        accessToken: ({ chatId }) => mintChatAccessToken(chatId),
        startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
      });

      const { messages, sendMessage, stop, status } = useChat({ transport });
      const [input, setInput] = useState("");

      return (
        <div>
          {messages.map((m) => (
            <div key={m.id}>
              <strong>{m.role}:</strong>
              {m.parts.map((part, i) =>
                part.type === "text" ? <span key={i}>{part.text}</span> : null
              )}
            </div>
          ))}

          <form
            onSubmit={(e) => {
              e.preventDefault();
              if (input.trim()) {
                sendMessage({ text: input });
                setInput("");
              }
            }}
          >
            <input
              value={input}
              onChange={(e) => setInput(e.target.value)}
              placeholder="Type a message..."
            />
            <button type="submit" disabled={status === "streaming"}>
              Send
            </button>
            {status === "streaming" && (
              <button type="button" onClick={stop}>
                Stop
              </button>
            )}
          </form>
        </div>
      );
    }
    ```
  </Step>
</Steps>

## Next steps

* [Backend](/docs/ai-chat/backend) — Lifecycle hooks, persistence, session iterator, raw task primitives
* [Tools](/docs/ai-chat/tools): Declare tools so `toModelOutput` survives across turns, typed in `run()`
* [Frontend](/docs/ai-chat/frontend) — Session management, client data, reconnection
* [Types](/docs/ai-chat/types) — `chat.withUIMessage`, `InferChatUIMessage`, and related typing
* [`chat.local`](/docs/ai-chat/chat-local) — Per-run typed state across hooks, run, tools, subtasks
* [Sub-agents pattern](/docs/ai-chat/patterns/sub-agents) — Subtask-as-tool, `target: "root"` streaming, `ai.toolExecute` helpers
* [Background injection](/docs/ai-chat/background-injection) — `chat.inject()` and `chat.defer()` for between-turn work


# API Reference
Source: https://trigger.dev/docs/ai-chat/reference

Complete API reference for the AI Agents SDK — backend options, events, frontend transport, and hooks.

## Compatibility

| Dependency                                                      | Supported                                   | Notes                                                                                                                                                                                                                                                                                                          |
| --------------------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@trigger.dev/sdk`                                              | `>=4.5.0`                                   | The chat agent surface lives in this SDK release. Install with `@trigger.dev/sdk@latest`.                                                                                                                                                                                                                      |
| `ai` (Vercel AI SDK)                                            | `^5.0.0 \|\| ^6.0.0 \|\| >=7.0.0-canary <8` | Declared as a peer. v6 is what we develop against day to day; v5 and v7 work too (v7 is in canary/beta upstream). Your installed `ai` major drives the chat surface's types.                                                                                                                                   |
| `@ai-sdk/otel`                                                  | `1.x` (v7 only)                             | Optional. AI SDK 7 moved model-call span emission out of `ai` core into this adapter. Install it alongside `ai@7` and the SDK auto-registers it, so your model calls show up as spans in the run trace. Not needed on v5/v6, where `ai` core emits spans. See [AI SDK 7 telemetry](#ai-sdk-7-telemetry) below. |
| `@ai-sdk/react`                                                 | matches your `ai` major                     | Pulled in by `useChat`. The transport works with whichever React hook ships in the same major as your `ai` version.                                                                                                                                                                                            |
| `react`                                                         | `^18.0 \|\| ^19.0`                          | Required only if you use `@trigger.dev/sdk/chat/react` (the frontend transport). Server-only consumers can skip React entirely.                                                                                                                                                                                |
| Node.js                                                         | `>=18.20.0`                                 | The SDK's engine constraint. The chat agent itself works on any version the SDK supports.                                                                                                                                                                                                                      |
| Provider packages (`@ai-sdk/openai`, `@ai-sdk/anthropic`, etc.) | versions that target your `ai` major        | Pick a provider package whose `ai` peer matches yours. The chat agent doesn't depend on any specific provider — pass whatever model you want into `streamText`.                                                                                                                                                |

The `ai` peer is **optional** — server-only setups that don't call `streamText` (raw `task()` with chat primitives) can skip the AI SDK entirely.

### AI SDK 7 telemetry

On **AI SDK 7**, model-call spans are emitted by `@ai-sdk/otel` rather than `ai` core. Install it alongside `ai@7`:

```bash theme={"theme":"css-variables"}
npm install @ai-sdk/otel
```

The SDK registers it once per worker at chat agent boot, so the `experimental_telemetry` config wired up by `chat.toStreamTextOptions()` keeps producing spans in your run trace with no extra setup. On v5 and v6 nothing changes: `ai` core emits the spans and `@ai-sdk/otel` isn't needed.

If you (or a library you import) already register `@ai-sdk/otel` yourself, the SDK detects the existing integration and skips its own registration, so you won't get duplicate spans. To opt out of the auto-registration entirely, set `TRIGGER_AI_SDK_OTEL_AUTOREGISTER=0`.

## ChatAgentOptions

Options for `chat.agent()`.

| Option                        | Type                                                                                              | Default                        | Description                                                                                                                                                                                                                                                           |
| ----------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                          | `string`                                                                                          | required                       | Task identifier                                                                                                                                                                                                                                                       |
| `run`                         | `(payload: ChatTaskRunPayload) => Promise<unknown>`                                               | required                       | Handler for each turn                                                                                                                                                                                                                                                 |
| `clientDataSchema`            | `TaskSchema`                                                                                      | —                              | Schema for validating and typing `clientData`                                                                                                                                                                                                                         |
| `onBoot`                      | `(event: BootEvent) => Promise<void> \| void`                                                     | —                              | Fires once per worker process — initial, preloaded, AND reactive continuation. Use for `chat.local` init and per-process resources. See [onBoot](/docs/ai-chat/lifecycle-hooks#onboot).                                                                                    |
| `onRecoveryBoot`              | `(event: RecoveryBootEvent) => Promise<RecoveryBootResult \| void> \| RecoveryBootResult \| void` | —                              | Fires on a continuation boot when the dead predecessor left recovered state (partial assistant or in-flight users). Override the smart default — drop partial, synthesize tool results, emit a recovery banner. See [Recovery boot](/docs/ai-chat/patterns/recovery-boot). |
| `onPreload`                   | `(event: PreloadEvent) => Promise<void> \| void`                                                  | —                              | Fires on preloaded runs before the first message                                                                                                                                                                                                                      |
| `onChatStart`                 | `(event: ChatStartEvent) => Promise<void> \| void`                                                | —                              | Fires once per chat, on the very first user message. Does NOT fire on continuation runs or OOM-retries — see [onChatStart](/docs/ai-chat/lifecycle-hooks#onchatstart).                                                                                                     |
| `onValidateMessages`          | `(event: ValidateMessagesEvent) => UIMessage[] \| Promise<UIMessage[]>`                           | —                              | Validate/transform UIMessages before model conversion. See [onValidateMessages](/docs/ai-chat/lifecycle-hooks#onvalidatemessages)                                                                                                                                          |
| `hydrateMessages`             | `(event: HydrateMessagesEvent) => UIMessage[] \| Promise<UIMessage[]>`                            | —                              | Load message history from backend, replacing the linear accumulator. See [hydrateMessages](/docs/ai-chat/lifecycle-hooks#hydratemessages)                                                                                                                                  |
| `actionSchema`                | `TaskSchema`                                                                                      | —                              | Schema for validating custom actions sent via `transport.sendAction()`. See [Actions](/docs/ai-chat/actions)                                                                                                                                                               |
| `onAction`                    | `(event: ActionEvent) => Promise<unknown> \| unknown`                                             | —                              | Handle custom actions. Actions are not turns — only `hydrateMessages` + `onAction` fire. Return a `StreamTextResult` (or `string` / `UIMessage`) for a model response; return `void` for side-effect-only. See [Actions](/docs/ai-chat/actions)                            |
| `onTurnStart`                 | `(event: TurnStartEvent) => Promise<void> \| void`                                                | —                              | Fires every turn before `run()`                                                                                                                                                                                                                                       |
| `onBeforeTurnComplete`        | `(event: BeforeTurnCompleteEvent) => Promise<void> \| void`                                       | —                              | Fires after response but before stream closes. Includes `writer`.                                                                                                                                                                                                     |
| `onTurnComplete`              | `(event: TurnCompleteEvent) => Promise<void> \| void`                                             | —                              | Fires after each turn completes (stream closed)                                                                                                                                                                                                                       |
| `onCompacted`                 | `(event: CompactedEvent) => Promise<void> \| void`                                                | —                              | Fires when compaction occurs. Includes `writer`. See [Compaction](/docs/ai-chat/compaction)                                                                                                                                                                                |
| `compaction`                  | `ChatAgentCompactionOptions`                                                                      | —                              | Automatic context compaction. See [Compaction](/docs/ai-chat/compaction)                                                                                                                                                                                                   |
| `pendingMessages`             | `PendingMessagesOptions`                                                                          | —                              | Mid-execution message injection. See [Pending Messages](/docs/ai-chat/pending-messages)                                                                                                                                                                                    |
| `prepareMessages`             | `(event: PrepareMessagesEvent) => ModelMessage[]`                                                 | —                              | Transform model messages before use (cache breaks, context injection, etc.)                                                                                                                                                                                           |
| `tools`                       | `ToolSet \| ((event: ResolveToolsEvent) => ToolSet \| Promise<ToolSet>)`                          | —                              | Tools for this agent. Threads each tool's `toModelOutput` through cross-turn history re-conversion, and hands the resolved set back on the run payload. Static set or per-turn function. See [Tools](/docs/ai-chat/tools).                                                 |
| `maxTurns`                    | `number`                                                                                          | `100`                          | Max conversational turns per run                                                                                                                                                                                                                                      |
| `turnTimeout`                 | `string`                                                                                          | `"1h"`                         | How long to wait for next message                                                                                                                                                                                                                                     |
| `idleTimeoutInSeconds`        | `number`                                                                                          | `30`                           | Seconds to stay idle before suspending                                                                                                                                                                                                                                |
| `chatAccessTokenTTL`          | `string`                                                                                          | `"1h"`                         | How long the scoped access token remains valid                                                                                                                                                                                                                        |
| `preloadIdleTimeoutInSeconds` | `number`                                                                                          | Same as `idleTimeoutInSeconds` | Idle timeout after `onPreload` fires                                                                                                                                                                                                                                  |
| `preloadTimeout`              | `string`                                                                                          | Same as `turnTimeout`          | Suspend timeout for preloaded runs                                                                                                                                                                                                                                    |
| `uiMessageStreamOptions`      | `ChatUIMessageStreamOptions`                                                                      | —                              | Default options for `toUIMessageStream()`. Per-turn override via `chat.setUIMessageStreamOptions()`                                                                                                                                                                   |
| `onChatSuspend`               | `(event: ChatSuspendEvent) => Promise<void> \| void`                                              | —                              | Fires right before the run suspends. See [onChatSuspend](/docs/ai-chat/lifecycle-hooks#onchatsuspend--onchatresume)                                                                                                                                                        |
| `onChatResume`                | `(event: ChatResumeEvent) => Promise<void> \| void`                                               | —                              | Fires right after the run resumes from suspension                                                                                                                                                                                                                     |
| `exitAfterPreloadIdle`        | `boolean`                                                                                         | `false`                        | Exit run after preload idle timeout instead of suspending. See [exitAfterPreloadIdle](/docs/ai-chat/lifecycle-hooks#exitafterpreloadidle)                                                                                                                                  |
| `oomMachine`                  | `MachinePresetName`                                                                               | —                              | Fallback machine when an attempt fails with OOM. Setting it enables a single OOM retry on the larger machine. See [OOM resilience](/docs/ai-chat/patterns/oom-resilience)                                                                                                  |

Plus most standard [TaskOptions](/docs/tasks/overview) — `queue`, `machine`, `maxDuration`, **`onWait`**, **`onResume`**, **`onComplete`**, and other lifecycle hooks. Generic `retry` is **not** exposed on `chat.agent`; use `oomMachine` for OOM recovery, or drop down to a raw [`task()`](/docs/ai-chat/custom-agents) if you need richer retry semantics. Standard hooks use the same parameter shapes as on a normal `task()` (including `ctx`).

## Task context (`ctx`)

All **`chat.agent`** lifecycle events (**`onBoot`**, **`onPreload`**, **`onChatStart`**, **`onTurnStart`**, **`onBeforeTurnComplete`**, **`onTurnComplete`**, **`onCompacted`**) and the object passed to **`run`** include **`ctx`**: the same **`TaskRunContext`** shape as the `ctx` in `task({ run: (payload, { ctx }) => ... })`.

<Note>
  **`onValidateMessages`** does not include `ctx` — it fires before message accumulation and is designed for pure validation/transformation of incoming messages.
</Note>

Use **`ctx`** for run metadata, tags, parent links, or any API that needs the full run record. The chat-specific string **`runId`** on events is always **`ctx.run.id`**; both are provided for convenience.

```ts theme={"theme":"css-variables"}
import type { TaskRunContext } from "@trigger.dev/sdk";
// Equivalent alias (same type):
import type { Context } from "@trigger.dev/sdk";
```

<Note>
  Prefer `import type { TaskRunContext } from "@trigger.dev/sdk"` in application code. Do not depend on `@trigger.dev/core` directly.
</Note>

## ChatTaskRunPayload

The payload passed to the `run` function.

| Field               | Type                                       | Description                                                                                                                                               |
| ------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ctx`               | `TaskRunContext`                           | Full task run context — same as `task` `run`’s `{ ctx }`                                                                                                  |
| `messages`          | `ModelMessage[]`                           | Model-ready messages — pass directly to `streamText`                                                                                                      |
| `tools`             | `ToolSet`                                  | Resolved tools declared on the agent config (empty object when none). Pass straight to `streamText`. See [Tools](/docs/ai-chat/tools).                         |
| `chatId`            | `string`                                   | Your conversation ID (the session's `externalId`)                                                                                                         |
| `sessionId`         | `string`                                   | Friendly ID of the backing Session (`session_*`). Use with `sessions.open()` for advanced cases. Always set — every chat.agent run is bound to a Session. |
| `trigger`           | `"submit-message" \| "regenerate-message"` | What triggered the request                                                                                                                                |
| `messageId`         | `string \| undefined`                      | Message ID (for regenerate)                                                                                                                               |
| `clientData`        | Typed by `clientDataSchema`                | Custom data from the frontend (typed when schema is provided)                                                                                             |
| `continuation`      | `boolean`                                  | Whether this run is continuing an existing chat (previous run ended)                                                                                      |
| `signal`            | `AbortSignal`                              | Combined stop + cancel signal                                                                                                                             |
| `cancelSignal`      | `AbortSignal`                              | Cancel-only signal                                                                                                                                        |
| `stopSignal`        | `AbortSignal`                              | Stop-only signal (per-turn)                                                                                                                               |
| `previousTurnUsage` | `LanguageModelUsage \| undefined`          | Token usage from the previous turn (undefined on turn 0)                                                                                                  |
| `totalUsage`        | `LanguageModelUsage`                       | Cumulative token usage across completed turns so far                                                                                                      |

## BootEvent

Passed to the `onBoot` callback.

| Field             | Type                        | Description                                                                                       |
| ----------------- | --------------------------- | ------------------------------------------------------------------------------------------------- |
| `ctx`             | `TaskRunContext`            | Full task run context — see [Task context](#task-context-ctx)                                     |
| `chatId`          | `string`                    | Chat session ID                                                                                   |
| `runId`           | `string`                    | The Trigger.dev run ID for this run boot                                                          |
| `chatAccessToken` | `string`                    | Scoped access token for this run                                                                  |
| `clientData`      | Typed by `clientDataSchema` | Custom data from the frontend                                                                     |
| `continuation`    | `boolean`                   | `true` when this run is taking over from a prior dead run (cancel / crash / `endRun` / OOM retry) |
| `previousRunId`   | `string \| undefined`       | Public id of the prior run when `continuation` is true                                            |
| `preloaded`       | `boolean`                   | Whether this run was triggered as a preload                                                       |

## RecoveryBootEvent

Passed to the `onRecoveryBoot` callback. See [Recovery boot](/docs/ai-chat/patterns/recovery-boot) for the full guide.

| Field              | Type                                                    | Description                                                                                       |
| ------------------ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `ctx`              | `TaskRunContext`                                        | Full task run context — see [Task context](#task-context-ctx)                                     |
| `chatId`           | `string`                                                | Chat session ID                                                                                   |
| `runId`            | `string`                                                | The Trigger.dev run ID for this run boot                                                          |
| `previousRunId`    | `string`                                                | Public id of the prior run that died                                                              |
| `cause`            | `"cancelled" \| "crashed" \| "unknown"`                 | Best-effort cause. Currently always `"unknown"` — forward-looking, don't branch on it             |
| `settledMessages`  | `TUIMessage[]`                                          | Chain persisted by the predecessor's last `onTurnComplete`                                        |
| `inFlightUsers`    | `TUIMessage[]`                                          | User messages on `session.in` past the cursor — the message(s) the predecessor never acknowledged |
| `partialAssistant` | `TUIMessage \| undefined`                               | The trailing assistant message whose stream never received `finish`                               |
| `pendingToolCalls` | [`RecoveryPendingToolCall[]`](#recoverypendingtoolcall) | Tool calls in `input-available` state extracted from `partialAssistant`                           |
| `writer`           | [`ChatWriter`](#chatwriter)                             | Lazy session.out writer — emit a recovery banner / signal here                                    |

## RecoveryBootResult

Return value of `onRecoveryBoot`. Every field is optional — omit to accept the smart default.

| Field            | Type                  | Description                                                                                                                                   |
| ---------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `chain`          | `TUIMessage[]`        | Replaces the seed chain. Default: `[...settledMessages, firstInFlightUser, partialAssistant]` when both present; `settledMessages` otherwise. |
| `recoveredTurns` | `TUIMessage[]`        | User messages to dispatch as fresh turns. Default: `inFlightUsers.slice(1)` when smart-default fires; `inFlightUsers` otherwise.              |
| `beforeBoot`     | `() => Promise<void>` | Runs after the writer flushes and before the first recovered turn fires. Use for blocking persistence work.                                   |

## RecoveryPendingToolCall

| Field        | Type      | Description                                                |
| ------------ | --------- | ---------------------------------------------------------- |
| `toolCallId` | `string`  | The AI SDK tool call id                                    |
| `toolName`   | `string`  | The tool name (the `tool-${name}` suffix on the part type) |
| `input`      | `unknown` | The input the model produced for the call                  |
| `partIndex`  | `number`  | Index into `partialAssistant.parts` for in-place edits     |

## PreloadEvent

Passed to the `onPreload` callback.

| Field             | Type                        | Description                                                    |
| ----------------- | --------------------------- | -------------------------------------------------------------- |
| `ctx`             | `TaskRunContext`            | Full task run context — see [Task context](#task-context-ctx)  |
| `chatId`          | `string`                    | Chat session ID                                                |
| `runId`           | `string`                    | The Trigger.dev run ID                                         |
| `chatAccessToken` | `string`                    | Scoped access token for this run                               |
| `clientData`      | Typed by `clientDataSchema` | Custom data from the frontend                                  |
| `writer`          | [`ChatWriter`](#chatwriter) | Stream writer for custom chunks. Lazy — no overhead if unused. |

## ChatStartEvent

Passed to the `onChatStart` callback.

| Field             | Type                        | Description                                                    |
| ----------------- | --------------------------- | -------------------------------------------------------------- |
| `ctx`             | `TaskRunContext`            | Full task run context — see [Task context](#task-context-ctx)  |
| `chatId`          | `string`                    | Chat session ID                                                |
| `messages`        | `ModelMessage[]`            | Initial model-ready messages                                   |
| `clientData`      | Typed by `clientDataSchema` | Custom data from the frontend                                  |
| `runId`           | `string`                    | The Trigger.dev run ID                                         |
| `chatAccessToken` | `string`                    | Scoped access token for this run                               |
| `continuation`    | `boolean`                   | Whether this run is continuing an existing chat                |
| `previousRunId`   | `string \| undefined`       | Previous run ID (only when `continuation` is true)             |
| `preloaded`       | `boolean`                   | Whether this run was preloaded before the first message        |
| `writer`          | [`ChatWriter`](#chatwriter) | Stream writer for custom chunks. Lazy — no overhead if unused. |

## ValidateMessagesEvent

Passed to the `onValidateMessages` callback.

| Field      | Type                                                               | Description                        |
| ---------- | ------------------------------------------------------------------ | ---------------------------------- |
| `messages` | `UIMessage[]`                                                      | Incoming UI messages for this turn |
| `chatId`   | `string`                                                           | Chat session ID                    |
| `turn`     | `number`                                                           | Turn number (0-indexed)            |
| `trigger`  | `"submit-message" \| "regenerate-message" \| "preload" \| "close"` | The trigger type for this turn     |

## ResolveToolsEvent

Passed to the `tools` function form on `chat.agent`, once per turn, to resolve the tool set for that turn. See [Tools](/docs/ai-chat/tools#static-or-per-turn-tools).

| Field          | Type                        | Description                                     |
| -------------- | --------------------------- | ----------------------------------------------- |
| `chatId`       | `string`                    | Chat session ID                                 |
| `turn`         | `number`                    | Turn number (0-indexed)                         |
| `continuation` | `boolean`                   | Whether this run is continuing an existing chat |
| `clientData`   | Typed by `clientDataSchema` | Custom data from the frontend                   |

## HydrateMessagesEvent

Passed to the `hydrateMessages` callback. See [hydrateMessages](/docs/ai-chat/lifecycle-hooks#hydratemessages).

| Field              | Type                                                   | Description                                                   |
| ------------------ | ------------------------------------------------------ | ------------------------------------------------------------- |
| `chatId`           | `string`                                               | Chat session ID                                               |
| `turn`             | `number`                                               | Turn number (0-indexed)                                       |
| `trigger`          | `"submit-message" \| "regenerate-message" \| "action"` | The trigger type for this turn                                |
| `incomingMessages` | `UIMessage[]`                                          | Validated wire messages from the frontend (empty for actions) |
| `previousMessages` | `UIMessage[]`                                          | Accumulated UI messages before this turn (`[]` on turn 0)     |
| `clientData`       | Typed by `clientDataSchema`                            | Custom data from the frontend                                 |
| `continuation`     | `boolean`                                              | Whether this run is continuing an existing chat               |
| `previousRunId`    | `string \| undefined`                                  | Previous run ID (only when `continuation` is true)            |

## ActionEvent

Passed to the `onAction` callback. See [Actions](/docs/ai-chat/actions).

| Field        | Type                        | Description                                          |
| ------------ | --------------------------- | ---------------------------------------------------- |
| `action`     | Typed by `actionSchema`     | The parsed and validated action payload              |
| `chatId`     | `string`                    | Chat session ID                                      |
| `turn`       | `number`                    | Turn number (0-indexed)                              |
| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend                        |
| `uiMessages` | `UIMessage[]`               | Accumulated UI messages (after hydration, if set)    |
| `messages`   | `ModelMessage[]`            | Accumulated model messages (after hydration, if set) |

## TurnStartEvent

Passed to the `onTurnStart` callback.

| Field             | Type                        | Description                                                    |
| ----------------- | --------------------------- | -------------------------------------------------------------- |
| `ctx`             | `TaskRunContext`            | Full task run context — see [Task context](#task-context-ctx)  |
| `chatId`          | `string`                    | Chat session ID                                                |
| `messages`        | `ModelMessage[]`            | Full accumulated conversation (model format)                   |
| `uiMessages`      | `UIMessage[]`               | Full accumulated conversation (UI format)                      |
| `turn`            | `number`                    | Turn number (0-indexed)                                        |
| `runId`           | `string`                    | The Trigger.dev run ID                                         |
| `chatAccessToken` | `string`                    | Scoped access token for this run                               |
| `clientData`      | Typed by `clientDataSchema` | Custom data from the frontend                                  |
| `continuation`    | `boolean`                   | Whether this run is continuing an existing chat                |
| `previousRunId`   | `string \| undefined`       | Previous run ID (only when `continuation` is true)             |
| `preloaded`       | `boolean`                   | Whether this run was preloaded                                 |
| `writer`          | [`ChatWriter`](#chatwriter) | Stream writer for custom chunks. Lazy — no overhead if unused. |

## TurnCompleteEvent

Passed to the `onTurnComplete` callback.

| Field                | Type                              | Description                                                             |
| -------------------- | --------------------------------- | ----------------------------------------------------------------------- |
| `ctx`                | `TaskRunContext`                  | Full task run context — see [Task context](#task-context-ctx)           |
| `chatId`             | `string`                          | Chat session ID                                                         |
| `messages`           | `ModelMessage[]`                  | Full accumulated conversation (model format)                            |
| `uiMessages`         | `UIMessage[]`                     | Full accumulated conversation (UI format)                               |
| `newMessages`        | `ModelMessage[]`                  | Only this turn's messages (model format)                                |
| `newUIMessages`      | `UIMessage[]`                     | Only this turn's messages (UI format)                                   |
| `responseMessage`    | `UIMessage \| undefined`          | The assistant's response for this turn                                  |
| `rawResponseMessage` | `UIMessage \| undefined`          | Raw response before abort cleanup                                       |
| `turn`               | `number`                          | Turn number (0-indexed)                                                 |
| `runId`              | `string`                          | The Trigger.dev run ID                                                  |
| `chatAccessToken`    | `string`                          | Scoped access token for this run                                        |
| `lastEventId`        | `string \| undefined`             | Stream position for resumption                                          |
| `stopped`            | `boolean`                         | Whether the user stopped generation during this turn                    |
| `continuation`       | `boolean`                         | Whether this run is continuing an existing chat                         |
| `usage`              | `LanguageModelUsage \| undefined` | Token usage for this turn                                               |
| `totalUsage`         | `LanguageModelUsage`              | Cumulative token usage across all turns                                 |
| `finishReason`       | `FinishReason \| undefined`       | Why the LLM stopped (`"stop"`, `"tool-calls"`, `"error"`, …)            |
| `error`              | `unknown`                         | Set when the turn threw; `responseMessage` is then undefined or partial |

## BeforeTurnCompleteEvent

Passed to the `onBeforeTurnComplete` callback. Same fields as `TurnCompleteEvent` (including **`ctx`**) plus a `writer`.

| Field                            | Type                        | Description                                                                   |
| -------------------------------- | --------------------------- | ----------------------------------------------------------------------------- |
| *(all TurnCompleteEvent fields)* |                             | See [TurnCompleteEvent](#turncompleteevent) (includes `ctx`)                  |
| `writer`                         | [`ChatWriter`](#chatwriter) | Stream writer — the stream is still open so chunks appear in the current turn |

## ChatSuspendEvent

Passed to the `onChatSuspend` callback. A discriminated union on `phase`.

| Field        | Type                        | Description                                          |
| ------------ | --------------------------- | ---------------------------------------------------- |
| `phase`      | `"preload" \| "turn"`       | Whether this is a preload or post-turn suspension    |
| `ctx`        | `TaskRunContext`            | Full task run context                                |
| `chatId`     | `string`                    | Chat session ID                                      |
| `runId`      | `string`                    | The Trigger.dev run ID                               |
| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend                        |
| `turn`       | `number`                    | Turn number (**`"turn"` phase only**)                |
| `messages`   | `ModelMessage[]`            | Accumulated model messages (**`"turn"` phase only**) |
| `uiMessages` | `UIMessage[]`               | Accumulated UI messages (**`"turn"` phase only**)    |

## ChatResumeEvent

Passed to the `onChatResume` callback. Same discriminated union shape as `ChatSuspendEvent`.

| Field        | Type                        | Description                                          |
| ------------ | --------------------------- | ---------------------------------------------------- |
| `phase`      | `"preload" \| "turn"`       | Whether this is a preload or post-turn resumption    |
| `ctx`        | `TaskRunContext`            | Full task run context                                |
| `chatId`     | `string`                    | Chat session ID                                      |
| `runId`      | `string`                    | The Trigger.dev run ID                               |
| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend                        |
| `turn`       | `number`                    | Turn number (**`"turn"` phase only**)                |
| `messages`   | `ModelMessage[]`            | Accumulated model messages (**`"turn"` phase only**) |
| `uiMessages` | `UIMessage[]`               | Accumulated UI messages (**`"turn"` phase only**)    |

## ChatWriter

A stream writer passed to lifecycle callbacks. Write custom `UIMessageChunk` parts (e.g. `data-*` parts) to the chat stream.

The writer is lazy — no stream is opened unless you call `write()` or `merge()`, so there's zero overhead for callbacks that don't use it.

| Method          | Type                                               | Description                                        |
| --------------- | -------------------------------------------------- | -------------------------------------------------- |
| `write(part)`   | `(part: UIMessageChunk) => void`                   | Write a single chunk to the chat stream            |
| `merge(stream)` | `(stream: ReadableStream<UIMessageChunk>) => void` | Merge another stream's chunks into the chat stream |

```ts theme={"theme":"css-variables"}
onTurnStart: async ({ writer }) => {
  // Write a custom data part — render it on the frontend
  writer.write({ type: "data-status", data: { loading: true } });
},
onBeforeTurnComplete: async ({ writer, usage }) => {
  // Stream is still open — these chunks arrive before the turn ends
  writer.write({ type: "data-usage", data: { tokens: usage?.totalTokens } });
},
```

## ChatAgentCompactionOptions

Options for the `compaction` field on `chat.agent()`. See [Compaction](/docs/ai-chat/compaction) for usage guide.

| Option                 | Type                                                                         | Required | Description                                                                  |
| ---------------------- | ---------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------- |
| `shouldCompact`        | `(event: ShouldCompactEvent) => boolean \| Promise<boolean>`                 | Yes      | Decide whether to compact. Return `true` to trigger                          |
| `summarize`            | `(event: SummarizeEvent) => Promise<string>`                                 | Yes      | Generate a summary from the current messages                                 |
| `compactUIMessages`    | `(event: CompactMessagesEvent) => UIMessage[] \| Promise<UIMessage[]>`       | No       | Transform UI messages after compaction. Default: preserve all                |
| `compactModelMessages` | `(event: CompactMessagesEvent) => ModelMessage[] \| Promise<ModelMessage[]>` | No       | Transform model messages after compaction. Default: replace all with summary |

## CompactMessagesEvent

Passed to `compactUIMessages` and `compactModelMessages` callbacks.

| Field           | Type                 | Description                                          |
| --------------- | -------------------- | ---------------------------------------------------- |
| `summary`       | `string`             | The generated summary text                           |
| `uiMessages`    | `UIMessage[]`        | Current UI messages (full conversation)              |
| `modelMessages` | `ModelMessage[]`     | Current model messages (full conversation)           |
| `chatId`        | `string`             | Chat session ID                                      |
| `turn`          | `number`             | Current turn (0-indexed)                             |
| `clientData`    | `unknown`            | Custom data from the frontend                        |
| `source`        | `"inner" \| "outer"` | Whether compaction is between steps or between turns |

## CompactedEvent

Passed to the `onCompacted` callback.

| Field          | Type                        | Description                                                   |
| -------------- | --------------------------- | ------------------------------------------------------------- |
| `ctx`          | `TaskRunContext`            | Full task run context — see [Task context](#task-context-ctx) |
| `summary`      | `string`                    | The generated summary text                                    |
| `messages`     | `ModelMessage[]`            | Messages that were compacted (pre-compaction)                 |
| `messageCount` | `number`                    | Number of messages before compaction                          |
| `usage`        | `LanguageModelUsage`        | Token usage from the triggering step/turn                     |
| `totalTokens`  | `number \| undefined`       | Total token count that triggered compaction                   |
| `inputTokens`  | `number \| undefined`       | Input token count                                             |
| `outputTokens` | `number \| undefined`       | Output token count                                            |
| `stepNumber`   | `number`                    | Step number (-1 for outer loop)                               |
| `chatId`       | `string \| undefined`       | Chat session ID                                               |
| `turn`         | `number \| undefined`       | Current turn                                                  |
| `writer`       | [`ChatWriter`](#chatwriter) | Stream writer for custom chunks during compaction             |

## PendingMessagesOptions

Options for the `pendingMessages` field. See [Pending Messages](/docs/ai-chat/pending-messages) for usage guide.

| Option         | Type                                                                              | Required | Description                                                                               |
| -------------- | --------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------- |
| `shouldInject` | `(event: PendingMessagesBatchEvent) => boolean \| Promise<boolean>`               | No       | Decide whether to inject the batch between tool-call steps. If absent, no injection.      |
| `prepare`      | `(event: PendingMessagesBatchEvent) => ModelMessage[] \| Promise<ModelMessage[]>` | No       | Transform the batch before injection. Default: convert each via `convertToModelMessages`. |
| `onReceived`   | `(event: PendingMessageReceivedEvent) => void \| Promise<void>`                   | No       | Called when a message arrives during streaming (per-message).                             |
| `onInjected`   | `(event: PendingMessagesInjectedEvent) => void \| Promise<void>`                  | No       | Called after a batch is injected via prepareStep.                                         |

## PendingMessagesBatchEvent

Passed to `shouldInject` and `prepare` callbacks.

| Field           | Type               | Description                   |
| --------------- | ------------------ | ----------------------------- |
| `messages`      | `UIMessage[]`      | All pending messages (batch)  |
| `modelMessages` | `ModelMessage[]`   | Current conversation          |
| `steps`         | `CompactionStep[]` | Completed steps so far        |
| `stepNumber`    | `number`           | Current step (0-indexed)      |
| `chatId`        | `string`           | Chat session ID               |
| `turn`          | `number`           | Current turn (0-indexed)      |
| `clientData`    | `unknown`          | Custom data from the frontend |

## PendingMessagesInjectedEvent

Passed to `onInjected` callback.

| Field                   | Type             | Description                           |
| ----------------------- | ---------------- | ------------------------------------- |
| `messages`              | `UIMessage[]`    | All injected UI messages              |
| `injectedModelMessages` | `ModelMessage[]` | The model messages that were injected |
| `chatId`                | `string`         | Chat session ID                       |
| `turn`                  | `number`         | Current turn                          |
| `stepNumber`            | `number`         | Step where injection occurred         |

## UsePendingMessagesReturn

Return value of `usePendingMessages` hook. See [Pending Messages — Frontend](/docs/ai-chat/pending-messages#frontend-usependingmessages-hook).

| Property/Method         | Type                                   | Description                                                     |
| ----------------------- | -------------------------------------- | --------------------------------------------------------------- |
| `pending`               | `PendingMessage[]`                     | Current pending messages with mode and injection status         |
| `steer`                 | `(text: string) => void`               | Send a steering message (or normal message when not streaming)  |
| `queue`                 | `(text: string) => void`               | Queue for next turn (or send normally when not streaming)       |
| `promoteToSteering`     | `(id: string) => void`                 | Convert a queued message to steering                            |
| `isInjectionPoint`      | `(part: unknown) => boolean`           | Check if an assistant message part is an injection confirmation |
| `getInjectedMessageIds` | `(part: unknown) => string[]`          | Get message IDs from an injection point                         |
| `getInjectedMessages`   | `(part: unknown) => InjectedMessage[]` | Get messages (id + text) from an injection point                |

## ChatSessionOptions

Options for `chat.createSession()`.

| Option                 | Type                         | Default     | Description                                                                                                      |
| ---------------------- | ---------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- |
| `signal`               | `AbortSignal`                | required    | Run-level cancel signal                                                                                          |
| `idleTimeoutInSeconds` | `number`                     | `30`        | Seconds to stay idle between turns                                                                               |
| `timeout`              | `string`                     | `"1h"`      | Duration string for suspend timeout                                                                              |
| `maxTurns`             | `number`                     | `100`       | Max turns before ending                                                                                          |
| `compaction`           | `ChatAgentCompactionOptions` | `undefined` | Automatic context [compaction](/docs/ai-chat/compaction) — same options as `chat.agent({ compaction })`               |
| `pendingMessages`      | `PendingMessagesOptions`     | `undefined` | Mid-execution [message injection](/docs/ai-chat/pending-messages) — same options as `chat.agent({ pendingMessages })` |

## ChatTurn

Each turn yielded by `chat.createSession()`.

| Field               | Type                              | Description                                                                                                                     |
| ------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `number`            | `number`                          | Turn number (0-indexed)                                                                                                         |
| `chatId`            | `string`                          | Chat session ID                                                                                                                 |
| `trigger`           | `string`                          | What triggered this turn                                                                                                        |
| `clientData`        | `unknown`                         | Client data from the transport                                                                                                  |
| `messages`          | `ModelMessage[]`                  | Full accumulated model messages                                                                                                 |
| `uiMessages`        | `UIMessage[]`                     | Full accumulated UI messages                                                                                                    |
| `signal`            | `AbortSignal`                     | Combined stop+cancel signal (fresh each turn)                                                                                   |
| `stopped`           | `boolean`                         | Whether the user stopped generation this turn                                                                                   |
| `continuation`      | `boolean`                         | Whether this is a continuation run                                                                                              |
| `previousTurnUsage` | `LanguageModelUsage \| undefined` | Token usage from the previous turn (undefined on turn 0)                                                                        |
| `totalUsage`        | `LanguageModelUsage`              | Cumulative token usage across all completed turns                                                                               |
| `handover`          | `{ isFinal: boolean } \| null`    | The [`chat.headStart`](/docs/ai-chat/fast-starts#handover-with-custom-agents) handover for this turn (turn 0 only); `null` otherwise |

| Method                    | Returns                           | Description                                                                                                                                                                           |
| ------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `complete(source?)`       | `Promise<UIMessage \| undefined>` | Pipe, capture, accumulate, cleanup, and signal turn-complete. Call with no source on a final head-start handover (`handover.isFinal`), where the warm partial is already the response |
| `done()`                  | `Promise<void>`                   | Signal turn-complete (when you've piped manually)                                                                                                                                     |
| `addResponse(response)`   | `Promise<void>`                   | Add response to accumulator manually                                                                                                                                                  |
| `setMessages(uiMessages)` | `Promise<void>`                   | Replace the accumulated messages (continuation seeding, compaction)                                                                                                                   |
| `prepareStep()`           | `function \| undefined`           | `prepareStep` callback wiring compaction + injection — pass to `streamText` when not using `chat.toStreamTextOptions()`                                                               |

## HeadStartHandlerOptions

Options for [`chat.headStart()`](/docs/ai-chat/fast-starts#head-start), the warm-server first-turn handler (`@trigger.dev/sdk/chat-server`).

| Option                 | Type                                                    | Default     | Description                                                                                                                                                                         |
| ---------------------- | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agentId`              | `string`                                                | required    | The `chat.agent` / `chat.customAgent` id to hand off to                                                                                                                             |
| `run`                  | `(args: HeadStartRunArgs) => Promise<StreamTextResult>` | required    | First-turn callback. Call `streamText` and spread `chat.toStreamTextOptions({ tools })`                                                                                             |
| `idleTimeoutInSeconds` | `number`                                                | `60`        | How long the agent waits for the handover signal                                                                                                                                    |
| `triggerConfig`        | `Partial<SessionTriggerConfig>`                         | `undefined` | Run options (tags, queue, machine, maxAttempts, maxDuration, region, lockToVersion) for the auto-triggered handover-prepare run. The `chat:{chatId}` tag is prepended automatically |

`chat.headStart(options)` returns the handler `(req: Request) => Promise<Response>`. The `run` callback receives `HeadStartRunArgs`: `{ messages: UIMessage[], signal: AbortSignal, chat: HeadStartChatHelper }`, where the helper exposes `chat.toStreamTextOptions({ tools })` and a `chat.session` escape hatch. See [Head Start](/docs/ai-chat/fast-starts#head-start) for the full guide.

## chat namespace

All methods available on the `chat` object from `@trigger.dev/sdk/ai`.

| Method                                            | Description                                                                                                                                                                                                                     |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chat.agent(options)`                             | Create a chat agent                                                                                                                                                                                                             |
| `chat.createSession(payload, options)`            | Create an async iterator for chat turns                                                                                                                                                                                         |
| `chat.pipe(source, options?)`                     | Pipe a stream to the frontend (from anywhere inside a task)                                                                                                                                                                     |
| `chat.pipeAndCapture(source, options?)`           | Pipe and capture the response; returns `{ message, status, error }`                                                                                                                                                             |
| `chat.writeTurnComplete(options?)`                | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors                                                                                                                                                |
| `chat.createStopSignal()`                         | Create a managed stop signal wired to the stop input stream                                                                                                                                                                     |
| `chat.messages`                                   | Input stream for incoming messages — use `.waitWithIdleTimeout()`                                                                                                                                                               |
| `chat.local<T>({ id })`                           | Create a per-run typed local (see [`chat.local`](/docs/ai-chat/chat-local))                                                                                                                                                          |
| `chat.createStartSessionAction(taskId, options?)` | Returns a server action that creates a chat Session + triggers the first run + returns a session-scoped PAT. Idempotent on `(env, externalId)`.                                                                                 |
| `chat.waitForHandover(options)`                   | Wait for a [`chat.headStart`](/docs/ai-chat/fast-starts#handover-with-custom-agents) handover signal in a custom loop. Returns the signal or `null`. `chat.MessageAccumulator` wraps this as `consumeHandover()` / `applyHandover()` |
| `chat.requestUpgrade()`                           | End the current run after this turn so the next message starts on the latest agent version. Server-orchestrated handoff.                                                                                                        |
| `chat.setTurnTimeout(duration)`                   | Override turn timeout at runtime (e.g. `"2h"`)                                                                                                                                                                                  |
| `chat.setTurnTimeoutInSeconds(seconds)`           | Override turn timeout at runtime (in seconds)                                                                                                                                                                                   |
| `chat.setIdleTimeoutInSeconds(seconds)`           | Override idle timeout at runtime                                                                                                                                                                                                |
| `chat.setUIMessageStreamOptions(options)`         | Override `toUIMessageStream()` options for the current turn                                                                                                                                                                     |
| `chat.defer(promise)`                             | Run background work in parallel with streaming, awaited before `onTurnComplete`                                                                                                                                                 |
| `chat.isStopped()`                                | Check if the current turn was stopped by the user                                                                                                                                                                               |
| `chat.cleanupAbortedParts(message)`               | Remove incomplete parts from a stopped response message                                                                                                                                                                         |
| `chat.response.write(chunk)`                      | Write a data part that streams to the frontend AND persists in `onTurnComplete`'s `responseMessage`                                                                                                                             |
| `chat.stream`                                     | Raw chat output stream — use `.writer()`, `.pipe()`, `.append()`, `.read()`. Chunks are NOT accumulated into the response.                                                                                                      |
| `chat.history.all()`                              | Read the current accumulated UI messages (returns a copy). See [chat.history](/docs/ai-chat/backend#chat-history)                                                                                                                    |
| `chat.history.set(messages)`                      | Replace all accumulated messages (same as `chat.setMessages()`)                                                                                                                                                                 |
| `chat.history.remove(messageId)`                  | Remove a specific message by ID                                                                                                                                                                                                 |
| `chat.history.rollbackTo(messageId)`              | Keep messages up to and including the given ID (undo/rollback)                                                                                                                                                                  |
| `chat.history.replace(messageId, message)`        | Replace a specific message by ID (edit)                                                                                                                                                                                         |
| `chat.history.slice(start, end?)`                 | Keep only messages in the given range                                                                                                                                                                                           |
| `chat.MessageAccumulator`                         | Class that accumulates conversation messages across turns                                                                                                                                                                       |
| `chat.withUIMessage(config?)`                     | Returns a [ChatBuilder](/docs/ai-chat/types#chatbuilder) with a fixed `UIMessage` subtype. See [Types](/docs/ai-chat/types)                                                                                                               |
| `chat.withClientData({ schema })`                 | Returns a [ChatBuilder](/docs/ai-chat/types#chatbuilder) with a fixed client data schema. See [Types](/docs/ai-chat/types#typed-client-data-with-chatwithclientdata)                                                                      |

## `chat.withUIMessage`

Returns a [`ChatBuilder`](/docs/ai-chat/types#chatbuilder) with a fixed `UIMessage` subtype. Chain `.withClientData()`, hook methods, and `.agent()`.

```ts theme={"theme":"css-variables"}
chat.withUIMessage<TUIM>(config?: ChatWithUIMessageConfig<TUIM>): ChatBuilder<TUIM>;
```

| Parameter              | Type                               | Description                                                                                                                                             |
| ---------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `config.streamOptions` | `ChatUIMessageStreamOptions<TUIM>` | Optional defaults for `toUIMessageStream()`. Shallow-merged with `uiMessageStreamOptions` on the inner `.agent({ ... })` (agent wins on key conflicts). |

Use this when you need [`InferChatUIMessage`](#inferchatuimessage) / typed `data-*` parts / `InferUITools` to line up across backend hooks and `useChat`. Full guide: [Types](/docs/ai-chat/types).

## `chat.withClientData`

Returns a [`ChatBuilder`](/docs/ai-chat/types#chatbuilder) with a fixed client data schema. All hooks and `run` get typed `clientData` without passing `clientDataSchema` in `.agent()` options.

```ts theme={"theme":"css-variables"}
chat.withClientData<TSchema>({ schema: TSchema }): ChatBuilder<UIMessage, TSchema>;
```

| Parameter | Type         | Description                                        |
| --------- | ------------ | -------------------------------------------------- |
| `schema`  | `TaskSchema` | Zod, ArkType, Valibot, or any supported schema lib |

Full guide: [Typed client data](/docs/ai-chat/types#typed-client-data-with-chatwithclientdata).

## `ChatWithUIMessageConfig`

| Field           | Type                               | Description                                                             |
| --------------- | ---------------------------------- | ----------------------------------------------------------------------- |
| `streamOptions` | `ChatUIMessageStreamOptions<TUIM>` | Default `toUIMessageStream()` options for agents created via `.agent()` |

## `InferChatUIMessage`

Type helper: extracts the `UIMessage` subtype from a chat agent’s wire payload.

```ts theme={"theme":"css-variables"}
import type { InferChatUIMessage } from "@trigger.dev/sdk/ai";
// Use the /chat/react re-export when you're already importing other React helpers.

type Msg = InferChatUIMessage<typeof myChat>;
```

Use with `useChat<Msg>({ transport })` when using [`chat.withUIMessage`](/docs/ai-chat/types). For agents defined with plain `chat.agent()` (no custom generic), this resolves to the base `UIMessage`.

## `InferChatUIMessageFromTools`

Type helper: derives the chat `UIMessage` type (with typed `tool-${name}` parts) directly from a tool set. Shorthand for `UIMessage<unknown, UIDataTypes, InferUITools<typeof tools>>`.

```ts theme={"theme":"css-variables"}
import type { InferChatUIMessageFromTools } from "@trigger.dev/sdk/ai";

const tools = { search, readFile };
type ChatUiMessage = InferChatUIMessageFromTools<typeof tools>;
```

Pin it on the agent with [`chat.withUIMessage<ChatUiMessage>()`](/docs/ai-chat/types) and reuse it on the client. See [Tools](/docs/ai-chat/tools#typing-messages-from-your-tools).

## AI helpers (`ai` from `@trigger.dev/sdk/ai`)

| Export                                                                              | Status         | Description                                                                                                                                                                                |
| ----------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ai.toolExecute(task)`                                                              | **Preferred**  | Returns the `execute` function for AI SDK `tool()`. Runs the task via `triggerAndSubscribe` and attaches tool/chat metadata (same behavior the deprecated wrapper used internally).        |
| `ai.tool(task, options?)`                                                           | **Deprecated** | Wraps `tool()` / `dynamicTool()` and the same execute path. Migrate to `tool({ ..., execute: ai.toolExecute(task) })`. See [Task-backed AI tools](/docs/tasks/schemaTask#task-backed-ai-tools). |
| `ai.toolCallId`, `ai.chatContext`, `ai.chatContextOrThrow`, `ai.currentToolOptions` | Supported      | Work for any task-backed tool execute path, including `ai.toolExecute`.                                                                                                                    |

## ChatUIMessageStreamOptions

Options for customizing `toUIMessageStream()`. Set as static defaults via `uiMessageStreamOptions` on `chat.agent()`, or override per-turn via `chat.setUIMessageStreamOptions()`. See [Stream options](/docs/ai-chat/backend#stream-options) for usage examples.

Derived from the AI SDK's `UIMessageStreamOptions` with `onFinish` and `originalMessages` omitted (managed internally — `onFinish` for response capture, `originalMessages` for cross-turn message ID reuse).

| Option              | Type                              | Default               | Description                                                                                                                                   |
| ------------------- | --------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `onError`           | `(error: unknown) => string`      | Raw error message     | Called on LLM errors and tool execution errors. Return a sanitized string — sent as `{ type: "error", errorText }` to the frontend.           |
| `sendReasoning`     | `boolean`                         | `true`                | Send reasoning parts to the client                                                                                                            |
| `sendSources`       | `boolean`                         | `false`               | Send source parts to the client                                                                                                               |
| `sendFinish`        | `boolean`                         | `true`                | Send the finish event. Set to `false` when chaining multiple `streamText` calls.                                                              |
| `sendStart`         | `boolean`                         | `true`                | Send the message start event. Set to `false` when chaining.                                                                                   |
| `messageMetadata`   | `(options: { part }) => metadata` | —                     | Extract message metadata to send to the client. Called on `start` and `finish` events.                                                        |
| `generateMessageId` | `() => string`                    | AI SDK's `generateId` | Custom message ID generator for response messages (e.g. UUID-v7). IDs are shared between frontend and backend via the stream's `start` chunk. |

## TriggerChatTransport options

Options for the frontend transport constructor and `useTriggerChatTransport` hook.

| Option                 | Type                                                                                                      | Default                     | Description                                                                                                                                                                                                                                                     |
| ---------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `task`                 | `string`                                                                                                  | required                    | Task ID the transport's session is bound to. Threaded into `startSession`'s params.                                                                                                                                                                             |
| `accessToken`          | `(params: AccessTokenParams) => string \| Promise<string>`                                                | required                    | Pure refresh — mints a fresh session-scoped PAT. Called on 401/403. See [callback shape](#accesstoken-callback).                                                                                                                                                |
| `startSession`         | `(params: StartSessionParams<TClientData>) => Promise<StartSessionResult>`                                | optional                    | Creates the chat Session and returns the session-scoped PAT. Called on `transport.preload(chatId)` and lazily on the first `sendMessage` for any chatId without a cached PAT. See [callback shape](#startsession-callback).                                     |
| `baseURL`              | `string \| (ctx: { endpoint: "in" \| "out"; chatId: string }) => string`                                  | `"https://api.trigger.dev"` | API base URL. String form applies to every endpoint; function form lets you pick per endpoint — e.g. route `.in/append` through a trusted edge proxy while keeping `.out` SSE direct (see [Trusted edge signals](/docs/ai-chat/patterns/trusted-edge-signals)).      |
| `fetch`                | `(url: string, init: RequestInit, ctx: { endpoint: "in" \| "out"; chatId: string }) => Promise<Response>` | —                           | Per-request fetch override. Invoked for both `.in/append` POSTs and the `.out` SSE GET. Use for header injection (tracing), custom retries, or proxy rewrites beyond what `baseURL` can express.                                                                |
| `headers`              | `Record<string, string>`                                                                                  | —                           | Extra headers for API requests                                                                                                                                                                                                                                  |
| `streamTimeoutSeconds` | `number`                                                                                                  | `120`                       | How long to wait for stream data                                                                                                                                                                                                                                |
| `clientData`           | Typed by `clientDataSchema`                                                                               | —                           | Default client data merged into per-turn `metadata` and threaded through `startSession`'s params (so the first run's `payload.metadata` matches per-turn `metadata`). Live-updated when the option value changes.                                               |
| `sessions`             | `Record<string, ChatSession>`                                                                             | —                           | Restore sessions from storage. See [ChatSession](#chatsession).                                                                                                                                                                                                 |
| `onSessionChange`      | `(chatId, session \| null) => void`                                                                       | —                           | Fires when session state changes. `session` is the full `ChatSession` or `null` when the run ends.                                                                                                                                                              |
| `onEvent`              | `(event: ChatTransportEvent) => void`                                                                     | —                           | Observability callback for transport lifecycle events (send outcomes, stream connects, first chunk, turn completion). See [Transport events](#transport-events). Exceptions thrown by the callback are swallowed.                                               |
| `multiTab`             | `boolean`                                                                                                 | `false`                     | Enable multi-tab claim coordination via `BroadcastChannel`. See [Frontend → multi-tab](/docs/ai-chat/frontend#multi-tab-coordination).                                                                                                                               |
| `watch`                | `boolean`                                                                                                 | `false`                     | Read-only watcher mode — keep the SSE subscription open across `trigger:turn-complete` so a viewer sees turns 2, 3, … through one long-lived stream.                                                                                                            |
| `headStart`            | `string`                                                                                                  | —                           | URL of a [`chat.headStart`](/docs/ai-chat/fast-starts#head-start) route handler. When set, the FIRST message of a brand-new chat POSTs to this URL so step 1's LLM call runs in your warm process while the agent run boots in parallel. Subsequent turns bypass it. |

### Transport events

The `onEvent` callback receives a `ChatTransportEvent` (exported from `@trigger.dev/sdk/chat` and `@trigger.dev/sdk/chat/react`) for each lifecycle moment the transport observes. All events carry `chatId` and `timestamp`.

| Event                 | Extra fields                                                                      | Fires when                                                                                                                                                                                                                                                                                                                                         |
| --------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `message-sent`        | `messageId?`, `source`, `durationMs`, `partId?`, `bodyBytes?`                     | A send was durably acknowledged — a 2xx from the session input stream append (or the `headStart` POST), after any internal token-refresh retries. This means the message is durably written to the stream the agent consumes from, not merely "request accepted". `partId` is the append's idempotency key, also stored on the server-side record. |
| `message-send-failed` | `messageId?`, `source`, `error`, `status?`, `durationMs`, `partId?`, `bodyBytes?` | A send definitively failed after internal retries. Fires in addition to `useChat`'s `onError`.                                                                                                                                                                                                                                                     |
| `stream-connected`    | `resumed`, `lastEventId?`, `messageId?`                                           | The SSE subscription to the session's output stream started delivering. `resumed: true` when reconnecting from a stored cursor (page reload) rather than following a fresh send. `lastEventId` is the cursor it connected from.                                                                                                                    |
| `first-chunk`         | `chunkType?`, `lastEventId?`, `messageId?`, `sinceSendMs?`                        | The first response chunk of a turn arrived. `sinceSendMs` is the delta from the last turn-producing send — time to first token without any bookkeeping.                                                                                                                                                                                            |
| `turn-completed`      | `lastEventId?`, `sessionInEventId?`, `messageId?`, `sinceSendMs?`                 | The agent's turn-complete control record arrived — the "finished answering" signal. `sinceSendMs` is the full turn latency; `sessionInEventId` is the agent's committed input-stream cursor.                                                                                                                                                       |
| `stream-error`        | `error`, `status?`                                                                | The output stream failed unrecoverably.                                                                                                                                                                                                                                                                                                            |

`source` identifies the send path: `"submit-message"`, `"regenerate-message"`, `"steer"` (`sendPendingMessage`), `"action"` (`sendAction`), `"stop"` (`stopGeneration`), or `"head-start"`.

`messageId` on the response-side events is client-side attribution: the id from the most recent turn-producing send (`submit-message`, `regenerate-message`, or `head-start`) on that chat.

See [Monitoring message delivery](/docs/ai-chat/frontend#monitoring-message-delivery) for the metrics and watchdog patterns these enable.

### `accessToken` callback

The transport invokes `accessToken` whenever it needs a *fresh* session-scoped PAT — initial use after no PAT is cached, or after a 401/403 from any session-PAT-authed request. The callback's job is to **return a token, not to start a run.**

`AccessTokenParams`:

| Field    | Type     | Description          |
| -------- | -------- | -------------------- |
| `chatId` | `string` | The conversation id. |

Customer implementation typically wraps `auth.createPublicToken` server-side:

```ts theme={"theme":"css-variables"}
"use server";
import { auth } from "@trigger.dev/sdk";

export async function mintChatAccessToken(chatId: string) {
  return auth.createPublicToken({
    scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
    expirationTime: "1h",
  });
}
```

```ts theme={"theme":"css-variables"}
const transport = useTriggerChatTransport({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
});
```

### `startSession` callback

The transport invokes `startSession` when it needs to *create* the session — on `transport.preload(chatId)`, and lazily on the first `sendMessage` for any chatId without a cached PAT. Concurrent and repeat calls dedupe via an in-flight promise, and the customer's wrapped helper is idempotent on `(env, externalId)` so two tabs / two `preload` calls converge on the same session.

`StartSessionParams<TClientData>`:

| Field        | Type          | Description                                                                                                                                                          |
| ------------ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `taskId`     | `string`      | The transport's `task` value.                                                                                                                                        |
| `chatId`     | `string`      | The conversation id (the session's `externalId`).                                                                                                                    |
| `clientData` | `TClientData` | The transport's current `clientData` option. Pass through to `triggerConfig.basePayload.metadata` so the first run's `payload.metadata` matches per-turn `metadata`. |

Customer implementation wraps `chat.createStartSessionAction(taskId)`:

```ts theme={"theme":"css-variables"}
"use server";
import { chat } from "@trigger.dev/sdk/ai";

export const startChatSession = chat.createStartSessionAction("my-chat");
```

```ts theme={"theme":"css-variables"}
const transport = useTriggerChatTransport({
  task: "my-chat",
  startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
});
```

`startSession` is optional only when the customer fully manages the session lifecycle externally (e.g. by hydrating `sessions: { [chatId]: ... }` and never calling `preload`). Most customers should provide it.

### multiTab

Enable multi-tab coordination. When `true`, only one browser tab can send messages to a given chatId at a time. Other tabs enter read-only mode with real-time message updates via `BroadcastChannel`.

```ts theme={"theme":"css-variables"}
const transport = useTriggerChatTransport({
  task: "my-chat",
  accessToken,
  multiTab: true,
});
```

No-op when `BroadcastChannel` is unavailable (SSR, Node.js). See [Multi-tab coordination](/docs/ai-chat/frontend#multi-tab-coordination).

### Trigger configuration

Trigger config (machine, queue, tags, maxAttempts, idleTimeoutInSeconds) lives server-side in `chat.createStartSessionAction(taskId, options?)`. The transport doesn't accept these options directly — pass them when wrapping the action:

```ts theme={"theme":"css-variables"}
"use server";
import { chat } from "@trigger.dev/sdk/ai";

export const startChatSession = chat.createStartSessionAction("my-chat", {
  triggerConfig: {
    machine: "small-1x",
    queue: "chat-queue",
    tags: ["user:123"],
    maxAttempts: 3,
    idleTimeoutInSeconds: 60,
  },
});
```

A `chat:{chatId}` tag is automatically added to every run.

For per-call values that vary by chatId (e.g. plan-tier-driven machine), accept extra params on the customer's server action and pass them into `chat.createStartSessionAction(...)`'s options at call time.

### transport.stopGeneration()

Stop the current generation for a chat session. Sends a stop signal to the backend task and closes the active SSE connection.

```ts theme={"theme":"css-variables"}
transport.stopGeneration(chatId: string): Promise<boolean>
```

Returns `true` if the stop signal was sent, `false` if there's no active session. Works for both initial connections and reconnected streams (after page refresh with `resume: true`).

Use alongside `useChat`'s `stop()` for a complete stop experience:

```tsx theme={"theme":"css-variables"}
const { stop: aiStop } = useChat({ transport });

const stop = useCallback(() => {
  transport.stopGeneration(chatId);
  aiStop();
}, [transport, chatId, aiStop]);
```

See [Stop generation](/docs/ai-chat/frontend#stop-generation) for full details.

### transport.sendAction()

Send a custom action to the agent. Actions wake the agent from suspension and fire `onAction`. They are not turns — `run()` and turn lifecycle hooks do not fire. If `onAction` returns a `StreamTextResult`, the response is auto-piped to the frontend.

```ts theme={"theme":"css-variables"}
transport.sendAction(chatId: string, action: unknown): Promise<ReadableStream<UIMessageChunk>>
```

The action payload is validated against the agent's `actionSchema` on the backend.

```tsx theme={"theme":"css-variables"}
// Undo button
<button onClick={() => transport.sendAction(chatId, { type: "undo" })}>
  Undo
</button>
```

See [Actions](/docs/ai-chat/actions) for backend setup and [Sending actions](/docs/ai-chat/frontend#sending-actions) for frontend usage.

### transport.preload()

Eagerly trigger a run before the first message.

```ts theme={"theme":"css-variables"}
transport.preload(chatId): Promise<void>
```

No-op if a session already exists for this chatId. The preload idle window is set by `preloadIdleTimeoutInSeconds` on the agent, not by this call. See [Preload](/docs/ai-chat/fast-starts#preload) for full details.

## useTriggerChatTransport

React hook that creates and memoizes a `TriggerChatTransport` instance. Import from `@trigger.dev/sdk/chat/react`.

```tsx theme={"theme":"css-variables"}
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import type { myChat } from "@/trigger/chat";

const transport = useTriggerChatTransport<typeof myChat>({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
  startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  sessions: savedSessions,
  onSessionChange: handleSessionChange,
});
```

The transport is created once on first render and reused across re-renders. Pass a type parameter for compile-time validation of the task ID.

## AgentChat options

Options for the server-side chat client constructor. Import `AgentChat` from `@trigger.dev/sdk/chat`.

| Option                 | Type                                                                                                      | Default                    | Description                                                                                                                                                                       |
| ---------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agent`                | `string`                                                                                                  | required                   | Task ID of the chat agent to converse with.                                                                                                                                       |
| `id`                   | `string`                                                                                                  | `crypto.randomUUID()`      | Conversation ID. Used as the Session `externalId` and for tagging runs.                                                                                                           |
| `clientData`           | Typed by `clientDataSchema`                                                                               | —                          | Client data included in every request. Same shape as the agent's `clientDataSchema`.                                                                                              |
| `session`              | `ChatSession`                                                                                             | —                          | Restore a previous session (pass `lastEventId` to resume SSE).                                                                                                                    |
| `triggerConfig`        | `Partial<SessionTriggerConfig>`                                                                           | —                          | Default trigger config used when starting a new session (machine, tags, etc.).                                                                                                    |
| `streamTimeoutSeconds` | `number`                                                                                                  | `120`                      | SSE timeout in seconds.                                                                                                                                                           |
| `onTriggered`          | `(event) => void \| Promise<void>`                                                                        | —                          | Fires when a new run is triggered for this session.                                                                                                                               |
| `onTurnComplete`       | `(event) => void \| Promise<void>`                                                                        | —                          | Fires when a turn completes. Persist `event.lastEventId` for stream resumption.                                                                                                   |
| `baseURL`              | `string \| (ctx: { endpoint: "in" \| "out"; chatId: string }) => string`                                  | `apiClientManager.baseURL` | API base URL. String form applies to every endpoint; function form picks per endpoint. Defaults to whatever `@trigger.dev/sdk` was configured with (typically `TRIGGER_API_URL`). |
| `fetch`                | `(url: string, init: RequestInit, ctx: { endpoint: "in" \| "out"; chatId: string }) => Promise<Response>` | —                          | Per-request fetch override. Invoked for both `.in/append` POSTs and the `.out` SSE GET. Use for header injection, custom retries, or proxy rewrites.                              |

## createStartSessionAction options

Second argument to `chat.createStartSessionAction(taskId, options?)`. Controls how the server-mediated session-create call reaches the trigger.dev API.

| Option          | Type                                                                                                             | Default                    | Description                                                                                                                                                    |
| --------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tokenTTL`      | `string \| number \| Date`                                                                                       | `"1h"`                     | TTL for the session-scoped public access token returned to the browser.                                                                                        |
| `triggerConfig` | `Partial<SessionTriggerConfig>`                                                                                  | —                          | Default trigger config (machine, tags, queue, etc.). Per-call config shallow-merges on top.                                                                    |
| `baseURL`       | `string \| (ctx: { endpoint: "sessions" \| "auth"; chatId: string }) => string`                                  | `apiClientManager.baseURL` | API base URL. `endpoint` is `"sessions"` for `POST /api/v1/sessions` or `"auth"` for `POST /api/v1/auth/jwt/claims` (only fires when `tokenTTL` is set).       |
| `fetch`         | `(url: string, init: RequestInit, ctx: { endpoint: "sessions" \| "auth"; chatId: string }) => Promise<Response>` | —                          | Per-request fetch override. Use to route session-create through a trusted edge proxy so `basePayload.metadata` is rewritten before reaching `api.trigger.dev`. |

## useMultiTabChat

React hook for multi-tab message coordination. Import from `@trigger.dev/sdk/chat/react`.

```tsx theme={"theme":"css-variables"}
import { useMultiTabChat } from "@trigger.dev/sdk/chat/react";

const { isReadOnly } = useMultiTabChat(transport, chatId, messages, setMessages);
```

| Parameter     | Type                   | Description                              |
| ------------- | ---------------------- | ---------------------------------------- |
| `transport`   | `TriggerChatTransport` | Transport instance with `multiTab: true` |
| `chatId`      | `string`               | The chat session ID                      |
| `messages`    | `UIMessage[]`          | Current messages from `useChat`          |
| `setMessages` | `(messages) => void`   | Message setter from `useChat`            |

**Returns:** `{ isReadOnly: boolean }` — `true` when another tab is actively sending to this chatId.

The hook handles:

* Tracking read-only state from the transport's `BroadcastChannel` coordinator
* Broadcasting messages when this tab is the active sender
* Receiving messages from other tabs and updating via `setMessages`

See [Multi-tab coordination](/docs/ai-chat/frontend#multi-tab-coordination).

## ChatSession

Persistable session state for the frontend `TriggerChatTransport` and the server-side `AgentChat`. The underlying Session row is keyed on `chatId` (durable across runs); the persistable shape is just the SSE resume cursor and a refresh token.

| Field               | Type                   | Description                                                                                                                                                                                                                                         |
| ------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `publicAccessToken` | `string`               | Session-scoped JWT (`read:sessions:{chatId} + write:sessions:{chatId}`). Refreshed automatically on 401/403 via the transport's `accessToken` callback.                                                                                             |
| `lastEventId`       | `string \| undefined`  | Last SSE event received on `.out`. Used to resume mid-stream after a disconnect.                                                                                                                                                                    |
| `isStreaming`       | `boolean \| undefined` | Optional. If persisted, `reconnectToStream` uses it as a fast-path short-circuit. If omitted, the server decides via the session's [`X-Session-Settled`](/docs/ai-chat/client-protocol#x-session-settled-fast-close-on-idle-reconnects) response header. |

## ChatInputChunk

The wire shape for records sent on `.in`. Consumed by `chat.agent` internally — you typically don't write these yourself; `transport.sendMessage`, `transport.stopGeneration`, and `transport.sendAction` all serialize into this shape.

```ts theme={"theme":"css-variables"}
type ChatInputChunk<TMessage = UIMessage, TMetadata = unknown> =
  | { kind: "message"; payload: ChatTaskWirePayload<TMessage, TMetadata> }
  | { kind: "stop"; message?: string };
```

| Variant           | When                                             | Payload                                                                                                                                       |
| ----------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `kind: "message"` | New message, action, approval response, or close | `payload` is a full `ChatTaskWirePayload` — its `trigger` field (`"submit-message"` / `"action"` / `"close"`) determines the agent's dispatch |
| `kind: "stop"`    | Client aborted the active turn                   | Optional `message` surfaces in the stop handler                                                                                               |

For the raw wire format, see [Client Protocol — ChatInputChunk](/docs/ai-chat/client-protocol#chatinputchunk).

## Session token scopes

Tokens minted for `TriggerChatTransport` and `AgentChat` are session-scoped — keyed on the chat's `externalId` (the `chatId` you assign).

| Scope                     | Grants                                                                |
| ------------------------- | --------------------------------------------------------------------- |
| `read:sessions:<chatId>`  | Subscribe to `.out`, HEAD probe the stream, retrieve the session row  |
| `write:sessions:<chatId>` | Append to `.in`, close the session, end-and-continue, update metadata |

Tokens are produced by `auth.createPublicToken({ scopes: { read: { sessions: chatId }, write: { sessions: chatId } } })` (used by the customer's `accessToken` server action) or returned automatically from `chat.createStartSessionAction` / `POST /api/v1/sessions`. Either form authorizes both URL forms (`/sessions/{chatId}/...` and `/sessions/session_*/...`) on every read and write route.

## Related

* [Realtime Streams](/docs/tasks/streams) — How streams work under the hood
* [Using the Vercel AI SDK](/docs/guides/examples/vercel-ai-sdk) — Basic AI SDK usage with Trigger.dev
* [Realtime React Hooks](/docs/realtime/react-hooks/overview) — Lower-level realtime hooks
* [Authentication](/docs/realtime/auth) — Public access tokens and trigger tokens


# Server-Side Chat
Source: https://trigger.dev/docs/ai-chat/server-chat

Use AgentChat to interact with chat agents from server-side code — tasks, webhooks, scripts, or other agents.

`AgentChat` lets you chat with agents from server-side code. It works inside tasks (agent-to-agent), request handlers, webhook processors, and scripts.

```ts theme={"theme":"css-variables"}
import { AgentChat } from "@trigger.dev/sdk/chat";

const chat = new AgentChat({ agent: "my-agent" });
const stream = await chat.sendMessage("Hello!");
const text = await stream.text();
await chat.close();
```

## Type-safe client data

Pass `typeof yourAgent` as a type parameter and `clientData` is automatically typed from the agent's `withClientData` schema:

```ts theme={"theme":"css-variables"}
import { AgentChat } from "@trigger.dev/sdk/chat";
import type { myAgent } from "./trigger/my-agent";

const chat = new AgentChat<typeof myAgent>({
  agent: "my-agent",
  clientData: { userId: "user_123" }, // ← typed from agent definition
});
```

## Conversation lifecycle

Each `AgentChat` instance represents one conversation. The conversation ID is auto-generated or can be set explicitly:

```ts theme={"theme":"css-variables"}
// Auto-generated ID
const chat = new AgentChat({ agent: "my-agent" });

// Explicit ID — useful for persistence or finding the run later
const chat = new AgentChat({ agent: "my-agent", id: `review-${prNumber}` });
```

### Sending messages

`sendMessage()` triggers a new run on the first call, then reuses the same run for subsequent messages via input streams:

```ts theme={"theme":"css-variables"}
// First message — triggers a new run
const stream1 = await chat.sendMessage("Review PR #42");
const review = await stream1.text();

// Follow-up — same run, agent has full context
const stream2 = await chat.sendMessage("Can you fix the main bug?");
const fix = await stream2.text();
```

### Preloading (optional)

If you want the agent to initialize before the first message (e.g., load data, authenticate), call `preload()`. This is optional — `sendMessage()` triggers the run automatically if needed.

```ts theme={"theme":"css-variables"}
await chat.preload();
// Agent's onPreload hook fires now, before user types anything
const stream = await chat.sendMessage("Hello");
```

### Closing

Signal the agent to exit its loop gracefully:

```ts theme={"theme":"css-variables"}
await chat.close();
```

Without `close()`, the agent exits on its own when its idle/suspend timeout expires.

## Reading responses

`sendMessage()` returns a `ChatStream` — a typed wrapper around the response.

### Get the full text

```ts theme={"theme":"css-variables"}
const stream = await chat.sendMessage("What is Trigger.dev?");
const text = await stream.text();
```

### Get structured results

```ts theme={"theme":"css-variables"}
const stream = await chat.sendMessage("Research this topic");
const { text, toolCalls, toolResults } = await stream.result();

for (const tc of toolCalls) {
  console.log(`Tool: ${tc.toolName}, Input: ${JSON.stringify(tc.input)}`);
}
```

### Stream chunks in real-time

```ts theme={"theme":"css-variables"}
const stream = await chat.sendMessage("Write a report");

for await (const chunk of stream) {
  if (chunk.type === "text-delta") {
    process.stdout.write(chunk.delta);
  }
  if (chunk.type === "tool-input-available") {
    console.log(`Using tool: ${chunk.toolName}`);
  }
}
```

## Stateless request handlers

In a stateless environment (HTTP handler, serverless function), you need to persist and restore the session across requests.

Each chat is backed by a durable Session row that outlives any single run. `AgentChat` exposes the persistable state via `chat.session` (the SSE resume cursor) and surfaces the current run id via the `onTriggered` callback for telemetry / dashboard linking.

```ts theme={"theme":"css-variables"}
import { AgentChat } from "@trigger.dev/sdk/chat";

export async function POST(req: Request) {
  const { chatId, message } = await req.json();
  const saved = await db.sessions.find({ chatId });

  const chat = new AgentChat({
    agent: "my-agent",
    id: chatId,
    // Restore from previous request — `lastEventId` is the SSE resume
    // cursor; the underlying Session is keyed on `chatId` so it's
    // implicit and durable.
    session: saved ? { lastEventId: saved.lastEventId } : undefined,
    // Useful for telemetry / dashboard linking. The `runId` is the
    // current run, which may change across continuations and upgrades.
    onTriggered: async ({ runId }) => {
      await db.sessions.upsert({ chatId, runId });
    },
    // Persist after each turn for stream resumption
    onTurnComplete: async ({ lastEventId }) => {
      await db.sessions.update({ chatId, lastEventId });
    },
  });

  const stream = await chat.sendMessage(message);
  const text = await stream.text();

  return Response.json({ text });
}
```

<Info>
  The Session row is the run manager — a chat that was active yesterday
  resumes against the same chatId today, even if the original run has
  long since exited. `AgentChat` (server-side) and `TriggerChatTransport`
  (browser) both rely on this: send a new message and the server
  triggers a fresh continuation run on the same session, carrying the
  conversation forward without losing history or identity.
</Info>

## Sub-agent tool pattern

`AgentChat` can be used inside an AI SDK tool to delegate work to a durable sub-agent. The sub-agent's response streams as preliminary tool results:

```ts theme={"theme":"css-variables"}
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();
    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." };
  },
});
```

This supports single-turn delegation, multi-turn LLM-driven conversations with persistent sub-agents, and cross-turn state that survives snapshot/restore.

See the [Sub-Agents guide](/docs/ai-chat/patterns/sub-agents) for the full pattern including multi-turn conversations, cleanup, and what the frontend sees.

## Additional methods

### Steering

Send a message during an active stream without interrupting it:

```ts theme={"theme":"css-variables"}
await chat.steer("Focus on security issues specifically");
```

### Stop generation

Abort the current `streamText` call without ending the run:

```ts theme={"theme":"css-variables"}
await chat.stop();
```

### Raw messages

For full control over the UIMessage shape:

```ts theme={"theme":"css-variables"}
const rawStream = await chat.sendRaw([
  {
    id: "msg-1",
    role: "user",
    parts: [
      { type: "text", text: "Hello" },
      { type: "file", url: "https://...", mediaType: "image/png" },
    ],
  },
]);
```

### Reconnect

Resume a stream subscription after a disconnect:

```ts theme={"theme":"css-variables"}
const stream = await chat.reconnect();
```

## AgentChat options

| Option                 | Type                                                                                                      | Default                    | Description                                                                                                                                                                                                                                                               |
| ---------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agent`                | `string`                                                                                                  | required                   | The agent task ID to trigger                                                                                                                                                                                                                                              |
| `id`                   | `string`                                                                                                  | `crypto.randomUUID()`      | Conversation ID for tagging and correlation                                                                                                                                                                                                                               |
| `clientData`           | typed from agent                                                                                          | `undefined`                | Client data included in every request                                                                                                                                                                                                                                     |
| `session`              | `ChatSession` (`{ lastEventId?: string }`)                                                                | `undefined`                | Restore a previous session's SSE resume cursor. The Session row itself is keyed on `chatId` (durable) — no other state to thread.                                                                                                                                         |
| `onTriggered`          | `(event) => void`                                                                                         | `undefined`                | Called when a new run is created                                                                                                                                                                                                                                          |
| `onTurnComplete`       | `(event) => void`                                                                                         | `undefined`                | Called when a turn's stream ends                                                                                                                                                                                                                                          |
| `streamTimeoutSeconds` | `number`                                                                                                  | `120`                      | SSE timeout in seconds                                                                                                                                                                                                                                                    |
| `triggerConfig`        | `SessionTriggerConfig`                                                                                    | `undefined`                | Tags, queue, machine, `maxAttempts`, `idleTimeoutInSeconds`, `basePayload` — folded into `sessions.start({...})`                                                                                                                                                          |
| `baseURL`              | `string \| (ctx: { endpoint: "in" \| "out"; chatId: string }) => string`                                  | `apiClientManager.baseURL` | API base URL. String form applies to every endpoint; function form picks per endpoint — useful for routing `.in/append` through an edge proxy while keeping `.out` SSE direct. Defaults to whatever `@trigger.dev/sdk` was configured with (typically `TRIGGER_API_URL`). |
| `fetch`                | `(url: string, init: RequestInit, ctx: { endpoint: "in" \| "out"; chatId: string }) => Promise<Response>` | `undefined`                | Per-request fetch override. Invoked for both `.in/append` POSTs and the `.out` SSE GET. Use for header injection, custom retries, or proxy rewrites.                                                                                                                      |

## ChatStream methods

| Method                   | Returns                          | Description                                               |
| ------------------------ | -------------------------------- | --------------------------------------------------------- |
| `text()`                 | `Promise<string>`                | Consume stream, return accumulated text                   |
| `result()`               | `Promise<ChatStreamResult>`      | Consume stream, return `{ text, toolCalls, toolResults }` |
| `messages()`             | `AsyncGenerator<UIMessage>`      | Yield accumulated UIMessage snapshots (sub-agent pattern) |
| `[Symbol.asyncIterator]` | `UIMessageChunk`                 | Iterate over typed stream chunks                          |
| `.stream`                | `ReadableStream<UIMessageChunk>` | Raw stream for AI SDK utilities                           |


# Sessions
Source: https://trigger.dev/docs/ai-chat/sessions

A Session is a stateful execution of an agent, with two-way streaming and durable compute. A single Session can have multiple runs associated with it.

**A Session is a stateful execution of an agent.** It includes two-way streaming and durable compute, and a single Session can have multiple runs associated with it.

The **two-way streaming** is a pair of durable streams. The input stream (`.in`) carries incoming user messages to your task. The output stream (`.out`) carries everything the agent produces back to your clients: AI generation parts (text, reasoning, tool calls) and any custom data parts you write.

The **durable compute** is the runs that process those streams. A Session is keyed on your stable id (`externalId` — for chat, the `chatId`) and owns its current run: when a run suspends, idles out, or hands off to a new version, the Session starts or swaps to a fresh run and the streams carry on. Clients keep sending and reading against the same id; they never know a run changed underneath.

```mermaid theme={"theme":"css-variables"}
flowchart LR
  C[Browser / backend clients] -- "user messages" --> IN([Session .in])
  IN --> R["current run<br/>(runs come and go)"]
  R -- "text, reasoning, tool calls,<br/>data parts" --> OUT([Session .out])
  OUT --> C
```

`chat.agent` is built on Sessions. You can also use them directly for any pattern that needs durable bi-directional streaming across runs: long-lived agent inboxes, multi-step approval flows, server-to-server pipelines that survive worker restarts.

## A minimal example

A task that echoes whatever lands on its input stream, and a backend that starts the session, sends a message, and reads the reply:

```ts trigger/inbox.ts theme={"theme":"css-variables"}
import { task, sessions } from "@trigger.dev/sdk";

export const inboxAgent = task({
  id: "inbox-agent",
  run: async (payload: { sessionId: string }) => {
    const session = sessions.open(payload.sessionId);

    while (true) {
      // Suspends the run (no compute billed) until a record arrives.
      const next = await session.in.wait<{ text: string }>({ timeout: "1h" });
      if (!next.ok) return;
      await session.out.append({ type: "reply", text: `echo: ${next.output.text}` });
    }
  },
});
```

```ts Your backend theme={"theme":"css-variables"}
import { sessions } from "@trigger.dev/sdk";

// Atomically create the session AND trigger its first run.
await sessions.start({
  type: "inbox",
  externalId: userId,
  taskIdentifier: "inbox-agent",
  triggerConfig: { basePayload: { sessionId: userId } },
});

const session = sessions.open(userId);
await session.in.send({ text: "hello" });

const stream = await session.out.read({ signal: AbortSignal.timeout(30_000) });
for await (const chunk of stream) {
  console.log(chunk); // { type: "reply", text: "echo: hello" }
}
```

The run can suspend, crash, or be replaced between the `send` and the `read` — the streams are durable, so nothing is lost and the client code doesn't change.

## Sessions and runs

One Session spans many runs over its lifetime. The Session row tracks `currentRunId`; the runs do the work:

* **First run**: created atomically by `sessions.start` (no gap where the session exists but nothing is listening).
* **Idle suspend**: a run blocked on `in.wait` suspends and frees compute. A new record on `.in` wakes it.
* **Continuation**: when a run ends (idle timeout, `chat.endRun`, a crash, a version upgrade), the next incoming record triggers a fresh run against the same Session. The new run picks up the streams where the old one left off.

This is what makes a Session the durable identity for a conversation: runs are an execution detail, the Session (and its `externalId`) is what your clients address. See [How it works](/docs/ai-chat/how-it-works) for how `chat.agent` drives this loop.

## When to reach for Sessions directly

`chat.agent` handles 90% of chat-shaped workloads — message accumulation, the turn loop, stop signals, lifecycle hooks. Use the raw `sessions` API when you need any of:

* **Non-chat conversational state**: an agent inbox where each "turn" is a webhook event rather than a UI message.
* **Server-to-server bi-directional streaming** where an external service produces records the task consumes (and vice-versa) over the same durable channel.
* **A custom turn loop** where the agent abstraction doesn't fit but you still want session-survival across runs.

For chat use cases, prefer [`chat.agent`](/docs/ai-chat/backend#chat-agent) or [`chat.createSession`](/docs/ai-chat/backend#chat-createsession).

## `sessions` namespace

```ts theme={"theme":"css-variables"}
import { sessions } from "@trigger.dev/sdk";
```

### `sessions.start(body, requestOptions?)`

Atomically create a Session row and trigger its first run. Idempotent on `(env, externalId)` — two concurrent calls with the same `externalId` converge to one session.

```ts theme={"theme":"css-variables"}
const { id, runId, publicAccessToken, isCached } = await sessions.start({
  type: "chat.agent",
  externalId: chatId,
  taskIdentifier: "my-chat",
  triggerConfig: {
    tags: [`chat:${chatId}`],
    basePayload: { /* whatever your task's payload shape is */ },
  },
});
```

| Field            | Type                       | Notes                                                                                                                   |
| ---------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `type`           | `string`                   | Free-form discriminator. `chat.agent` uses `"chat.agent"`.                                                              |
| `externalId`     | `string?`                  | Your stable identity. Cannot start with `session_` (reserved).                                                          |
| `taskIdentifier` | `string`                   | Task this session triggers runs against.                                                                                |
| `triggerConfig`  | `SessionTriggerConfig`     | Trigger options applied to every run: `tags`, `queue`, `machine`, `maxAttempts`, `idleTimeoutInSeconds`, `basePayload`. |
| `tags`           | `string[]?`                | Up to 10 tags on the Session row (separate from `triggerConfig.tags`).                                                  |
| `metadata`       | `Record<string, unknown>?` | Arbitrary JSON.                                                                                                         |
| `expiresAt`      | `Date?`                    | Hard retention deadline.                                                                                                |

Returns `CreatedSessionResponseBody`:

| Field               | Type      | Notes                                                            |
| ------------------- | --------- | ---------------------------------------------------------------- |
| `id`                | `string`  | Server-assigned `session_*` friendlyId.                          |
| `runId`             | `string`  | The first run created alongside the session.                     |
| `publicAccessToken` | `string`  | Session-scoped PAT (`read:sessions:{id} + write:sessions:{id}`). |
| `isCached`          | `boolean` | `true` if the session already existed (idempotent upsert).       |

### `sessions.retrieve(idOrExternalId, requestOptions?)`

Retrieve a Session by either its server-assigned `session_*` id or your user-supplied `externalId`. The server disambiguates via the `session_` prefix.

```ts theme={"theme":"css-variables"}
const session = await sessions.retrieve(chatId);
console.log(session.currentRunId, session.tags, session.closedAt);
```

### `sessions.update(idOrExternalId, body, requestOptions?)`

Mutate `tags` or `metadata` on an existing Session. `externalId` is read-only after create: it cannot be changed or cleared (it keys the session's durable streams and token scope), so sending a different value returns `422`.

### `sessions.close(idOrExternalId, body?, requestOptions?)`

Mark a Session as closed. Terminal and idempotent. The optional `reason` is stored on the row.

```ts theme={"theme":"css-variables"}
await sessions.close(chatId, { reason: "user signed out" });
```

### `sessions.list(options?, requestOptions?)`

Cursor-paginated list of Sessions in the current environment. Returns a `CursorPagePromise` you can iterate with `for await`.

```ts theme={"theme":"css-variables"}
for await (const s of sessions.list({
  type: "chat.agent",
  tag: `user:${userId}`,
  status: "ACTIVE",
  limit: 50,
})) {
  console.log(s.id, s.externalId, s.createdAt);
}
```

| Filter                       | Type                                | Notes                                   |
| ---------------------------- | ----------------------------------- | --------------------------------------- |
| `type`                       | `string \| string[]`                | e.g. `"chat.agent"`                     |
| `tag`                        | `string \| string[]`                | Matches `triggerConfig.tags`            |
| `taskIdentifier`             | `string \| string[]`                | Filter by task                          |
| `externalId`                 | `string`                            | Exact match                             |
| `status`                     | `"ACTIVE" \| "CLOSED" \| "EXPIRED"` | Lifecycle state                         |
| `period` / `from` / `to`     | window                              | Time-range filter                       |
| `limit` / `after` / `before` | cursor                              | Pagination (1–100 per page; default 20) |

### `sessions.open(idOrExternalId)`

Open a lightweight `SessionHandle` to the realtime channels. Does **not** hit the network — each handle method calls the corresponding endpoint lazily.

```ts theme={"theme":"css-variables"}
const session = sessions.open(chatId);
await session.out.append({ kind: "message", text: "hello" });
const next = await session.in.once<MyEvent>({ timeoutMs: 30_000 });
```

## `SessionHandle`

```ts theme={"theme":"css-variables"}
class SessionHandle {
  readonly id: string;
  readonly in: SessionInputChannel;
  readonly out: SessionOutputChannel;
}
```

The two channels mirror the producer/consumer pair in `streams.define` (out) and `streams.input` (in), but are **session-scoped** rather than run-scoped — they survive across run boundaries.

## `session.out` — task → clients

The output channel. The task writes; external clients (browser, server action, another task) read via SSE. The underlying HTTP endpoints are documented in [Session channels](/docs/management/sessions/channels) for non-SDK callers.

### `out.append(value, options?)`

Append a single record. Routes through `writer` internally so SSE consumers see the same parsed-object shape on every event.

### `out.pipe(stream, options?)`

Pipe an `AsyncIterable` or `ReadableStream` directly to S2 (the durable backing store). Returns `{ stream, waitUntilComplete }`.

### `out.writer({ execute, ... })`

Imperative writer. `execute({ write, merge })` runs against an in-memory queue whose records are piped to S2.

```ts theme={"theme":"css-variables"}
session.out.writer<MyChunk>({
  execute: ({ write }) => {
    write({ type: "text", text: "hi" });
    write({ type: "text", text: " there" });
  },
});
```

### `out.read(options?)`

Subscribe to SSE records on `.out`. Returns an async-iterable stream with auto-retry and `Last-Event-ID` resume.

```ts theme={"theme":"css-variables"}
const stream = await session.out.read<MyChunk>({
  signal: AbortSignal.timeout(30_000),
  lastEventId: lastSeenSeqNum,
});
for await (const chunk of stream) {
  // ...
}
```

### `out.writeControl(subtype, extraHeaders?)`

Write a Trigger control record. Carries a `trigger-control` header valued with `subtype` (e.g. `turn-complete`, `upgrade-required`); the body is empty. The SDK transport filters control records out of the consumer-facing chunk stream — readers route them via `onControl` instead.

Returns `{ lastEventId }` — useful for trim chains.

### `out.trimTo(earliestSeqNum)`

Append an S2 `trim` command. Records with `seq_num < earliestSeqNum` are eventually deleted. Idempotent and monotonic. `chat.agent` uses this to keep `session.out` bounded to roughly one turn at steady state.

## `session.in` — clients → task

The input channel. External clients call `send`; the task consumes via `on` / `once` / `peek` / `wait` / `waitWithIdleTimeout`. The underlying HTTP endpoints are documented in [Session channels](/docs/management/sessions/channels) for non-SDK callers.

### `in.send(value, requestOptions?)`

Append a single record. Called from outside the task (browser, server action, another task).

```ts theme={"theme":"css-variables"}
const session = sessions.open(chatId);
await session.in.send({ kind: "user-event", payload: { ... } });
```

### `in.on(handler)`

Register a handler that fires for every record landing on `.in`. Buffered records flush on attach. Returns `{ off }`.

### `in.once(options?)`

Wait for the next record without suspending the run. `{ ok: true, output }` or `{ ok: false, error }` on timeout. Chain `.unwrap()` to get the data directly.

```ts theme={"theme":"css-variables"}
const result = await session.in.once<MyEvent>({ timeoutMs: 5_000 });
if (result.ok) handle(result.output);
```

### `in.peek()`

Non-blocking peek at the head of the `.in` buffer.

### `in.wait(options?)`

Suspend the current run until the next record arrives — frees compute while blocked. Only callable from inside `task.run()`.

```ts theme={"theme":"css-variables"}
const next = await session.in.wait<MyEvent>({ timeout: "1h" });
```

### `in.waitWithIdleTimeout({ idleTimeoutInSeconds, timeout, ... })`

Hybrid: stay warm for `idleTimeoutInSeconds`, then suspend via `wait` if nothing arrives. `chat.agent`'s turn loop uses this to balance responsiveness and cost.

```ts theme={"theme":"css-variables"}
const next = await session.in.waitWithIdleTimeout<MyEvent>({
  idleTimeoutInSeconds: 30,
  timeout: "1h",
  onSuspend: () => { /* persist before suspending */ },
  onResume: () => { /* re-hydrate after resume */ },
});
```

### `in.lastDispatchedSeqNum()`

The highest S2 `seq_num` this channel has delivered to a consumer. Used by `chat.agent` to persist a resume cursor on each `turn-complete` so the next worker boot subscribes past already-processed records.

## Authorization

Browser and server-side clients use a session-scoped Public Access Token:

```ts theme={"theme":"css-variables"}
import { auth } from "@trigger.dev/sdk";

const pat = await auth.createPublicToken({
  scopes: {
    read: { sessions: chatId },
    write: { sessions: chatId },
  },
  expirationTime: "1h",
});
```

Tokens authorize **both** URL forms: `/sessions/{externalId}/...` and `/sessions/session_*/...`.

For the `chat.agent` transport, `auth.createPublicToken` is wrapped by `accessToken` in `useTriggerChatTransport`; for direct session access from your server, mint a token per request just like any other realtime resource.

See [Session scopes](/docs/management/authentication#session-scopes) for exactly what `read:sessions` and `write:sessions` grant, and why updating, closing, and appending to `.out` require a secret key.

## See also

* [Sessions HTTP API](/docs/management/sessions/create) — The REST endpoints for creating, listing, retrieving, updating, and closing sessions, plus the [channel endpoints](/docs/management/sessions/channels) for non-SDK callers.
* [Session scopes](/docs/management/authentication#session-scopes) — The public-token scopes that authorize session and channel access.
* [How it works](/docs/ai-chat/how-it-works) — How `chat.agent` builds on Sessions.
* [Backend](/docs/ai-chat/backend) — `chat.agent` / `chat.createSession` / raw `task()` with chat primitives.
* [Client Protocol](/docs/ai-chat/client-protocol) — The wire-level view of `.in/append` and `.out` SSE.
* [Persistence and replay](/docs/ai-chat/patterns/persistence-and-replay) — How tails are read at boot.


# Testing
Source: https://trigger.dev/docs/ai-chat/testing

Drive a chat.agent through real turns in unit tests — no network, no task runtime, no mocking the SDK.

## Overview

`@trigger.dev/sdk/ai/test` exports `mockChatAgent`, an offline harness that runs your `chat.agent` definition's `run()` function inside an in-memory task runtime. You send messages, actions, and stop signals through driver methods and assert against the chunks the agent emits.

Under the hood the harness drives the agent's backing Session channels — `.in` receives the records your `sendMessage` / `sendStop` / `sendAction` produce, `.out` captures the chunks the agent emits. The harness API itself is session-agnostic; you don't need to manage `sessionId` in tests.

The harness exercises the real turn loop, lifecycle hooks, validation, hydration, and action routing — only the language model and the surrounding Trigger.dev runtime are replaced. Pair it with [`MockLanguageModelV3`](https://sdk.vercel.ai/docs/reference/ai-sdk-core/mock-language-model-v3) and `simulateReadableStream` from `ai` to control LLM responses.

<Note>
  Import `@trigger.dev/sdk/ai/test` **before** your agent module. It installs the resource catalog so `chat.agent({ id, ... })` can register tasks during testing.
</Note>

## Quick start

```ts trigger/my-chat.test.ts theme={"theme":"css-variables"}
import { mockChatAgent } from "@trigger.dev/sdk/ai/test";

import { describe, expect, it } from "vitest";
import { simulateReadableStream, stepCountIs } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
import { myChatAgent } from "./my-chat.js";

function modelWithText(text: string) {
  const chunks: LanguageModelV3StreamPart[] = [
    { type: "text-start", id: "t1" },
    { type: "text-delta", id: "t1", delta: text },
    { type: "text-end", id: "t1" },
    {
      type: "finish",
      finishReason: { unified: "stop", raw: "stop" },
      usage: {
        inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
        outputTokens: { total: 10, text: 10, reasoning: undefined },
      },
    },
  ];
  return new MockLanguageModelV3({
    doStream: async () => ({ stream: simulateReadableStream({ chunks }) }),
  });
}

describe("myChatAgent", () => {
  it("streams the model's response", async () => {
    const model = modelWithText("hello world");
    const harness = mockChatAgent(myChatAgent, {
      chatId: "test-1",
      clientData: { model },
    });

    try {
      const turn = await harness.sendMessage({
        id: "u1",
        role: "user",
        parts: [{ type: "text", text: "hi" }],
      });

      const text = turn.chunks
        .filter((c) => c.type === "text-delta")
        .map((c) => (c as { delta: string }).delta)
        .join("");
      expect(text).toBe("hello world");
    } finally {
      await harness.close();
    }
  });
});
```

The agent reads the mock model from `clientData`:

```ts trigger/my-chat.ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, type LanguageModel } from "ai";
import { z } from "zod";

type ClientData = { model: LanguageModel };

export const myChatAgent = chat
  .withClientData({
    schema: z.custom<ClientData>(
      (v) => !!v && typeof v === "object" && "model" in (v as object)
    ),
  })
  .agent({
    id: "my-chat",
    run: async ({ messages, clientData, signal }) => {
      return streamText({
        model: clientData?.model ?? "openai/gpt-4o-mini",
        messages,
        abortSignal: signal,
        stopWhen: stepCountIs(15),
      });
    },
  });
```

## Setup

### Install dev dependencies

The harness itself ships with `@trigger.dev/sdk`. You need a test runner and the AI SDK's mock model utilities:

```bash theme={"theme":"css-variables"}
pnpm add -D vitest ai @ai-sdk/provider
```

`@ai-sdk/provider` is only needed to type the chunk array as `LanguageModelV3StreamPart[]` — drop it if you cast inline.

### Vitest config

A minimal `vitest.config.ts` for a Trigger.dev project:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["src/**/*.test.ts"],
    environment: "node",
  },
});
```

### Import order

`mockChatAgent` must be imported **first** so the resource catalog is installed before any `chat.agent({ id, ... })` registration runs:

```ts theme={"theme":"css-variables"}
// ✅ Correct
import { mockChatAgent } from "@trigger.dev/sdk/ai/test";
import { myAgent } from "./my-agent.js";

// ❌ Wrong — agent loads before the catalog exists
import { myAgent } from "./my-agent.js";
import { mockChatAgent } from "@trigger.dev/sdk/ai/test";
```

If the agent isn't registered when `mockChatAgent` runs, you'll get:

```
mockChatAgent: no task registered with id "my-chat".
```

## Inject the model via clientData

`MockLanguageModelV3` lives in test code and shouldn't leak into your agent module. Pass it through `clientData` so the agent picks it up at runtime in tests, and falls back to a real model in production:

```ts trigger/agent.ts theme={"theme":"css-variables"}
type ClientData = { model?: LanguageModel };

export const agent = chat
  .withClientData({ schema: z.custom<ClientData>() })
  .agent({
    id: "agent",
    run: async ({ messages, clientData, signal }) => {
      return streamText({
        model: clientData?.model ?? anthropic("claude-haiku-4-5"),
        messages,
        abortSignal: signal,
        stopWhen: stepCountIs(15),
      });
    },
  });
```

```ts agent.test.ts theme={"theme":"css-variables"}
const harness = mockChatAgent(agent, {
  chatId: "test",
  clientData: { model: mockModel },
});
```

## Driving turns

The harness exposes one method per chat trigger. Each waits for the next `trigger:turn-complete` chunk before resolving.

### sendMessage

```ts theme={"theme":"css-variables"}
const turn = await harness.sendMessage({
  id: "u1",
  role: "user",
  parts: [{ type: "text", text: "hi" }],
});
```

Pass an array to send multiple messages at once.

### sendRegenerate

```ts theme={"theme":"css-variables"}
const turn = await harness.sendRegenerate(messages);
```

Equivalent to the frontend's `useChat().regenerate()` — replays a turn with the given message history.

### sendAction

Routes a payload through `actionSchema` + `onAction`. Actions are not turns: only `hydrateMessages` and `onAction` fire on the agent side — no turn lifecycle hooks, no `run()`. The returned `turn.rawChunks` contains whatever `onAction` produced (a streamed model response if it returned a `StreamTextResult`, otherwise just `trigger:turn-complete`):

```ts theme={"theme":"css-variables"}
const turn = await harness.sendAction({ type: "undo" });
```

If the action fails schema validation, an `error` chunk appears in `turn.rawChunks`.

### sendStop

Fires a stop signal. Does **not** wait for a turn — the agent's `signal.aborted` becomes `true` and the current turn unwinds:

```ts theme={"theme":"css-variables"}
await harness.sendStop("user requested stop");
```

### close

Sends a `close` trigger, closes the session's `.in` channel, and aborts the run signal so the task exits cleanly. Always call this at the end of every test:

```ts theme={"theme":"css-variables"}
afterEach(() => harness.close());
// or with a try/finally
try {
  await harness.sendMessage(...);
} finally {
  await harness.close();
}
```

## Inspecting output

Each turn returns:

```ts theme={"theme":"css-variables"}
type MockChatAgentTurn = {
  chunks: UIMessageChunk[];   // text-delta, tool-call, etc.
  rawChunks: unknown[];       // includes control chunks (turn-complete, errors)
};
```

The harness also exposes accumulators across all turns:

```ts theme={"theme":"css-variables"}
harness.allChunks;     // every UIMessageChunk since creation
harness.allRawChunks;  // every raw chunk including control frames
```

A small helper to assemble streamed text:

```ts theme={"theme":"css-variables"}
function collectText(chunks: UIMessageChunk[]): string {
  return chunks
    .filter((c) => c.type === "text-delta")
    .map((c) => (c as { delta: string }).delta)
    .join("");
}
```

## Common patterns

### Asserting hook order

```ts theme={"theme":"css-variables"}
const events: string[] = [];
const agent = chat.agent({
  id: "hook-order",
  onChatStart: async () => { events.push("onChatStart"); },
  onTurnStart: async () => { events.push("onTurnStart"); },
  onBeforeTurnComplete: async () => { events.push("onBeforeTurnComplete"); },
  onTurnComplete: async () => { events.push("onTurnComplete"); },
  run: async ({ messages, signal }) => {
    events.push("run");
    return streamText({ model, messages, abortSignal: signal });
  },
});

const harness = mockChatAgent(agent, { chatId: "t" });
await harness.sendMessage(userMessage("hi"));

// onTurnComplete fires after the turn-complete chunk is written —
// give it a tick before asserting.
await new Promise((r) => setTimeout(r, 20));
expect(events).toEqual([
  "onChatStart",
  "onTurnStart",
  "run",
  "onBeforeTurnComplete",
  "onTurnComplete",
]);
await harness.close();
```

### Testing onValidateMessages

```ts theme={"theme":"css-variables"}
const turn = await harness.sendMessage(userMessage("hello blocked-word"));

// The turn completes with an error chunk, not text
expect(collectText(turn.chunks)).toBe("");
expect(turn.rawChunks.some((c) =>
  typeof c === "object" && c !== null &&
  (c as { type?: string }).type === "trigger:turn-complete"
)).toBe(true);
```

### Testing actions and rejection

```ts theme={"theme":"css-variables"}
// Valid action
await harness.sendAction({ type: "undo" });

// Invalid action — schema validation fails, error chunk emitted
const turn = await harness.sendAction({ type: "not-a-real-action" });
const errors = turn.rawChunks.filter((c) =>
  typeof c === "object" && c !== null &&
  (c as { type?: string }).type === "error"
);
expect(errors.length).toBeGreaterThan(0);
```

### Multi-turn accumulation

The harness preserves chat history across turns, just like the real runtime:

```ts theme={"theme":"css-variables"}
const seenLengths: number[] = [];
const agent = chat.agent({
  id: "multi-turn",
  run: async ({ messages, signal }) => {
    seenLengths.push(messages.length);
    return streamText({ model, messages, abortSignal: signal });
  },
});

const harness = mockChatAgent(agent, { chatId: "t" });
await harness.sendMessage(userMessage("first"));
await harness.sendMessage(userMessage("second"));
await harness.sendMessage(userMessage("third"));

// Turn 1: 1 message; turn 2: user + assistant + user = 3; turn 3: 5
expect(seenLengths).toEqual([1, 3, 5]);
```

### Hydrating from a "database"

Use `clientData` to seed a synthetic prior context for `hydrateMessages`:

```ts theme={"theme":"css-variables"}
const hydrated = [
  { id: "h1", role: "user", parts: [{ type: "text", text: "prior question" }] },
  { id: "h2", role: "assistant", parts: [{ type: "text", text: "prior answer" }] },
];

const harness = mockChatAgent(agent, {
  chatId: "test-hydrate",
  clientData: { model, hydrated: [...hydrated, userMessage("follow up")] },
});

await harness.sendMessage(userMessage("follow up"));

// Model should have been called with the hydrated context
expect(model.doStreamCalls[0]!.prompt.length).toBeGreaterThanOrEqual(3);
```

The agent reads `clientData.hydrated` inside its `hydrateMessages` hook:

```ts theme={"theme":"css-variables"}
hydrateMessages: async ({ clientData, incomingMessages }) => {
  return clientData?.hydrated ?? incomingMessages;
},
```

### Testing continuation runs

A continuation run is a new run picking up an existing session after the prior run ended — `chat.endRun`, waitpoint timeout, or `chat.requestUpgrade`. The contract differs from a fresh run in two ways:

* `onChatStart` does **not** fire (it's once-per-chat — fires only on the chat's very first user message ever).
* The boot payload arrives with `continuation: true` and no `message`. The SDK waits silently on `session.in` until the next user message arrives.

Pass `continuation: true` to drive this path:

```ts theme={"theme":"css-variables"}
const onChatStart = vi.fn();
const onTurnStart = vi.fn();

const agent = chat.agent({
  id: "my-chat",
  onChatStart,
  onTurnStart,
  run: async ({ messages, signal }) =>
    streamText({ model, messages, abortSignal: signal }),
});

const harness = mockChatAgent(agent, {
  chatId: "test-continuation",
  // Auto-selects `mode: "continuation"` — boots with `trigger` omitted
  // and `continuation: true` in the wire payload, exactly as the server
  // produces it on continuation runs in production.
  continuation: true,
  previousRunId: "run_test_prior",
});

try {
  // The SDK enters continuation-wait; sendMessage wakes it and drives turn 0.
  await harness.sendMessage({
    id: "u1",
    role: "user",
    parts: [{ type: "text", text: "where were we?" }],
  });
  await new Promise((r) => setTimeout(r, 20));

  expect(onChatStart).not.toHaveBeenCalled();
  expect(onTurnStart).toHaveBeenCalledTimes(1);
} finally {
  await harness.close();
}
```

To simulate an **OOM-retry attempt** (also a continuation by contract — same `onChatStart` skip), bump `ctx.attempt.number`:

```ts theme={"theme":"css-variables"}
const harness = mockChatAgent(agent, {
  chatId: "test-oom-retry",
  taskContext: {
    ctx: { attempt: { number: 2, startedAt: new Date(0), status: "EXECUTING" } },
  },
});

await harness.sendMessage(/* ... */);
expect(onChatStart).not.toHaveBeenCalled();
```

### Testing recovery boot

`onRecoveryBoot` fires when the dead predecessor left state behind — a partial assistant on `session.out`, in-flight users on `session.in`, or both. The harness exposes two seeders to drive this state at boot time:

* `harness.seedSessionOutPartial(message)` — pre-seed a trailing partial assistant. The next boot's replay surfaces it as `event.partialAssistant`.
* `harness.seedSessionInTail(messages)` — pre-seed user messages on the input tail. The next boot's replay surfaces them as `event.inFlightUsers`.

Combined with `continuation: true`, this drives the full recovery boot path:

```ts theme={"theme":"css-variables"}
import { mockChatAgent } from "@trigger.dev/sdk/ai/test";

const onRecoveryBoot = vi.fn(async () => {
  // accept smart default
});

const agent = chat.agent({
  id: "my-chat",
  onRecoveryBoot,
  run: async ({ messages, signal }) =>
    streamText({ model, messages, abortSignal: signal }),
});

const harness = mockChatAgent(agent, {
  chatId: "test-recovery",
  continuation: true,
  previousRunId: "run_prior",
});

// Predecessor was answering "write an essay" and got cut off mid-stream
// after producing some text. Customer then sent a follow-up.
harness.seedSessionOutPartial({
  id: "a-orphan",
  role: "assistant",
  parts: [{ type: "text", text: "Espresso originated in..." }],
});
harness.seedSessionInTail([
  { id: "u-1", role: "user", parts: [{ type: "text", text: "Write an essay about espresso." }] },
  { id: "u-2", role: "user", parts: [{ type: "text", text: "keep going" }] },
]);

await new Promise((r) => setTimeout(r, 50));

expect(onRecoveryBoot).toHaveBeenCalledTimes(1);
const event = onRecoveryBoot.mock.calls[0]![0];
expect(event.partialAssistant?.id).toBe("a-orphan");
expect(event.inFlightUsers).toHaveLength(2);
```

Use `harness.seedSnapshot({ messages: [...] })` alongside these to model a continuation where settled history exists. See the [Recovery boot](/docs/ai-chat/patterns/recovery-boot) pattern for what each field means and what the smart default does with it.

## Testing against a database

Most agents call into a database from `hydrateMessages` or `onTurnComplete` to load history and persist replies. You shouldn't pass database clients through `clientData` — that's wire-data from the browser. Use **`locals` for dependency injection** instead.

`locals` are task-scoped, server-side only, and untyped to the wire format. The mock harness exposes a `setupLocals` callback that pre-seeds them before the agent's `run()` starts.

### Define a locals key for the dependency

Create a single key per dependency, exported from your project:

```ts db.ts theme={"theme":"css-variables"}
import { locals } from "@trigger.dev/sdk";
import { PrismaClient } from "@prisma/client";

export type Db = PrismaClient;
export const dbKey = locals.create<Db>("db");

export function getDb(): Db {
  // Returns the seeded test instance if present, otherwise lazy-creates prod.
  return locals.get(dbKey) ?? locals.set(dbKey, new PrismaClient());
}
```

### Use the dependency from agent hooks

Hooks read from `locals` instead of constructing clients themselves:

```ts trigger/agent.ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { getDb } from "../db";

export const agent = chat.agent({
  id: "agent",
  hydrateMessages: async ({ chatId }) => {
    const db = getDb();
    const row = await db.chat.findUnique({ where: { id: chatId } });
    return (row?.messages as UIMessage[]) ?? [];
  },
  onTurnComplete: async ({ chatId, messages }) => {
    const db = getDb();
    await db.chat.upsert({
      where: { id: chatId },
      create: { id: chatId, messages },
      update: { messages },
    });
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

### Inject a test database in the harness

`setupLocals` runs *before* the agent starts, so `getDb()` returns the test instance for every hook:

```ts agent.test.ts theme={"theme":"css-variables"}
import { mockChatAgent } from "@trigger.dev/sdk/ai/test";
import { dbKey } from "./db";
import { agent } from "./trigger/agent";

const harness = mockChatAgent(agent, {
  chatId: "test-1",
  setupLocals: ({ set }) => {
    set(dbKey, testDb); // testDb = your testcontainers Prisma client, sqlite stub, etc.
  },
});
```

### Pick a backing database

You still need to decide what `testDb` actually is:

* **Testcontainers (recommended).** Spin up Postgres in Docker via `@internal/testcontainers` (or `testcontainers` directly), run migrations, hand the resulting `PrismaClient` to `set(dbKey, ...)`. Highest fidelity — catches schema drift, migration bugs, transaction issues.
* **Embedded SQLite / PGlite.** Fast and no Docker, but a different SQL dialect from production. Fine for hooks that only do simple CRUD; risky for raw SQL or Postgres-specific features.
* **In-memory fake.** Hand-rolled object with the same interface as your DB module. Fastest, lowest fidelity — works when you only care about whether the agent *called* the right method, not what the DB *did* with it.

### Drizzle, Kysely, etc.

The pattern is the same — replace `PrismaClient` with your client class:

```ts db.ts theme={"theme":"css-variables"}
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";

export type Db = ReturnType<typeof drizzle>;
export const dbKey = locals.create<Db>("db");

export function getDb(): Db {
  return locals.get(dbKey) ?? locals.set(
    dbKey,
    drizzle(new Pool({ connectionString: process.env.DATABASE_URL })),
  );
}
```

<Tip>
  The same `setupLocals` pattern works for any server-side dependency: feature flag clients, Stripe SDK, internal HTTP clients, Sentry. Anything you'd normally inject via constructor parameters in a class-based design.
</Tip>

## API reference

### mockChatAgent(agent, options?)

```ts theme={"theme":"css-variables"}
function mockChatAgent(
  agent: { id: string },
  options?: MockChatAgentOptions,
): MockChatAgentHarness;
```

#### MockChatAgentOptions

| Option          | Type                                                                    | Default       | Description                                                                                                                                                                                                                                                                                      |
| --------------- | ----------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `chatId`        | `string`                                                                | `"test-chat"` | Chat session id passed in every wire payload.                                                                                                                                                                                                                                                    |
| `clientData`    | `unknown`                                                               | `undefined`   | Client-provided data forwarded to `run()` and every hook.                                                                                                                                                                                                                                        |
| `taskContext`   | `MockTaskContextOptions`                                                | `{}`          | Overrides for the mock `TaskRunContext`. Use `ctx.attempt.number > 1` to simulate an OOM-retry attempt — the agent skips `onChatStart` (same as continuation runs).                                                                                                                              |
| `preload`       | `boolean`                                                               | `true`        | Start in preload mode. When `false`, the first `sendMessage()` starts turn 0 directly without preload. Ignored when `mode` is set explicitly.                                                                                                                                                    |
| `mode`          | `"preload" \| "submit-message" \| "handover-prepare" \| "continuation"` | derived       | Initial boot trigger. Defaults to `"preload"` (or `"submit-message"` when `preload: false`, or `"continuation"` when `continuation: true`). See [Boot modes](#boot-modes) below.                                                                                                                 |
| `continuation`  | `boolean`                                                               | `false`       | Boot as a continuation run (a new run on an existing session). Auto-selects `mode: "continuation"` if `mode` is not set — boots with `trigger` omitted and `continuation: true` in the payload, exercising the SDK's continuation-wait branch. `onChatStart` does NOT fire on continuation runs. |
| `previousRunId` | `string`                                                                | `undefined`   | Set `payload.previousRunId` on the initial wire payload. Typically paired with `continuation: true`.                                                                                                                                                                                             |
| `snapshot`      | `ChatSnapshotV1`                                                        | `undefined`   | Pre-seed the snapshot the agent reads at run boot (replaces the real S3 GET). Use to drive resume scenarios with prior history. See [Persistence and replay](/docs/ai-chat/patterns/persistence-and-replay) for the production snapshot model.                                                        |
| `setupLocals`   | `({ set }) => void \| Promise<void>`                                    | `undefined`   | Callback invoked before `run()` starts. Use `set(key, value)` to inject server-side dependencies (DB clients, service stubs) that the agent reads via `locals.get()`.                                                                                                                            |

##### Boot modes

The harness's initial wire payload depends on `mode`:

| Mode                 | Wire payload                            | Use when                                                                                                                                                                                                                                                                                                       |
| -------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"preload"`          | `{ trigger: "preload" }`                | Simulating a `transport.preload(chatId)` warm-up. Fires `onPreload`, waits for the first `sendMessage()`.                                                                                                                                                                                                      |
| `"submit-message"`   | `{ trigger: "submit-message" }`         | Skipping preload — `sendMessage()` drives turn 0 directly.                                                                                                                                                                                                                                                     |
| `"continuation"`     | `{ continuation: true }` (no `trigger`) | A new run picking up an existing session after the prior run ended (`chat.endRun`, waitpoint timeout, `chat.requestUpgrade`). Mirrors the boot payload the server's `ensureRunForSession` / `swapSessionRun` produce. The SDK enters its continuation-wait branch — `onPreload` and `onChatStart` do NOT fire. |
| `"handover-prepare"` | `{ trigger: "handover-prepare" }`       | Driving the `chat.handover` wait path. Use `sendHandover()` / `sendHandoverSkip()` to dispatch the handover signal.                                                                                                                                                                                            |

#### MockChatAgentHarness

| Member                                                            | Description                                                                                                                                                                                     |
| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chatId`                                                          | The chat session id used by this harness.                                                                                                                                                       |
| `sendMessage(message)`                                            | Send a single user message (or tool-approval-responded assistant message). Slim wire: at most ONE message per record. Returns the chunks produced during the resulting turn.                    |
| `sendRegenerate()`                                                | Send a regenerate-message trigger (no body — slim wire). The agent trims trailing assistant messages from its accumulator and re-runs.                                                          |
| `sendHeadStart({ messages })`                                     | Drive the head-start path: sends `trigger: "handover-prepare"` with `headStartMessages` carrying the first-turn UIMessage history. Used only at the very first turn before any snapshot exists. |
| `sendHandover({ partialAssistantMessage, isFinal?, messageId? })` | Dispatch a `handover` signal — only meaningful when started with `mode: "handover-prepare"`. The agent picks up partial assistant messages and continues the turn.                              |
| `sendHandoverSkip()`                                              | Dispatch a `handover-skip` signal — only meaningful when started with `mode: "handover-prepare"`. The agent exits cleanly without firing turn hooks.                                            |
| `sendAction(action)`                                              | Route a custom action through `actionSchema` + `onAction`.                                                                                                                                      |
| `sendStop(message?)`                                              | Fire a stop signal. Does not wait for the turn — the run's `signal.aborted` becomes `true`.                                                                                                     |
| `seedSnapshot(snapshot)`                                          | Pre-seed the snapshot read for the next boot. Effective on the next run boot only.                                                                                                              |
| `seedSessionOutTail(chunks?)`                                     | Pre-seed `session.out` chunks for the next boot's replay. Reduces to settled assistant turns.                                                                                                   |
| `seedSessionOutPartial(message?)`                                 | Pre-seed a trailing partial assistant for the next boot's replay. Surfaces as `event.partialAssistant` in `onRecoveryBoot`.                                                                     |
| `seedSessionInTail(messages)`                                     | Pre-seed user messages on `session.in` for the next boot. Surfaces as `event.inFlightUsers` in `onRecoveryBoot`.                                                                                |
| `getSnapshot()`                                                   | The most recently written snapshot, or `undefined` if no snapshot was written.                                                                                                                  |
| `close()`                                                         | Send a `close` trigger, abort the signal, wait for `run()` to return. Always call at end of test.                                                                                               |
| `allChunks`                                                       | Every `UIMessageChunk` emitted since the harness was created.                                                                                                                                   |
| `allRawChunks`                                                    | Every raw chunk emitted since creation, including control chunks (`trigger:turn-complete`, errors).                                                                                             |

### runInMockTaskContext

`mockChatAgent` is a higher-level wrapper around `runInMockTaskContext`, re-exported from `@trigger.dev/sdk/ai/test` so you don't need to depend on `@trigger.dev/core` directly. Use it when you need to drive a non-chat task offline:

```ts theme={"theme":"css-variables"}
import { runInMockTaskContext } from "@trigger.dev/sdk/ai/test";

await runInMockTaskContext(
  async ({ inputs, outputs, ctx }) => {
    setTimeout(() => {
      inputs.send("chat-messages", { messages: [], chatId: "c1" });
    }, 0);

    await myTask.fns.run(payload, {
      ctx,
      signal: new AbortController().signal,
    });

    expect(outputs.chunks("chat")).toContainEqual(
      expect.objectContaining({ type: "text-delta", delta: "hi" }),
    );
  },
  { ctx: { run: { id: "run_abc" } } },
);
```

## Limitations

* **No network.** The mock task context replaces realtime streams, run metadata, lifecycle managers, and the runtime. Anything that bypasses these (raw `fetch`, direct DB clients) runs against the real network.
* **Single agent per process.** The resource catalog is process-global; tests within a file are sequential by default. If you parallelize across files, vitest runs each file in its own worker, which avoids registry collisions.
* **Time-sensitive hooks.** `onTurnComplete` runs *after* the `turn-complete` chunk is written, so `sendMessage()` resolves before that hook finishes. Add a brief `await new Promise((r) => setTimeout(r, 20))` if you need to assert on hook side-effects.
* **No real LLM.** The harness does not call providers — you must inject `MockLanguageModelV3` (or another mock) yourself.


# Tools
Source: https://trigger.dev/docs/ai-chat/tools

Declare tools on chat.agent so toModelOutput survives across turns, get them back typed in run(), and type your messages from them.

`chat.agent` doesn't call the model for you. Your tools still go to [`streamText`](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling) inside `run()`. But you should **also declare them on the agent config**:

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs, tool } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
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, // ← declare here
  run: async ({ messages, tools, signal }) =>
    streamText({
      ...chat.toStreamTextOptions({ tools }), // ← the same set, handed back on the payload
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    }),
});
```

Declaring `tools` on the config does two things you can't get by passing them to `streamText` alone:

* It threads your tools into the SDK's internal message conversion, so each tool's [`toModelOutput`](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling#tomodeloutput) is re-applied when prior-turn history is re-converted (see [`toModelOutput` across turns](#tomodeloutput-across-turns)).
* It hands the resolved set back, typed, on the `run()` payload as `tools`, so you declare them once and don't re-import the map.

## Where tools go

There are three places a tool set shows up. Declare once, reuse:

| Surface                               | What it's for                                                                                                                                                              |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chat.agent({ tools })`               | Re-applies `toModelOutput` on prior-turn history; hands the set back typed on the `run()` payload.                                                                         |
| `chat.toStreamTextOptions({ tools })` | Detects which tool calls need [HITL approval](/docs/ai-chat/patterns/human-in-the-loop) (`needsApproval`) and merges any auto-injected [skill](/docs/ai-chat/patterns/skills) tools. |
| `streamText({ tools })`               | What the model actually calls. `chat.toStreamTextOptions({ tools })` already sets this, so spread it instead of passing `tools` twice.                                     |

The canonical pattern: declare `tools` on the config, read them back from the `run()` payload, and pass that to `chat.toStreamTextOptions({ tools })`. One declaration flows everywhere.

<Tip>
  Conversion only reads each tool's `inputSchema` and `toModelOutput`, never `execute`. If you keep heavy `execute` dependencies out of a module (for bundle reasons), you can declare a lightweight schema-only tool map on the config and add the executes where you call `streamText`.
</Tip>

## `toModelOutput` across turns

`toModelOutput` transforms a tool's result before it enters the model's context, turning raw image bytes into an image content part, or compressing a long sub-agent transcript into a one-line summary. The full result still streams to the frontend; the model only sees the transformed version.

The catch is multi-turn. After each turn, `chat.agent` persists the conversation as `UIMessage[]` and re-converts it to model messages at the start of the next turn. That conversion needs your tools to find each `toModelOutput`. **If you only pass tools to `streamText` and not to the config, the transform runs on turn 1 but is skipped on every later turn.** The raw output gets stringified back into the prompt instead, and the model loses the transformed view.

Declaring `tools` on the config fixes this: the SDK threads them into the conversion, so `toModelOutput` is re-applied on every turn.

```ts theme={"theme":"css-variables"}
const tools = {
  renderChart: tool({
    description: "Render a chart and return it as an image.",
    inputSchema: z.object({ spec: z.string() }),
    execute: async ({ spec }) => renderToPng(spec), // raw bytes
    // The model should see an image part, not base64 bytes:
    toModelOutput: ({ output }) => ({
      type: "content",
      value: [{ type: "media", mediaType: "image/png", data: output.base64 }],
    }),
  }),
};

export const chartChat = chat.agent({
  id: "chart-chat",
  tools, // ← without this, the image is "remembered" on turn 1 and gone from turn 2
  run: async ({ messages, tools, signal }) =>
    streamText({
      ...chat.toStreamTextOptions({ tools }),
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    }),
});
```

## Static or per-turn tools

`tools` accepts either a static `ToolSet` or a function that returns one per turn, for tools that depend on the user, a feature flag, or anything in the turn context:

```ts theme={"theme":"css-variables"}
export const myChat = chat
  .withClientData({ schema: z.object({ userId: z.string(), plan: z.string() }) })
  .agent({
    id: "my-chat",
    tools: ({ clientData }) => ({
      searchDocs,
      ...(clientData?.plan === "pro" ? { deepResearch } : {}),
    }),
    run: async ({ messages, tools, signal }) =>
      streamText({
        ...chat.toStreamTextOptions({ tools }),
        model: anthropic("claude-sonnet-4-5"),
        messages,
        abortSignal: signal,
        stopWhen: stepCountIs(15),
      }),
  });
```

The function receives a `ResolveToolsEvent` and runs once per turn (after `clientData` is parsed):

| Field          | Type          | Description                                      |
| -------------- | ------------- | ------------------------------------------------ |
| `chatId`       | `string`      | The chat session ID.                             |
| `turn`         | `number`      | The current turn number (0-indexed).             |
| `continuation` | `boolean`     | Whether this run is continuing an existing chat. |
| `clientData`   | `TClientData` | Parsed client data from the frontend.            |

The resolved set is what lands on the `run()` payload's `tools`.

## Typed tools in `run()`

The `run()` payload's `tools` is typed to whatever you declared, so you can pass it straight through without re-importing the map:

```ts theme={"theme":"css-variables"}
run: async ({ messages, tools, signal }) => {
  // `tools` is typed as your tool set, not a broad `ToolSet`
  return streamText({
    ...chat.toStreamTextOptions({ tools }),
    model: anthropic("claude-sonnet-4-5"),
    messages,
    abortSignal: signal,
  });
};
```

When no `tools` are declared, the payload's `tools` is an empty object and behaves exactly as before, so declaring tools is fully opt-in.

## Typing messages from your tools

To get typed tool parts (`tool-${name}` with typed input/output) on your `UIMessage`, in hooks like `onTurnComplete` and on the frontend, derive the message type from your tool set with `InferChatUIMessageFromTools`:

```ts theme={"theme":"css-variables"}
import type { InferChatUIMessageFromTools } from "@trigger.dev/sdk/ai";

const tools = { searchDocs, renderChart };

export type ChatUiMessage = InferChatUIMessageFromTools<typeof tools>;
```

This is shorthand for `UIMessage<unknown, UIDataTypes, InferUITools<typeof tools>>`. Pin it on the agent with [`chat.withUIMessage<ChatUiMessage>()`](/docs/ai-chat/types#custom-uimessage-with-chat-withuimessage) and reuse it on the client. If you also have custom `data-*` parts, build the `UIMessage` generic directly instead. See [Types](/docs/ai-chat/types).

## Skills

[Agent skills](/docs/ai-chat/patterns/skills) are auto-injected as tools (`loadSkill`, `readFile`, `bash`) by `chat.toStreamTextOptions()`. They're separate from your config `tools`: declare your own tools on the config (so their `toModelOutput` survives across turns), and let `toStreamTextOptions` merge the skill tools on top at call time. Skill tools don't define `toModelOutput`, so they don't need to be on the config.

## Manual turn loops (`chat.customAgent`)

The `tools` config option belongs to the managed [`chat.agent`](/docs/ai-chat/backend#chat-agent). When you drive the loop yourself with [`chat.customAgent`](/docs/ai-chat/custom-agents#chat-customagent) (or build messages from `chat.history`), you own the conversion, so pass your tools to `convertToModelMessages` directly to get the same cross-turn `toModelOutput` behavior:

```ts theme={"theme":"css-variables"}
import { convertToModelMessages, streamText } from "ai";

// Inside your loop, with `tools` in scope:
const uiMessages = chat.history.all();
const messages = await convertToModelMessages(uiMessages, {
  tools,
  ignoreIncompleteToolCalls: true,
});

return streamText({ model: anthropic("claude-sonnet-4-5"), messages, tools });
```

## Learn more

* [Human-in-the-loop](/docs/ai-chat/patterns/human-in-the-loop): tools that pause for approval.
* [Sub-agents](/docs/ai-chat/patterns/sub-agents): tools that delegate to other agents and compress their output with `toModelOutput`.
* [Tool result auditing](/docs/ai-chat/patterns/tool-result-auditing): logging tool results as they resolve.
* [AI SDK: Tools and tool calling](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling).


# Types
Source: https://trigger.dev/docs/ai-chat/types

TypeScript types for AI Agents, UI messages, and the frontend transport.

TypeScript patterns for [AI Chat](/docs/ai-chat/overview). This page covers how to pin a custom AI SDK [`UIMessage`](https://sdk.vercel.ai/docs/reference/ai-sdk-core/ui-message) subtype with `chat.withUIMessage`, fix a typed `clientData` schema with `chat.withClientData`, chain builder-level hooks, and align types on the client.

## Custom `UIMessage` with `chat.withUIMessage`

`chat.agent()` types the wire payload with the base AI SDK `UIMessage`. That is enough for many apps.

When you add **custom `data-*` parts** (via `chat.stream` / `writer`) or a **typed tool map** (e.g. `InferUITools<typeof tools>`), you want a **narrower** `UIMessage` generic so that:

* `onTurnStart`, `onTurnComplete`, and similar hooks expose correctly typed `uiMessages`
* Stream options like `sendReasoning` align with your message shape
* The frontend can treat `useChat` messages as the same subtype end-to-end

`chat.withUIMessage<YourUIMessage>(config?)` returns a [ChatBuilder](#chatbuilder) where `.agent(...)` accepts the **same options as** [`chat.agent()`](/docs/ai-chat/backend#chat-agent) but fixes `YourUIMessage` as the UI message type for that chat agent.

### Defining a `UIMessage` subtype

Build the type from AI SDK helpers and your tools object:

```ts theme={"theme":"css-variables"}
import type { InferUITools, UIDataTypes, UIMessage } from "ai";
import { tool, stepCountIs } from "ai";
import { z } from "zod";

const myTools = {
  lookup: tool({
    description: "Look up a record",
    inputSchema: z.object({ id: z.string() }),
    execute: async ({ id }) => ({ id, label: "example" }),
  }),
};

type MyChatTools = InferUITools<typeof myTools>;

type MyChatDataTypes = UIDataTypes & {
  "turn-status": { status: "preparing" | "streaming" | "done" };
};

export type MyChatUIMessage = UIMessage<unknown, MyChatDataTypes, MyChatTools>;
```

<Tip>
  If you don't need custom `data-*` parts, [`InferChatUIMessageFromTools<typeof myTools>`](/docs/ai-chat/tools#typing-messages-from-your-tools) from `@trigger.dev/sdk/ai` collapses the tools half into one line (it's shorthand for `UIMessage<unknown, UIDataTypes, InferUITools<typeof myTools>>`).
</Tip>

Task-backed tools should use AI SDK [`tool()`](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling) with `execute: ai.toolExecute(schemaTask)` where needed — see [Task-backed AI tools](/docs/tasks/schemaTask#task-backed-ai-tools).

### Backend: `chat.withUIMessage(...).agent(...)`

Call `withUIMessage` **once**, then chain `.agent({ ... })` instead of `chat.agent({ ... })`. You can also chain `.withClientData()` and hook methods before `.agent()`:

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, tool } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
import type { MyChatUIMessage } from "./my-chat-types";

const myTools = {
  lookup: tool({
    description: "Look up a record",
    inputSchema: z.object({ id: z.string() }),
    execute: async ({ id }) => ({ id, label: "example" }),
  }),
};

export const myChat = chat
  .withUIMessage<MyChatUIMessage>({
    streamOptions: {
      sendReasoning: true,
      onError: (error) =>
        error instanceof Error ? error.message : "Something went wrong.",
    },
  })
  .withClientData({
    schema: z.object({ userId: z.string() }),
  })
  .agent({
    id: "my-chat",
    tools: myTools,
    onTurnStart: async ({ uiMessages, writer }) => {
      // uiMessages is MyChatUIMessage[] — custom data parts are typed
      writer.write({
        type: "data-turn-status",
        data: { status: "preparing" },
      });
    },
    run: async ({ messages, tools, signal }) => {
      // `tools` is myTools, typed, handed back on the payload
      return streamText({
        model: anthropic("claude-sonnet-4-5"),
        messages,
        tools,
        abortSignal: signal,
        stopWhen: stepCountIs(15),
      });
    },
  });
```

### Default stream options

The optional `streamOptions` object becomes the **default** [`uiMessageStreamOptions`](/docs/ai-chat/reference#chatagentoptions) for `toUIMessageStream()`.

If you also set `uiMessageStreamOptions` on the inner `.agent({ ... })`, the two objects are **shallow-merged** — keys on the **agent** win on conflicts. Per-turn overrides via [`chat.setUIMessageStreamOptions()`](/docs/ai-chat/backend#stream-options) still apply on top.

### Frontend: `InferChatUIMessage`

Import the helper type and pass it to `useChat` so `messages` and render logic match the backend:

```tsx theme={"theme":"css-variables"}
import { useChat } from "@ai-sdk/react";
import { useTriggerChatTransport, type InferChatUIMessage } from "@trigger.dev/sdk/chat/react";
import type { myChat } from "./myChat";

type Msg = InferChatUIMessage<typeof myChat>;

export function Chat() {
  const transport = useTriggerChatTransport<typeof myChat>({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  });

  const { messages } = useChat<Msg>({ transport });

  return messages.map((m) => (
    <div key={m.id}>{/* m.parts narrowed for your UIMessage subtype */}</div>
  ));
}
```

You can also import `InferChatUIMessage` from `@trigger.dev/sdk/ai` in non-React modules.

## Typed client data with `chat.withClientData`

`chat.withClientData({ schema })` returns a [ChatBuilder](#chatbuilder) that fixes the client data schema. All hooks and `run` receive typed `clientData` without needing `clientDataSchema` in `.agent()` options.

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { z } from "zod";

export const myChat = chat
  .withClientData({
    schema: z.object({ userId: z.string(), model: z.string().optional() }),
  })
  .agent({
    id: "my-chat",
    onPreload: async ({ clientData }) => {
      // clientData is typed as { userId: string; model?: string }
      await initUser(clientData.userId);
    },
    run: async ({ messages, clientData, signal }) => {
      return streamText({
        model: getModel(clientData.model),
        messages,
        abortSignal: signal,
        stopWhen: stepCountIs(15),
      });
    },
  });
```

## ChatBuilder

Both `chat.withUIMessage()` and `chat.withClientData()` return a **ChatBuilder** — a chainable object that accumulates configuration before creating the agent with `.agent()`.

Builder methods can be chained in any order:

```ts theme={"theme":"css-variables"}
export const myChat = chat
  .withUIMessage<MyChatUIMessage>({
    streamOptions: { sendReasoning: true },
  })
  .withClientData({
    schema: z.object({ userId: z.string() }),
  })
  .onChatSuspend(async ({ ctx }) => {
    await disposeCodeSandbox(ctx.run.id);
  })
  .onChatResume(async ({ ctx }) => {
    warmCache(ctx.run.id);
  })
  .agent({
    id: "my-chat",
    run: async ({ messages, signal }) => {
      return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
    },
  });
```

### Builder-level hooks

All [lifecycle hooks](/docs/ai-chat/lifecycle-hooks) can be set on the builder: `onPreload`, `onChatStart`, `onTurnStart`, `onBeforeTurnComplete`, `onTurnComplete`, `onCompacted`, `onChatSuspend`, `onChatResume`.

Builder hooks and task-level hooks **coexist**. When both are defined for the same event, the builder hook runs first, then the task hook:

```ts theme={"theme":"css-variables"}
chat
  .withUIMessage<MyChatUIMessage>()
  .onPreload(async (event) => {
    // Runs first — shared setup across tasks using this builder
    await initializeSharedState(event.chatId);
  })
  .agent({
    id: "my-chat",
    onPreload: async (event) => {
      // Runs second — task-specific logic
      await createChatRecord(event.chatId);
    },
    run: async ({ messages, signal }) => {
      return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
    },
  });
```

<Tip>
  Set types first (`.withUIMessage()`, `.withClientData()`), then hooks. Hook parameters are typed based on the builder's current generics — so hooks registered after `.withClientData()` get typed `clientData`.
</Tip>

### When plain `chat.agent()` is enough

If you do not rely on custom `UIMessage` generics (only default text, reasoning, and built-in tool UI types), **`chat.agent()` alone is fine** — no need for `withUIMessage`.

## See also

* [Backend — `chat.agent()`](/docs/ai-chat/backend#chat-agent)
* [Lifecycle hooks](/docs/ai-chat/lifecycle-hooks)
* [Frontend — transport & `useChat`](/docs/ai-chat/frontend)
* [API reference — `chat.withUIMessage`](/docs/ai-chat/reference#chat-withuimessage)
* [API reference — `chat.withClientData`](/docs/ai-chat/reference#chat-withclientdata)
* [Task-backed AI tools — `ai.toolExecute`](/docs/tasks/schemaTask#task-backed-ai-tools)


# Upgrade Guide: prerelease → Sessions-as-run-manager
Source: https://trigger.dev/docs/ai-chat/upgrade-guide

Migrating chat.agent code from the prerelease API to the Sessions-as-run-manager release.

This guide is for customers who tried `chat.agent` during the prerelease period.
The public surface of `chat.agent({...})`, `useTriggerChatTransport`,
`AgentChat`, `chat.defer`, and `chat.history` is largely
unchanged — but the transport's auth callbacks and the server-side helpers
that feed them were reshaped, so most prerelease apps need a small wiring
update.

## TL;DR

<CodeGroup>
  ```ts before.ts theme={"theme":"css-variables"}
  // Single accessToken callback, dispatches on purpose
  accessToken: async ({ chatId, purpose }) => {
    if (purpose === "trigger") {
      return chat.createAccessToken<typeof myChat>("my-chat");
    }
    // purpose === "preload" — same call, same trigger token
    return chat.createAccessToken<typeof myChat>("my-chat");
  };
  ```

  ```ts after.ts theme={"theme":"css-variables"}
  // Two callbacks: pure refresh + server action that creates the session
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
  startSession: ({ chatId, clientData }) =>
        startChatSession({ chatId, clientData }),
  ```
</CodeGroup>

What changed:

* `accessToken` is now a **pure session-PAT mint** — called only on 401/403
  to refresh. It must return a token scoped to the session, not a
  `trigger:tasks` JWT.
* `startSession` is a **new callback** that wraps a server action calling
  `chat.createStartSessionAction(taskId)`. The transport invokes it on
  `transport.preload(chatId)` and lazily on the first `sendMessage` for
  any chatId without a cached PAT.
* `ChatSession` persistable state drops `runId` — store only
  `{publicAccessToken, lastEventId?}`.
* Per-call options on `transport.preload(chatId, ...)` are gone. Trigger
  config (machine, idleTimeoutInSeconds, tags, queue, maxAttempts) lives
  server-side in `chat.createStartSessionAction(taskId, options)`.

<Note>
  The architectural shift is that `chat.agent` no longer rolls its own
  per-run streams. It runs on top of a durable **Session** row that owns
  its current run, persists across run lifecycles, and orchestrates
  upgrades server-side. The customer-facing surface is similar; the wire
  path beneath it changed completely.
</Note>

## Step 1: Replace your access-token server action with two server actions

The old pattern was a single helper that minted a trigger token:

```ts app/actions.ts (before) theme={"theme":"css-variables"}
"use server";

import { chat } from "@trigger.dev/sdk/ai";
import type { myChat } from "@/trigger/chat";

export const getChatToken = () =>
  chat.createAccessToken<typeof myChat>("my-chat");
```

Replace with two helpers — one for session creation, one for PAT refresh:

```ts app/actions.ts (after) theme={"theme":"css-variables"}
"use server";

import { auth } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";

// Server-side wrapper for session creation. Idempotent on (env, chatId).
// The customer's server is the only entry point that creates Session rows;
// the browser never holds a `trigger:tasks` JWT.
export const startChatSession = chat.createStartSessionAction("my-chat");

// Pure session-PAT mint for the transport's 401/403 retry path.
export async function mintChatAccessToken(chatId: string) {
  return auth.createPublicToken({
    scopes: {
      read: { sessions: chatId },
      write: { sessions: chatId },
    },
    expirationTime: "1h",
  });
}
```

`chat.createStartSessionAction(taskId)` returns a server action that:

1. Creates the Session row for `chatId` (idempotent on the
   `(env, externalId)` unique pair).
2. Triggers the agent task's first run with
   `basePayload: {messages: [], trigger: "preload"}` defaults plus any
   overrides you pass.
3. Returns `{sessionId, runId, publicAccessToken}` to the browser.

## Step 2: Update the transport wiring

The transport now takes two callbacks instead of one:

```tsx app/components/chat.tsx (after) theme={"theme":"css-variables"}
"use client";

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",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  });

  const { messages, sendMessage, status } = useChat({ transport });
  // ...
}
```

The transport calls them in two distinct flows:

| Trigger                                                          | Callback fired              |
| ---------------------------------------------------------------- | --------------------------- |
| `transport.preload(chatId)`                                      | `startSession`              |
| First `sendMessage` for a chatId with no cached PAT              | `startSession` (auto)       |
| Any 401/403 from `.in/append`, `.out` SSE, or `end-and-continue` | `accessToken`               |
| Page hydrates with `sessions: { [chatId]: ... }`                 | Neither (uses hydrated PAT) |

`startSession` is deduped via an in-flight promise — concurrent
`preload` + `sendMessage` calls converge to one server action invocation.

## Step 3: Drop transport-level trigger config

The prerelease transport accepted `triggerConfig`, `triggerOptions`, and
per-call options on `preload`. All of that moved server-side:

```ts before theme={"theme":"css-variables"}
const transport = useTriggerChatTransport({
  task: "my-chat",
  accessToken: getChatToken,
  triggerConfig: { basePayload: { /* ... */ } },
  triggerOptions: { tags: [...], machine: "small-1x", maxAttempts: 3 },
});

transport.preload(chatId, { idleTimeoutInSeconds: 60, metadata: { ... } });
```

```ts after theme={"theme":"css-variables"}
// Trigger config now lives in chat.createStartSessionAction
export const startChatSession = chat.createStartSessionAction("my-chat", {
  triggerConfig: {
    machine: "small-1x",
    maxAttempts: 3,
    tags: ["my-tag"],
    idleTimeoutInSeconds: 60,
  },
});

// Browser side
const transport = useTriggerChatTransport<typeof myChat>({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
  startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
});

transport.preload(chatId);  // no second arg
```

For metadata that varies per chat, use `clientData` on the transport (see
the next step) — it's typed and threaded through `startSession` automatically.

## Step 4: Use `clientData` for typed payload metadata

If your agent uses `withClientData({schema})`, the transport's `clientData`
option is now the canonical place to set it. The same value:

* Is passed to your `startSession` callback as `params.clientData`, where
  you forward it into `chat.createStartSessionAction`'s
  `triggerConfig.basePayload.metadata`. The agent's first run sees it in
  `payload.metadata` (visible to `onPreload` / `onChatStart`).
* Merges into per-turn `metadata` on every `.in/append` chunk
  (visible to `onTurnStart` / inside `run` via `turn.clientData`).

```tsx theme={"theme":"css-variables"}
const transport = useTriggerChatTransport<typeof myChat>({
  task: "my-chat",
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
  startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  clientData: {
    userId: currentUser.id,
    plan: currentUser.plan,
  },
});
```

The `clientData` value is live-updated when the option changes (the hook
calls `setClientData` under the hood), so dynamic values work without
reconstructing the transport.

<Tip>
  Server-side authorization can still override or augment the
  browser-claimed `clientData` inside `startSession` — never trust the
  browser's identity claim. A typical pattern: the server action looks up
  the user from the request session, then merges the trusted server fields
  on top of `params.clientData`.
</Tip>

## Step 5: Update your `ChatSession` persistence

If you persist session state across page loads, drop the `runId` field:

```ts before theme={"theme":"css-variables"}
type ChatSession = {
  runId: string;
  publicAccessToken: string;
  lastEventId?: string;
};
```

```ts after theme={"theme":"css-variables"}
type ChatSession = {
  publicAccessToken: string;
  lastEventId?: string;
};
```

If your DB has a `runId` column, you can drop it (the transport doesn't
read it) or keep it for telemetry. The current run ID lives on the
Session row server-side now.

Hydration on page reload is unchanged:

```tsx theme={"theme":"css-variables"}
const transport = useTriggerChatTransport<typeof myChat>({
  // ...
  sessions: persistedSession
    ? { [chatId]: persistedSession }
    : {},
});
```

## `chat.requestUpgrade()`: same call, faster handoff

Calling `chat.requestUpgrade()` inside `onTurnStart` /
`onValidateMessages` still ends the current run so the next message starts
on the latest version. What changed is the mechanism:

* **Before:** the agent emitted a `trigger:upgrade-required` chunk on
  `.out`; the transport consumed it browser-side and triggered a new run.
* **After:** the agent calls `endAndContinueSession` server-to-server;
  the webapp triggers a new run and atomically swaps `Session.currentRunId`
  via optimistic locking. The browser's existing SSE subscription keeps
  receiving chunks across the swap — no transport-side bookkeeping.

The new run is recorded in a `SessionRun` audit row with
`reason: "upgrade"` for dashboard provenance.

## Hitting raw URLs

If your code talks to the realtime API directly instead of going through
the SDK, the URL shapes changed:

| Before                                                            | After                                                                                |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `GET /realtime/v1/streams/{runId}/chat`                           | `GET /realtime/v1/sessions/{chatId}/out`                                             |
| `POST /realtime/v1/streams/{runId}/{target}/chat-messages/append` | `POST /realtime/v1/sessions/{chatId}/in/append` (body: `{kind: "message", payload}`) |
| `POST /realtime/v1/streams/{runId}/{target}/chat-stop/append`     | `POST /realtime/v1/sessions/{chatId}/in/append` (body: `{kind: "stop"}`)             |

The session-scoped PAT
(`read:sessions:{chatId} + write:sessions:{chatId}`) authorizes both the
externalId form (`/sessions/my-chat-id/...`) and the friendlyId form
(`/sessions/session_abc.../...`). The transport always uses the
externalId form; the friendlyId form is available for dashboard tooling
and direct API consumers.

## What didn't change

* `chat.agent({...})` definition — `id`, `idleTimeoutInSeconds`,
  `clientDataSchema`, `actionSchema`, `hydrateMessages`, `onPreload`,
  `onChatStart`, `onValidateMessages`, `onTurnStart`, `onTurnComplete`,
  `onChatSuspend`, `run`. All callbacks have the same signature and
  fire at the same lifecycle points.
* `onAction` is still defined the same way, but its semantics changed
  in the [May 6 prerelease](/docs/ai-chat/changelog) — actions are no longer
  turns, and `onAction` returning a `StreamTextResult` produces a model
  response.
* `chat.customAgent({...})` and the `chat.createSession(payload, ...)`
  helper for building a session loop manually inside a custom agent.
* `chat.defer` (deferred work) and `chat.history` (imperative history
  mutations from inside `onAction`).
* `AgentChat` (server-side chat client) — `agent`, `id`, `clientData`,
  `session`, `onTriggered`, `onTurnComplete`, `sendMessage`, `text()`.
* `useTriggerChatTransport` React semantics (created once, kept in a
  ref, callbacks updated under the hood).
* Multi-tab coordination (`multiTab: true`),
  [pending messages / steering](/docs/ai-chat/pending-messages),
  [background injection](/docs/ai-chat/background-injection),
  [compaction](/docs/ai-chat/compaction).
* Per-turn `metadata` flowing through
  `sendMessage({ text }, { metadata })` to `turn.metadata` server-side.

## Verifying the migration

After updating, the smoke check is the same as before: send a message,
confirm the assistant streams a response, reload mid-stream, confirm
resume.

A few new things worth verifying once you've cut over:

* **Eager preload.** Click the button (or call `transport.preload(id)`
  programmatically) — your `startSession` callback should fire and a
  Session row + first run should be created before you send a message.
* **Idle-timeout continuation.** Wait past the agent's
  `idleTimeoutInSeconds` so the run exits, then send another message —
  the transport's `.in/append` should boot a new run on the same
  Session, with a `SessionRun` row of `reason: "continuation"`.
* **PAT refresh.** Force a stale PAT in your DB (corrupt the signature)
  and reload — the first request should 401, your `accessToken`
  callback should fire, and the retry should succeed.

If any of those misfire, check that:

* Your `accessToken` callback returns a token minted via
  `auth.createPublicToken({ scopes: { read: { sessions: chatId }, write: { sessions: chatId } } })`, **not**
  `chat.createAccessToken` or `auth.createTriggerPublicToken`. The
  transport rejects trigger tokens now.
* Your `startSession` callback returns
  `{publicAccessToken: string}` — the result of
  `chat.createStartSessionAction(taskId)({chatId, ...})` already has
  this shape.
* You haven't left a stale `getStartToken` option on the transport;
  it's not part of `TriggerChatTransportOptions` anymore.

## v4.5 wire format change

A second migration lands on top of the Sessions release. v4.5 removes the full-history wire payload — clients now ship at most one new `UIMessage` per `.in/append`, and the agent rebuilds prior history from a durable JSON snapshot in object storage plus a replay of the `session.out` tail.

If you use the built-in `TriggerChatTransport` / `AgentChat` and don't reach into the wire shape directly, **most apps need no changes** — the change is below the customer-facing surface. Customers who built custom transports, hit `/realtime/v1/sessions/{id}/in/append` directly, or rely on specific behaviors of `hydrateMessages` / `onChatStart` should read this section.

### Why the change

Long chats with heavy tool results were hitting the realtime API's 512 KiB body cap on `/in/append` once the accumulated `UIMessage[]` history (which the wire shipped in full on every send) crossed the limit. The 413 surfaced as a CORS error in browsers and stalled chats around turn 10–30 with tool use.

The wire is now **delta-only**: each `.in/append` carries at most one new `UIMessage`. The agent rebuilds prior history at run boot. The 512 KiB ceiling stops being pressure — typical payloads are a few KB regardless of chat length.

### Object-store configuration

Snapshot read/write uses Trigger.dev's existing object-store infrastructure — the same presigned-URL routes used for large payloads. Set the standard `OBJECT_STORE_*` env vars on your webapp deployment if you haven't already; MinIO and S3-compatible stores work via `OBJECT_STORE_DEFAULT_PROTOCOL`.

| Env var                          | Purpose                            |
| -------------------------------- | ---------------------------------- |
| `OBJECT_STORE_BASE_URL`          | Endpoint URL (S3, MinIO, R2, etc.) |
| `OBJECT_STORE_ACCESS_KEY_ID`     | Access key                         |
| `OBJECT_STORE_SECRET_ACCESS_KEY` | Secret key                         |
| `OBJECT_STORE_DEFAULT_PROTOCOL`  | `s3` (default), `minio`, etc.      |

Snapshots are written under `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json`. Each snapshot is small (typically tens of KB) and overwritten every turn — no append-only growth.

<Warning>
  **No object store + no `hydrateMessages` = conversations don't survive run boundaries.** With neither piece of state, a continuation boots empty and the agent can't reconstruct prior turns. Either configure an object store or register `hydrateMessages`. The runtime logs a warning at agent registration time when both are missing.
</Warning>

### Custom transports

If you've built your own transport (Slack bot, CLI, native app) against the [Client Protocol](/docs/ai-chat/client-protocol), the `ChatTaskWirePayload` shape changed:

```ts before theme={"theme":"css-variables"}
type ChatTaskWirePayload = {
  messages: UIMessage[];        // full history
  chatId: string;
  trigger: "submit-message" | "regenerate-message" | "preload" | "close" | "action";
  // ...
};
```

```ts after theme={"theme":"css-variables"}
type ChatTaskWirePayload = {
  message?: UIMessage;          // singular, optional
  headStartMessages?: UIMessage[];  // chat.headStart only, "handover-prepare"
  chatId: string;
  trigger:
    | "submit-message"
    | "regenerate-message"
    | "preload"
    | "close"
    | "action"
    | "handover-prepare";
  // ...
};
```

What to send per trigger:

| Trigger                              | What to put in the payload                                                         |
| ------------------------------------ | ---------------------------------------------------------------------------------- |
| `submit-message`                     | The new user message (or a tool-approval-responded assistant message) in `message` |
| `regenerate-message`                 | No `message` — the agent trims its own tail                                        |
| `preload` / `close` / `action`       | No `message`                                                                       |
| `handover-prepare` (head-start only) | Full prior history in `headStartMessages` (route handler — not on `/in/append`)    |

The full wire breakdown is in the rewritten [Client Protocol](/docs/ai-chat/client-protocol).

### `hydrateMessages` consumers

The hook signature is unchanged. Two behavior tightenings worth knowing:

1. **`incomingMessages` is now consistently 0-or-1-length.** Previously some triggers (`regenerate-message`, continuation) shipped full history; now all triggers ship at most one. If you assumed `incomingMessages` could contain multiple messages and acted on them as a batch, the loop now runs zero or one times. Patterns like the one below work the same — they just iterate fewer messages:

```ts theme={"theme":"css-variables"}
hydrateMessages: async ({ incomingMessages }) => {
  for (const msg of incomingMessages) {  // 0-or-1 iterations
    for (const r of chat.history.extractNewToolResults(msg)) {
      await auditLog.record({ id: r.toolCallId, output: r.output });
    }
  }
  return await db.getMessages(chatId);
}
```

2. **Registering `hydrateMessages` short-circuits snapshot+replay.** The runtime trusts your hook to be the source of truth, so it doesn't read or write the JSON snapshot or replay `session.out`. Zero object-store traffic. Trade-off: you own persistence end-to-end.

### `onChatStart` is now once-per-chat

`onChatStart` no longer fires on continuation runs (post-`endRun`, post-waitpoint-timeout, post-`chat.requestUpgrade`, post-cancel, post-crash) or on OOM-retry attempts. It fires **exactly once per chat**, on the very first user message of the chat's lifetime. The `continuation` and `previousRunId` fields on `ChatStartEvent` are now `@deprecated` (always `false` / `undefined` when the hook fires).

This makes once-per-chat setup code (create the Chat DB row, mint chat-scoped resources) safe to write without continuation gates. Drop any `if (continuation) return;` checks from `onChatStart`:

```ts before theme={"theme":"css-variables"}
onChatStart: async ({ continuation, chatId, clientData }) => {
  if (continuation) return;           // ❌ no longer needed — fires only on first message ever
  await db.chat.create({ /* ... */ });
}
```

```ts after theme={"theme":"css-variables"}
onChatStart: async ({ chatId, clientData }) => {
  await db.chat.create({ /* ... */ });  // ✅ guaranteed first-message-of-chat
}
```

If you need per-turn setup that **does** run on continuations, move it to [`onTurnStart`](/docs/ai-chat/lifecycle-hooks#onturnstart) — that hook still fires on every turn, including the first turn of a continuation run.

### Move `chat.local` init from `onChatStart` to `onBoot`

Because `onChatStart` no longer fires on continuation runs, **`chat.local`** state initialized there will be missing when a continuation run starts — `run()` then crashes with `"chat.local can only be modified after initialization"`. The fix is to move per-process initialization to the new [`onBoot`](/docs/ai-chat/lifecycle-hooks#onboot) hook, which fires once per worker boot (initial, preloaded, AND continuation):

```ts before theme={"theme":"css-variables"}
const userContext = chat.local<{ name: string; plan: string }>({ id: "userContext" });

onChatStart: async ({ clientData }) => {
  const user = await db.user.findUnique({ where: { id: clientData.userId } });
  userContext.init({ name: user.name, plan: user.plan }); // ❌ never runs on continuation
}
```

```ts after theme={"theme":"css-variables"}
const userContext = chat.local<{ name: string; plan: string }>({ id: "userContext" });

onBoot: async ({ clientData }) => {
  const user = await db.user.findUnique({ where: { id: clientData.userId } });
  userContext.init({ name: user.name, plan: user.plan }); // ✅ runs on every fresh worker
}
```

Anything else that's per-process (DB connection pools, sandbox handles, in-memory caches) belongs in `onBoot` for the same reason. Branch on `continuation` inside `onBoot` if you need to re-load state from your DB on takeover.

### Client-side `setMessages` doesn't round-trip

The new wire makes one thing explicit that was implicit before: **mutating `useChat()`'s messages on the client doesn't change the agent's history.** Full-history mutations were silently overwritten by the wire's accumulator before this release; now they aren't even shipped.

For history compaction, summarization, or branch-swap, mutate the agent's accumulator inside `onTurnStart` using [`chat.setMessages()`](/docs/ai-chat/backend) or [`chat.history.set()`](/docs/ai-chat/backend#chat-history). The client's `useChat` will reconcile against the next `session.out` payload.

### Verifying the v4.5 migration

After updating, the smoke check is the same as for v4.4:

* Send a message, confirm the assistant streams a response.
* Reload mid-stream, confirm resume.
* Send 30+ turns with tool calls — `.in/append` body sizes stay under \~5 KB the entire time. (Pre-change baseline: payloads grew past 512 KB around turn 10-30.)
* Idle out a run, send another message — the new run reads the snapshot, replays the tail, and continues seamlessly.

If continuations boot empty:

* Confirm `OBJECT_STORE_*` env vars are set on the webapp.
* Confirm the bucket key `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json` exists after a successful turn.
* Or — register `hydrateMessages` and let your DB be the source of truth.

## Reference

* [TriggerChatTransport options](/docs/ai-chat/reference#triggerchattransport-options)
* [`chat.createStartSessionAction`](/docs/ai-chat/reference)
* [Backend setup](/docs/ai-chat/backend)
* [Frontend setup](/docs/ai-chat/frontend)
* [Client Protocol](/docs/ai-chat/client-protocol) — wire format reference
* [Persistence and replay](/docs/ai-chat/patterns/persistence-and-replay) — snapshot model end-to-end


# LLM observability
Source: https://trigger.dev/docs/ai/observability

Capture Vercel AI SDK calls in a task as spans in the run trace, with model, token usage, cost, and latency. Opt in per call, link calls to prompt versions, and query usage across runs.

**LLM observability turns a Vercel AI SDK call inside a task into its own span in the run trace, next to your logs and other spans.** Each span carries the model, provider, input, output, and total token counts, cost, and latency, so you can see what each generation did and what it cost without leaving the run.

Everything shows up inline in the run trace you already use to debug runs. There is no separate product and no dashboard to set up.

<Note>
  Observability is opt-in per call and only covers [Vercel AI SDK](https://ai-sdk.dev) functions (`generateText`, `streamText`, `generateObject`). Calls you make with a raw `fetch`, a provider's own SDK, or any other HTTP client are not captured automatically.
</Note>

## Turn it on

Set `experimental_telemetry: { isEnabled: true }` on the AI SDK call. There is nothing to install for AI SDK 6, and nothing to configure on the Trigger.dev side.

```ts /trigger/summarize.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";

export const summarize = task({
  id: "summarize",
  run: async (payload: { text: string }) => {
    const result = await generateText({
      model: openai("gpt-4o"),
      prompt: `Summarize the following text:\n\n${payload.text}`,
      experimental_telemetry: { isEnabled: true },
    });

    return { summary: result.text };
  },
});
```

Trigger the task and open the run. The `generateText` call appears as a span in the trace. `streamText` and `generateObject` work the same way: add the same `experimental_telemetry` flag to each call you want captured.

<Note>
  **AI SDK 7** moved span emission out of `ai` core into the `@ai-sdk/otel` adapter. In a task, install `@ai-sdk/otel` and register it once yourself, for example at the top of your task file:

  ```ts /trigger/summarize.ts theme={"theme":"css-variables"}
  import { registerTelemetry } from "ai";
  import { OpenTelemetry } from "@ai-sdk/otel";

  registerTelemetry(new OpenTelemetry());
  ```

  A [`chat.agent()`](/docs/ai-chat/overview) run registers the adapter for you at run start, so chat agents need only the install. On AI SDK 5 and 6, `ai` core emits spans directly and no adapter is needed.
</Note>

## What each span shows

Open an AI generation span in the run trace to get a dedicated inspector with three tabs:

* **Overview**: model, provider, token usage, cost, and a preview of the input and output.
* **Messages**: the full message thread, including the system prompt and any tool results.
* **Tools**: the tool definitions passed to the model, plus every tool call the model made with its arguments.

A fourth **Prompt** tab appears when the call is linked to an [AI Prompt](/docs/ai/prompts) (see below).

## Link a call to its prompt

If you manage prompts with [AI Prompts](/docs/ai/prompts), resolve the prompt and spread `toAISDKTelemetry()` into the call. This sets `experimental_telemetry` for you and links the span back to the exact prompt version that produced it.

```ts /trigger/support.ts theme={"theme":"css-variables"}
import { task, prompts } from "@trigger.dev/sdk";
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
import type { supportPrompt } from "./prompts";

export const handleSupport = task({
  id: "handle-support",
  run: async (payload: { name: string; plan: string; issue: string }) => {
    const resolved = await prompts.resolve<typeof supportPrompt>("customer-support", {
      customerName: payload.name,
      plan: payload.plan,
      issue: payload.issue,
    });

    const result = await generateText({
      model: openai(resolved.model ?? "gpt-4o"),
      system: resolved.text,
      prompt: payload.issue,
      ...resolved.toAISDKTelemetry(),
    });

    return { response: result.text };
  },
});
```

The span's **Prompt** tab now shows the linked template, its version, and the input variables the prompt was resolved with.

Pass custom attributes to `toAISDKTelemetry()` to tag the span with your own metadata:

```ts theme={"theme":"css-variables"}
const result = await generateText({
  model: openai(resolved.model ?? "gpt-4o"),
  system: resolved.text,
  prompt: payload.issue,
  ...resolved.toAISDKTelemetry({
    "task.type": "summarization",
    "customer.tier": "enterprise",
  }),
});
```

Custom attributes are stored on the span's `metadata`, so you can filter or group by them in TRQL, for example `metadata['task.type']`.

<Note>
  When you build an agent with `chat.agent()` and store a prompt with `chat.prompt.set()`, `chat.toStreamTextOptions()` sets `experimental_telemetry` for you, so those generations are captured without adding the flag by hand. Without a stored prompt, set `experimental_telemetry` on the call yourself. See [Prompts](/docs/ai/prompts#using-with-chatagent).
</Note>

## Query usage across runs

Every captured generation is also written to the `llm_metrics` table, which you can query with [TRQL](/docs/observability/query). This lets you aggregate token usage, cost, and latency across many runs rather than inspecting one span at a time.

Cost and token usage by model:

```sql theme={"theme":"css-variables"}
SELECT
  response_model,
  gen_ai_system AS provider,
  count() AS calls,
  sum(total_tokens) AS tokens,
  round(sum(total_cost), 4) AS cost_usd
FROM llm_metrics
GROUP BY response_model, gen_ai_system
ORDER BY cost_usd DESC
LIMIT 20
```

Spend per task:

```sql theme={"theme":"css-variables"}
SELECT
  task_identifier,
  sum(input_tokens) AS input_tokens,
  sum(output_tokens) AS output_tokens,
  round(sum(total_cost), 4) AS cost_usd
FROM llm_metrics
GROUP BY task_identifier
ORDER BY cost_usd DESC
LIMIT 20
```

Cost by prompt version, when calls are linked to an [AI Prompt](/docs/ai/prompts):

```sql theme={"theme":"css-variables"}
SELECT
  prompt_slug,
  prompt_version,
  count() AS calls,
  round(sum(total_cost), 4) AS cost_usd
FROM llm_metrics
WHERE prompt_slug != ''
GROUP BY prompt_slug, prompt_version
ORDER BY prompt_slug, prompt_version
```

Set the time window with the query's [period filter](/docs/observability/query#time-ranges) rather than in the SQL itself. Run these from the [Query dashboard](/docs/observability/query#using-the-query-dashboard), the SDK with `query.execute()`, or the REST API. `llm_metrics` also exposes `ms_to_first_chunk` and `tokens_per_second` for latency and throughput, plus `finish_reason`, `request_model`, `cached_read_tokens`, `reasoning_tokens`, and per-direction `input_cost` / `output_cost` for finer breakdowns.

## Next steps

<CardGroup>
  <Card title="Prompts" icon="message-lines" href="/ai/prompts">
    Version prompts as code and link generations to the exact prompt version that produced them.
  </Card>

  <Card title="Query (TRQL)" icon="magnifying-glass-chart" href="/observability/query">
    Write custom queries against your runs, metrics, and LLM usage.
  </Card>
</CardGroup>


# Prompts
Source: https://trigger.dev/docs/ai/prompts

Define prompt templates as code, version them on deploy, and override from the dashboard without redeploying.

## Overview

AI Prompts let you define prompt templates in your codebase alongside your tasks. When you deploy, Trigger.dev automatically versions your prompts. You can then:

* View all prompt versions in the dashboard
* Create **overrides** to change the prompt text or model without redeploying
* Track every generation that used each prompt version
* See token usage, cost, and latency metrics per prompt
* Manage prompts programmatically via SDK methods

## Defining a prompt

Use `prompts.define()` to create a prompt with typed variables:

```ts theme={"theme":"css-variables"}
import { prompts } from "@trigger.dev/sdk";
import { z } from "zod";

export const supportPrompt = prompts.define({
  id: "customer-support",
  description: "System prompt for customer support interactions",
  model: "gpt-4o",
  config: { temperature: 0.7 },
  variables: z.object({
    customerName: z.string(),
    plan: z.string(),
    issue: z.string(),
  }),
  content: `You are a support agent for Acme SaaS.

## Customer context

- **Name:** {{customerName}}
- **Plan:** {{plan}}
- **Issue:** {{issue}}

Respond to the customer's issue. Be concise and helpful.`,
});
```

### Options

| Option        | Type               | Required | Description                                                         |
| ------------- | ------------------ | -------- | ------------------------------------------------------------------- |
| `id`          | `string`           | Yes      | Unique identifier (becomes the prompt slug)                         |
| `description` | `string`           | No       | Shown in the dashboard                                              |
| `model`       | `string`           | No       | Default model (e.g. `"gpt-4o"`, `"claude-sonnet-4-6"`)              |
| `config`      | `object`           | No       | Default config (temperature, maxTokens, etc.)                       |
| `variables`   | Zod/ArkType schema | No       | Schema for template variables (enables validation and dashboard UI) |
| `content`     | `string`           | Yes      | The prompt template with `{{variable}}` placeholders                |

### Template syntax

Templates use Mustache-style placeholders:

* `{{variableName}}` — replaced with the variable value
* `{{#conditionalVar}}...{{/conditionalVar}}` — content only included if the variable is truthy

```ts theme={"theme":"css-variables"}
export const prompt = prompts.define({
  id: "summarizer",
  model: "gpt-4o-mini",
  variables: z.object({
    text: z.string(),
    maxSentences: z.string().optional(),
  }),
  content: `Summarize the following text{{#maxSentences}} in {{maxSentences}} sentences or fewer{{/maxSentences}}:

{{text}}`,
});
```

## Resolving a prompt

### Via prompt handle

Call `.resolve()` on the handle returned by `define()`:

```ts theme={"theme":"css-variables"}
const resolved = await supportPrompt.resolve({
  customerName: "Alice",
  plan: "Pro",
  issue: "Cannot access billing dashboard",
});

console.log(resolved.text);    // The compiled prompt with variables filled in
console.log(resolved.version); // e.g. 3
console.log(resolved.model);   // "gpt-4o"
console.log(resolved.labels);  // ["current"] or ["override"]
```

### Via standalone prompts.resolve()

Resolve any prompt by slug without needing a handle. Pass the prompt handle as a type parameter for full type safety:

```ts theme={"theme":"css-variables"}
import { prompts } from "@trigger.dev/sdk";
import type { supportPrompt } from "./prompts";

// Fully typesafe — ID and variables are checked at compile time
const resolved = await prompts.resolve<typeof supportPrompt>("customer-support", {
  customerName: "Alice",
  plan: "Pro",
  issue: "Cannot access billing dashboard",
});
```

Without the generic, the function still works but accepts any string slug and `Record<string, unknown>` variables.

### Resolve options

You can resolve a specific version or label:

```ts theme={"theme":"css-variables"}
// Resolve a specific version
const v2 = await supportPrompt.resolve(variables, { version: 2 });

// Resolve by label
const current = await supportPrompt.resolve(variables, { label: "current" });
```

By default, `resolve()` returns the **override** version if one is active, otherwise the **current** (latest deployed) version.

<Note>
  Both `promptHandle.resolve()` and `prompts.resolve()` call the Trigger.dev API when a client is configured. During local dev with `trigger dev`, this means you'll always get the server version (including overrides).
</Note>

## Using with the AI SDK

The resolved prompt integrates with the [Vercel AI SDK](https://ai-sdk.dev) via `toAISDKTelemetry()`. This links AI generation spans to the prompt in the dashboard.

### generateText

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { generateText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

export const supportTask = task({
  id: "handle-support",
  run: async (payload) => {
    const resolved = await supportPrompt.resolve({
      customerName: payload.name,
      plan: payload.plan,
      issue: payload.issue,
    });

    const result = await generateText({
      model: openai(resolved.model ?? "gpt-4o"),
      system: resolved.text,
      prompt: payload.issue,
      ...resolved.toAISDKTelemetry(),
    });

    return { response: result.text };
  },
});
```

### streamText

```ts theme={"theme":"css-variables"}
import { streamText } from "ai";

export const streamTask = task({
  id: "stream-support",
  run: async (payload) => {
    const resolved = await supportPrompt.resolve({
      customerName: payload.name,
      plan: payload.plan,
      issue: payload.issue,
    });

    const result = streamText({
      model: openai(resolved.model ?? "gpt-4o"),
      system: resolved.text,
      prompt: payload.issue,
      ...resolved.toAISDKTelemetry(),
      stopWhen: stepCountIs(15),
    });

    let fullText = "";
    for await (const chunk of result.textStream) {
      fullText += chunk;
    }

    return { response: fullText };
  },
});
```

### Custom telemetry metadata

Pass additional metadata to `toAISDKTelemetry()` that will appear on the generation span:

```ts theme={"theme":"css-variables"}
const result = await generateText({
  model: anthropic("claude-sonnet-4-5"),
  prompt: resolved.text,
  ...resolved.toAISDKTelemetry({
    "task.type": "summarization",
    "customer.tier": "enterprise",
  }),
});
```

## Using with chat.agent()

Prompts integrate with `chat.agent()` via `chat.prompt` — a run-scoped store for the resolved prompt. Store a prompt once in a lifecycle hook, then access it anywhere during the run.

### chat.prompt.set() and chat.prompt()

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { prompts } from "@trigger.dev/sdk";
import { streamText, createProviderRegistry } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

const registry = createProviderRegistry({ anthropic });

const systemPrompt = prompts.define({
  id: "my-chat-system",
  model: "anthropic:claude-sonnet-4-5",
  config: { temperature: 0.7 },
  variables: z.object({ name: z.string() }),
  content: `You are a helpful assistant for {{name}}.`,
});

export const myChat = chat.agent({
  id: "my-chat",
  onChatStart: async ({ clientData }) => {
    const resolved = await systemPrompt.resolve({ name: clientData.name });
    chat.prompt.set(resolved);
  },
  run: async ({ messages, signal }) => {
    return streamText({
      ...chat.toStreamTextOptions({ registry }),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

### chat.toStreamTextOptions()

Returns an options object ready to spread into `streamText()`. When a prompt is stored via `chat.prompt.set()`, it includes:

* `system` — the compiled prompt text
* `model` — resolved via the `registry` when provided
* `temperature`, `maxTokens`, etc. — from the prompt's `config`
* `experimental_telemetry` — links generations to the prompt in the dashboard

```ts theme={"theme":"css-variables"}
// With registry — model is resolved automatically
const options = chat.toStreamTextOptions({ registry });
// { system: "...", model: LanguageModel, temperature: 0.7, experimental_telemetry: { ... } }

// Without registry — model is not included
const options = chat.toStreamTextOptions();
// { system: "...", temperature: 0.7, experimental_telemetry: { ... } }
```

<Tip>
  When the user provides a `registry` and the prompt has a `model` string (e.g. `"anthropic:claude-sonnet-4-5"`), the model is resolved via `registry.languageModel()` and included in the returned options. This means `streamText` uses the prompt's model by default — no manual model selection needed.
</Tip>

### Reading the prompt

Access the stored prompt from anywhere in the run:

```ts theme={"theme":"css-variables"}
run: async ({ messages, signal }) => {
  const prompt = chat.prompt(); // Throws if not set
  console.log(prompt.text);     // The compiled prompt
  console.log(prompt.model);    // "anthropic:claude-sonnet-4-5"
  console.log(prompt.version);  // 3

  return streamText({
    ...chat.toStreamTextOptions({ registry }),
    messages,
    abortSignal: signal,
    stopWhen: stepCountIs(15),
  });
},
```

You can also set a plain string if you don't need the full prompt system:

```ts theme={"theme":"css-variables"}
chat.prompt.set("You are a helpful assistant.");
```

## Prompt management SDK

The `prompts` namespace includes methods for managing prompts programmatically. These work both inside tasks and outside (e.g. scripts, API handlers) as long as an API client is configured.

### List prompts

```ts theme={"theme":"css-variables"}
const allPrompts = await prompts.list();
```

### List versions

```ts theme={"theme":"css-variables"}
const versions = await prompts.versions("customer-support");
```

### Create an override

Create a new override that takes priority over the deployed version:

```ts theme={"theme":"css-variables"}
const result = await prompts.createOverride("customer-support", {
  textContent: "New prompt template: Hello {{customerName}}!",
  model: "gpt-4o-mini",
  commitMessage: "Shorter prompt",
});
```

### Update an override

```ts theme={"theme":"css-variables"}
await prompts.updateOverride("customer-support", {
  textContent: "Updated template: Hi {{customerName}}!",
  model: "gpt-4o",
});
```

### Remove an override

Remove the active override, reverting to the deployed version:

```ts theme={"theme":"css-variables"}
await prompts.removeOverride("customer-support");
```

### Promote a version

```ts theme={"theme":"css-variables"}
await prompts.promote("customer-support", 2);
```

### All management methods

| Method                                        | Description                                 |
| --------------------------------------------- | ------------------------------------------- |
| `prompts.list()`                              | List all prompts in the current environment |
| `prompts.versions(slug)`                      | List all versions for a prompt              |
| `prompts.resolve(slug, variables?, options?)` | Resolve a prompt by slug                    |
| `prompts.promote(slug, version)`              | Promote a version to current                |
| `prompts.createOverride(slug, body)`          | Create an override                          |
| `prompts.updateOverride(slug, body)`          | Update the active override                  |
| `prompts.removeOverride(slug)`                | Remove the active override                  |
| `prompts.reactivateOverride(slug, version)`   | Reactivate a removed override               |

## Overrides

Overrides let you change a prompt's template or model from the dashboard or SDK without redeploying your code. When an override is active, `resolve()` returns the override version instead of the deployed version.

### How overrides work

* Overrides take priority over the deployed ("current") version
* Only one override can be active at a time
* Creating a new override replaces the previous one
* Removing an override reverts to the deployed version
* Overrides are environment-scoped (dev, staging, production are independent)

### Creating an override (dashboard)

1. Go to the prompt detail page
2. Click **Create Override**
3. Edit the template text and/or model
4. Add an optional commit message
5. Click **Create override**

### Version resolution order

When `resolve()` is called, versions are resolved in this order:

1. **Specific version** — if `{ version: N }` is passed
2. **Override** — if an override is active in this environment
3. **Label** — if `{ label: "..." }` is passed (defaults to `"current"`)
4. **Current** — the latest deployed version with the "current" label

## Dashboard

### Prompts list

The prompts list page shows all prompts in the current environment with the current or override version, default model, and a usage sparkline.

### Prompt detail

Click a prompt to see:

* **Template panel** — the prompt template for the selected version
* **Details tab** — slug, description, model, config, source file, and variable schema
* **Versions tab** — all versions with labels, source, and commit messages
* **Generations tab** — every AI generation that used this prompt, with live polling
* **Metrics tab** — token usage, cost, and latency charts

### AI span inspectors

When you use `toAISDKTelemetry()`, AI generation spans in the run trace get a custom inspector showing:

* **Overview** — model, provider, token usage, cost, input/output preview
* **Messages** — the full message thread
* **Tools** — tool definitions and tool call details
* **Prompt** — the linked prompt's metadata, input variables, and template content

## Type utilities

```ts theme={"theme":"css-variables"}
import type { PromptHandle, PromptIdentifier, PromptVariables } from "@trigger.dev/sdk";

type Id = PromptIdentifier<typeof supportPrompt>;   // "customer-support"
type Vars = PromptVariables<typeof supportPrompt>;   // { customerName: string; plan: string; issue: string }
```


# API keys
Source: https://trigger.dev/docs/apikeys

How to authenticate with Trigger.dev so you can trigger tasks.

### Authentication and your secret keys

When you [trigger a task](/docs/triggering) from your backend code, you need to set the `TRIGGER_SECRET_KEY` environment variable.

Each environment has its own secret key. You can find the value on the API keys page in the Trigger.dev dashboard:

<img alt="How to find your secret key" />

<Note>
  For preview branches, you need to also set the `TRIGGER_PREVIEW_BRANCH` environment variable as
  well. You can find the value on the API keys page when you're on the preview branch.
</Note>

### Automatically Configuring the SDK

To automatically configure the SDK with your secret key, you can set the `TRIGGER_SECRET_KEY` environment variable. The SDK will automatically use this value when calling API methods (like `trigger`).

```bash .env theme={"theme":"css-variables"}
TRIGGER_SECRET_KEY="tr_dev_…"
TRIGGER_PREVIEW_BRANCH="my-branch" # Only needed for preview branches
```

You can do the same if you are self-hosting and need to change the default URL by using `TRIGGER_API_URL`.

```bash .env theme={"theme":"css-variables"}
TRIGGER_API_URL="https://trigger.example.com"
TRIGGER_PREVIEW_BRANCH="my-branch" # Only needed for preview branches
```

The default URL is `https://api.trigger.dev`.

### Manually Configuring the SDK

If you prefer to manually configure the SDK, you can call the `configure` method:

```ts theme={"theme":"css-variables"}
import { configure } from "@trigger.dev/sdk";
import { myTask } from "./trigger/myTasks";

configure({
  secretKey: "tr_dev_1234", // WARNING: Never actually hardcode your secret key like this
  previewBranch: "my-branch", // Only needed for preview branches
  baseURL: "https://mytrigger.example.com", // Optional
});

async function triggerTask() {
  await myTask.trigger({ userId: "1234" }); // This will use the secret key and base URL you configured
}
```


# Billing limits and alerts
Source: https://trigger.dev/docs/billing-limits

Set a monthly compute spend cap for your organization and get email alerts before you reach it.

Billing limits let you cap your organization's monthly compute spend so a runaway task or unexpected traffic spike can't blow your budget. Billing alerts notify you by email as you approach thresholds you choose.

<Note>Billing limits and alerts are available to all [Trigger.dev Cloud](https://trigger.dev) organizations. They don't apply to self-hosted instances.</Note>

You can find the settings in the dashboard: open the **Organization** menu in the top left, then **Settings** → **Billing limits**.

<picture>
  <source type="image/webp" />

  <img alt="Billing limit settings" />
</picture>

## Setting a billing limit

Choose one of three options:

* **Plan limit**: Use your plan's maximum as the spending cap.
* **Custom limit**: Set your own monthly spend threshold.
* **No limit**: No cap is enforced. This is the default.

A billing limit applies to your whole organization and covers billable environments: `production`, `staging`, and `preview`. Your `dev` environment is not affected.

Optionally, enable **Cancel in-progress runs when this limit is reached** to immediately cancel executing runs when the limit is hit, instead of letting them finish naturally.

## Billing alerts

Billing alerts are email notifications sent when your monthly spend crosses a threshold. You can add multiple thresholds:

* **With a billing limit set**: thresholds are percentages of your limit (e.g. 50%, 80%).
* **Without a billing limit**: thresholds are dollar amounts.

<picture>
  <source type="image/webp" />

  <img alt="Billing alerts settings" />
</picture>

Alerts only notify you — they never pause environments or reject runs. Use them on their own for visibility, or alongside a limit to get advance warning before enforcement kicks in.

## What happens when you reach your limit

When your organization's spend reaches the billing limit, billable environments enter a **grace period**:

1. Queues pause across `production`, `staging`, and `preview`. In-progress runs finish naturally (unless you enabled **Cancel in-progress runs**).
2. New runs can still be triggered and are queued, but they won't start executing. Queued runs incur no compute cost until they start.
3. You have **24 hours** to review and decide what to do.

If you don't act before the grace period ends, queued runs are canceled and new triggers are rejected for the rest of the billing cycle.

<Note>
  Billing limits are **soft limits**, not instantaneous hard caps. Usage is evaluated on a short
  delay, so spend can briefly exceed your limit before enforcement applies. See our
  [terms](https://trigger.dev/terms) for refund policy details.
</Note>

## Resuming after hitting a limit

To resume execution, increase or remove the billing limit from the **Billing limits** page. You'll be asked what to do with the runs that queued up during the pause:

* **Resume queued runs**: everything that built up during the pause runs in order.
* **Cancel queued runs**: the backlog is discarded and only new triggers run going forward.

Execution resumes automatically once you've resolved the limit. Limits also reset at the start of each billing cycle.

## Tracking spend against your limit

On the **Usage** page (Organization menu → **Usage**), a **Billing limit** marker appears on the usage bar alongside your current spend and plan included usage, so you can see how close you are at a glance.

For tips on lowering your spend in the first place, see [How to reduce your spend](/docs/how-to-reduce-your-spend).


# Building with AI: overview
Source: https://trigger.dev/docs/building-with-ai

Tools and resources for building Trigger.dev projects with AI coding assistants.

## Quick setup

We provide multiple tools to help AI coding assistants write correct Trigger.dev code. Use one or all of them for the best developer experience.

<Steps>
  <Step title="Install the MCP Server">
    Give your AI assistant direct access to Trigger.dev tools — search docs, trigger tasks, deploy projects, and monitor runs. Works with Claude Code, Cursor, Windsurf, VS Code (Copilot), and Zed.

    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest install-mcp
    ```

    [Learn more →](/docs/mcp-introduction)
  </Step>

  <Step title="Install Skills">
    Portable instruction sets that teach any AI coding assistant Trigger.dev best practices: writing tasks, realtime frontends, and `chat.agent` AI agents. They install with the CLI into Claude Code, Cursor, VS Code (Copilot), and AGENTS-compatible tools such as Codex via `.agents/skills/`, and draw their API guidance from a version-pinned reference shipped in `@trigger.dev/sdk`.

    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest skills
    ```

    [Learn more →](/docs/skills)
  </Step>
</Steps>

## Skills and the MCP server

Skills and the MCP server do different jobs and work best together. Here's how they compare:

|                   | **Skills**                                                                 | **MCP Server**                                      |
| :---------------- | :------------------------------------------------------------------------- | :-------------------------------------------------- |
| **What it does**  | Drops skill files into your project that teach Trigger.dev patterns        | Runs a live server your AI connects to              |
| **Installs to**   | `.claude/skills/`, `.cursor/skills/`, `.github/skills/`, `.agents/skills/` | `mcp.json`, `~/.claude.json`, etc.                  |
| **Updates**       | Re-run `npx trigger.dev@latest skills`, or auto-prompted on `trigger dev`  | Always latest (uses `@latest`)                      |
| **Best for**      | Teaching patterns and best practices                                       | Live project interaction (deploy, trigger, monitor) |
| **Works offline** | Yes                                                                        | No (calls Trigger.dev API)                          |

**Our recommendation:** Install both. Skills teach your AI *how* to write Trigger.dev code; the MCP Server lets it *do things* in your project.

## Project-level context snippet

If you prefer a lightweight/passive approach, paste the snippet below into a context file at the root of your project. Different AI tools read different files:

| File                              | Read by                       |
| :-------------------------------- | :---------------------------- |
| `CLAUDE.md`                       | Claude Code                   |
| `AGENTS.md`                       | OpenAI Codex, Jules, OpenCode |
| `.cursor/rules/*.md`              | Cursor                        |
| `.github/copilot-instructions.md` | GitHub Copilot                |
| `CONVENTIONS.md`                  | Windsurf, Cline, and others   |

Create the file that matches your AI tool (or multiple files if your team uses different tools) and paste the snippet below. This gives the AI essential Trigger.dev context without installing anything.

<Accordion title="Copy the snippet">
  ````markdown theme={"theme":"css-variables"}
  # Trigger.dev rules

  ## Imports

  Always import from `@trigger.dev/sdk` — never from `@trigger.dev/sdk/v3` or use the deprecated `client.defineJob` pattern.

  ## Task pattern

  Every task must be exported. Use `task()` from `@trigger.dev/sdk`:

  ```ts
  import { task } from "@trigger.dev/sdk";

  export const myTask = task({
    id: "my-task",
    retry: {
      maxAttempts: 3,
      factor: 1.8,
      minTimeoutInMs: 500,
      maxTimeoutInMs: 30_000,
    },
    run: async (payload: { url: string }) => {
      // No timeouts — runs can take as long as needed
      return { success: true };
    },
  });
  ```

  ## Triggering tasks

  From your backend (Next.js route, Express handler, etc.):

  ```ts
  import type { myTask } from "./trigger/my-task";
  import { tasks } from "@trigger.dev/sdk";

  // Fire and forget
  const handle = await tasks.trigger<typeof myTask>("my-task", { url: "https://example.com" });

  // Batch trigger (up to 1,000 items)
  const batchHandle = await tasks.batchTrigger<typeof myTask>("my-task", [
    { payload: { url: "https://example.com/1" } },
    { payload: { url: "https://example.com/2" } },
  ]);
  ```

  ### From inside other tasks

  ```ts
  export const parentTask = task({
    id: "parent-task",
    run: async (payload) => {
      // Fire and forget
      await childTask.trigger({ data: "value" });

      // Wait for result — returns a Result object, NOT the output directly
      const result = await childTask.triggerAndWait({ data: "value" });
      if (result.ok) {
        console.log(result.output); // The actual return value
      } else {
        console.error(result.error);
      }

      // Or use .unwrap() to get output directly (throws on failure)
      const output = await childTask.triggerAndWait({ data: "value" }).unwrap();
    },
  });
  ```

  > Never wrap `triggerAndWait` or `batchTriggerAndWait` in `Promise.all` — this is not supported.

  ## Error handling

  ```ts
  import { task, retry, AbortTaskRunError } from "@trigger.dev/sdk";

  export const resilientTask = task({
    id: "resilient-task",
    retry: { maxAttempts: 5 },
    run: async (payload) => {
      // Permanent error — skip retrying
      if (!payload.isValid) {
        throw new AbortTaskRunError("Invalid payload, will not retry");
      }

      // Retry a specific block (not the whole task)
      const data = await retry.onThrow(
        async () => await fetchExternalApi(payload),
        { maxAttempts: 3 }
      );

      return data;
    },
  });
  ```

  ## Schema validation

  Use `schemaTask` with Zod for payload validation:

  ```ts
  import { schemaTask } from "@trigger.dev/sdk";
  import { z } from "zod";

  export const processVideo = schemaTask({
    id: "process-video",
    schema: z.object({ videoUrl: z.string().url() }),
    run: async (payload) => {
      // payload is typed and validated
    },
  });
  ```

  ## Waits

  Use `wait.for` for delays, `wait.until` for dates, and `wait.forToken` for external callbacks:

  ```ts
  import { wait } from "@trigger.dev/sdk";
  await wait.for({ seconds: 30 });
  await wait.until({ date: new Date("2025-01-01") });
  ```

  ## Configuration

  `trigger.config.ts` lives at the project root:

  ```ts
  import { defineConfig } from "@trigger.dev/sdk/build";

  export default defineConfig({
    project: "<your-project-ref>",
    dirs: ["./trigger"],
  });
  ```

  ## Common mistakes

  1. **Forgetting to export tasks** — every task must be a named export
  2. **Importing from `@trigger.dev/sdk/v3`** — this is the old v3 path; always use `@trigger.dev/sdk`
  3. **Using `client.defineJob()`** — this is the deprecated v2 API
  4. **Calling `task.trigger()` directly** — use `tasks.trigger<typeof myTask>("task-id", payload)` from your backend
  5. **Using `triggerAndWait` result as output** — it returns a `Result` object; check `result.ok` then access `result.output`, or use `.unwrap()`
  6. **Wrapping waits/triggerAndWait in `Promise.all`** — not supported in Trigger.dev tasks
  7. **Adding timeouts to tasks** — tasks have no built-in timeout; use `maxDuration` in config if needed
  ````
</Accordion>

## llms.txt

We also publish machine-readable documentation for LLM consumption:

* [trigger.dev/docs/llms.txt](https://trigger.dev/docs/llms.txt) — concise overview
* [trigger.dev/docs/llms-full.txt](https://trigger.dev/docs/llms-full.txt) — full documentation

These follow the [llms.txt standard](https://llmstxt.org) and can be fed directly into any LLM context window.

## Troubleshooting

<AccordionGroup>
  <Accordion title="AI keeps generating old v2/v3 code">
    Install [Skills](/docs/skills); they override the outdated patterns in the AI's training data. The [context snippet](#project-level-context-snippet) above is a quick alternative.
  </Accordion>

  <Accordion title="MCP server won't connect">
    1. Make sure you've restarted your AI client after adding the config
    2. Run `npx trigger.dev@latest install-mcp` again — it will detect and fix common issues
    3. Check that `npx trigger.dev@latest mcp` runs without errors in your terminal
    4. See the [MCP introduction](/docs/mcp-introduction) for client-specific config details
  </Accordion>

  <Accordion title="Which tool should I install?">
    Both if possible:

    * **Skills** to teach your AI how to write Trigger.dev code (tasks, realtime, chat.agent)
    * **MCP Server** if you need to trigger tasks, deploy, and search docs from your AI
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup>
  <Card title="MCP Server" icon="sparkles" href="/mcp-introduction">
    Install and configure the MCP Server for live project interaction.
  </Card>

  <Card title="Skills" icon="wand-magic-sparkles" href="/skills">
    Install Trigger.dev agent skills into any AI coding assistant.
  </Card>

  <Card title="Writing tasks" icon="code" href="/tasks/overview">
    Learn the task patterns your AI assistant will follow.
  </Card>
</CardGroup>


# Bulk actions in the dashboard
Source: https://trigger.dev/docs/bulk-actions

Replay or cancel multiple runs from the dashboard using filters or selected run IDs.

**Bulk actions let you replay or cancel multiple runs asynchronously from the dashboard.**

Use bulk actions when you need to retry failed runs after deploying a fix, or stop a group of queued or executing runs.

<Note>
  For backend code, see [Bulk actions with the SDK](/docs/runs/bulk-actions).
</Note>

<video />

## Create a bulk action in the dashboard

<Steps>
  <Step title="Open the bulk action panel">
    Open the runs page and click **Bulk action** in the top right.

    <img alt="Open the bulk action panel from the runs page" />
  </Step>

  <Step title="Choose the runs">
    Filter the runs table to target a group of runs, or select individual runs from the table.
  </Step>

  <Step title="Configure the action">
    Choose **Replay** or **Cancel**, add an optional name, then confirm the action.

    <img alt="Configure and create a bulk action" />
  </Step>

  <Step title="Track progress">
    Open the bulk action page to see progress, view affected runs, or replay the action.

    <img alt="View bulk action progress" />
  </Step>
</Steps>

<Note>
  You can only cancel runs that are still cancelable, such as queued or executing runs. Runs that have already reached a final state cannot be canceled.
</Note>

## Create a bulk action from the SDK

Use `runs.bulk.replay()` or `runs.bulk.cancel()` when you want to create a bulk action from your backend code.

```ts Your backend code theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

const action = await runs.bulk.replay({
  filter: {
    status: "FAILED",
    taskIdentifier: "sync-customer",
    period: "24h",
  },
  name: "Replay failed customer syncs",
});

const completed = await runs.bulk.poll(action.id);
console.log(completed.status, completed.counts);
```

See [Bulk actions with the SDK](/docs/runs/bulk-actions) for canceling, listing, polling, and aborting bulk actions from your backend code.


# Changelog
Source: https://trigger.dev/docs/changelog



Our [changelog](https://trigger.dev/changelog) is the best way to stay up to date with the latest changes to Trigger.


# CLI deploy command
Source: https://trigger.dev/docs/cli-deploy-commands

Use the deploy command to deploy your tasks to Trigger.dev.

Run the command like this:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest deploy
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest deploy
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest deploy
  ```
</CodeGroup>

<Warning>
  This will fail in CI if any version mismatches are detected. Ensure everything runs locally first
  using the [dev](/docs/cli-dev-commands) command and don't bypass the version checks!
</Warning>

It performs a few steps to deploy:

1. Optionally updates packages when running locally.
2. Compiles and bundles the code.
3. Deploys the code to the Trigger.dev instance.
4. Registers the tasks as a new version in the environment (prod by default).

## Deploying from CI

When deploying from CI/CD environments such as GitHub Actions, GitLab CI, or Jenkins, you need to authenticate non-interactively by setting the `TRIGGER_ACCESS_TOKEN` environment variable. Please see the [CI / GitHub Actions guide](/docs/github-actions) for more information.

## Arguments

```
npx trigger.dev@latest deploy [path]
```

<ParamField type="[path]">
  The path to the project. Defaults to the current directory.
</ParamField>

## Options

<ParamField type="--config | -c">
  The name of the config file found at the project path. Defaults to `trigger.config.ts`
</ParamField>

<ParamField type="--project-ref | -p">
  The project ref. Required if there is no config file.
</ParamField>

<ParamField type="--env-file">
  Load environment variables from a file. This will only hydrate the `process.env` of the CLI
  process, not the tasks.
</ParamField>

<ParamField type="--skip-update-check">
  Skip checking for `@trigger.dev` package updates.
</ParamField>

<ParamField type="--env | -e">
  Defaults to `prod` but you can specify `staging` or `preview`. If you specify `preview` we will
  try and automatically detect the branch name from git.
</ParamField>

<ParamField type="--branch | -b">
  When using `--env preview` the branch is automatically detected from git. But you can manually
  specify it by using this option, e.g. `--branch my-branch` or `-b my-branch`.
</ParamField>

<ParamField type="--dry-run">
  Create a deployable build but don't deploy it. Prints out the build path so you can inspect it.
</ParamField>

<ParamField type="--skip-promotion">
  Skips automatically promoting the newly deployed version to the "current" deploy.
</ParamField>

<ParamField type="--skip-sync-env-vars">
  Turn off syncing environment variables with the Trigger.dev instance.
</ParamField>

<ParamField type="--local-build">
  Force building the deployment image locally using your local Docker. This is automatic when self-hosting.
</ParamField>

### Common options

These options are available on most commands.

<ParamField type="--profile">
  The login profile to use. Defaults to "default".
</ParamField>

<ParamField type="--api-url | -a">
  Override the default API URL. If not specified, it uses `https://api.trigger.dev`. This can also be set via the `TRIGGER_API_URL` environment variable.
</ParamField>

<ParamField type="--log-level | -l">
  The CLI log level to use. Options are `debug`, `info`, `log`, `warn`, `error`, and `none`. This does not affect the log level of your trigger.dev tasks. Defaults to `log`.
</ParamField>

<ParamField type="--skip-telemetry">
  Opt-out of sending telemetry data. This can also be done via the `TRIGGER_TELEMETRY_DISABLED` environment variable. Just set it to anything other than an empty string.
</ParamField>

<ParamField type="--help | -h">
  Shows the help information for the command.
</ParamField>

<ParamField type="--version | -v">
  Displays the version number of the CLI.
</ParamField>

### Self-hosting

When [self-hosting](/docs/self-hosting/overview), builds are performed locally by default. Once you've logged in to your self-hosted instance using the CLI, you can deploy with:

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest deploy
```

For CI/CD environments, set `TRIGGER_ACCESS_TOKEN` and `TRIGGER_API_URL` environment variables. See the [GitHub Actions guide](/docs/github-actions#self-hosting) for more details.


# CLI dev command
Source: https://trigger.dev/docs/cli-dev-commands

The `trigger.dev dev` command is used to run your tasks locally.

This runs a server on your machine that can execute Trigger.dev tasks:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest dev
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest dev
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest dev
  ```
</CodeGroup>

It will first perform an update check to prevent version mismatches, failed deploys, and other errors. You will always be prompted first.

You will see in the terminal that the server is running and listening for tasks. When you run a task, you will see it in the terminal along with a link to view it in the dashboard.

It is worth noting that each task runs in a separate Node process. This means that if you have a long-running task, it will not block other tasks from running.

## Options

<ParamField type="--config | -c">
  The name of the config file found at the project path. Defaults to `trigger.config.ts`
</ParamField>

<ParamField type="--project-ref | -p">
  The project ref. Required if there is no config file.
</ParamField>

<ParamField type="--env-file">
  Load environment variables from a file. This will only hydrate the `process.env` of the CLI
  process, not the tasks.
</ParamField>

<ParamField type="--skip-update-check">
  Skip checking for `@trigger.dev` package updates.
</ParamField>

<ParamField type="--analyze">
  Analyzes the build output and displays detailed import timings. This is useful for debugging the
  start times for your runs which can be caused by importing lots of code or heavy packages.
</ParamField>

### Common options

These options are available on most commands.

<ParamField type="--profile">
  The login profile to use. Defaults to "default".
</ParamField>

<ParamField type="--api-url | -a">
  Override the default API URL. If not specified, it uses `https://api.trigger.dev`. This can also be set via the `TRIGGER_API_URL` environment variable.
</ParamField>

<ParamField type="--log-level | -l">
  The CLI log level to use. Options are `debug`, `info`, `log`, `warn`, `error`, and `none`. This does not affect the log level of your trigger.dev tasks. Defaults to `log`.
</ParamField>

<ParamField type="--skip-telemetry">
  Opt-out of sending telemetry data. This can also be done via the `TRIGGER_TELEMETRY_DISABLED` environment variable. Just set it to anything other than an empty string.
</ParamField>

<ParamField type="--help | -h">
  Shows the help information for the command.
</ParamField>

<ParamField type="--version | -v">
  Displays the version number of the CLI.
</ParamField>

## Concurrently running the terminal

Install the concurrently package as a dev dependency:

```ts theme={"theme":"css-variables"}
concurrently --raw --kill-others npm:dev:remix npm:dev:trigger
```

Then add something like this in your package.json scripts:

```json theme={"theme":"css-variables"}
"scripts": {
  "dev": "concurrently --raw --kill-others npm:dev:*",
  "dev:trigger": "npx trigger.dev@latest dev",
  // Add your framework-specific dev script here, for example:
  // "dev:next": "next dev",
  // "dev:remix": "remix dev",
  //...
}
```


# CLI init command
Source: https://trigger.dev/docs/cli-init-commands

Use these options when running the CLI `init` command.

Run the command like this:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest init
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest init
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest init
  ```
</CodeGroup>

## Options

<ParamField type="--javascript">
  By default, the init command assumes you are using TypeScript. Use this flag to initialize a
  project that uses JavaScript.
</ParamField>

<ParamField type="--project-ref | -p">
  The project ref to use when initializing the project.
</ParamField>

<ParamField type="--tag | -t">
  The version of the `@trigger.dev/sdk` package to install. Defaults to `latest`.
</ParamField>

<ParamField type="--skip-package-install">
  Skip installing the `@trigger.dev/sdk` package.
</ParamField>

<ParamField type="--override-config">
  Override the existing config file if it exists.
</ParamField>

<ParamField type="--pkg-args">
  Additional arguments to pass to the package manager. Accepts CSV for multiple args.
</ParamField>

### Common options

These options are available on most commands.

<ParamField type="--profile">
  The login profile to use. Defaults to "default".
</ParamField>

<ParamField type="--api-url | -a">
  Override the default API URL. If not specified, it uses `https://api.trigger.dev`. This can also be set via the `TRIGGER_API_URL` environment variable.
</ParamField>

<ParamField type="--log-level | -l">
  The CLI log level to use. Options are `debug`, `info`, `log`, `warn`, `error`, and `none`. This does not affect the log level of your trigger.dev tasks. Defaults to `log`.
</ParamField>

<ParamField type="--skip-telemetry">
  Opt-out of sending telemetry data. This can also be done via the `TRIGGER_TELEMETRY_DISABLED` environment variable. Just set it to anything other than an empty string.
</ParamField>

<ParamField type="--help | -h">
  Shows the help information for the command.
</ParamField>

<ParamField type="--version | -v">
  Displays the version number of the CLI.
</ParamField>


# Introduction
Source: https://trigger.dev/docs/cli-introduction

The Trigger.dev CLI has a number of options and commands to help you develop locally, self host, and deploy your tasks.

## Options

<ParamField type="--help | -h">
  Shows the help information for the command.
</ParamField>

<ParamField type="--version | -v">
  Displays the version number of the CLI.
</ParamField>

## Commands

| Command                                      | Description                                                        |
| :------------------------------------------- | :----------------------------------------------------------------- |
| [login](/docs/cli-login-commands)                 | Login with Trigger.dev so you can perform authenticated actions.   |
| [init](/docs/cli-init-commands)                   | Initialize your existing project for development with Trigger.dev. |
| [dev](/docs/cli-dev-commands)                     | Run your Trigger.dev tasks locally.                                |
| [deploy](/docs/cli-deploy-commands)               | Deploy your Trigger.dev project to the cloud.                      |
| [whoami](/docs/cli-whoami-commands)               | Display the current logged in user and project details.            |
| [logout](/docs/cli-logout-commands)               | Logout of Trigger.dev.                                             |
| [list-profiles](/docs/cli-list-profiles-commands) | List all of your CLI profiles.                                     |
| [update](/docs/cli-update-commands)               | Updates all `@trigger.dev/*` packages to match the CLI version.    |


# CLI list-profiles command
Source: https://trigger.dev/docs/cli-list-profiles-commands

Use these options when using the `list-profiles` CLI command.

Run the command like this:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest list-profiles
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest list-profiles
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest list-profiles
  ```
</CodeGroup>

## Options

### Common options

These options are available on most commands.

<ParamField type="--log-level | -l">
  The CLI log level to use. Options are `debug`, `info`, `log`, `warn`, `error`, and `none`. This does not affect the log level of your trigger.dev tasks. Defaults to `log`.
</ParamField>

<ParamField type="--skip-telemetry">
  Opt-out of sending telemetry data. This can also be done via the `TRIGGER_TELEMETRY_DISABLED` environment variable. Just set it to anything other than an empty string.
</ParamField>

<ParamField type="--help | -h">
  Shows the help information for the command.
</ParamField>

<ParamField type="--version | -v">
  Displays the version number of the CLI.
</ParamField>


# CLI login command
Source: https://trigger.dev/docs/cli-login-commands

Use these options when logging in to Trigger.dev using the CLI.

Run the command like this:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest login
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest login
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest login
  ```
</CodeGroup>

## Options

### Common options

These options are available on most commands.

<ParamField type="--profile">
  The login profile to use. Defaults to "default".
</ParamField>

<ParamField type="--api-url | -a">
  Override the default API URL. If not specified, it uses `https://api.trigger.dev`. This can also be set via the `TRIGGER_API_URL` environment variable.
</ParamField>

<ParamField type="--log-level | -l">
  The CLI log level to use. Options are `debug`, `info`, `log`, `warn`, `error`, and `none`. This does not affect the log level of your trigger.dev tasks. Defaults to `log`.
</ParamField>

<ParamField type="--skip-telemetry">
  Opt-out of sending telemetry data. This can also be done via the `TRIGGER_TELEMETRY_DISABLED` environment variable. Just set it to anything other than an empty string.
</ParamField>

<ParamField type="--help | -h">
  Shows the help information for the command.
</ParamField>

<ParamField type="--version | -v">
  Displays the version number of the CLI.
</ParamField>


# CLI logout command
Source: https://trigger.dev/docs/cli-logout-commands

Use these options when using the `logout` CLI command.

Run the command like this:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest logout
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest logout
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest logout
  ```
</CodeGroup>

## Options

### Common options

These options are available on most commands.

<ParamField type="--profile">
  The login profile to use. Defaults to "default".
</ParamField>

<ParamField type="--api-url | -a">
  Override the default API URL. If not specified, it uses `https://api.trigger.dev`. This can also be set via the `TRIGGER_API_URL` environment variable.
</ParamField>

<ParamField type="--log-level | -l">
  The CLI log level to use. Options are `debug`, `info`, `log`, `warn`, `error`, and `none`. This does not affect the log level of your trigger.dev tasks. Defaults to `log`.
</ParamField>

<ParamField type="--skip-telemetry">
  Opt-out of sending telemetry data. This can also be done via the `TRIGGER_TELEMETRY_DISABLED` environment variable. Just set it to anything other than an empty string.
</ParamField>

<ParamField type="--help | -h">
  Shows the help information for the command.
</ParamField>

<ParamField type="--version | -v">
  Displays the version number of the CLI.
</ParamField>


# CLI preview archive command
Source: https://trigger.dev/docs/cli-preview-archive

The `trigger.dev preview archive` command can be used to archive a preview branch.

Run the command like this:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest preview archive
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest preview archive
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest preview archive
  ```
</CodeGroup>

It will archive the preview branch, automatically detecting the branch name from git. You can manually specify the branch using the `--branch` option.

## Arguments

```
npx trigger.dev@latest preview archive [path]
```

<ParamField type="[path]">
  The path to the project. Defaults to the current directory.
</ParamField>

## Options

<ParamField type="--branch | -b">
  When using `--env preview` the branch is automatically detected from git. But you can manually
  specify it by using this option, e.g. `--branch my-branch` or `-b my-branch`.
</ParamField>

<ParamField type="--config | -c">
  The name of the config file found at the project path. Defaults to `trigger.config.ts`
</ParamField>

<ParamField type="--project-ref | -p">
  The project ref. Required if there is no config file.
</ParamField>

<ParamField type="--env-file">
  Load environment variables from a file. This will only hydrate the `process.env` of the CLI
  process, not the tasks.
</ParamField>

<ParamField type="--skip-update-check">
  Skip checking for `@trigger.dev` package updates.
</ParamField>

### Common options

These options are available on most commands.

<ParamField type="--profile">
  The login profile to use. Defaults to "default".
</ParamField>

<ParamField type="--api-url | -a">
  Override the default API URL. If not specified, it uses `https://api.trigger.dev`. This can also be set via the `TRIGGER_API_URL` environment variable.
</ParamField>

<ParamField type="--log-level | -l">
  The CLI log level to use. Options are `debug`, `info`, `log`, `warn`, `error`, and `none`. This does not affect the log level of your trigger.dev tasks. Defaults to `log`.
</ParamField>

<ParamField type="--skip-telemetry">
  Opt-out of sending telemetry data. This can also be done via the `TRIGGER_TELEMETRY_DISABLED` environment variable. Just set it to anything other than an empty string.
</ParamField>

<ParamField type="--help | -h">
  Shows the help information for the command.
</ParamField>

<ParamField type="--version | -v">
  Displays the version number of the CLI.
</ParamField>


# CLI promote command
Source: https://trigger.dev/docs/cli-promote-commands

Use the promote command to promote a previously deployed version to the current version.

Run the command like this:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest promote [version]
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest promote [version]
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest promote [version]
  ```
</CodeGroup>

## Arguments

```
npx trigger.dev@latest promote [version]
```

<ParamField type="[version]">
  The version to promote. This is the version that was previously deployed.
</ParamField>

### Common options

These options are available on most commands.

<ParamField type="--profile">
  The login profile to use. Defaults to "default".
</ParamField>

<ParamField type="--api-url | -a">
  Override the default API URL. If not specified, it uses `https://api.trigger.dev`. This can also be set via the `TRIGGER_API_URL` environment variable.
</ParamField>

<ParamField type="--log-level | -l">
  The CLI log level to use. Options are `debug`, `info`, `log`, `warn`, `error`, and `none`. This does not affect the log level of your trigger.dev tasks. Defaults to `log`.
</ParamField>

<ParamField type="--skip-telemetry">
  Opt-out of sending telemetry data. This can also be done via the `TRIGGER_TELEMETRY_DISABLED` environment variable. Just set it to anything other than an empty string.
</ParamField>

<ParamField type="--help | -h">
  Shows the help information for the command.
</ParamField>

<ParamField type="--version | -v">
  Displays the version number of the CLI.
</ParamField>


# CLI switch command
Source: https://trigger.dev/docs/cli-switch

The `trigger.dev switch` command can be used to switch between profiles.

Run the command like this:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest switch [profile]
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest switch [profile]
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest switch [profile]
  ```
</CodeGroup>

It will switch to the specified profile. If no profile is specified, it will list all available profiles and run interactively.

## Arguments

```
npx trigger.dev@latest switch [profile]
```

<ParamField type="[profile]">
  The profile to switch to. If not specified, it will list all available profiles and run interactively.
</ParamField>


# CLI update command
Source: https://trigger.dev/docs/cli-update-commands

Use these options when using the `update` CLI command.

Run the command like this:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest update
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest update
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest update
  ```
</CodeGroup>

## Options

### Common options

These options are available on most commands.

<ParamField type="--log-level | -l">
  The CLI log level to use. Options are `debug`, `info`, `log`, `warn`, `error`, and `none`. This does not affect the log level of your trigger.dev tasks. Defaults to `log`.
</ParamField>

<ParamField type="--skip-telemetry">
  Opt-out of sending telemetry data. This can also be done via the `TRIGGER_TELEMETRY_DISABLED` environment variable. Just set it to anything other than an empty string.
</ParamField>

<ParamField type="--help | -h">
  Shows the help information for the command.
</ParamField>

<ParamField type="--version | -v">
  Displays the version number of the CLI.
</ParamField>


# CLI whoami command
Source: https://trigger.dev/docs/cli-whoami-commands

Use these options to display the current logged in user and project details.

Run the command like this:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest whoami
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest whoami
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest whoami
  ```
</CodeGroup>

## Options

### Common options

These options are available on most commands.

<ParamField type="--profile">
  The login profile to use. Defaults to "default".
</ParamField>

<ParamField type="--api-url | -a">
  Override the default API URL. If not specified, it uses `https://api.trigger.dev`. This can also be set via the `TRIGGER_API_URL` environment variable.
</ParamField>

<ParamField type="--log-level | -l">
  The CLI log level to use. Options are `debug`, `info`, `log`, `warn`, `error`, and `none`. This does not affect the log level of your trigger.dev tasks. Defaults to `log`.
</ParamField>

<ParamField type="--skip-telemetry">
  Opt-out of sending telemetry data. This can also be done via the `TRIGGER_TELEMETRY_DISABLED` environment variable. Just set it to anything other than an empty string.
</ParamField>

<ParamField type="--help | -h">
  Shows the help information for the command.
</ParamField>

<ParamField type="--version | -v">
  Displays the version number of the CLI.
</ParamField>


# Discord Community
Source: https://trigger.dev/docs/community



Please [join our community on Discord](https://trigger.dev/discord) to ask questions, share your projects, and get help from other developers.


# The trigger.config.ts file
Source: https://trigger.dev/docs/config/config-file

This file is used to configure your project and how it's built.

The `trigger.config.ts` file is used to configure your Trigger.dev project. It is a TypeScript file at the root of your project that exports a default configuration object. Here's an example:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  // Your project ref (you can see it on the Project settings page in the dashboard)
  project: "<project ref>",
  //The paths for your trigger folders
  dirs: ["./trigger"],
  retries: {
    //If you want to retry a task in dev mode (when using the CLI)
    enabledInDev: false,
    //the default retry settings. Used if you don't specify on a task.
    default: {
      maxAttempts: 3,
      minTimeoutInMs: 1000,
      maxTimeoutInMs: 10000,
      factor: 2,
      randomize: true,
    },
  },
});
```

The config file handles a lot of things, like:

* Specifying where your trigger tasks are located using the `dirs` option.
* Setting the default retry settings.
* Configuring OpenTelemetry instrumentations.
* Customizing the build process.
* Adding global task lifecycle functions.

<Note>
  The config file is bundled with your project, so code imported in the config file is also bundled,
  which can have an effect on build times and cold start duration. One important qualification is
  anything defined in the `build` config is automatically stripped out of the config file, and
  imports used inside build config with be tree-shaken out.
</Note>

## Dirs

You can specify the directories where your tasks are located using the `dirs` option:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  dirs: ["./trigger"],
});
```

If you omit the `dirs` option, we will automatically detect directories that are named `trigger` in your project, but we recommend specifying the directories explicitly. The `dirs` option is an array of strings, so you can specify multiple directories if you have tasks in multiple locations.

We will search for TypeScript and JavaScript files in the specified directories and include them in the build process. We automatically exclude files that have `.test` or `.spec` in the name, but you can customize this by specifying glob patterns in the `ignorePatterns` option:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  dirs: ["./trigger"],
  ignorePatterns: ["**/*.my-test.ts"],
});
```

## Custom tsconfig path

You can specify a custom path to your tsconfig file. This is useful if you have a custom tsconfig file that you want to use.

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  dirs: ["./trigger"],
  tsconfig: "./custom-tsconfig.json", // Custom tsconfig path
});
```

## Lifecycle functions

You can add lifecycle functions to get notified when any task starts, succeeds, or fails using `onStart`, `onSuccess` and `onFailure`:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  onSuccess: async ({ payload, output, ctx }) => {
    console.log("Task succeeded", ctx.task.id);
  },
  onFailure: async ({ payload, error, ctx }) => {
    console.log("Task failed", ctx.task.id);
  },
  onStart: async ({ payload, ctx }) => {
    console.log("Task started", ctx.task.id);
  },
  init: async ({ payload, ctx }) => {
    console.log("I run before any task is run");
  },
});
```

Read more about task lifecycle functions in the [tasks overview](/docs/tasks/overview).

## Instrumentations

We use OpenTelemetry (OTEL) for our run logs. This means you get a lot of information about your tasks with no effort. But you probably want to add more information to your logs. For example, here's all the Prisma calls automatically logged:

<img alt="The run log" />

Here we add Prisma and OpenAI instrumentations to your `trigger.config.ts` file.

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { PrismaInstrumentation } from "@prisma/instrumentation";
import { OpenAIInstrumentation } from "@traceloop/instrumentation-openai";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  telemetry: {
    instrumentations: [new PrismaInstrumentation(), new OpenAIInstrumentation()],
  },
});
```

There is a [huge library of instrumentations](https://opentelemetry.io/ecosystem/registry/?language=js) you can easily add to your project like this.

Some ones we recommend:

| Package                               | Description                                                                                                              |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `@opentelemetry/instrumentation-http` | Logs all HTTP calls                                                                                                      |
| `@prisma/instrumentation`             | Logs all Prisma calls, you need to [enable tracing](https://github.com/prisma/prisma/tree/main/packages/instrumentation) |
| `@traceloop/instrumentation-openai`   | Logs all OpenAI calls                                                                                                    |

<Note>
  `@opentelemetry/instrumentation-fs` which logs all file system calls is currently not supported.
</Note>

### Telemetry Exporters

You can also configure custom telemetry exporters to send your traces, logs, and metrics to other external services. For example, you can send your logs to [Axiom](https://axiom.co/docs/guides/opentelemetry-nodejs#exporter-instrumentation-ts). First, add the opentelemetry exporter packages to your package.json file:

```json package.json theme={"theme":"css-variables"}
"dependencies": {
  "@opentelemetry/exporter-logs-otlp-http": "0.52.1",
  "@opentelemetry/exporter-trace-otlp-http": "0.52.1",
  "@opentelemetry/exporter-metrics-otlp-proto": "0.52.1"
}
```

<Note>
  Axiom's `/v1/metrics` endpoint only supports protobuf (`application/x-protobuf`), not JSON. Use
  `@opentelemetry/exporter-metrics-otlp-proto` instead of
  `@opentelemetry/exporter-metrics-otlp-http` for metrics. Traces and logs work fine with the
  `-http` (JSON) exporters.
</Note>

Then, configure the exporters in your `trigger.config.ts` file:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";

// Initialize OTLP trace exporter with the endpoint URL and headers;
export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  telemetry: {
    instrumentations: [
      // Your instrumentations here
    ],
    logExporters: [
      new OTLPLogExporter({
        url: "https://api.axiom.co/v1/logs",
        headers: {
          Authorization: `Bearer ${process.env.AXIOM_API_TOKEN}`,
          "X-Axiom-Dataset": process.env.AXIOM_DATASET,
        },
      }),
    ],
    exporters: [
      new OTLPTraceExporter({
        url: "https://api.axiom.co/v1/traces",
        headers: {
          Authorization: `Bearer ${process.env.AXIOM_API_TOKEN}`,
          "X-Axiom-Dataset": process.env.AXIOM_DATASET,
        },
      }),
    ],
    metricExporters: [
      new OTLPMetricExporter({
        url: "https://api.axiom.co/v1/metrics",
        headers: {
          Authorization: `Bearer ${process.env.AXIOM_API_TOKEN}`,
          "x-axiom-metrics-dataset": process.env.AXIOM_METRICS_DATASET,
        },
      }),
    ],
  },
});
```

Make sure to set the `AXIOM_API_TOKEN`, `AXIOM_DATASET`, and `AXIOM_METRICS_DATASET` environment variables in your project. Axiom requires a separate, dedicated dataset for metrics — you cannot reuse the same dataset for traces/logs and metrics.

It's important to note that you cannot configure exporters using `OTEL_*` environment variables, as they would conflict with our internal telemetry. Instead you should configure the exporters via passing in arguments to the `OTLPTraceExporter`, `OTLPLogExporter`, and `OTLPMetricExporter` constructors. For example, here is how you can configure exporting to Honeycomb:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";

// Initialize OTLP trace exporter with the endpoint URL and headers;
export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  telemetry: {
    instrumentations: [
      // Your instrumentations here
    ],
    logExporters: [
      new OTLPLogExporter({
        url: "https://api.honeycomb.io/v1/logs",
        headers: {
          "x-honeycomb-team": process.env.HONEYCOMB_API_KEY,
          "x-honeycomb-dataset": process.env.HONEYCOMB_DATASET,
        },
      }),
    ],
    exporters: [
      new OTLPTraceExporter({
        url: "https://api.honeycomb.io/v1/traces",
        headers: {
          "x-honeycomb-team": process.env.HONEYCOMB_API_KEY,
          "x-honeycomb-dataset": process.env.HONEYCOMB_DATASET,
        },
      }),
    ],
    metricExporters: [
      new OTLPMetricExporter({
        url: "https://api.honeycomb.io/v1/metrics",
        headers: {
          "x-honeycomb-team": process.env.HONEYCOMB_API_KEY,
          "x-honeycomb-dataset": process.env.HONEYCOMB_DATASET,
        },
      }),
    ],
  },
});
```

## Runtime

We currently only officially support the `node` runtime, but you can try our experimental `bun` runtime by setting the `runtime` option in your config file:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  runtime: "bun",
});
```

See our [Bun guide](/docs/guides/frameworks/bun) for more information.

### Node.js versions

Trigger.dev runs your tasks on specific Node.js versions:

* Node.js `21.7.3` (default)
* Node.js `22.16.0` (`node-22`)
* Node.js `24.18.0` (`node-24`)
* Node.js `26.4.0` (`node-26`)
* Bun `1.3.3` (`bun`)

You can change the runtime by setting the `runtime` field in your `trigger.config.ts` file.

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  // "node", "node-22", "node-24", "node-26" or "bun"
  runtime: "node-24",
  project: "<your-project-ref>",
});
```

## Default machine

You can specify the default machine for all tasks in your project:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  machine: "large-1x",
});
```

See our [machines documentation](/docs/machines) for more information.

## Log level

You can set the log level for your project:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  logLevel: "debug",
});
```

The `logLevel` only determines which logs are sent to the Trigger.dev instance when using the `logger` API. All `console` based logs are always sent.

## Console logging

You can control console logging behavior in development:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  enableConsoleLogging: true, // Enable console logging while running dev CLI
  disableConsoleInterceptor: false, // Disable console interceptor (prevents logs from being sent to the trigger.dev dashboard)
});
```

## Max duration

You can set the default `maxDuration` for all tasks in your project:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  maxDuration: 60, // 60 seconds
});
```

See our [maxDuration guide](/docs/runs/max-duration) for more information.

## TTL

You can set a default time-to-live (TTL) for all task runs in your project. If a run is not dequeued within this time, it will expire and never execute.

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  ttl: "1h", // Also accepts a number of seconds, e.g. 3600
});
```

You can override this on a per-task basis by setting `ttl` on the task definition, or per-trigger by passing `ttl` in the trigger options. To opt a specific task out of the config-level TTL, set `ttl: 0` on the task. See [Time-to-live (TTL)](/docs/runs#time-to-live-ttl) for more information.

## Process keep alive

Keep the process alive after the task has finished running so the next task doesn't have to wait for the process to start up again.

Note that the process could be killed at any time, and we don't make any guarantees about the process being alive for a certain amount of time

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  processKeepAlive: true,
});
```

You can pass an object to the `processKeepAlive` option to configure the behavior:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  processKeepAlive: {
    enabled: true,
    // The maximum number of executions per process. If the process has run more than this number of times, it will be killed.
    maxExecutionsPerProcess: 50, // Default: 50
    // The maximum number of concurrent processes to keep alive in dev.
    devMaxPoolSize: 25, // Default: 25
  },
});
```

## Development behavior

You can control the working directory behavior in development:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  legacyDevProcessCwdBehaviour: false, // Default: true
});
```

When set to `false`, the current working directory will be set to the build directory, which more closely matches production behavior.

## CA certificates

CA Cert file to be added to NODE\_EXTRA\_CA\_CERT environment variable, useful in use with self signed cert in the trigger.dev environment.

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  // Must start with "./" and be relative to project root
  extraCACerts: "./certs/ca.crt",
});
```

## Build configuration

You can customize the build process using the `build` option:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    // Don't bundle these packages
    external: ["header-generator"],
    // Automatically detect external dependencies (default: true)
    autoDetectExternal: true,
    // Keep function/class names in bundle (default: true)
    keepNames: true,
    // Minify generated code (default: false, experimental)
    minify: false,
  },
});
```

<Note>
  The `trigger.config.ts` file is included in the bundle, but with the `build` configuration
  stripped out. These means any imports only used inside the `build` configuration are also removed
  from the final bundle.
</Note>

### External

All code is bundled by default, but you can exclude some packages from the bundle using the `external` option:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    external: ["header-generator"],
  },
});
```

When a package is excluded from the bundle, it will be added to a dynamically generated package.json file in the build directory. The version of the package will be the same as the version found in your `node_modules` directory.

Each entry in the external should be a package name, not necessarily the import path. For example, if you want to exclude the `ai` package, but you are importing `ai/rsc`, you should just include `ai` in the `external` array:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    external: ["ai"],
  },
});
```

#### WebAssembly (WASM) packages

Packages that use WebAssembly (WASM) must be added to the `external` array. WASM files are binary modules that need to be loaded at runtime and cannot be bundled into JavaScript code. When you add a WASM package to `external`, the package will be installed as a dependency in the runtime environment, ensuring the WASM files are available at their expected paths.

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    external: ["your-wasm-package-name"],
  },
});
```

<Note>
  Any packages that install or build a native binary or use WebAssembly (WASM) should be added to
  external, as they cannot be bundled. For example, `re2`, `sharp`, `sqlite3`, and WASM packages
  should be added to external.
</Note>

### JSX

You can customize the `jsx` options that are passed to `esbuild` using the `jsx` option:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    jsx: {
      // Use the Fragment component instead of React.Fragment
      fragment: "Fragment",
      // Use the h function instead of React.createElement
      factory: "h",
      // Turn off automatic runtime
      automatic: false,
    },
  },
});
```

By default we enabled [esbuild's automatic JSX runtime](https://esbuild.github.io/content-types/#auto-import-for-jsx) which means you don't need to import `React` in your JSX files. You can disable this by setting `automatic` to `false`.

See the [esbuild JSX documentation](https://esbuild.github.io/content-types/#jsx) for more information.

### Conditions

You can add custom [import conditions](https://esbuild.github.io/api/#conditions) to your build using the `conditions` option:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    conditions: ["react-server"],
  },
});
```

These conditions effect how imports are resolved during the build process. For example, the `react-server` condition will resolve `ai/rsc` to the server version of the `ai/rsc` export.

Custom conditions will also be passed to the `node` runtime when running your tasks.

### Extensions

Build extension allow you to hook into the build system and customize the build process or the resulting bundle and container image (in the case of deploying). You can use pre-built extensions by installing the `@trigger.dev/build` package into your `devDependencies`, or you can create your own.

#### additionalFiles

See the [additionalFiles documentation](/docs/config/extensions/additionalFiles) for more information.

#### `additionalPackages`

See the [additionalPackages documentation](/docs/config/extensions/additionalPackages) for more information.

#### `emitDecoratorMetadata`

See the [emitDecoratorMetadata documentation](/docs/config/extensions/emitDecoratorMetadata) for more information.

#### Prisma

See the [prismaExtension documentation](/docs/config/extensions/prismaExtension) for more information.

#### syncEnvVars

See the [syncEnvVars documentation](/docs/config/extensions/syncEnvVars) for more information.

#### puppeteer

See the [puppeteer documentation](/docs/config/extensions/puppeteer) for more information.

#### ffmpeg

See the [ffmpeg documentation](/docs/config/extensions/ffmpeg) for more information.

#### esbuild plugins

See the [esbuild plugins documentation](/docs/config/extensions/esbuildPlugin) for more information.

#### aptGet

See the [aptGet documentation](/docs/config/extensions/aptGet) for more information.


# Additional Files
Source: https://trigger.dev/docs/config/extensions/additionalFiles

Use the additionalFiles build extension to copy additional files to the build directory

Import the `additionalFiles` build extension and use it in your `trigger.config.ts` file:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { additionalFiles } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  // We strongly recommend setting this to false
  // When set to `false`, the current working directory will be set to the build directory, which more closely matches production behavior.
  legacyDevProcessCwdBehaviour: false, // Default: true
  build: {
    extensions: [additionalFiles({ files: ["./assets/**", "wrangler/wrangler.toml"] })],
  },
});
```

This will copy the files specified in the `files` array to the build directory. The `files` array can contain globs. The output paths will match the path of the file, relative to the root of the project.

This extension effects both the `dev` and the `deploy` commands, and the resulting paths will be the same for both.

If you use `legacyDevProcessCwdBehaviour: false`, you can then do this:

```ts theme={"theme":"css-variables"}
import path from "node:path";

// You can use `process.cwd()` if you use `legacyDevProcessCwdBehaviour: false`
const interRegularFont = path.join(process.cwd(), "assets/Inter-Regular.ttf");
```

<Note>The root of the project is the directory that contains the trigger.config.ts file</Note>


# Additional Packages
Source: https://trigger.dev/docs/config/extensions/additionalPackages

Use the additionalPackages build extension to include additional packages in the build

Import the `additionalPackages` build extension and use it in your `trigger.config.ts` file:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { additionalPackages } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    // Omit version for auto-resolution; for reproducible builds use e.g. packages: ["wrangler@X.Y.Z"]
    extensions: [additionalPackages({ packages: ["wrangler"] })],
  },
});
```

This allows you to include additional packages in the build that are not automatically included via imports. This is useful if you want to install a package that includes a CLI tool you can invoke in your tasks via `exec`. We will try to automatically resolve the version of the package but you can specify the version by using the `@` symbol.

If you omit the version, the build may use a cached or older resolution. For reproducible builds, pin the exact version (e.g. `wrangler@X.Y.Z`).

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { additionalPackages } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [additionalPackages({ packages: ["wrangler@X.Y.Z"] })],
  },
});
```

This extension does not do anything in `dev` mode, but it will install the packages in the build directory when you run `deploy`. The packages will be installed in the `node_modules` directory in the build directory.


# apt-get
Source: https://trigger.dev/docs/config/extensions/aptGet

Use the aptGet build extension to install system packages into the deployed image

You can install system packages into the deployed image using the `aptGet` extension:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { aptGet } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [aptGet({ packages: ["ffmpeg"] })],
  },
});
```

If you want to install a specific version of a package, you can specify the version like this:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [aptGet({ packages: ["ffmpeg=6.0-4"] })],
  },
});
```


# Audio Waveform
Source: https://trigger.dev/docs/config/extensions/audioWaveform

Use the audioWaveform build extension to add support for Audio Waveform in your project

Previously, we installed [Audio Waveform](https://github.com/bbc/audiowaveform) in the build image. That's been moved to a build extension:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { audioWaveform } from "@trigger.dev/build/extensions/audioWaveform";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [audioWaveform()], // uses verson 1.1.0 of audiowaveform by default
  },
});
```


# Custom build extensions
Source: https://trigger.dev/docs/config/extensions/custom

Customize how your project is built and deployed to Trigger.dev with your own custom build extensions

Build extensions allow you to hook into the build system and customize the build process or the resulting bundle and container image (in the case of deploying). See our [build extension overview](/docs/config/extensions/overview) for more information on how to install and use our built-in extensions. Build extensions can do the following:

* Add additional files to the build
* Add dependencies to the list of externals
* Add esbuild plugins
* Add additional npm dependencies
* Add additional system packages to the image build container
* Add commands to run in the image build container
* Add environment variables to the image build container
* Sync environment variables to your Trigger.dev project

## Creating a build extension

Build extensions are added to your `trigger.config.ts` file, with a required `name` and optional build hook functions. Here's a simple example of a build extension that just logs a message when the build starts:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "my-project",
  build: {
    extensions: [
      {
        name: "my-extension",
        onBuildStart: async (context) => {
          console.log("Build starting!");
        },
      },
    ],
  },
});
```

You can also extract that out into a function instead of defining it inline, in which case you will need to import the `BuildExtension` type from the `@trigger.dev/build` package:

<Note>
  You'll need to add the `@trigger.dev/build` package to your `devDependencies` before the below
  code will work. Make sure it's version matches that of the installed `@trigger.dev/sdk` package.
</Note>

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { BuildExtension } from "@trigger.dev/build";

export default defineConfig({
  project: "my-project",
  build: {
    extensions: [myExtension()],
  },
});

function myExtension(): BuildExtension {
  return {
    name: "my-extension",
    onBuildStart: async (context) => {
      console.log("Build starting!");
    },
  };
}
```

## Build hooks

### externalsForTarget

This allows the extension to add additional dependencies to the list of externals for the build. This is useful for dependencies that are not included in the bundle, but are expected to be available at runtime.

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "my-project",
  build: {
    extensions: [
      {
        name: "my-extension",
        externalsForTarget: async (target) => {
          return ["my-dependency"];
        },
      },
    ],
  },
});
```

### onBuildStart

This hook runs before the build starts. It receives the `BuildContext` object as an argument.

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "my-project",
  build: {
    extensions: [
      {
        name: "my-extension",
        onBuildStart: async (context) => {
          console.log("Build starting!");
        },
      },
    ],
  },
});
```

If you want to add an esbuild plugin, you must do so in the `onBuildStart` hook. Here's an example of adding a custom esbuild plugin:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "my-project",
  build: {
    extensions: [
      {
        name: "my-extension",
        onBuildStart: async (context) => {
          context.registerPlugin({
            name: "my-plugin",
            setup(build) {
              build.onLoad({ filter: /.*/, namespace: "file" }, async (args) => {
                return {
                  contents: "console.log('Hello, world!')",
                  loader: "js",
                };
              });
            },
          });
        },
      },
    ],
  },
});
```

You can use the `BuildContext.target` property to determine if the build is for `dev` or `deploy`:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "my-project",
  build: {
    extensions: [
      {
        name: "my-extension",
        onBuildStart: async (context) => {
          if (context.target === "dev") {
            console.log("Building for dev");
          } else {
            console.log("Building for deploy");
          }
        },
      },
    ],
  },
});
```

### onBuildComplete

This hook runs after the build completes. It receives the `BuildContext` object and a `BuildManifest` object as arguments. This is where you can add in one or more `BuildLayer`'s to the context.

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "my-project",
  build: {
    extensions: [
      {
        name: "my-extension",
        onBuildComplete: async (context, manifest) => {
          context.addLayer({
            id: "more-dependencies",
            dependencies,
          });
        },
      },
    ],
  },
});
```

See the [addLayer](#addlayer) documentation for more information on how to use `addLayer`.

## BuildTarget

Can either be `dev` or `deploy`, matching the CLI command name that is being run.

```sh theme={"theme":"css-variables"}
npx trigger.dev@latest dev # BuildTarget is "dev"
npx trigger.dev@latest deploy # BuildTarget is "deploy"
```

## BuildContext

### addLayer()

<ParamField type="BuildLayer">
  The layer to add to the build context. See the [BuildLayer](#buildlayer) documentation for more
  information.
</ParamField>

### registerPlugin()

<ParamField type="esbuild.Plugin">
  The esbuild plugin to register.
</ParamField>

<ParamField type="object">
  <Expandable title="properties">
    <ResponseField name="target" type="BuildTarget">
      An optional target to register the plugin for. If not provided, the plugin will be registered
      for all targets.
    </ResponseField>

    <ResponseField name="placement" type="first | last">
      An optional placement for the plugin. If not provided, the plugin will be registered in place.
      This allows you to control the order of plugins.
    </ResponseField>
  </Expandable>
</ParamField>

### resolvePath()

Resolves a path relative to the project's working directory.

<ParamField type="string">
  The path to resolve.
</ParamField>

```ts theme={"theme":"css-variables"}
const resolvedPath = context.resolvePath("my-other-dependency");
```

### properties

<ParamField type="BuildTarget">
  The target of the build, either `dev` or `deploy`.
</ParamField>

<ParamField type="ResolvedConfig">
  <Expandable title="properties">
    <ResponseField name="runtime" type="string">
      The runtime of the project (either node or bun)
    </ResponseField>

    <ResponseField name="project" type="string">
      The project ref
    </ResponseField>

    <ResponseField name="dirs" type="string[]">
      The trigger directories to search for tasks
    </ResponseField>

    <ResponseField name="build" type="object">
      The build configuration object
    </ResponseField>

    <ResponseField name="workingDir" type="string">
      The working directory of the project
    </ResponseField>

    <ResponseField name="workspaceDir" type="string">
      The root workspace directory of the project
    </ResponseField>

    <ResponseField name="packageJsonPath" type="string">
      The path to the package.json file
    </ResponseField>

    <ResponseField name="lockfilePath" type="string">
      The path to the lockfile (package-lock.json, yarn.lock, or pnpm-lock.yaml)
    </ResponseField>

    <ResponseField name="configFile" type="string">
      The path to the trigger.config.ts file
    </ResponseField>

    <ResponseField name="tsconfigPath" type="string">
      The path to the tsconfig.json file
    </ResponseField>
  </Expandable>
</ParamField>

<ParamField type="BuildLogger">
  A logger object that can be used to log messages to the console.
</ParamField>

## BuildLayer

<ParamField type="string">
  A unique identifier for the layer.
</ParamField>

<ParamField type="string[]">
  An array of commands to run in the image build container.

  ```ts theme={"theme":"css-variables"}
  commands: ["echo 'Hello, world!'"];
  ```

  These commands are run after packages have been installed and the code copied into the container in the "build" stage of the Dockerfile. This means you cannot install system packages in these commands because they won't be available in the final stage. To do that, please use the `pkgs` property of the `image` object.
</ParamField>

<ParamField type="object">
  <Expandable title="properties">
    <ResponseField name="pkgs" type="string[]">
      An array of system packages to install in the image build container.
    </ResponseField>

    <ResponseField name="instructions" type="string[]">
      An array of instructions to add to the Dockerfile.
    </ResponseField>
  </Expandable>
</ParamField>

<ParamField type="object">
  <Expandable title="properties">
    <ResponseField name="env" type="Record<string, string>">
      Environment variables to add to the image build container, but only during the "build" stage
      of the Dockerfile. This is where you'd put environment variables that are needed when running
      any of the commands in the `commands` array.
    </ResponseField>
  </Expandable>
</ParamField>

<ParamField type="object">
  <Expandable title="properties">
    <ResponseField name="env" type="Record<string, string>">
      Environment variables that should sync to the Trigger.dev project, which will then be avalable
      in your tasks at runtime. Importantly, these are NOT added to the image build container, but
      are instead added to the Trigger.dev project and stored securely.
    </ResponseField>
  </Expandable>
</ParamField>

<ParamField type="Record<string, string>">
  An object of dependencies to add to the build. The key is the package name and the value is the
  version.

  ```ts theme={"theme":"css-variables"}
  dependencies: {
    "my-dependency": "^1.0.0",
  };
  ```
</ParamField>

### examples

Add a command that will echo the value of an environment variable:

```ts theme={"theme":"css-variables"}
context.addLayer({
  id: "my-layer",
  commands: [`echo $MY_ENV_VAR`],
  build: {
    env: {
      MY_ENV_VAR: "Hello, world!",
    },
  },
});
```

## Troubleshooting

When creating a build extension, you may run into issues with the build process. One thing that can help is turning on `debug` logging when running either `dev` or `deploy`:

```sh theme={"theme":"css-variables"}
npx trigger.dev@latest dev --log-level debug
npx trigger.dev@latest deploy --log-level debug
```

Another helpful tool is the `--dry-run` flag on the `deploy` command, which will bundle your project and generate the Containerfile (e.g. the Dockerfile) without actually deploying it. This can help you see what the final image will look like and debug any issues with the build process.

```sh theme={"theme":"css-variables"}
npx trigger.dev@latest deploy --dry-run
```

You should also take a look at our built in extensions for inspiration on how to create your own. You can find them in in [the source code here](https://github.com/triggerdotdev/trigger.dev/tree/main/packages/build/src/extensions).


# Emit Decorator Metadata
Source: https://trigger.dev/docs/config/extensions/emitDecoratorMetadata

Use the emitDecoratorMetadata build extension to enable support for the emitDecoratorMetadata TypeScript compiler option

If you need support for the `emitDecoratorMetadata` typescript compiler option, import the `emitDecoratorMetadata` build extension and use it in your `trigger.config.ts` file:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { emitDecoratorMetadata } from "@trigger.dev/build/extensions/typescript";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [emitDecoratorMetadata()],
  },
});
```

This is usually required if you are using certain ORMs, like TypeORM, that require this option to be enabled. It's not enabled by default because there is a performance cost to enabling it.

<Note>
  `emitDecoratorMetadata` hooks into the esbuild bundle process and uses the TypeScript compiler API
  to compile files containing decorators. Enable `emitDecoratorMetadata` in your `tsconfig.json` and
  install `typescript` in your `devDependencies`.
</Note>

## Using with TypeScript 7

TypeScript 7 does not expose the JavaScript compiler API required by this extension. Install the TypeScript 6 compatibility package alongside TypeScript 7:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npm install --save-dev @typescript/typescript6@latest
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm add --save-dev @typescript/typescript6@latest
  ```

  ```bash bun theme={"theme":"css-variables"}
  bun add --dev @typescript/typescript6@latest
  ```
</CodeGroup>

Your project continues using TypeScript 7 for its normal type checking and compiler commands. The extension loads the compatibility package only when it needs to emit decorator metadata.

Restart the Trigger.dev dev server after installing the package.


# esbuild Plugin
Source: https://trigger.dev/docs/config/extensions/esbuildPlugin

Use the esbuildPlugin build extension to add existing or custom esbuild plugins to your build process

You can easily add existing or custom esbuild plugins to your build process using the `esbuildPlugin` extension:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { esbuildPlugin } from "@trigger.dev/build/extensions";
import { sentryEsbuildPlugin } from "@sentry/esbuild-plugin";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [
      esbuildPlugin(
        sentryEsbuildPlugin({
          org: process.env.SENTRY_ORG,
          project: process.env.SENTRY_PROJECT,
          authToken: process.env.SENTRY_AUTH_TOKEN,
        }),
        // optional - only runs during the deploy command, and adds the plugin to the end of the list of plugins
        { placement: "last", target: "deploy" }
      ),
    ],
  },
});
```


# FFmpeg
Source: https://trigger.dev/docs/config/extensions/ffmpeg

Use the ffmpeg build extension to include FFmpeg in your project

You can add the `ffmpeg` build extension to your build process:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { ffmpeg } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [ffmpeg()],
  },
});
```

By default, this will install the version of `ffmpeg` that is available in the Debian package manager (via `apt`).

## FFmpeg 7.x (static build)

If you need FFmpeg 7.x, you can pass `{ version: "7" }` to the extension. This will install a static build of FFmpeg 7.x instead of using the Debian package:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { ffmpeg } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [ffmpeg({ version: "7" })],
  },
});
```

This extension will also add the `FFMPEG_PATH` and `FFPROBE_PATH` to your environment variables, making it easy to use popular ffmpeg libraries like `fluent-ffmpeg`.

Note that `fluent-ffmpeg` needs to be added to [`external`](/docs/config/config-file#external) in your `trigger.config.ts` file.

Follow [this example](/docs/guides/examples/ffmpeg-video-processing) to get setup with Trigger.dev and FFmpeg in your project.


# Lightpanda build extension
Source: https://trigger.dev/docs/config/extensions/lightpanda

Use the lightpanda build extension to add Lightpanda browser to your project

To use the Lightpanda browser in your project, add the extension to your `trigger.config.ts` file:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { lightpanda } from "@trigger.dev/build/extensions/lightpanda";

export default defineConfig({
  project: "<project ref>",
  build: {
    extensions: [lightpanda()],
  },
});
```

## Options

* `version`: The version of the browser to install. Default: `"latest"`.
* `disableTelemetry`: Whether to disable telemetry. Default: `false`.

For example:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { lightpanda } from "@trigger.dev/build/extensions/lightpanda";

export default defineConfig({
  project: "<project ref>",
  build: {
    extensions: [
      lightpanda({
        version: "nightly",
        disableTelemetry: true,
      }),
    ],
  },
});
```

## Development

When running in dev, you will first have to download the Lightpanda browser binary and make sure it's in your `PATH`. See [Lightpanda's installation guide](https://lightpanda.io/docs/getting-started/installation).

## Next steps

<CardGroup>
  <Card title="Lightpanda" icon="bolt" href="/guides/examples/lightpanda">
    Learn how to use Lightpanda in your project.
  </Card>
</CardGroup>


# Build extensions
Source: https://trigger.dev/docs/config/extensions/overview

Customize how your project is built and deployed to Trigger.dev with build extensions

Build extensions allow you to hook into the build system and customize the build process or the resulting bundle and container image (in the case of deploying).

You can use pre-built extensions by installing the `@trigger.dev/build` package into your `devDependencies`, or you can create your own.

Build extensions are added to your `trigger.config.ts` file under the `build.extensions` property:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "my-project",
  build: {
    extensions: [
      {
        name: "my-extension",
        onBuildStart: async (context) => {
          console.log("Build starting!");
        },
      },
    ],
  },
});
```

If you are using a pre-built extension, you can import it from the `@trigger.dev/build` package:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { ffmpeg } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  project: "my-project",
  build: {
    extensions: [ffmpeg()],
  },
});
```

## Built-in extensions

Trigger.dev provides a set of built-in extensions that you can use to customize how your project is built and deployed. These extensions are available out of the box and can be configured in your `trigger.config.ts` file.

| Extension                                                                 | Description                                                                    |
| :------------------------------------------------------------------------ | :----------------------------------------------------------------------------- |
| [prismaExtension](/docs/config/extensions/prismaExtension)                     | Using prisma in your Trigger.dev tasks                                         |
| [pythonExtension](/docs/config/extensions/pythonExtension)                     | Execute Python scripts in your project                                         |
| [puppeteer](/docs/config/extensions/puppeteer)                                 | Use Puppeteer in your Trigger.dev tasks                                        |
| [ffmpeg](/docs/config/extensions/ffmpeg)                                       | Use FFmpeg in your Trigger.dev tasks                                           |
| [aptGet](/docs/config/extensions/aptGet)                                       | Install system packages in your build image                                    |
| [additionalFiles](/docs/config/extensions/additionalFiles)                     | Copy additional files to your build image                                      |
| [additionalPackages](/docs/config/extensions/additionalPackages)               | Install additional npm packages in your build image                            |
| [syncEnvVars](/docs/config/extensions/syncEnvVars)                             | Automatically sync environment variables from external services to Trigger.dev |
| [syncVercelEnvVars](/docs/config/extensions/syncEnvVars#syncVercelEnvVars)     | Automatically sync environment variables from Vercel to Trigger.dev            |
| [syncSupabaseEnvVars](/docs/config/extensions/syncEnvVars#syncSupabaseEnvVars) | Automatically sync environment variables from Supabase to Trigger.dev          |
| [esbuildPlugin](/docs/config/extensions/esbuildPlugin)                         | Add existing or custom esbuild extensions to customize your build process      |
| [emitDecoratorMetadata](/docs/config/extensions/emitDecoratorMetadata)         | Enable `emitDecoratorMetadata` in your TypeScript build                        |
| [audioWaveform](/docs/config/extensions/audioWaveform)                         | Add Audio Waveform to your build image                                         |

## Custom extensions

If one of the built-in extensions doesn't meet your needs, you can create your own custom extension. See our [guide on creating custom build extensions](/docs/config/extensions/custom) for more information.


# Playwright
Source: https://trigger.dev/docs/config/extensions/playwright

Use the playwright build extension to use Playwright with Trigger.dev

If you are using [Playwright](https://playwright.dev/), you should use the Playwright build extension.

* Automatically installs Playwright and required browser dependencies
* Allows you to specify which browsers to install (chromium, firefox, webkit)
* Supports headless or non-headless mode
* Lets you specify the Playwright version, or auto-detects it
* Installs only the dependencies needed for the selected browsers to optimize build time and image size

<Note>
  This extension only affects the build and deploy process, not the `dev` command.
</Note>

You can use it for a simple Playwright setup like this:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { playwright } from "@trigger.dev/build/extensions/playwright";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [
      playwright(),
    ],
  },
});
```

### Options

* `browsers`: Array of browsers to install. Valid values: `"chromium"`, `"firefox"`, `"webkit"`. Default: `["chromium"]`.
* `headless`: Run browsers in headless mode. Default: `true`. If set to `false`, a virtual display (Xvfb) will be set up automatically.
* `version`: Playwright version to install. If not provided, the version will be auto-detected from your dependencies (recommended).

  <Warning>
    Using a different version in your app than specified here will break things. We recommend not setting this option to automatically detect the version.
  </Warning>

### Custom browsers and version

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { playwright } from "@trigger.dev/build/extensions/playwright";

export default defineConfig({
  project: "<project ref>",
  build: {
    extensions: [
      playwright({
        browsers: ["chromium", "webkit"], // optional, will use ["chromium"] if not provided
        version: "1.43.1",  // optional, will automatically detect the version if not provided
      }),
    ],
  },
});
```

### Headless mode

By default, browsers are run in headless mode. If you need to run browsers with a UI (for example, for debugging), set `headless: false`. This will automatically set up a virtual display using Xvfb.

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { playwright } from "@trigger.dev/build/extensions/playwright";

export default defineConfig({
  project: "<project ref>",
  build: {
    extensions: [
      playwright({
        headless: false,
      }),
    ],
  },
});
```

### Environment variables

The extension sets the following environment variables during the build:

* `PLAYWRIGHT_BROWSERS_PATH`: Set to `/ms-playwright` so Playwright finds the installed browsers
* `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD`: Set to `1` to skip browser download at runtime
* `PLAYWRIGHT_SKIP_BROWSER_VALIDATION`: Set to `1` to skip browser validation at runtime
* `DISPLAY`: Set to `:99` if `headless: false` (for Xvfb)

## Troubleshooting

### Browser download failures

If you encounter errors during the build process related to browser downloads (e.g., "failed to solve: process did not complete successfully: exit code: 9"), this is a known issue with certain Playwright versions.

**Workaround:** Revert Playwright to version `1.40.0` in your project dependencies. You can specify this version explicitly in your config:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { playwright } from "@trigger.dev/build/extensions/playwright";

export default defineConfig({
  project: "<project ref>",
  build: {
    extensions: [
      playwright({
        version: "1.40.0",
      }),
    ],
  },
});
```

For more details, see [GitHub issue #2440](https://github.com/triggerdotdev/trigger.dev/issues/2440#issuecomment-3815104376).

## Managing browser instances

To prevent issues with waits and resumes, you can use middleware and locals to manage the browser instance. This will ensure the browser is available for the whole run, and is properly cleaned up on waits, resumes, and after the run completes.

Here's an example using `chromium`, but you can adapt it for other browsers:

```ts theme={"theme":"css-variables"}
import { logger, tasks, locals } from "@trigger.dev/sdk";
import { chromium, type Browser } from "playwright";

// Create a locals key for the browser instance
const PlaywrightBrowserLocal = locals.create<{ browser: Browser }>("playwright-browser");

export function getBrowser() {
  return locals.getOrThrow(PlaywrightBrowserLocal).browser;
}

export function setBrowser(browser: Browser) {
  locals.set(PlaywrightBrowserLocal, { browser });
}

tasks.middleware("playwright-browser", async ({ next }) => {
  // Launch the browser before the task runs
  const browser = await chromium.launch();
  setBrowser(browser);
  logger.log("[chromium]: Browser launched (middleware)");

  try {
    await next();
  } finally {
    // Always close the browser after the task completes
    await browser.close();
    logger.log("[chromium]: Browser closed (middleware)");
  }
});

tasks.onWait("playwright-browser", async () => {
  // Close the browser when the run is waiting
  const browser = getBrowser();
  await browser.close();
  logger.log("[chromium]: Browser closed (onWait)");
});

tasks.onResume("playwright-browser", async () => {
  // Relaunch the browser when the run resumes
  // Note: You will have to have to manually get a new browser instance in the run function
  const browser = await chromium.launch();
  setBrowser(browser);
  logger.log("[chromium]: Browser launched (onResume)");
});
```

You can then use `getBrowser()` in your task's `run` function to access the browser instance:

```ts theme={"theme":"css-variables"}
export const playwrightTestTask = task({
  id: "playwright-test",
  run: async () => {
    const browser = getBrowser();
    const page = await browser.newPage();
    await page.goto("https://google.com");
    await page.screenshot({ path: "screenshot.png" });
    await page.close();

    // Waits will gracefully close the browser
    await wait.for({ seconds: 10 });

    // On resume, we will re-launch the browser but you will have to manually get the new instance
    const newBrowser = getBrowser();
    const newPage = await newBrowser.newPage();
    await newPage.goto("https://playwright.dev");
    await newPage.screenshot({ path: "screenshot2.png" });
    await newPage.close();
  },
});
```


# Prisma
Source: https://trigger.dev/docs/config/extensions/prismaExtension

Use the prismaExtension build extension to use Prisma with Trigger.dev

The `prismaExtension` supports multiple Prisma versions and deployment strategies through **three distinct modes** that handle the evolving Prisma ecosystem, from legacy setups to Prisma 7.

<Note>
  The `prismaExtension` requires an explicit `mode` parameter. All configurations must specify a
  mode.
</Note>

## Migration from previous versions

### Before (pre 4.1.1)

```ts theme={"theme":"css-variables"}
import { prismaExtension } from "@trigger.dev/build/extensions/prisma";

extensions: [
  prismaExtension({
    schema: "prisma/schema.prisma",
    migrate: true,
    typedSql: true,
    directUrlEnvVarName: "DATABASE_URL_UNPOOLED",
  }),
];
```

### After (4.1.1+)

```ts theme={"theme":"css-variables"}
import { prismaExtension } from "@trigger.dev/build/extensions/prisma";

extensions: [
  prismaExtension({
    mode: "legacy", // MODE IS REQUIRED
    schema: "prisma/schema.prisma",
    migrate: true,
    typedSql: true,
    directUrlEnvVarName: "DATABASE_URL_UNPOOLED",
  }),
];
```

## Choosing the right mode

Use this decision tree to determine which mode is right for your project:

```mermaid theme={"theme":"css-variables"}
flowchart TD
    Start([Which mode should I use?]) --> Q1{Using Prisma 7 or<br/>6.20+ beta?}
    Q1 -->|Yes| Modern[Modern Mode]
    Q1 -->|No| Q2{Using Prisma 6.16+<br/>with engineType='client'?}
    Q2 -->|Yes| Modern
    Q2 -->|No| Q3{Need custom client<br/>output path?}
    Q3 -->|Yes| Q4{Want to manage<br/>prisma generate yourself?}
    Q4 -->|Yes| Engine[Engine-only Mode]
    Q4 -->|No| Legacy[Legacy Mode]
    Q3 -->|No| Legacy

    Modern -->|Features| ModernFeatures["• Zero configuration<br/>• Database adapters<br/>• Plain TypeScript (no binaries)<br/>• You manage generation"]
    Engine -->|Features| EngineFeatures["• Only installs engines<br/>• Auto version detection<br/>• You manage generation<br/>• Minimal overhead"]
    Legacy -->|Features| LegacyFeatures["• Auto prisma generate<br/>• Migrations support<br/>• TypedSQL support<br/>• Config file support"]

    style Modern fill:#10b981,stroke:#059669,color:#fff
    style Engine fill:#3b82f6,stroke:#2563eb,color:#fff
    style Legacy fill:#8b5cf6,stroke:#7c3aed,color:#fff
```

## Extension modes

### Legacy mode

**Use when:** You're using Prisma 6.x or earlier with the `prisma-client-js` provider.

**Features:**

* Automatic `prisma generate` during deployment
* Supports single-file schemas (`prisma/schema.prisma`)
* Supports multi-file schemas (Prisma 6.7+, directory-based schemas)
* Supports Prisma config files (`prisma.config.ts`) via `@prisma/config` package
* Migration support with `migrate: true`
* TypedSQL support with `typedSql: true`
* Custom generator selection
* Handles Prisma client versioning automatically (with filesystem fallback detection)
* Automatic extraction of schema and migrations paths from config files

**Schema configuration:**

```prisma theme={"theme":"css-variables"}
generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["typedSql"]
}

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DATABASE_URL_UNPOOLED")
}
```

**Extension configuration:**

```ts theme={"theme":"css-variables"}
// Single-file schema
prismaExtension({
  mode: "legacy",
  schema: "prisma/schema.prisma",
  migrate: true,
  typedSql: true,
  directUrlEnvVarName: "DATABASE_URL_UNPOOLED",
});

// Multi-file schema (Prisma 6.7+)
prismaExtension({
  mode: "legacy",
  schema: "./prisma", // Point to directory
  migrate: true,
  typedSql: true,
  directUrlEnvVarName: "DATABASE_URL_UNPOOLED",
});
```

**Tested versions:**

* Prisma 6.14.0
* Prisma 6.7.0+ (multi-file schema support)
* Prisma 5.x

***

### Engine-only mode

**Use when:** You have a custom Prisma client output path and want to manage `prisma generate` yourself.

**Features:**

* Only installs Prisma engine binaries (no client generation)
* Automatic version detection from `@prisma/client`
* Manual override of version and binary target
* Minimal overhead - just ensures engines are available
* You control when and how `prisma generate` runs

**Schema configuration:**

```prisma theme={"theme":"css-variables"}
generator client {
  provider      = "prisma-client-js"
  output        = "../src/generated/prisma"
  // Ensure the "debian-openssl-3.0.x" binary target is included for deployment to the trigger.dev cloud
  binaryTargets = ["native", "debian-openssl-3.0.x"]
}

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DATABASE_URL_UNPOOLED")
}
```

**Extension configuration:**

```ts theme={"theme":"css-variables"}
// Auto-detect version
prismaExtension({
  mode: "engine-only",
});

// Explicit version (recommended for reproducible builds)
prismaExtension({
  mode: "engine-only",
  version: "6.19.0",
});
```

**Important notes:**

* You **must** run `prisma generate` yourself (typically in a prebuild script)
* Your schema **must** include the correct `binaryTargets` for deployment to the trigger.dev cloud. The binary target is `debian-openssl-3.0.x`.
* The extension sets `PRISMA_QUERY_ENGINE_LIBRARY` and `PRISMA_QUERY_ENGINE_SCHEMA_ENGINE` environment variables to the correct paths for the binary targets.

**package.json example:**

```json theme={"theme":"css-variables"}
{
  "scripts": {
    "prebuild": "prisma generate",
    "dev": "trigger dev",
    "deploy": "trigger deploy"
  }
}
```

**Tested versions:**

* Prisma 6.19.0
* Prisma 6.16.0+

***

### Modern mode

**Use when:** You're using Prisma 6.16+ with the new `prisma-client` provider (with `engineType = "client"`) or preparing for Prisma 7.

**Features:**

* Designed for the new Prisma architecture
* Zero configuration required
* Automatically marks `@prisma/client` as external
* Works with Prisma 7 beta releases & Prisma 7 when released
* You manage client generation (like engine-only mode)

**Schema configuration (Prisma 6.16+ with engineType):**

```prisma theme={"theme":"css-variables"}
generator client {
  provider        = "prisma-client"
  output          = "../src/generated/prisma"
  engineType      = "client"
  previewFeatures = ["views"]
}

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DATABASE_URL_UNPOOLED")
}
```

**Schema configuration (Prisma 7):**

```prisma theme={"theme":"css-variables"}
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}
```

**Extension configuration:**

```ts theme={"theme":"css-variables"}
prismaExtension({
  mode: "modern",
});
```

**Prisma config (Prisma 7):**

```ts theme={"theme":"css-variables"}
// prisma.config.ts
import { defineConfig, env } from "prisma/config";
import "dotenv/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: env("DATABASE_URL"),
  },
});
```

**Important notes:**

* You **must** run `prisma generate` yourself.
* Requires Prisma 6.16.0+ or Prisma 7 beta
* The new `prisma-client` provider generates plain TypeScript (no Rust binaries)
* Requires database adapters (e.g., `@prisma/adapter-pg` for PostgreSQL)

**Tested versions:**

* Prisma 6.16.0 with `engineType = "client"`
* Prisma 6.20.0-integration-next.8 (Prisma 7 beta)

***

## Migration guide

### From old prismaExtension to legacy mode

If you were using the previous `prismaExtension`, migrate to legacy mode:

```ts theme={"theme":"css-variables"}
// Old
prismaExtension({
  schema: "prisma/schema.prisma",
  migrate: true,
});

// New - add mode
prismaExtension({
  mode: "legacy",
  schema: "prisma/schema.prisma",
  migrate: true,
});
```

### Preparing for Prisma 7

If you want to adopt the new Prisma architecture, use modern mode:

1. Update your schema to use `prisma-client` provider
2. Add database adapters to your dependencies
3. Configure the extension:

```ts theme={"theme":"css-variables"}
prismaExtension({
  mode: "modern",
});
```

### Manage your own prisma generate step

When using `modern` and `engine-only` modes, you'll need to ensure that you run `prisma generate` yourself before deploying your project.

#### Github Actions

If you are deploying your project using GitHub Actions, you can add a step to your workflow to run `prisma generate` before deploying your project, for example:

```yaml theme={"theme":"css-variables"}
steps:
  - name: Generate Prisma client
    run: npx prisma@6.19.0 generate
  - name: Deploy Trigger.dev
    run: npx trigger.dev@4.1.1 deploy
    env:
      TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
```

#### Trigger.dev Github integration

If you are using the [Trigger.dev Github integration](/docs/github-integration), you can configure a pre-build command to run `prisma generate` before deploying your project. Navigate to your project's settings page and configure the pre-build command to run `prisma generate`, for example:

<img alt="Pre-build command" />

***

## Version compatibility matrix

| Prisma version   | Recommended mode      | Notes                                        |
| ---------------- | --------------------- | -------------------------------------------- |
| \< 5.0           | Legacy                | Older Prisma versions                        |
| 5.0 - 6.15       | Legacy                | Standard Prisma setup                        |
| 6.7+             | Legacy                | Multi-file schema support                    |
| 6.16+            | Engine-only or Modern | Modern mode requires `engineType = "client"` |
| 6.20+ (7.0 beta) | Modern                | Prisma 7 with new architecture               |

***

## Prisma config file support

Legacy mode supports loading configuration from a `prisma.config.ts` file using the official `@prisma/config` package.

**Use when:** You want to use Prisma's new config file format (Prisma 6+) to centralize your Prisma configuration.

**Benefits:**

* Single source of truth for Prisma configuration
* Automatic extraction of schema location and migrations path
* Type-safe configuration with TypeScript
* Works seamlessly with Prisma 7's config-first approach

**prisma.config.ts:**

```ts theme={"theme":"css-variables"}
import { defineConfig, env } from "prisma/config";
import "dotenv/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: env("DATABASE_URL"),
    directUrl: env("DATABASE_URL_UNPOOLED"),
  },
});
```

**trigger.config.ts:**

```ts theme={"theme":"css-variables"}
import { prismaExtension } from "@trigger.dev/build/extensions/prisma";

prismaExtension({
  mode: "legacy",
  configFile: "./prisma.config.ts", // Use config file instead of schema
  migrate: true,
  directUrlEnvVarName: "DATABASE_URL_UNPOOLED", // For migrations
});
```

**What gets extracted:**

* `schema` - The schema file or directory path
* `migrations.path` - The migrations directory path (if specified)

**Note:** Either `schema` or `configFile` must be specified, but not both.

**When to use which:**

| Use `schema` option          | Use `configFile` option           |
| ---------------------------- | --------------------------------- |
| Standard Prisma setup        | Using Prisma 6+ with config files |
| Single or multi-file schemas | Preparing for Prisma 7            |
| No `prisma.config.ts` file   | Centralized configuration needed  |
| Simple setup                 | Want migrations path in config    |

***

## Multi-file schema support

Prisma 6.7 introduced support for splitting your schema across multiple files in a directory structure.

**Example structure:**

```
prisma/
├── schema.prisma (main file with generator/datasource)
├── models/
│   ├── users.prisma
│   └── posts.prisma
└── sql/
    └── getUserByEmail.sql
```

**Configuration:**

```ts theme={"theme":"css-variables"}
prismaExtension({
  mode: "legacy",
  schema: "./prisma", // Point to directory instead of file
  migrate: true,
  typedSql: true,
});
```

**package.json:**

```json theme={"theme":"css-variables"}
{
  "prisma": {
    "schema": "./prisma"
  }
}
```

***

## TypedSQL support

TypedSQL is available in legacy mode for Prisma 5.19.0+ with the `typedSql` preview feature.

**Schema configuration:**

```prisma theme={"theme":"css-variables"}
generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["typedSql"]
}
```

**Extension configuration:**

```ts theme={"theme":"css-variables"}
prismaExtension({
  mode: "legacy",
  schema: "prisma/schema.prisma",
  typedSql: true, // Enable TypedSQL
});
```

**Usage in tasks:**

```ts theme={"theme":"css-variables"}
import { db, sql } from "./db";

const users = await db.$queryRawTyped(sql.getUserByEmail("user@example.com"));
```

***

## Database migration support

Migrations are supported in legacy mode only.

**Extension configuration:**

```ts theme={"theme":"css-variables"}
// Using schema option
prismaExtension({
  mode: "legacy",
  schema: "prisma/schema.prisma",
  migrate: true, // Run migrations on deployment
  directUrlEnvVarName: "DATABASE_URL_UNPOOLED", // For connection pooling
});

// Using configFile option
prismaExtension({
  mode: "legacy",
  configFile: "./prisma.config.ts", // Migrations path extracted from config
  migrate: true,
});
```

**What this does:**

1. Copies `prisma/migrations/` to the build output
2. Runs `prisma migrate deploy` before generating the client
3. Uses the `directUrlEnvVarName` for unpooled connections (required for migrations)

When using `configFile`, the migrations path is automatically extracted from your `prisma.config.ts`:

```ts theme={"theme":"css-variables"}
// prisma.config.ts
export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations", // Automatically used by the extension
  },
});
```

***

## Binary targets and deployment

### Trigger.dev Cloud

The default binary target is `debian-openssl-3.0.x` for Trigger.dev Cloud deployments.

**Legacy mode:** Handled automatically

**Engine-only mode:** Specify in schema like so:

```prisma theme={"theme":"css-variables"}
generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "debian-openssl-3.0.x"]
}
```

**Modern mode:** Handled by database adapters

### Self-hosted / local deployment

For local deployments (e.g., Docker on macOS), you may need a different binary target like so:

```ts theme={"theme":"css-variables"}
prismaExtension({
  mode: "engine-only",
  version: "6.19.0",
  binaryTarget: "linux-arm64-openssl-3.0.x", // For macOS ARM64
});
```

***

## Environment variables

### Required variables

All modes:

* `DATABASE_URL`: Your database connection string

Legacy mode with migrations:

* `DATABASE_URL_UNPOOLED` (or your custom `directUrlEnvVarName`): Direct database connection for migrations

### Auto-set variables

Engine-only mode sets:

* `PRISMA_QUERY_ENGINE_LIBRARY`: Path to the query engine
* `PRISMA_QUERY_ENGINE_SCHEMA_ENGINE`: Path to the schema engine

***

## Troubleshooting

### "Could not find Prisma schema"

**Legacy mode:** Ensure the `schema` path is correct relative to your working directory.

```ts theme={"theme":"css-variables"}
// If your project structure is:
// project/
//   trigger.config.ts
//   prisma/
//     schema.prisma

prismaExtension({
  mode: "legacy",
  schema: "./prisma/schema.prisma", // Correct
  // schema: "prisma/schema.prisma", // Also works
});
```

### "Could not determine @prisma/client version"

The extension includes improved version detection that tries multiple strategies:

1. Check if `@prisma/client` is imported in your code (externals)
2. Use the `version` option if specified
3. Detect from filesystem by looking for `@prisma/client` or `prisma` in `node_modules`

**Legacy mode:** The extension will automatically detect the version from your installed packages. If it still fails, specify the version explicitly:

```ts theme={"theme":"css-variables"}
prismaExtension({
  mode: "legacy",
  schema: "prisma/schema.prisma",
  version: "6.19.0", // Add explicit version
});
```

**Engine-only mode:** Specify the version explicitly:

```ts theme={"theme":"css-variables"}
prismaExtension({
  mode: "engine-only",
  version: "6.19.0", // Add explicit version
});
```

### "Binary target not found"

**Engine-only mode:** Make sure your schema includes the deployment binary target:

```prisma theme={"theme":"css-variables"}
generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "linux-arm64-openssl-3.0.x"]
}
```

### "Module not found: @prisma/client/sql"

**Legacy mode:** Make sure `typedSql: true` is set and you have Prisma 5.19.0+:

```ts theme={"theme":"css-variables"}
prismaExtension({
  mode: "legacy",
  schema: "prisma/schema.prisma",
  typedSql: true, // Required for TypedSQL
});
```

### "Config file not found" or config loading errors

**Legacy mode with configFile:** Ensure the config file path is correct:

```ts theme={"theme":"css-variables"}
prismaExtension({
  mode: "legacy",
  configFile: "./prisma.config.ts", // Path relative to project root
  migrate: true,
});
```

**Requirements:**

* The config file must exist at the specified path
* Your project must have the `prisma` package installed (Prisma 6+)
* The config file must have a default export
* The config must specify a `schema` path

**Debugging:**

Use `--log-level debug` in your `trigger deploy` command to see detailed logs:

```ts theme={"theme":"css-variables"}
npx trigger.dev@latest deploy --log-level debug
```

Then grep for `[PrismaExtension]` in your build logs to see detailed information about config loading, schema resolution, and migrations setup.

***

## Complete examples

### Example 1: Standard Prisma 6 setup (legacy mode)

**prisma/schema.prisma:**

```prisma theme={"theme":"css-variables"}
generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["typedSql"]
}

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DATABASE_URL_UNPOOLED")
}
```

**trigger.config.ts:**

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { prismaExtension } from "@trigger.dev/build/extensions/prisma";

export default defineConfig({
  project: process.env.TRIGGER_PROJECT_REF!,
  build: {
    extensions: [
      prismaExtension({
        mode: "legacy",
        schema: "prisma/schema.prisma",
        migrate: true,
        typedSql: true,
        directUrlEnvVarName: "DATABASE_URL_UNPOOLED",
      }),
    ],
  },
});
```

***

### Example 2: Multi-file schema (legacy mode)

**prisma/schema.prisma:**

```prisma theme={"theme":"css-variables"}
generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["typedSql"]
}

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DATABASE_URL_UNPOOLED")
}
```

**prisma/models/users.prisma:**

```prisma theme={"theme":"css-variables"}
model User {
  id        String  @id @default(cuid())
  email     String  @unique
  name      String?
  posts     Post[]
}
```

**prisma/models/posts.prisma:**

```prisma theme={"theme":"css-variables"}
model Post {
  id        String   @id @default(cuid())
  title     String
  content   String
  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
}
```

**package.json:**

```json theme={"theme":"css-variables"}
{
  "prisma": {
    "schema": "./prisma"
  }
}
```

**trigger.config.ts:**

```ts theme={"theme":"css-variables"}
prismaExtension({
  mode: "legacy",
  schema: "./prisma", // Directory, not file
  migrate: true,
  typedSql: true,
  directUrlEnvVarName: "DATABASE_URL_UNPOOLED",
});
```

***

### Example 3: Using Prisma config file (legacy mode)

Use a `prisma.config.ts` file to centralize your Prisma configuration.

**prisma.config.ts:**

```ts theme={"theme":"css-variables"}
import { defineConfig, env } from "prisma/config";
import "dotenv/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: env("DATABASE_URL"),
    directUrl: env("DATABASE_URL_UNPOOLED"),
  },
});
```

**prisma/schema.prisma:**

```prisma theme={"theme":"css-variables"}
generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["typedSql"]
}

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DATABASE_URL_UNPOOLED")
}

model User {
  id        String  @id @default(cuid())
  email     String  @unique
  name      String?
}
```

**trigger.config.ts:**

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { prismaExtension } from "@trigger.dev/build/extensions/prisma";

export default defineConfig({
  project: process.env.TRIGGER_PROJECT_REF!,
  build: {
    extensions: [
      prismaExtension({
        mode: "legacy",
        configFile: "./prisma.config.ts", // Load from config file
        migrate: true,
        typedSql: true,
        // schema and migrations path are extracted from prisma.config.ts
      }),
    ],
  },
});
```

**src/db.ts:**

```ts theme={"theme":"css-variables"}
import { PrismaClient } from "@prisma/client";
export * as sql from "@prisma/client/sql";

export const db = new PrismaClient({
  datasources: {
    db: {
      url: process.env.DATABASE_URL,
    },
  },
});
```

***

### Example 4: Custom output path (engine-only mode)

**prisma/schema.prisma:**

```prisma theme={"theme":"css-variables"}
generator client {
  provider      = "prisma-client-js"
  output        = "../src/generated/prisma"
  binaryTargets = ["native", "linux-arm64-openssl-3.0.x"]
}

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DATABASE_URL_UNPOOLED")
}
```

**package.json:**

```json theme={"theme":"css-variables"}
{
  "scripts": {
    "generate": "prisma generate",
    "dev": "pnpm generate && trigger dev",
    "deploy": "trigger deploy"
  }
}
```

**trigger.config.ts:**

```ts theme={"theme":"css-variables"}
prismaExtension({
  mode: "engine-only",
  version: "6.19.0",
  binaryTarget: "linux-arm64-openssl-3.0.x",
});
```

**src/db.ts:**

```ts theme={"theme":"css-variables"}
import { PrismaClient } from "./generated/prisma/client.js";

export const db = new PrismaClient({
  datasources: {
    db: {
      url: process.env.DATABASE_URL,
    },
  },
});
```

***

### Example 5: Prisma 7 beta (modern mode)

**prisma/schema.prisma:**

```prisma theme={"theme":"css-variables"}
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}
```

**prisma.config.ts:**

```ts theme={"theme":"css-variables"}
import { defineConfig, env } from "prisma/config";
import "dotenv/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: env("DATABASE_URL"),
  },
});
```

**package.json:**

```json theme={"theme":"css-variables"}
{
  "dependencies": {
    "@prisma/client": "6.20.0-integration-next.8",
    "@prisma/adapter-pg": "6.20.0-integration-next.8"
  },
  "scripts": {
    "generate": "prisma generate",
    "dev": "pnpm generate && trigger dev",
    "deploy": "trigger deploy"
  }
}
```

**trigger.config.ts:**

```ts theme={"theme":"css-variables"}
prismaExtension({
  mode: "modern",
});
```

**src/db.ts:**

```ts theme={"theme":"css-variables"}
import { PrismaClient } from "./generated/prisma/client.js";
import { PrismaPg } from "@prisma/adapter-pg";

const adapter = new PrismaPg({
  connectionString: process.env.DATABASE_URL!,
});

export const db = new PrismaClient({ adapter });
```

***

## Resources

* [Prisma Documentation](https://www.prisma.io/docs)
* [Multi-File Schema (Prisma 6.7+)](https://www.prisma.io/docs/orm/prisma-schema/overview/location#multi-file-prisma-schema)
* [TypedSQL (Prisma 5.19+)](https://www.prisma.io/docs/orm/prisma-client/using-raw-sql/typedsql)
* [Prisma 7 Beta Documentation](https://www.prisma.io/docs)

***


# Puppeteer build extension
Source: https://trigger.dev/docs/config/extensions/puppeteer

Use the puppeteer build extension to enable support for Puppeteer in your project

<ScrapingWarning />

To use Puppeteer in your project, add these build settings to your `trigger.config.ts` file:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { puppeteer } from "@trigger.dev/build/extensions/puppeteer";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [puppeteer()],
  },
});
```

And add the following environment variable in your Trigger.dev dashboard on the Environment Variables page:

```bash theme={"theme":"css-variables"}
PUPPETEER_EXECUTABLE_PATH: "/usr/bin/google-chrome-stable",
```

Follow [this example](/docs/guides/examples/puppeteer) to get setup with Trigger.dev and Puppeteer in your project.


# Python
Source: https://trigger.dev/docs/config/extensions/pythonExtension

Use the python build extension to add support for executing Python scripts in your project

If you need to execute Python scripts in your Trigger.dev project, you can use the `pythonExtension` build extension via the `@trigger.dev/python` package.

First, you'll need to install the `@trigger.dev/python` package:

```bash theme={"theme":"css-variables"}
npm add @trigger.dev/python
```

Then, you can use the `pythonExtension` build extension in your `trigger.config.ts` file:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { pythonExtension } from "@trigger.dev/python/extension";

export default defineConfig({
  project: "<project ref>",
  build: {
    extensions: [pythonExtension()],
  },
});
```

This will take care of adding python to the build image and setting up the necessary environment variables to execute Python scripts. You can then use our `python` utilities in the `@trigger.dev/python` package to execute Python scripts in your tasks. For example, running a Python script inline in a task:

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { python } from "@trigger.dev/python";

export const myScript = task({
  id: "my-python-script",
  run: async () => {
    const result = await python.runInline(`print("Hello, world!")`);
    return result.stdout;
  },
});
```

## Adding python scripts

You can automatically add python scripts to your project using the `scripts` option in the `pythonExtension` function. This will copy the specified scripts to the build directory during the deploy process. For example:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { pythonExtension } from "@trigger.dev/python/extension";

export default defineConfig({
  project: "<project ref>",
  build: {
    extensions: [
      pythonExtension({
        scripts: ["./python/**/*.py"],
      }),
    ],
  },
});
```

This will copy all Python files in the `python` directory to the build directory during the deploy process. You can then execute these scripts using the `python.runScript` function:

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { python } from "@trigger.dev/python";

export const myScript = task({
  id: "my-python-script",
  run: async () => {
    const result = await python.runScript("./python/my_script.py", ["hello", "world"]);
    return result.stdout;
  },
});
```

<Note>
  The pythonExtension will also take care of moving the scripts to the correct location during `dev`
  mode, so you can use the same exact path in development as you do in production.
</Note>

## Using requirements files

If you have a `requirements.txt` file in your project, you can use the `requirementsFile` option in the `pythonExtension` function to install the required packages during the build process. For example:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { pythonExtension } from "@trigger.dev/python/extension";

export default defineConfig({
  project: "<project ref>",
  build: {
    extensions: [
      pythonExtension({
        requirementsFile: "./requirements.txt",
      }),
    ],
  },
});
```

This will install the packages specified in the `requirements.txt` file during the build process. You can then use these packages in your Python scripts.

<Note>
  The `requirementsFile` option is only available in production mode. In development mode, you can
  install the required packages manually using the `pip` command.
</Note>

## Virtual environments

If you are using a virtual environment in your project, you can use the `devPythonBinaryPath` option in the `pythonExtension` function to specify the path to the Python binary in the virtual environment. For example:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { pythonExtension } from "@trigger.dev/python/extension";

export default defineConfig({
  project: "<project ref>",
  build: {
    extensions: [
      pythonExtension({
        devPythonBinaryPath: ".venv/bin/python",
      }),
    ],
  },
});
```

This has no effect in production mode, but in development mode, it will use the specified Python binary to execute Python scripts.

## Streaming output

All of the `python` functions have a streaming version that allows you to stream the output of the Python script as it runs. For example:

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { python } from "@trigger.dev/python";

export const myStreamingScript = task({
  id: "my-streaming-python-script",
  run: async () => {
    // You don't need to await the result
    const result = python.stream.runScript("./python/my_script.py", ["hello", "world"]);

    // result is an async iterable/readable stream
    for await (const chunk of streamingResult) {
      console.log(chunk);
    }
  },
});
```

## Environment variables

We automatically inject the environment variables in the `process.env` object when running Python scripts. You can access these environment variables in your Python scripts using the `os.environ` dictionary. For example:

```python theme={"theme":"css-variables"}
import os

print(os.environ["MY_ENV_VAR"])
```

You can also pass additional environment variables to the Python script using the `env` option in the `python.runScript` function. For example:

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { python } from "@trigger.dev/python";

export const myScript = task({
  id: "my-python-script",
  run: async () => {
    const result = await python.runScript("./python/my_script.py", ["hello", "world"], {
      env: {
        MY_ENV_VAR: "my value",
      },
    });
    return result.stdout;
  },
});
```


# Sync env vars
Source: https://trigger.dev/docs/config/extensions/syncEnvVars

Use the syncEnvVars build extension to automatically sync environment variables to Trigger.dev

The `syncEnvVars` build extension will sync env vars from another service into Trigger.dev before the deployment starts. This is useful if you are using a secret store service like Infisical or AWS Secrets Manager to store your secrets.

`syncEnvVars` takes an async callback function, and any env vars returned from the callback will be synced to Trigger.dev.

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { syncEnvVars } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  build: {
    extensions: [
      syncEnvVars(async (ctx) => {
        return [
          { name: "SECRET_KEY", value: "secret-value" },
          { name: "ANOTHER_SECRET", value: "another-secret-value" },
        ];
      }),
    ],
  },
});
```

The callback is passed a context object with the following properties:

* `environment`: The environment name that the task is being deployed to (e.g. `production`, `staging`, etc.)
* `projectRef`: The project ref of the Trigger.dev project
* `env`: The environment variables that are currently set in the Trigger.dev project

### Marking variables as secrets

Return the array form and set `isSecret: true` on any variable you want stored as a [secret](/docs/deploy-environment-variables). Secret env vars are redacted in the dashboard and their values can't be revealed after they're set, just like manually created secret variables. You can mix secret and non-secret variables in the same callback.

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { syncEnvVars } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  build: {
    extensions: [
      syncEnvVars(async (ctx) => {
        return [
          { name: "PUBLIC_API_URL", value: "https://api.example.com" },
          { name: "DATABASE_URL", value: "postgres://...", isSecret: true },
        ];
      }),
    ],
  },
});
```

<Note>
  `isSecret` is only available when you return the array form (`{ name, value, isSecret }`). The
  record form (`{ KEY: "value" }`) always creates non-secret variables.
</Note>

### Example: Sync env vars from Infisical

In this example we're using env vars from [Infisical](https://infisical.com).

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { syncEnvVars } from "@trigger.dev/build/extensions/core";
import { InfisicalSDK } from "@infisical/sdk";

export default defineConfig({
  build: {
    extensions: [
      syncEnvVars(async (ctx) => {
        const client = new InfisicalSDK();

        await client.auth().universalAuth.login({
          clientId: process.env.INFISICAL_CLIENT_ID!,
          clientSecret: process.env.INFISICAL_CLIENT_SECRET!,
        });

        const { secrets } = await client.secrets().listSecrets({
          environment: ctx.environment,
          projectId: process.env.INFISICAL_PROJECT_ID!,
        });

        return secrets.map((secret) => ({
          name: secret.secretKey,
          value: secret.secretValue,
        }));
      }),
    ],
  },
});
```

### syncVercelEnvVars

The `syncVercelEnvVars` build extension syncs environment variables from your Vercel project to Trigger.dev.

<AccordionGroup>
  <Accordion title="Setting up authentication including team projects">
    You need to set the `VERCEL_ACCESS_TOKEN` and `VERCEL_PROJECT_ID` environment variables, or pass
    in the token and project ID as arguments to the `syncVercelEnvVars` build extension. If you're
    working with a team project, you'll also need to set `VERCEL_TEAM_ID`, which can be found in your
    team settings.

    You can find / generate the `VERCEL_ACCESS_TOKEN` in your Vercel
    [dashboard](https://vercel.com/account/settings/tokens). Make sure the scope of the token covers
    the project with the environment variables you want to sync.
  </Accordion>

  <Accordion title="Running in Vercel build environment">
    When running the build from a Vercel build environment (e.g., during a Vercel deployment), the
    environment variable values will be read from `process.env` instead of fetching them from the
    Vercel API. This is determined by checking if the `VERCEL` environment variable is present.

    The API is still used to determine which environment variables are configured for your project, but
    the actual values come from the local environment. Reading values from `process.env` allows the
    extension to use values that Vercel integrations (such as the Neon integration) set per preview
    deployment in the "Provisioning Integrations" phase that happens just before the Vercel build
    starts.
  </Accordion>

  <Accordion title="Using with Neon database Vercel integration">
    If you have the Neon database Vercel integration installed and are running builds outside of the
    Vercel environment, we recommend using `syncNeonEnvVars` in addition to `syncVercelEnvVars` for your
    database environment variables.

    This ensures that the correct database connection strings are used for your
    selected environment and current branch, as `syncVercelEnvVars` may not accurately reflect
    branch-specific database credentials when run locally.
  </Accordion>
</AccordionGroup>

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { syncVercelEnvVars } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    // This will automatically use the VERCEL_ACCESS_TOKEN and VERCEL_PROJECT_ID environment variables
    extensions: [syncVercelEnvVars()],
  },
});
```

Or you can pass in the token and project ID as arguments:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { syncVercelEnvVars } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [
      syncVercelEnvVars({
        projectId: "your-vercel-project-id",
        vercelAccessToken: "your-vercel-access-token", // optional, we recommend to keep it as env variable
        vercelTeamId: "your-vercel-team-id", // optional
      }),
    ],
  },
});
```

### syncNeonEnvVars

The `syncNeonEnvVars` build extension syncs environment variables from your Neon database project to Trigger.dev. It automatically detects branches and builds the appropriate database connection strings for your environment.

<AccordionGroup>
  <Accordion title="Setting up authentication">
    You need to set the `NEON_ACCESS_TOKEN` and `NEON_PROJECT_ID` environment variables, or pass them
    as arguments to the `syncNeonEnvVars` build extension.

    You can generate a `NEON_ACCESS_TOKEN` in your Neon [dashboard](https://console.neon.tech/app/settings/api-keys).
  </Accordion>

  <Accordion title="Running in Vercel environment">
    When running the build from a Vercel environment (determined by checking if the `VERCEL`
    environment variable is present), this extension is skipped entirely.

    This is because Neon's Vercel integration already handles environment variable synchronization in Vercel environments.
  </Accordion>

  <Accordion title="Using with Neon database Vercel integration">
    If you have the Neon database Vercel integration installed and are running builds outside of the
    Vercel environment, we recommend using `syncNeonEnvVars` in addition to `syncVercelEnvVars` for your
    database environment variables.

    This ensures that the correct database connection strings are used for your selected environment and current branch, as `syncVercelEnvVars` may not accurately reflect branch-specific database credentials when run locally.
  </Accordion>
</AccordionGroup>

<Note>
  This extension is skipped for `prod` environments. It is designed to sync branch-specific
  database connections for preview/staging environments.
</Note>

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { syncNeonEnvVars } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    // This will automatically use the NEON_ACCESS_TOKEN and NEON_PROJECT_ID environment variables
    extensions: [syncNeonEnvVars()],
  },
});
```

Or you can pass in the token and project ID as arguments:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { syncNeonEnvVars } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [
      syncNeonEnvVars({
        projectId: "your-neon-project-id",
        neonAccessToken: "your-neon-access-token", // optional, we recommend to keep it as env variable
        branch: "your-branch-name", // optional, defaults to ctx.branch
        databaseName: "your-database-name", // optional, defaults to the first database
        roleName: "your-role-name", // optional, defaults to the database owner
        envVarPrefix: "MY_PREFIX_", // optional, prefix for all synced env vars
      }),
    ],
  },
});
```

The extension syncs the following environment variables (with optional prefix):

* `DATABASE_URL` - Pooled connection string
* `DATABASE_URL_UNPOOLED` - Direct connection string
* `POSTGRES_URL`, `POSTGRES_URL_NO_SSL`, `POSTGRES_URL_NON_POOLING`
* `POSTGRES_PRISMA_URL` - Connection string optimized for Prisma
* `POSTGRES_HOST`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DATABASE`
* `PGHOST`, `PGHOST_UNPOOLED`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`

### syncSupabaseEnvVars

The `syncSupabaseEnvVars` build extension syncs environment variables from your Supabase project to Trigger.dev. It uses [Supabase Branching](https://supabase.com/docs/guides/deployment/branching) to automatically detect branches and build the appropriate database connection strings and API keys for your environment.

<AccordionGroup>
  <Accordion title="Setting up authentication">
    You need to set the `SUPABASE_ACCESS_TOKEN` and `SUPABASE_PROJECT_ID` environment variables, or pass them
    as arguments to the `syncSupabaseEnvVars` build extension.

    You can generate a `SUPABASE_ACCESS_TOKEN` in your Supabase [dashboard](https://supabase.com/dashboard/account/tokens).
  </Accordion>

  <Accordion title="Running in Vercel environment">
    When running the build from a Vercel environment (determined by checking if the `VERCEL`
    environment variable is present), this extension is skipped entirely.
  </Accordion>
</AccordionGroup>

<Note>
  For `prod` environments, this extension uses credentials from your default Supabase
  branch. For `preview` and `staging` environments, it matches the git branch name to a Supabase
  branch and syncs the corresponding database connection strings and API keys. `dev` environments are skipped.
</Note>

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { syncSupabaseEnvVars } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    // This will automatically use the SUPABASE_ACCESS_TOKEN and SUPABASE_PROJECT_ID environment variables
    extensions: [syncSupabaseEnvVars()],
  },
});
```

Or you can pass in the token, project ID, and other options as arguments:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { syncSupabaseEnvVars } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [
      syncSupabaseEnvVars({
        projectId: "your-supabase-project-id",
        supabaseAccessToken: "your-supabase-access-token", // optional, we recommend to keep it as env variable
        branch: "your-branch-name", // optional, defaults to ctx.branch
        envVarPrefix: "MY_PREFIX_", // optional, prefix for all synced env vars
      }),
    ],
  },
});
```

The extension syncs the following environment variables (with optional prefix):

* `DATABASE_URL`, `POSTGRES_URL`, `SUPABASE_DB_URL` — PostgreSQL connection strings
* `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE` — Individual connection parameters
* `SUPABASE_URL` — Supabase API URL
* `SUPABASE_ANON_KEY` — Anonymous API key
* `SUPABASE_SERVICE_ROLE_KEY` — Service role API key
* `SUPABASE_JWT_SECRET` — JWT secret


# Context
Source: https://trigger.dev/docs/context

Get the context of a task run.

Context (`ctx`) is a way to get information about a run.

<Note>
  The context object does not change whilst your code is executing. This means values like
  `ctx.run.durationMs` will be fixed at the moment the `run()` function is called.
</Note>

<RequestExample>
  ```typescript Context example theme={"theme":"css-variables"}
  import { task } from "@trigger.dev/sdk";

  export const parentTask = task({
    id: "parent-task",
    run: async (payload: { message: string }, { ctx }) => {
      if (ctx.environment.type === "DEVELOPMENT") {
        return;
      }
    },
  });
  ```
</RequestExample>

## Context properties

<ResponseField name="task" type="object">
  <Expandable title="properties">
    <ResponseField name="exportName" type="string">
      The exported function name of the task e.g. `myTask` if you defined it like this: `export
                  const myTask = task(...)`.
    </ResponseField>

    <ResponseField name="id" type="string">
      The ID of the task.
    </ResponseField>

    <ResponseField name="filePath" type="string">
      The file path of the task.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="attempt" type="object">
  <Expandable title="properties">
    <ResponseField name="id" type="string">
      The ID of the execution attempt.
    </ResponseField>

    <ResponseField name="number" type="number">
      The attempt number.
    </ResponseField>

    <ResponseField name="startedAt" type="date">
      The start time of the attempt.
    </ResponseField>

    <ResponseField name="backgroundWorkerId" type="string">
      The ID of the background worker.
    </ResponseField>

    <ResponseField name="backgroundWorkerTaskId" type="string">
      The ID of the background worker task.
    </ResponseField>

    <ResponseField name="status" type="string">
      The current status of the attempt.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="run" type="object">
  <Expandable title="properties">
    <ResponseField name="id" type="string">
      The ID of the task run.
    </ResponseField>

    <ResponseField name="context" type="any">
      The context of the task run.
    </ResponseField>

    <ResponseField name="tags" type="array">
      An array of [tags](/docs/tags) associated with the task run.
    </ResponseField>

    <ResponseField name="isTest" type="boolean">
      Whether this is a [test run](/docs/run-tests).
    </ResponseField>

    <ResponseField name="isReplay" type="boolean">
      Whether this run is a [replay](/docs/replaying) of a previous run.
    </ResponseField>

    <ResponseField name="createdAt" type="date">
      The creation time of the task run.
    </ResponseField>

    <ResponseField name="startedAt" type="date">
      The start time of the task run.
    </ResponseField>

    <ResponseField name="idempotencyKey" type="string">
      An optional [idempotency key](/docs/idempotency) for the task run.
    </ResponseField>

    <ResponseField name="maxAttempts" type="number">
      The [maximum number of attempts](/docs/triggering#maxattempts) allowed for this task run.
    </ResponseField>

    <ResponseField name="durationMs" type="number">
      The duration of the task run in milliseconds when the `run()` function is called. For live
      values use the [usage SDK functions](/docs/run-usage).
    </ResponseField>

    <ResponseField name="costInCents" type="number">
      The cost of the task run in cents when the `run()` function is called. For live values use the
      [usage SDK functions](/docs/run-usage).
    </ResponseField>

    <ResponseField name="baseCostInCents" type="number">
      The base cost of the task run in cents when the `run()` function is called. For live values
      use the [usage SDK functions](/docs/run-usage).
    </ResponseField>

    <ResponseField name="version" type="string">
      The [version](/docs/versioning) of the task run.
    </ResponseField>

    <ResponseField name="maxDuration" type="number">
      The [maximum allowed duration](/docs/runs/max-duration) for the task run.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="queue" type="object">
  <Expandable title="properties">
    <ResponseField name="id" type="string">
      The ID of the queue.
    </ResponseField>

    <ResponseField name="name" type="string">
      The name of the queue.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="environment" type="object">
  <Expandable title="properties">
    <ResponseField name="id" type="string">
      The ID of the environment.
    </ResponseField>

    <ResponseField name="slug" type="string">
      The slug of the environment.
    </ResponseField>

    <ResponseField name="type" type="string">
      The type of the environment (PRODUCTION, STAGING, DEVELOPMENT, or PREVIEW).
    </ResponseField>

    <ResponseField name="branchName" type="string">
      If the environment is `PREVIEW` then this will be the branch name.
    </ResponseField>

    <ResponseField name="git" type="object">
      <Expandable title="properties">
        <ResponseField name="commitAuthorName" type="string">
          The name of the commit author.
        </ResponseField>

        <ResponseField name="commitMessage" type="string">
          The message of the commit.
        </ResponseField>

        <ResponseField name="commitRef" type="string">
          The ref of the commit.
        </ResponseField>

        <ResponseField name="commitSha" type="string">
          The SHA of the commit.
        </ResponseField>

        <ResponseField name="dirty" type="boolean">
          Whether the commit is dirty, i.e. there are uncommitted changes.
        </ResponseField>

        <ResponseField name="remoteUrl" type="string">
          The remote URL of the repository.
        </ResponseField>

        <ResponseField name="pullRequestNumber" type="number">
          The number of the pull request.
        </ResponseField>

        <ResponseField name="pullRequestTitle" type="string">
          The title of the pull request.
        </ResponseField>

        <ResponseField name="pullRequestState" type="string">
          The state of the pull request (open, closed, or merged).
        </ResponseField>
      </Expandable>
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="organization" type="object">
  <Expandable title="properties">
    <ResponseField name="id" type="string">
      The ID of the organization.
    </ResponseField>

    <ResponseField name="slug" type="string">
      The slug of the organization.
    </ResponseField>

    <ResponseField name="name" type="string">
      The name of the organization.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="project" type="object">
  <Expandable title="properties">
    <ResponseField name="id" type="string">
      The ID of the project.
    </ResponseField>

    <ResponseField name="ref" type="string">
      The reference of the project.
    </ResponseField>

    <ResponseField name="slug" type="string">
      The slug of the project.
    </ResponseField>

    <ResponseField name="name" type="string">
      The name of the project.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="batch" type="object">
  Optional information about the batch, if applicable.

  <Expandable title="properties">
    <ResponseField name="id" type="string">
      The ID of the batch.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="machine" type="object">
  Optional information about the machine preset used for execution.

  <Expandable title="properties">
    <ResponseField name="name" type="string">
      The name of the machine preset.
    </ResponseField>

    <ResponseField name="cpu" type="number">
      The CPU allocation for the machine.
    </ResponseField>

    <ResponseField name="memory" type="number">
      The memory allocation for the machine.
    </ResponseField>

    <ResponseField name="centsPerMs" type="number">
      The cost in cents per millisecond for this machine preset.
    </ResponseField>
  </Expandable>
</ResponseField>


# Database connections
Source: https://trigger.dev/docs/database-connections

Connect a database to your tasks: where to create the client, how to size the pool for your provider's connection limit, and how to release connections so you don't run out.

Tasks connect to your database from their own process. This guide covers the recommended setup for each client, how to size the pool against your provider's connection limit, and how to release connections at waits.

## Create the client once

Create the client at module scope and import it wherever you query. The worker loads the module once per process, so every run on that worker reuses the same pool. Keep the pool small (see [Size the pool](#size-the-pool)) and attach an error handler, since an idle connection can error asynchronously and an unhandled `error` event crashes the worker.

<CodeGroup>
  ```ts lib/db.ts (node-postgres) theme={"theme":"css-variables"}
  import { Pool } from "pg";

  export const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
    max: 1, // one connection per run; raise only for in-run parallel queries
  });

  pool.on("error", (err) => console.error("pg pool error", err));
  ```

  ```ts lib/db.ts (Prisma) theme={"theme":"css-variables"}
  import { PrismaPg } from "@prisma/adapter-pg";
  import { PrismaClient } from "./generated/prisma/client";

  const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 1 });

  export const prisma = new PrismaClient({ adapter });
  ```

  ```ts lib/db.ts (Drizzle) theme={"theme":"css-variables"}
  import { drizzle } from "drizzle-orm/node-postgres";
  import { Pool } from "pg";

  const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
  pool.on("error", (err) => console.error("pg pool error", err));

  export const db = drizzle({ client: pool });
  ```

  ```ts lib/db.ts (MongoDB) theme={"theme":"css-variables"}
  import { MongoClient } from "mongodb";

  export const client = new MongoClient(process.env.DATABASE_URL!, { maxPoolSize: 5 });
  ```
</CodeGroup>

<Note>
  Import this one client everywhere. Don't create a client inside `run()` or a lifecycle hook, which opens a new pool on every run, and don't store one in [`chat.local`](/docs/ai-chat/chat-local), which is per-run state that gets serialized into subtasks.
</Note>

## Size the pool

A run uses connections only while it is actively executing. Queued, waiting, and suspended runs use none. So the connections in use at any moment are:

> concurrent executing runs × pool size per run

Set the pool small. A task usually runs its queries in sequence, so one connection per run (`max: 1`) is enough for node-postgres, Prisma, and Drizzle; raise it only when a single run issues queries in parallel. The MongoDB driver shares one pool across all operations, so keep `maxPoolSize` in the low single digits. Each client's out-of-the-box default is far larger:

| Client                                                                                                                                  | Default pool size               |
| --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| [node-postgres (`pg`)](https://node-postgres.com/guides/pool-sizing)                                                                    | 10                              |
| postgres-js                                                                                                                             | 10                              |
| [Prisma (v7, `pg` adapter)](https://www.prisma.io/docs/orm/prisma-client/setup-and-configuration/databases-connections/connection-pool) | 10 (the adapter's `pg` default) |
| Drizzle (node-postgres)                                                                                                                 | 10 (the underlying `pg` pool)   |
| [MongoDB driver](https://www.mongodb.com/docs/drivers/node/current/connect/connection-options/connection-pools/)                        | 100 (`maxPoolSize`)             |

Keep `concurrent runs × pool size` under your provider's connection limit, and cap how many runs execute at once with [concurrency limits](/docs/queue-concurrency) so runs queue instead of overrunning the database. Direct connection limits for common Postgres providers:

| Provider                                                                                           | Direct connection limit                                                     |
| -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| [PostgreSQL (self-hosted)](https://www.postgresql.org/docs/current/runtime-config-connection.html) | `max_connections`, default `100`                                            |
| [Supabase](https://supabase.com/docs/guides/platform/compute-and-disk)                             | `60` (Nano/Micro) up to `500` (16XL), by compute size                       |
| [Neon](https://neon.com/docs/connect/connection-pooling)                                           | `104` (0.25 CU) up to `4000` (capped at 9 CU and above), by compute size    |
| [AWS RDS / Aurora](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Limits.html)        | `LEAST(DBInstanceClassMemory / 9531392, 5000)`, \~5 reserved for superusers |
| [PlanetScale Postgres](https://planetscale.com/docs/postgres/connecting)                           | set per cluster size (Cluster, then Parameters, then `max_connections`)     |

[MongoDB Atlas](https://www.mongodb.com/docs/atlas/reference/atlas-limits/) limits connections per node: `500` on Free and Flex, `1500` on M10, `3000` on M20.

When `concurrent runs × pool size` approaches these numbers, connect through a pooler instead.

## Use a connection pooler

A pooler (PgBouncer, RDS Proxy, Supavisor, Prisma Accelerate) sits between your tasks and the database and multiplexes many client connections onto a few backend connections. Point your connection string at the pooler's endpoint and the ceiling rises without changing your code. Use one when many runs execute concurrently, and for chat agents.

| Provider                                                                                         | Pooled endpoint                    | Pooled client limit                |
| ------------------------------------------------------------------------------------------------ | ---------------------------------- | ---------------------------------- |
| [Supabase Supavisor](https://supabase.com/docs/guides/database/connection-management)            | port `6543` (transaction mode)     | `200` (Nano) up to `12,000` (16XL) |
| [Neon](https://neon.com/docs/connect/connection-pooling)                                         | add `-pooler` to the endpoint host | up to `10,000`                     |
| [AWS RDS Proxy](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-proxy.html)           | the proxy endpoint                 | managed                            |
| [PlanetScale Postgres](https://planetscale.com/blog/scaling-postgres-connections-with-pgbouncer) | PgBouncer endpoint                 | managed                            |
| Self-hosted                                                                                      | PgBouncer or PgCat                 | configured                         |

Use the pooled endpoint for your tasks. Use the direct endpoint for schema migrations (Prisma Migrate, Drizzle Kit), which need a stable session that a transaction pooler does not provide.

Transaction-mode poolers (Supavisor on `6543`, PgBouncer in transaction mode) do not keep server-side prepared statements across queries. With Prisma, add `?pgbouncer=true` to the pooled URL. With node-postgres, don't rely on prepared statements.

## Private databases

If your database lives in a private VPC and isn't reachable over the public internet, connect to it with [private networking](/docs/private-networking/overview), which links your tasks to resources in your own AWS account over AWS PrivateLink. It supports Postgres (RDS, Aurora), MySQL, MongoDB, and any other TCP service behind an internal load balancer.

Once the connection is active, set your connection-string variable (for example `DATABASE_URL`) to the endpoint IP shown in the dashboard, and the client setup above is unchanged. Private networking is a Pro and Enterprise feature, and the endpoint is reachable only from deployed environments, so use a public connection in local development.

## Provider notes

* [Supabase](https://supabase.com/docs/guides/database/connecting-to-postgres): the direct connection (`db.<ref>.supabase.co:5432`) resolves to IPv6 only and is unreachable from many environments, so connect through the Supavisor pooler or add the IPv4 add-on. The pooler presents Supabase's own CA, so prefer passing that CA and keeping verification on (`rejectUnauthorized: true`). Use `rejectUnauthorized: false` only as a temporary troubleshooting step in non-production environments.
* A `DATABASE_URL` with `sslmode=verify-full&sslrootcert=system` uses a libpq feature the `pg` driver (node-postgres, and the Prisma and Drizzle pools built on it) cannot read. Build the pool from discrete fields with `ssl: { rejectUnauthorized: true }` (Node's CA store), or point `sslrootcert` at a real CA file.

## Release connections at a wait

A run holds its connections while it is paused at a wait until the process is torn down, which is not instant. Free them sooner so other runs can reuse them. How you release depends on the client:

* Prisma reconnects lazily, so disconnect it from a global [`tasks.onWait`](/docs/tasks/overview#onwait-and-onresume-functions) handler colocated with the client. One handler covers every task.
* A `pg` Pool (node-postgres and Drizzle) and the MongoDB client can't be reused after a full close, so give them a short idle timeout instead. Idle connections close themselves during the wait while the pool stays usable.

<CodeGroup>
  ```ts lib/db.ts (node-postgres) theme={"theme":"css-variables"}
  import { Pool } from "pg";

  export const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
    max: 1,
    idleTimeoutMillis: 10_000, // idle connections close during a wait; the pool stays usable
  });

  pool.on("error", (err) => console.error("pg pool error", err));
  ```

  ```ts lib/db.ts (Prisma) theme={"theme":"css-variables"}
  import { tasks } from "@trigger.dev/sdk";
  import { PrismaPg } from "@prisma/adapter-pg";
  import { PrismaClient } from "./generated/prisma/client";

  const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 1 });
  export const prisma = new PrismaClient({ adapter });

  // Disconnect when any run pauses; Prisma reconnects on the next query.
  tasks.onWait("db", () => prisma.$disconnect());
  ```

  ```ts lib/db.ts (Drizzle) theme={"theme":"css-variables"}
  import { drizzle } from "drizzle-orm/node-postgres";
  import { Pool } from "pg";

  const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
    max: 1,
    idleTimeoutMillis: 10_000,
  });
  pool.on("error", (err) => console.error("pg pool error", err));

  export const db = drizzle({ client: pool });
  ```

  ```ts lib/db.ts (MongoDB) theme={"theme":"css-variables"}
  import { MongoClient } from "mongodb";

  export const client = new MongoClient(process.env.DATABASE_URL!, {
    maxPoolSize: 5,
    maxIdleTimeMS: 10_000, // idle sockets close during a wait; the client stays usable
  });
  ```
</CodeGroup>

Don't hold a client across a slow await, either. `pool.query()` checks a connection out and returns it in one call. If you `pool.connect()` and keep the client across an external HTTP call or a model stream, you pin that connection for the whole operation. Query, release, then do the slow work.

## Chat agents

A chat agent runs one long-lived worker per conversation and suspends between messages, so its connection count tracks the conversations streaming a turn at the same moment. The global `tasks.onWait` handler above covers chat agents too. Two more specifics:

* Don't hold a connection across `streamText()`. A turn spends most of its time waiting on the model, so query and release before the stream starts.
* To release only when a conversation goes idle (rather than on every internal wait within a turn), use [`onChatSuspend`](/docs/ai-chat/lifecycle-hooks#onchatsuspend--onchatresume) instead of the global handler.

```ts /trigger/chat.ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
import { prisma } from "@/lib/db";

export const myChat = chat.agent({
  id: "my-chat",
  run: async ({ messages, clientData, signal }) => {
    const user = await prisma.user.findUnique({ where: { id: clientData.userId } });
    // The connection is back in the pool before the model stream starts.
    return streamText({
      model: openai("gpt-4o"),
      system: `Helping ${user?.name ?? "the user"}.`,
      messages,
      abortSignal: signal,
    });
  },
});
```

## Troubleshooting

`too many connections` or connection refused: `concurrent runs × pool size` is over your provider's limit. Lower the pool size, cap [concurrency](/docs/queue-concurrency), or connect through a pooler.

The worker crashes right after resuming from a wait: an idle connection that closed during the suspend emitted an unhandled `error` event. Attach `pool.on("error", ...)` on a `pg` pool (node-postgres or Drizzle); Prisma and the MongoDB driver handle this internally.

## How suspend affects connections

When a task waits, the runtime can [checkpoint](/docs/how-it-works#the-checkpoint-resume-system) the run: it snapshots the process and frees the compute, then restores the process when the wait resolves. Process memory comes back, so your pool object survives, but the database closed the idle connections in the meantime. The pool reconnects on the first query after resume. This is why a suspended run holds no connections, and why the pool needs an error handler to absorb the closed connection cleanly.

## See also

* [Wait](/docs/wait) for the primitives that trigger a checkpoint.
* [Concurrency and queues](/docs/queue-concurrency) to cap how many runs execute at once.
* [Lifecycle functions](/docs/tasks/overview#onwait-and-onresume-functions) for global `tasks.onWait` and `tasks.onResume`.
* [Chat agent lifecycle hooks](/docs/ai-chat/lifecycle-hooks) for `onChatSuspend` and `onChatResume`.


# Environment Variables
Source: https://trigger.dev/docs/deploy-environment-variables

Any environment variables used in your tasks need to be added so the deployed code will run successfully.

An environment variable in Node.js is accessed in your code using `process.env.MY_ENV_VAR`.

We deploy your tasks and scale them up and down when they are triggered. So any environment variables you use in your tasks need to accessible to us so your code will run successfully.

## In the dashboard

### Setting environment variables

<Steps>
  <Step title="Go to the Environment Variables page">
    In the sidebar select the "Environment Variables" page, then press the "New environment variable"
    button. <img alt="Environment variables page" />
  </Step>

  <Step title="Add your environment variables">
    You can add values for your local dev environment, staging and prod. <img alt="Environment variables
    page" />
  </Step>
</Steps>

<Note>
  Specifying Dev values is optional. They will be overriden by values in your .env file when running
  locally.
</Note>

### Secret environment variables

When creating an environment variable, you can mark it as a **Secret**. Secret values are hidden in the dashboard and cannot be viewed after creation.

<Warning>
  Marking a variable as a Secret is irreversible and can only be done when creating the variable. To
  change this setting, you must delete the variable and create a new one.
</Warning>

### Editing environment variables

You can edit an environment variable's values. You cannot edit the key name, you must delete and create a new one.

<Steps>
  <Step title="Press the action button on a variable">
    <img alt="Environment variables page" />
  </Step>

  <Step title="Press edit">
    <img alt="Environment variables page" />
  </Step>
</Steps>

### Deleting environment variables

<Warn>
  Environment variables are fetched and injected before a runs begins. So if you delete one you can
  cause runs to fail that are expecting variables to be set.
</Warn>

<Steps>
  <Step title="Press the action button on a variable">
    <img alt="Environment variables page" />
  </Step>

  <Step title="Press delete">
    This will immediately delete the variable. <img alt="Environment variables
    page" />
  </Step>
</Steps>

## Local development

When running `npx trigger.dev dev`, the CLI automatically loads environment variables from these files in order (later files override any duplicate keys from earlier ones):

* `.env`
* `.env.development`
* `.env.local`
* `.env.development.local`
* `dev.vars`

These variables are available to your tasks via `process.env`. You don't need to use the `--env-file` flag for this automatic loading.

## In your code

You can use our SDK to get and manipulate environment variables. You can also easily sync environment variables from another service into Trigger.dev.

### Directly manipulating environment variables

We have a complete set of SDK functions (and REST API) you can use to directly manipulate environment variables.

| Function                                           | Description                                                 |
| -------------------------------------------------- | ----------------------------------------------------------- |
| [envvars.list()](/docs/management/envvars/list)         | List all environment variables                              |
| [envvars.upload()](/docs/management/envvars/import)     | Upload multiple env vars. You can override existing values. |
| [envvars.create()](/docs/management/envvars/create)     | Create a new environment variable                           |
| [envvars.retrieve()](/docs/management/envvars/retrieve) | Retrieve an environment variable                            |
| [envvars.update()](/docs/management/envvars/update)     | Update a single environment variable                        |
| [envvars.del()](/docs/management/envvars/delete)        | Delete a single environment variable                        |

#### Initial load from .env file

To initially load environment variables from a `.env` file into your Trigger.dev cloud environment, you can use `envvars.upload()`. This is useful for one-time bulk imports when setting up a new project or environment.

```ts theme={"theme":"css-variables"}
import { envvars } from "@trigger.dev/sdk";
import { readFileSync } from "fs";
import { parse } from "dotenv";

// Read and parse your .env file
const envContent = readFileSync(".env.production", "utf-8");
const parsed = parse(envContent);

// Upload to Trigger.dev (replace with your project ref and environment slug)
await envvars.upload("proj_your_project_ref", "prod", {
  variables: parsed,
  override: false, // Set to true to override existing variables
});
```

When called inside a task, you can omit the project ref and environment slug as they'll be automatically inferred from the task context:

```ts theme={"theme":"css-variables"}
import { envvars, task } from "@trigger.dev/sdk";
import { readFileSync } from "fs";
import { parse } from "dotenv";

export const setupEnvVars = task({
  id: "setup-env-vars",
  run: async () => {
    const envContent = readFileSync(".env.production", "utf-8");
    const parsed = parse(envContent);

    // projectRef and environment slug are automatically inferred from ctx
    await envvars.upload({
      variables: parsed,
      override: false,
    });
  },
});
```

<Note>
  This is different from `syncEnvVars` which automatically syncs variables during every deploy. Use `envvars.upload()` for one-time initial loads, and `syncEnvVars` for ongoing synchronization.
</Note>

#### Getting the current environment

When using `envvars.retrieve()` inside a task, you can access the current environment information from the task context (`ctx`). The `envvars.retrieve()` function doesn't return the environment, but you can get it from `ctx.environment`:

```ts theme={"theme":"css-variables"}
import { envvars, task } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload, { ctx }) => {
    // Get the current environment information
    const currentEnv = ctx.environment.slug; // e.g., "dev", "prod", "staging"
    const envType = ctx.environment.type; // e.g., "DEVELOPMENT", "PRODUCTION", "STAGING", "PREVIEW"

    // Retrieve an environment variable
    // When called inside a task, projectRef and slug are automatically inferred
    const apiKey = await envvars.retrieve("API_KEY");

    console.log(`Retrieved API_KEY from environment: ${currentEnv} (${envType})`);
    console.log(`Value: ${apiKey.value}`);
  },
});
```

The context object provides:

* `ctx.environment.slug` - The environment slug (e.g., "dev", "prod")
* `ctx.environment.type` - The environment type ("DEVELOPMENT", "PRODUCTION", "STAGING", or "PREVIEW")
* `ctx.environment.id` - The environment ID
* `ctx.project.ref` - The project reference

For more information about the context object, see the [Context documentation](/docs/context).

### Sync env vars from another service

You could use the SDK functions above but it's much easier to use our `syncEnvVars` build extension in your `trigger.config` file.

<Note>
  To use the `syncEnvVars` build extension, you should first install the `@trigger.dev/build`
  package into your devDependencies.
</Note>

In this example we're using env vars from [Infisical](https://infisical.com).

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { syncEnvVars } from "@trigger.dev/build/extensions/core";
import { InfisicalSDK } from "@infisical/sdk";

export default defineConfig({
  build: {
    extensions: [
      syncEnvVars(async (ctx) => {
        const client = new InfisicalSDK();

        await client.auth().universalAuth.login({
          clientId: process.env.INFISICAL_CLIENT_ID!,
          clientSecret: process.env.INFISICAL_CLIENT_SECRET!,
        });

        const { secrets } = await client.secrets().listSecrets({
          environment: ctx.environment,
          projectId: process.env.INFISICAL_PROJECT_ID!,
        });

        return secrets.map((secret) => ({
          name: secret.secretKey,
          value: secret.secretValue,
        }));
      }),
    ],
  },
});
```

#### Syncing environment variables from Vercel

To sync environment variables from your Vercel projects to Trigger.dev, you can use our build extension. Check out our [syncing environment variables from Vercel guide](/docs/guides/examples/vercel-sync-env-vars).

#### Deploy

When you run the [CLI deploy command](/docs/cli-deploy-commands) directly or using [GitHub Actions](/docs/github-actions) it will sync the environment variables from [Infisical](https://infisical.com) to Trigger.dev. This means they'll appear on the Environment Variables page so you can confirm that it's worked.

This means that you need to redeploy your Trigger.dev tasks if you change the environment variables in [Infisical](https://infisical.com).

<Note>
  The `process.env.INFISICAL_CLIENT_ID`, `process.env.INFISICAL_CLIENT_SECRET` and
  `process.env.INFISICAL_PROJECT_ID` will need to be supplied to the `deploy` CLI command. You can
  do this via the `--env-file .env` flag or by setting them as environment variables in your
  terminal.
</Note>

#### Dev

`syncEnvVars` does not have any effect when running the `dev` command locally. If you want to inject environment variables from another service into your local environment you can do so via a `.env` file or just supplying them as environment variables in your terminal. Most services will have a CLI tool that allows you to run a command with environment variables set:

```sh theme={"theme":"css-variables"}
infisical run -- npx trigger.dev@latest dev
```

Any environment variables set in the CLI command will be available to your local Trigger.dev tasks.

### The syncEnvVars callback return type

You can return env vars as an object with string keys and values, or an array of names + values.

```ts theme={"theme":"css-variables"}
return {
  MY_ENV_VAR: "my value",
  MY_OTHER_ENV_VAR: "my other value",
};
```

or

```ts theme={"theme":"css-variables"}
return [
  {
    name: "MY_ENV_VAR",
    value: "my value",
  },
  {
    name: "MY_OTHER_ENV_VAR",
    value: "my other value",
  },
];
```

This should mean that for most secret services you won't need to convert the data into a different format.

### Using Google credential JSON files

Securely pass a Google credential JSON file to your Trigger.dev task using environment variables.

<Steps>
  <Step title="Convert the Google credential file to base64">
    In your terminal, run the following command and copy the resulting base64 string:

    ```
    base64 -i path/to/your/service-account-file.json
    ```
  </Step>

  <Step title="Set up the environment variable in Trigger.dev">
    Follow [these steps](/docs/deploy-environment-variables) to set a new environment variable using the base64 string as the value.

    ```
    GOOGLE_CREDENTIALS_BASE64="<your base64 string>"
    ```
  </Step>

  <Step title="Use the environment variable in your code">
    Add the following code to your Trigger.dev task:

    ```ts theme={"theme":"css-variables"}
    import { google } from "googleapis";

    const credentials = JSON.parse(
      Buffer.from(process.env.GOOGLE_CREDENTIALS_BASE64, "base64").toString("utf8")
    );

    const auth = new google.auth.GoogleAuth({
      credentials,
      scopes: ["https://www.googleapis.com/auth/cloud-platform"],
    });

    const client = await auth.getClient();
    ```
  </Step>

  <Step title="Use the client in your code">
    You can now use the `client` object to make authenticated requests to Google APIs
  </Step>
</Steps>

## Using `.env.production` or dotenvx with Trigger.dev

Trigger.dev does not automatically load `.env.production` files or dotenvx files during deploys.\
To use these files in your Trigger.dev environment:

### Option 1 — Manually add your environment variables

1. Open your `.env.production` (or `.env`) file
2. Copy the full contents
3. Go to your Trigger.dev project → **Environment Variables**
4. Click **Add variables**
5. Paste the contents directly into the editor

Trigger.dev will automatically parse and create each key/value pair.

This is the simplest way to bring dotenvx or `.env.production` variables into your Trigger.dev environment.

### Option 2 — Sync variables automatically using `syncEnvVars`

If you'd prefer an automated flow, you can use the `syncEnvVars` build extension to programmatically load and return your variables:

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { syncEnvVars } from "@trigger.dev/build/extensions/core";
import dotenvx from "@dotenvx/dotenvx";
import { readFileSync } from "fs";

export default defineConfig({
  project: "<project id>",
  build: {
    extensions: [
      syncEnvVars(async () => {
        const envContent = readFileSync(".env.production", "utf-8");
        const parsed = dotenvx.parse(envContent);
        return parsed ?? {};
      }),
    ],
  },
});
```

This will read your .env.production file using dotenvx and sync the variables to Trigger.dev during every deploy.

**Summary**

* Trigger.dev does not automatically detect .env.production or dotenvx files
* You can paste them manually into the dashboard
* Or sync them automatically using a build extension

## Multi-tenant applications

If you're building a multi-tenant application where each tenant needs different environment variables (like tenant-specific API keys or database credentials), you don't need a separate project for each tenant. Instead, use a single project and load tenant-specific secrets at runtime.

<Note>
  This is different from [syncing environment variables at deploy time](#sync-env-vars-from-another-service).
  Here, secrets are loaded dynamically during task execution, not synced to Trigger.dev's environment variables.
</Note>

### Recommended approach

Use a secrets service (Infisical, AWS Secrets Manager, HashiCorp Vault, etc.) to store tenant-specific secrets, then retrieve them at the start of each task run based on the tenant identifier in your payload or context.

**Important:** Never pass secrets in the task payload, as payloads are logged and visible in the dashboard.

### Example implementation

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";

export const processTenantData = task({
  id: "process-tenant-data",
  run: async (payload: { tenantId: string; data: unknown }) => {
    // Retrieve tenant-specific secret at runtime
    const client = new SecretsManagerClient({ region: "us-east-1" });
    const response = await client.send(
      new GetSecretValueCommand({
        SecretId: `tenants/${payload.tenantId}/supabase-key`,
      })
    );

    const supabaseKey = JSON.parse(response.SecretString!).SUPABASE_SERVICE_KEY;

    // Your task logic using the tenant-specific secret
    // ...
  },
});
```

You can use any secrets service - see the [sync env vars section](#sync-env-vars-from-another-service) for an example with Infisical.

### Benefits

* **Single codebase** - Deploy once, works for all tenants
* **Secure** - Secrets never appear in payloads or logs
* **Scalable** - No project limit constraints
* **Flexible** - Easy to add new tenants without redeploying

This approach allows you to support unlimited tenants with a single Trigger.dev project, avoiding the [project limit](/docs/limits#projects) while maintaining security and separation of tenant data.


# Atomic deploys
Source: https://trigger.dev/docs/deployment/atomic-deployment

Use atomic deploys to coordinate changes to your tasks and your application.

Atomic deploys in Trigger.dev allow you to synchronize the deployment of your application with a specific version of your tasks. This ensures that your application always uses the correct version of its associated tasks, preventing inconsistencies or errors due to version mismatches.

## How it works

Atomic deploys achieve synchronization by deploying your tasks to Trigger.dev without promoting them to the default version. Instead, you explicitly specify the deployed task version in your application’s environment. Here’s the process at a glance:

1. **Deploy Tasks to Trigger.dev**: Use the Trigger.dev CLI to deploy your tasks with the `--skip-promotion` flag. This creates a new task version without making it the default.
2. **Capture the Deployment Version**: The CLI outputs the version of the deployed tasks, which you’ll use in the next step.
3. **Deploy Your Application**: Deploy your application (e.g., to Vercel), setting an environment variable like `TRIGGER_VERSION` to the captured task version.

## Vercel CLI & GitHub Actions

If you deploy to Vercel via their CLI, you can use this sample workflow that demonstrates performing atomic deploys with GitHub Actions, Trigger.dev, and Vercel:

```yml theme={"theme":"css-variables"}
name: Deploy to Trigger.dev (prod)
on:
  push:
    branches:
      - main
concurrency:
  group: ${{ github.workflow }}
  cancel-in-progress: true
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Use Node.js 20.x
        uses: actions/setup-node@v4
        with:
          node-version: "20.x"

      - name: Install dependencies
        run: npm install

      - name: Deploy Trigger.dev
        id: deploy-trigger
        env:
          TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
        run: |
          npx trigger.dev@latest deploy --skip-promotion

      - name: Deploy to Vercel
        run: npx vercel --yes --prod -e TRIGGER_VERSION=$TRIGGER_VERSION --token $VERCEL_TOKEN
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
          TRIGGER_VERSION: ${{ steps.deploy-trigger.outputs.deploymentVersion }}

      - name: Promote Trigger.dev Version
        run: npx trigger.dev@latest promote $TRIGGER_VERSION
        env:
          TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
          TRIGGER_VERSION: ${{ steps.deploy-trigger.outputs.deploymentVersion }}
```

* Deploy to Trigger.dev

  * The `npx trigger.dev deploy` command uses `--skip-promotion` to deploy the tasks without setting the version as the default.
  * The step’s id: `deploy-trigger` allows us to capture the deployment version in the output (deploymentVersion).

* Deploy to Vercel:
  * The `npx vercel` command deploys the application, setting the `TRIGGER_VERSION` environment variable to the task version from the previous step.
  * The --prod flag ensures a production deployment, and -e passes the environment variable.
  * The `@trigger.dev/sdk` automatically uses the `TRIGGER_VERSION` environment variable to trigger the correct version of the tasks.

For this workflow to work, you need to set up the following secrets in your GitHub repository:

* `TRIGGER_ACCESS_TOKEN`: Your Trigger.dev personal access token. View the instructions [here](/docs/github-actions) to learn more.
* `VERCEL_TOKEN`: Your Vercel personal access token. You can find this in your Vercel account settings.

## Vercel GitHub integration

If you're are using Vercel, chances are you are using their GitHub integration and deploying your application directly from pushes to GitHub. This section covers how to achieve atomic deploys with Trigger.dev in this setup.

### Turn off automatic promotion

By default, Vercel automatically promotes new deployments to production. To prevent this, you need to disable the auto-promotion feature in your Vercel project settings:

1. Go to your Production environment settings in Vercel at `https://vercel.com/<team-slug>/<project-slug>/settings/environments/production`
2. Disable the "Auto-assign Custom Production Domains" setting:

<img alt="Vercel project settings showing the auto-promotion setting" />

3. Hit the "Save" button to apply the changes.

Now whenever you push to your main branch, Vercel will deploy your application to the production environment without promoting it, and you can control the promotion manually.

### Deploy with Trigger.dev

Now we want to deploy that same commit to Trigger.dev, and then promote the Vercel deployment when that completes. Here's a sample GitHub Actions workflow that does this:

```yml theme={"theme":"css-variables"}
name: Deploy to Trigger.dev (prod)

on:
  push:
    branches:
      - main

concurrency:
  group: ${{ github.workflow }}
  cancel-in-progress: true

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Use Node.js 20.x
        uses: actions/setup-node@v4
        with:
          node-version: "20.x"

      - name: Install dependencies
        run: npm install

      - name: Wait for vercel deployment (push)
        id: wait-for-vercel
        uses: ludalex/vercel-wait@v1
        with:
          project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          team-id: ${{ secrets.VERCEL_SCOPE_NAME }}
          token: ${{ secrets.VERCEL_TOKEN }}
          sha: ${{ github.sha }}

      - name: 🚀 Deploy Trigger.dev
        id: deploy-trigger
        env:
          TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
        run: |
          npx trigger.dev@latest deploy

      - name: Promote Vercel deploy
        run: npx vercel promote $VERCEL_DEPLOYMENT_ID --yes --token $VERCEL_TOKEN --scope $VERCEL_SCOPE_NAME
        env:
          VERCEL_DEPLOYMENT_ID: ${{ steps.wait-for-vercel.outputs.deployment-id }}
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
          VERCEL_SCOPE_NAME: ${{ secrets.VERCEL_SCOPE_NAME }}
```

This workflow does the following:

1. Waits for the Vercel deployment to complete using the `ludalex/vercel-wait` action.
2. Deploys the tasks to Trigger.dev using the `npx trigger.dev deploy` command. There's no need to use the `--skip-promotion` flag because we want to promote the deployment.
3. Promotes the Vercel deployment using the `npx vercel promote` command.

For this workflow to work, you need to set up the following secrets in your GitHub repository:

* `TRIGGER_ACCESS_TOKEN`: Your Trigger.dev personal access token. View the instructions [here](/docs/github-actions) to learn more.
* `VERCEL_TOKEN`: Your Vercel personal access token. You can find this in your Vercel account settings.
* `VERCEL_PROJECT_ID`: Your Vercel project ID. You can find this in your Vercel project settings.
* `VERCEL_SCOPE_NAME`: Your Vercel team slug.

Checkout our [example repo](https://github.com/ericallam/vercel-atomic-deploys) to see this workflow in action.

<Note>
  We are using the `ludalex/vercel-wait` action above as a fork of the [official
  tj-actions/vercel-wait](https://github.com/tj-actions/vercel-wait) action because there is a bug
  in the official action that exits early if the deployment isn't found in the first check and due
  to the fact that it supports treating skipped (cancelled) Vercel deployments as valid (on by default).
  I've opened a PR for this issue [here](https://github.com/tj-actions/vercel-wait/pull/106).
</Note>


# Development branches
Source: https://trigger.dev/docs/deployment/dev-branches

Run multiple local dev sessions in isolation by giving each one its own development branch. Use branches to keep parallel work (in separate worktrees, directories, or agents) from clashing.

Every project starts with a single development environment called `default`. A **dev branch** is an isolated environment that lives under development, with its own runs, schedules, and concurrency.

Branches are useful when you run more than one local dev session at a time. Give each session its own branch so their runs don't collide:

* Run several [git worktrees](https://git-scm.com/docs/git-worktree) or copies of your project in parallel, one branch each.
* Let multiple coding agents each work in their own branch without stepping on one another.

When you're done with a branch, you can archive it to free up a slot or just re-use it.

## Run a dev session on a branch

Log in with the CLI first:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest login
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest login
  ```

  ```bash bun theme={"theme":"css-variables"}
  bunx trigger.dev@latest login
  ```
</CodeGroup>

Then start a dev session on a branch with the `--branch` flag. If the branch doesn't exist yet, it's created:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest dev --branch my-feature
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest dev --branch my-feature
  ```

  ```bash bun theme={"theme":"css-variables"}
  bunx trigger.dev@latest dev --branch my-feature
  ```
</CodeGroup>

Without `--branch`, the session runs on the `default` branch.

<Tip>
  You can also set the branch with the `TRIGGER_DEV_BRANCH` environment variable instead of the flag.
</Tip>

## Archive a branch

Archive a branch from the CLI when you no longer need it. The CLI detects your local git branch, or you can name one with `--branch`:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest dev archive --branch my-feature
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest dev archive --branch my-feature
  ```

  ```bash bun theme={"theme":"css-variables"}
  bunx trigger.dev@latest dev archive --branch my-feature
  ```
</CodeGroup>

You can also create and archive branches from the **Dev branches** page in the dashboard.

## Limits on active branches

Each branch has its own concurrency, so we limit how many can be active per project. Archive a branch at any time to unlock another slot.

| Plan  | Active dev branches |
| ----- | ------------------- |
| Free  | 25                  |
| Hobby | 25                  |
| Pro   | 25                  |

Need more? [Get in touch](https://trigger.dev/contact) and we'll raise the limit.


# Deployment
Source: https://trigger.dev/docs/deployment/overview

Learn how to deploy your tasks to Trigger.dev.

Before you can run production workloads on Trigger.dev, you need to deploy your tasks. The only way to do this at the moment is through the [deploy CLI command](/docs/cli-deploy-commands):

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest deploy
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest deploy
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest deploy
  ```
</CodeGroup>

## Deploying 101

Let's assume you have an existing trigger.dev project with a few tasks that you have been running locally but now want to deploy to the Trigger.dev cloud (or your self-hosted instance).

First, let's make sure you are logged in to the CLI (if you haven't already):

```bash theme={"theme":"css-variables"}
npx trigger.dev login
```

This will open a browser window where you can log in with your Trigger.dev account and link your CLI.

Now you can deploy your tasks:

```bash theme={"theme":"css-variables"}
npx trigger.dev deploy
```

This should print out a success message and let you know a new version has been deployed:

```bash theme={"theme":"css-variables"}
Trigger.dev (3.3.16)
------------------------------------------------------
┌  Deploying project
│
◇  Retrieved your account details for eric@trigger.dev
│
◇  Successfully built code
│
◇  Successfully deployed version 20250228.1
│
└  Version 20250228.1 deployed with 4 detected tasks
```

Now if you visit your Trigger.dev dashboard you should see the new version deployed:

<img alt="Trigger.dev dashboard showing the latest version deployed" />

<Note>
  Deploying consists of building your tasks and uploading them to the Trigger.dev cloud. This
  process can take a few seconds to a few minutes depending on the size of your project.
</Note>

## Triggering deployed tasks

Once you have deployed your tasks, you can trigger tasks exactly the same way you did locally, but with the "PROD" API key:

<img alt="Trigger.dev dashboard showing the API key" />

Copy the API key from the dashboard and set the `TRIGGER_SECRET_KEY` environment variable, and then any tasks you trigger will run against the deployed version:

```txt .env theme={"theme":"css-variables"}
TRIGGER_SECRET_KEY="tr_prod_abc123"
```

Now you can trigger your tasks:

```ts theme={"theme":"css-variables"}
import { myTask } from "./trigger/tasks";

await myTask.trigger({ foo: "bar" });
```

See our [triggering tasks](/docs/triggering) guide for more information.

## Versions

When you deploy your tasks, Trigger.dev creates a new version of all tasks in your project. A version is a snapshot of your tasks at a certain point in time. This ensures that tasks are not affected by changes to the code.

### Current version

When you deploy, the version number is automatically incremented, and the new version is set as the current version for that environment.

<Note>
  A single environment (prod, staging, etc.) can only have a single "current" version at a time.
</Note>

The current version defines which version of the code new task runs will execute against. When a task run starts, it is locked to the current version. This ensures that the task run is not affected by changes to the code. Retries of the task run will also be locked to the original version.

<Note>
  When you Replay a run in the dashboard we will create a new run, locked to the current version and
  not necessarily the version of the original run.
</Note>

### Version locking

You can optionally specify the version when triggering a task using the `version` parameter. This is useful when you want to run a task against a specific version of the code:

```ts theme={"theme":"css-variables"}
await myTask.trigger({ foo: "bar" }, { version: "20250228.1" });
```

If you want to set a global version to run all tasks against, you can use the `TRIGGER_VERSION` environment variable:

```bash theme={"theme":"css-variables"}
TRIGGER_VERSION=20250228.1
```

### Child tasks and auto-version locking

Trigger and wait functions version lock child task runs to the parent task run version. This ensures the results from child runs match what the parent task is expecting. If you don't wait then version locking doesn't apply.

| Trigger function        | Parent task version | Child task version | isLocked |
| ----------------------- | ------------------- | ------------------ | -------- |
| `trigger()`             | `20240313.2`        | Current            | No       |
| `batchTrigger()`        | `20240313.2`        | Current            | No       |
| `triggerAndWait()`      | `20240313.2`        | `20240313.2`       | Yes      |
| `batchTriggerAndWait()` | `20240313.2`        | `20240313.2`       | Yes      |

### Skipping promotion

When you deploy, the new version is automatically promoted be the current version. If you want to skip this promotion, you can use the `--skip-promotion` flag:

```bash theme={"theme":"css-variables"}
npx trigger.dev deploy --skip-promotion
```

This will create a new deployment version but not promote it to the current version:

<img alt="Trigger.dev dashboard showing the latest version deployed but not promoted" />

This allows you to deploy and test a new version without affecting new task runs. When you want to promote the version, you can do so from the CLI:

```bash theme={"theme":"css-variables"}
npx trigger.dev promote 20250228.1
```

Or from the dashboard:

<img alt="Trigger.dev dashboard showing the promote button" />

To learn more about skipping promotion and how this enables atomic deployments, see our [Atomic deployment](/docs/deployment/atomic-deployment) guide.

## Staging deploys

By default, the `deploy` command will deploy to the `prod` environment. If you want to deploy to a different environment, you can use the `--env` flag:

```bash theme={"theme":"css-variables"}
npx trigger.dev deploy --env staging
```

<Note>
  If you are using the Trigger.dev Cloud, staging deploys are only available on the Hobby and Pro
  plans.
</Note>

This will create an entirely new version of your tasks for the `staging` environment, with a new version number and an independent current version:

<img alt="Trigger.dev dashboard showing the staging environment" />

Now you can trigger tasks against the staging environment by setting the `TRIGGER_SECRET_KEY` environment variable to the staging API key:

```txt .env theme={"theme":"css-variables"}
TRIGGER_SECRET_KEY="tr_stg_abcd123"
```

For additional environments beyond `prod` and `staging`, you can use [preview branches](/docs/deployment/preview-branches), which allow you to create isolated environments for each branch of your code.

## Local builds

By default we use a remote build provider to speed up builds. However, you can also force the build to happen locally on your machine using the `--force-local-build` flag:

```bash theme={"theme":"css-variables"}
npx trigger.dev deploy --force-local-build
```

<Tip>
  Deploying with local builds can be a useful fallback in cases where our remote build provider is experiencing availability issues.
</Tip>

### System requirements

To use local builds, you need the following tools installed on your machine:

* Docker ([installation guide](https://docs.docker.com/get-started/get-docker))
* Docker Buildx ([installation guide](https://github.com/docker/buildx#installing))

## Environment variables

To add custom environment variables to your deployed tasks, you need to add them to your project in the Trigger.dev dashboard, or automatically sync them using our [syncEnvVars](/docs/config/config-file#syncenvvars) or [syncVercelEnvVars](/docs/config/config-file#syncvercelenvvars) build extensions.

For more information on environment variables, see our [environment variables](/docs/deploy-environment-variables) guide.

## Troubleshooting

When things go wrong with your deployment, there are a few things you can do to diagnose the issue:

### Dry runs

You can do a "dry run" of the deployment to see what is built and uploaded without actually deploying:

```bash theme={"theme":"css-variables"}
npx trigger.dev deploy --dry-run

#  Dry run complete. View the built project at /<project path>/.trigger/tmp/<build dir>
```

The dry run will output the build directory where you can inspect the built tasks and dependencies. You can also compress this directory and send it to us if you need help debugging.

### Debug logs

You can run the deploy command with `--log-level debug` at the end. This will print out a lot of information about the deploy. If you can't figure out the problem from the information below please join [our Discord](https://trigger.dev/discord) and create a help forum post. Do NOT share the extended debug logs publicly as they might reveal private information about your project.

### Common issues

#### `Failed to build project image: Error building image`

There should be a link below the error message to the full build logs on your machine. Take a look at these to see what went wrong. Join [our Discord](https://trigger.dev/discord) and you share it privately with us if you can't figure out what's going wrong. Do NOT share these publicly as the verbose logs might reveal private information about your project.

Sometimes these errors are caused by upstream availability issues with our remote build provider. In this case, you can try deploying with a local build using the `--force-local-build` flag. Refer to the [Local builds](#local-builds) section for more information.

#### `Deployment encountered an error`

Usually there will be some useful guidance below this message. If you can't figure out what's going wrong then join [our Discord](https://trigger.dev/discord) and create a Help forum post with a link to your deployment.

#### `No loader is configured for ".node" files`

This happens because `.node` files are native code and can't be bundled like other packages. To fix this, add your package to [`build.external`](/docs/config/config-file#external) in the `trigger.config.ts` file like this:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    external: ["your-node-package"],
  },
});
```

### `Cannot find matching keyid`

This error occurs when using Node.js v22 with corepack, as it's not yet compatible with the latest package manager signatures. To fix this, either:

1. Downgrade to Node.js v20 (LTS), or
2. Install corepack globally: `npm i -g corepack@latest`

The corepack bug and workaround are detailed in [this issue](https://github.com/npm/cli/issues/8075).


# Preview branches
Source: https://trigger.dev/docs/deployment/preview-branches

Create isolated environments for each branch of your code, allowing you to test changes before merging to production. You can create preview branches manually or automatically from your git branches.

## How to use preview branches

The preview environment is special – you create branches from it. The branches you create live under the preview environment and have all the features you're used to from other environments (like staging or production). That means you can trigger runs, have schedules, test them, use Realtime, etc.

<img alt="Preview environment and branches" />

We recommend you automatically create a preview branch for each git branch when a Pull Request is opened and then archive it automatically when the PR is merged/closed.

The process to use preview branches looks like this:

1. Create a preview branch
2. Deploy to the preview branch (1+ times)
3. Trigger runs using your Preview API key (`TRIGGER_SECRET_KEY`) and the branch name (`TRIGGER_PREVIEW_BRANCH`).
4. Archive the preview branch when the branch is done.

There are two main ways to do this:

1. Automatically: using GitHub Actions (recommended).
2. Manually: in the dashboard and/or using the CLI.

### Limits on active preview branches

We restrict the number of active preview branches (per project). You can archive a preview branch at any time (automatically or manually) to unlock another slot – or you can upgrade your plan.

Once archived you can still view the dashboard for the branch but you can't trigger or execute runs (or other write operations).

This limit exists because each branch has an independent concurrency limit. For the Cloud product these are the limits:

| Plan  | Active preview branches |
| ----- | ----------------------- |
| Free  | 0                       |
| Hobby | 5                       |
| Pro   | 20 (then paid for more) |

For full details see our [pricing page](https://trigger.dev/pricing).

## Triggering runs and using the SDK

Before we talk about how to deploy to preview branches, one important thing to understand is that you must set the `TRIGGER_PREVIEW_BRANCH` environment variable as well as the `TRIGGER_SECRET_KEY` environment variable.

When deploying to somewhere that supports `process.env` (like Node.js runtimes) you can just set the environment variables:

```bash theme={"theme":"css-variables"}
TRIGGER_SECRET_KEY="tr_preview_1234567890"
TRIGGER_PREVIEW_BRANCH="your-branch-name"
```

If you're deploying somewhere that doesn't support `process.env` (like some edge runtimes) you can manually configure the SDK:

```ts theme={"theme":"css-variables"}
import { configure } from "@trigger.dev/sdk";
import { myTask } from "./trigger/myTasks";

configure({
  secretKey: "tr_preview_1234567890", // WARNING: Never actually hardcode your secret key like this
  previewBranch: "your-branch-name",
});

async function triggerTask() {
  await myTask.trigger({ userId: "1234" }); // Trigger a run in your-branch-name
}
```

### Triggering across multiple branches from one process

If a single process needs to trigger runs in several preview branches (or a mix of prod and preview), use `new TriggerClient({...})` for each target instead of mutating global config. Each instance owns its own auth and branch.

```ts theme={"theme":"css-variables"}
import { TriggerClient } from "@trigger.dev/sdk";

const signupFlow = new TriggerClient({
  accessToken: process.env.TRIGGER_PREVIEW_KEY,
  previewBranch: "signup-flow",
});
const checkout = new TriggerClient({
  accessToken: process.env.TRIGGER_PREVIEW_KEY,
  previewBranch: "checkout-redesign",
});

const payload = { to: "user@example.com" };
await Promise.all([
  signupFlow.tasks.trigger("send-email", payload),
  checkout.tasks.trigger("send-email", payload),
]);
```

See [Multiple SDK clients](/docs/management/multiple-clients) for the full pattern.

## Preview branches with GitHub Actions (recommended)

This GitHub Action will:

1. Automatically create a preview branch for your Pull Request (if the branch doesn't already exist).
2. Deploy the preview branch.
3. Archive the preview branch when the Pull Request is merged/closed. This only works if your workflow runs on **closed** PRs (`types: [opened, synchronize, reopened, closed]`). If you omit `closed`, branches won't be archived automatically.

```yml .github/workflows/trigger-preview-branches.yml theme={"theme":"css-variables"}
name: Deploy to Trigger.dev (preview branches)

on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

jobs:
  deploy-preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Use Node.js 20.x
        uses: actions/setup-node@v4
        with:
          node-version: "20.x"

      - name: Install dependencies
        run: npm install

      - name: Deploy preview branch
        run: npx trigger.dev@latest deploy --env preview
        env:
          TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
```

For this workflow to work, you need to set the following secrets in your GitHub repository:

* `TRIGGER_ACCESS_TOKEN`: A Trigger.dev personal access token (they start with `tr_pat_`). [Learn how to create one and set it in GitHub](/docs/github-actions#creating-a-personal-access-token).

Notice that the deploy command has `--env preview` at the end. We automatically detect the preview branch from the GitHub actions env var.

You can manually specify the branch using `--branch <branch-name>` in the deploy command, but this isn't required.

## Preview branches with the CLI (manual)

### Deploying a preview branch

Creating and deploying a preview branch manually is easy:

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest deploy --env preview
```

This will create and deploy a preview branch, automatically detecting the git branch. If for some reason the auto-detection doesn't work it will let you know and tell you do this:

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest deploy --env preview --branch your-branch-name
```

### Archiving a preview branch

You can manually archive a preview branch with the CLI:

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest preview archive
```

Again we will try auto-detect the current branch. But you can specify the branch name with `--branch <branch-name>`.

## Creating and archiving preview branches from the dashboard

From the "Preview branches" page you can create a branch:

<img alt="Preview branches page" />

<img alt="Create preview branch" />

You can also archive a branch:

<img alt="Archive preview branch" />

## Environment variables

You can set environment variables for "Preview" and they will get applied to all branches (existing and new). You can also set environment variables for a specific branch. If they are set for both then the branch-specific variables will take precedence.

<img alt="Environment variables" />

These can be set manually in the dashboard, or automatically at deploy time using the [syncEnvVars()](/docs/config/extensions/syncEnvVars) or [syncVercelEnvVars()](/docs/config/extensions/syncEnvVars#syncvercelenvvars) build extensions.

### Sync environment variables

Full instructions are in the [syncEnvVars()](/docs/config/extensions/syncEnvVars) documentation.

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
// You will need to install the @trigger.dev/build package
import { syncEnvVars } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  //... other config
  build: {
    // This will automatically detect and sync environment variables
    extensions: [
      syncEnvVars(async (ctx) => {
        // You can fetch env variables from a 3rd party service like Infisical, Hashicorp Vault, etc.
        // The ctx.branch will be set if it's a preview deployment.
        return await fetchEnvVars(ctx.environment, ctx.branch);
      }),
    ],
  },
});
```

### Sync Vercel environment variables

You need to set the `VERCEL_ACCESS_TOKEN`, `VERCEL_PROJECT_ID` and `VERCEL_TEAM_ID` environment variables. You can find these in the Vercel dashboard. Full instructions are in the [syncVercelEnvVars()](/docs/config/extensions/syncEnvVars#syncvercelenvvars) documentation.

The extension will automatically detect a preview branch deploy from Vercel and sync the appropriate environment variables.

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
// You will need to install the @trigger.dev/build package
import { syncVercelEnvVars } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  //... other config
  build: {
    // This will automatically detect and sync environment variables
    extensions: [syncVercelEnvVars()],
  },
});
```


# Errors & Retrying
Source: https://trigger.dev/docs/errors-retrying

How to deal with errors and write reliable tasks.

When an uncaught error is thrown inside your task, that task attempt will fail.

You can configure retrying in two ways:

1. In your [trigger.config file](/docs/config/config-file) you can set the default retrying behavior for all tasks.
2. On each task you can set the retrying behavior.

<Note>Task-level retry settings override the defaults in your `trigger.config` file.</Note>

<Note>
  By default when you create your project using the CLI init command we disabled retrying in the DEV
  environment. You can enable it in your [trigger.config file](/docs/config/config-file).
</Note>

## A simple example with OpenAI

This task will retry 10 times with exponential backoff.

* `openai.chat.completions.create()` can throw an error.
* The result can be empty and we want to try again. So we manually throw an error.

```ts /trigger/openai.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

export const openaiTask = task({
  id: "openai-task",
  //specifying retry options overrides the defaults defined in your trigger.config file
  retry: {
    maxAttempts: 10,
    factor: 1.8,
    minTimeoutInMs: 500,
    maxTimeoutInMs: 30_000,
    randomize: false,
  },
  run: async (payload: { prompt: string }) => {
    //if this fails, it will throw an error and retry
    const chatCompletion = await openai.chat.completions.create({
      messages: [{ role: "user", content: payload.prompt }],
      model: "gpt-3.5-turbo",
    });

    if (chatCompletion.choices[0]?.message.content === undefined) {
      //sometimes OpenAI returns an empty response, let's retry by throwing an error
      throw new Error("OpenAI call failed");
    }

    return chatCompletion.choices[0].message.content;
  },
});
```

## Combining tasks

One way to gain reliability is to break your work into smaller tasks and [trigger](/docs/triggering) them from each other. Each task can have its own retrying behavior:

```ts /trigger/multiple-tasks.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  retry: {
    maxAttempts: 10,
  },
  run: async (payload: string) => {
    const result = await otherTask.triggerAndWait("some data");
    //...do other stuff
  },
});

export const otherTask = task({
  id: "other-task",
  retry: {
    maxAttempts: 5,
  },
  run: async (payload: string) => {
    return {
      foo: "bar",
    };
  },
});
```

Another benefit of this approach is that you can view the logs and retry each task independently from the dashboard.

## Retrying smaller parts of a task

Another complimentary strategy is to perform retrying inside of your task.

We provide some useful functions that you can use to retry smaller parts of a task. Of course, you can also write your own logic or use other packages.

### retry.onThrow()

You can retry a block of code that can throw an error, with the same retry settings as a task.

```ts /trigger/retry-on-throw.ts theme={"theme":"css-variables"}
import { task, logger, retry } from "@trigger.dev/sdk";

export const retryOnThrow = task({
  id: "retry-on-throw",
  run: async (payload: any) => {
    //Will retry up to 3 times. If it fails 3 times it will throw.
    const result = await retry.onThrow(
      async ({ attempt }) => {
        //throw on purpose the first 2 times, obviously this is a contrived example
        if (attempt < 3) throw new Error("failed");
        //...
        return {
          foo: "bar",
        };
      },
      { maxAttempts: 3, randomize: false }
    );

    //this will log out after 3 attempts of retry.onThrow
    logger.info("Result", { result });
  },
});
```

<Note>
  If all of the attempts with `retry.onThrow` fail, an error will be thrown. You can catch this or
  let it cause a retry of the entire task.
</Note>

### retry.fetch()

You can use `fetch`, `axios`, or any other library in your code.

But we do provide a convenient function to perform HTTP requests with conditional retrying based on the response:

```ts /trigger/retry-fetch.ts theme={"theme":"css-variables"}
import { task, logger, retry } from "@trigger.dev/sdk";

export const taskWithFetchRetries = task({
  id: "task-with-fetch-retries",
  run: async ({ payload, ctx }) => {
    //if the Response is a 429 (too many requests), it will retry using the data from the response. A lot of good APIs send these headers.
    const headersResponse = await retry.fetch("http://my.host/test-headers", {
      retry: {
        byStatus: {
          "429": {
            strategy: "headers",
            limitHeader: "x-ratelimit-limit",
            remainingHeader: "x-ratelimit-remaining",
            resetHeader: "x-ratelimit-reset",
            resetFormat: "unix_timestamp_in_ms",
          },
        },
      },
    });
    const json = await headersResponse.json();
    logger.info("Fetched headers response", { json });

    //if the Response is a 500-599 (issue with the server you're calling), it will retry up to 10 times with exponential backoff
    const backoffResponse = await retry.fetch("http://my.host/test-backoff", {
      retry: {
        byStatus: {
          "500-599": {
            strategy: "backoff",
            maxAttempts: 10,
            factor: 2,
            minTimeoutInMs: 1_000,
            maxTimeoutInMs: 30_000,
            randomize: false,
          },
        },
      },
    });
    const json2 = await backoffResponse.json();
    logger.info("Fetched backoff response", { json2 });

    //You can additionally specify a timeout. In this case if the response takes longer than 1 second, it will retry up to 5 times with exponential backoff
    const timeoutResponse = await retry.fetch("https://httpbin.org/delay/2", {
      timeoutInMs: 1000,
      retry: {
        timeout: {
          maxAttempts: 5,
          factor: 1.8,
          minTimeoutInMs: 500,
          maxTimeoutInMs: 30_000,
          randomize: false,
        },
      },
    });
    const json3 = await timeoutResponse.json();
    logger.info("Fetched timeout response", { json3 });

    return {
      result: "success",
      payload,
      json,
      json2,
      json3,
    };
  },
});
```

<Note>
  If all of the attempts with `retry.fetch` fail, an error will be thrown. You can catch this or let
  it cause a retry of the entire task.
</Note>

## Advanced error handling and retrying

We provide a `catchError` callback on the task and in your `trigger.config` file. This gets called when an uncaught error is thrown in your task.

You can

* Inspect the error, log it, and return a different error if you'd like.
* Modify the retrying behavior based on the error, payload, context, etc.

If you don't return anything from the function it will use the settings on the task (or inherited from the config). So you only need to use this to override things.

### OpenAI error handling example

OpenAI calls can fail for a lot of reasons and the ideal retry behavior is different for each.

In this complicated example:

* We skip retrying if there's no Response status.
* We skip retrying if you've run out of credits.
* If there are no Response headers we let the normal retrying logic handle it (return undefined).
* If we've run out of requests or tokens we retry at the time specified in the headers.

<CodeGroup>
  ```ts tasks.ts theme={"theme":"css-variables"}
  import { task } from "@trigger.dev/sdk";
  import { calculateISO8601DurationOpenAIVariantResetAt, openai } from "./openai.js";

  export const openaiTask = task({
    id: "openai-task",
    retry: {
      maxAttempts: 1,
    },
    run: async (payload: { prompt: string }) => {
      const chatCompletion = await openai.chat.completions.create({
        messages: [{ role: "user", content: payload.prompt }],
        model: "gpt-3.5-turbo",
      });

      return chatCompletion.choices[0].message.content;
    },
    catchError: async ({ payload, error, ctx, retryAt }) => {
      if (error instanceof OpenAI.APIError) {
        if (!error.status) {
          return {
            skipRetrying: true,
          };
        }

        if (error.status === 429 && error.type === "insufficient_quota") {
          return {
            skipRetrying: true,
          };
        }

        if (!error.headers) {
          //returning undefined means the normal retrying logic will be used
          return;
        }

        const remainingRequests = error.headers["x-ratelimit-remaining-requests"];
        const requestResets = error.headers["x-ratelimit-reset-requests"];

        if (typeof remainingRequests === "string" && Number(remainingRequests) === 0) {
          return {
            retryAt: calculateISO8601DurationOpenAIVariantResetAt(requestResets),
          };
        }

        const remainingTokens = error.headers["x-ratelimit-remaining-tokens"];
        const tokensResets = error.headers["x-ratelimit-reset-tokens"];

        if (typeof remainingTokens === "string" && Number(remainingTokens) === 0) {
          return {
            retryAt: calculateISO8601DurationOpenAIVariantResetAt(tokensResets),
          };
        }
      }
    },
  });
  ```

  ```ts openai.ts theme={"theme":"css-variables"}
  import { OpenAI } from "openai";

  export const openai = new OpenAI({ apiKey: env.OPENAI_API_KEY });

  export function calculateISO8601DurationOpenAIVariantResetAt(
    resets: string,
    now: Date = new Date()
  ): Date | undefined {
    // Check if the input is null or undefined
    if (!resets) return undefined;

    // Regular expression to match the duration string pattern
    const pattern = /^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+)ms)?$/;
    const match = resets.match(pattern);

    // If the string doesn't match the expected format, return undefined
    if (!match) return undefined;

    // Extract days, hours, minutes, seconds, and milliseconds from the string
    const days = parseInt(match[1] ?? "0", 10) || 0;
    const hours = parseInt(match[2] ?? "0", 10) || 0;
    const minutes = parseInt(match[3] ?? "0", 10) || 0;
    const seconds = parseFloat(match[4] ?? "0") || 0;
    const milliseconds = parseInt(match[5] ?? "0", 10) || 0;

    // Calculate the future date based on the current date plus the extracted time
    const resetAt = new Date(now);
    resetAt.setDate(resetAt.getDate() + days);
    resetAt.setHours(resetAt.getHours() + hours);
    resetAt.setMinutes(resetAt.getMinutes() + minutes);
    resetAt.setSeconds(resetAt.getSeconds() + Math.floor(seconds));
    resetAt.setMilliseconds(
      resetAt.getMilliseconds() + (seconds - Math.floor(seconds)) * 1000 + milliseconds
    );

    return resetAt;
  }
  ```
</CodeGroup>

## Preventing retries

### Using `AbortTaskRunError`

You can prevent retries by throwing an `AbortTaskRunError`. This will fail the task attempt and disable retrying.

```ts /trigger/myTasks.ts theme={"theme":"css-variables"}
import { task, AbortTaskRunError } from "@trigger.dev/sdk";

export const openaiTask = task({
  id: "openai-task",
  run: async (payload: { prompt: string }) => {
    //if this fails, it will throw an error and stop retrying
    const chatCompletion = await openai.chat.completions.create({
      messages: [{ role: "user", content: payload.prompt }],
      model: "gpt-3.5-turbo",
    });

    if (chatCompletion.choices[0]?.message.content === undefined) {
      // If OpenAI returns an empty response, abort retrying
      throw new AbortTaskRunError("OpenAI call failed");
    }

    return chatCompletion.choices[0].message.content;
  },
});
```

### Using try/catch

Sometimes you want to catch an error and don't want to retry the task. You can use try/catch as you normally would. In this example we fallback to using Replicate if OpenAI fails.

```ts /trigger/myTasks.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

export const openaiTask = task({
  id: "openai-task",
  run: async (payload: { prompt: string }) => {
    try {
      //if this fails, it will throw an error and retry
      const chatCompletion = await openai.chat.completions.create({
        messages: [{ role: "user", content: payload.prompt }],
        model: "gpt-3.5-turbo",
      });

      if (chatCompletion.choices[0]?.message.content === undefined) {
        //sometimes OpenAI returns an empty response, let's retry by throwing an error
        throw new Error("OpenAI call failed");
      }

      return chatCompletion.choices[0].message.content;
    } catch (error) {
      //use Replicate if OpenAI fails
      const prediction = await replicate.run(
        "meta/llama-2-70b-chat:02e509c789964a7ea8736978a43525956ef40397be9033abf9fd2badfe68c9e3",
        {
          input: {
            prompt: payload.prompt,
            max_new_tokens: 250,
          },
        }
      );

      if (prediction.output === undefined) {
        //retry if Replicate fails
        throw new Error("Replicate call failed");
      }

      return prediction.output;
    }
  },
});
```

## Replay failed runs in bulk

After you deploy a fix, use [bulk actions](/docs/bulk-actions) to replay multiple failed runs from the dashboard, or [bulk actions with the SDK](/docs/runs/bulk-actions) to replay them from backend code. Bulk replay creates an asynchronous action that targets selected run IDs or a `runs.list()` filter, so you can retry a known failure set without replaying each run individually.


# CI / GitHub Actions
Source: https://trigger.dev/docs/github-actions

You can easily deploy your tasks with GitHub actions and other CI environments.

The instructions below are specific to GitHub Actions, but the same concepts can be used with other CI systems.

<Tip>
  Check out our new [GitHub integration](/docs/github-integration) for automatic deployments, without adding any GitHub Actions workflows.
</Tip>

## GitHub Actions example

This simple GitHub action workflow will deploy your Trigger.dev tasks when new code is pushed to the `main` branch and the `trigger` directory has changes in it.

<Warning>
  The deploy step will fail if any version mismatches are detected. Please see the [version
  pinning](/docs/github-actions#version-pinning) section for more details.
</Warning>

<CodeGroup>
  ```yaml .github/workflows/release-trigger-prod.yml theme={"theme":"css-variables"}
  name: Deploy to Trigger.dev (prod)

  on:
    push:
      branches:
        - main

  jobs:
    deploy:
      runs-on: ubuntu-latest

      steps:
        - uses: actions/checkout@v4

        - name: Use Node.js 20.x
          uses: actions/setup-node@v4
          with:
            node-version: "20.x"

        - name: Install dependencies
          run: npm install

        - name: 🚀 Deploy Trigger.dev
          env:
            TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
          run: |
            npx trigger.dev@latest deploy
  ```

  ```yaml .github/workflows/release-trigger-staging.yml theme={"theme":"css-variables"}
  name: Deploy to Trigger.dev (staging)

  # Requires manually calling the workflow from a branch / commit to deploy to staging
  on:
    workflow_dispatch:

  jobs:
    deploy:
      runs-on: ubuntu-latest

      steps:
        - uses: actions/checkout@v4

        - name: Use Node.js 20.x
          uses: actions/setup-node@v4
          with:
            node-version: "20.x"

        - name: Install dependencies
          run: npm install

        - name: 🚀 Deploy Trigger.dev
          env:
            TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
          run: |
            npx trigger.dev@latest deploy --env staging
  ```
</CodeGroup>

If you already have a GitHub action file, you can just add the final step "🚀 Deploy Trigger.dev" to your existing file.

## Preview branches

To deploy to preview branches from Pull Requests and have them archived when PRs are merged or closed, use a workflow that runs on `pull_request` with **all four types** including `closed`:

```yaml .github/workflows/trigger-preview-branches.yml theme={"theme":"css-variables"}
name: Deploy to Trigger.dev (preview branches)

on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

jobs:
  deploy-preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Use Node.js 20.x
        uses: actions/setup-node@v4
        with:
          node-version: "20.x"

      - name: Install dependencies
        run: npm install

      - name: Deploy preview branch
        run: npx trigger.dev@latest deploy --env preview
        env:
          TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
```

<Note>
  **Include `closed`** in the `pull_request.types` list. Without it, preview branches won't be archived when PRs are merged or closed, and you may hit the limit on active preview branches. See [Preview branches](/docs/deployment/preview-branches#preview-branches-with-github-actions-recommended) for more details.
</Note>

## Creating a Personal Access Token

<Steps>
  <Step title="Create a new access token">
    Go to your profile page and click on the ["Personal Access
    Tokens"](https://cloud.trigger.dev/account/tokens) tab.
  </Step>

  <Step title="Go to your repository on GitHub.">
    Click on 'Settings' -> 'Secrets and variables' -> 'Actions' -> 'New repository secret'
  </Step>

  <Step title="Add the TRIGGER_ACCESS_TOKEN">
    Add the name `TRIGGER_ACCESS_TOKEN` and the value of your access token. <img alt="Add TRIGGER_ACCESS_TOKEN
    in GitHub" />
  </Step>
</Steps>

## CLI Version pinning

The CLI and `@trigger.dev/*` package versions need to be in sync with the `trigger.dev` CLI, otherwise there will be errors and unpredictable behavior. Hence, the `deploy` command will automatically fail during CI on any version mismatches.
Tip: add the `trigger.dev` CLI to your `devDependencies` and the deploy command to your `package.json` file to keep versions managed in the same place. For example:

```json theme={"theme":"css-variables"}
{
  "scripts": {
    "deploy:trigger-prod": "trigger deploy",
    "deploy:trigger": "trigger deploy --env staging"
  },
  "devDependencies": {
    "trigger.dev": "4.0.2"
  }
}
```

Your workflow file will follow the version specified in the `package.json` script, like so:

```yaml .github/workflows/release-trigger.yml theme={"theme":"css-variables"}
- name: 🚀 Deploy Trigger.dev
  env:
    TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
  run: |
    npm run deploy:trigger
```

You should use the version you run locally during dev and manual deploy. The current version is displayed in the banner, but you can also check it by appending `--version` to any command.

## Self-hosting

When self-hosting, you need to:

* Set up Docker Buildx in your CI environment for building images locally.
* Add your registry credentials to the GitHub secrets.
* Specify the `TRIGGER_API_URL` environment variable pointing to your webapp domain, for example: `https://trigger.example.com`

<CodeGroup>
  ```yaml .github/workflows/release-trigger-self-hosted.yml theme={"theme":"css-variables"}
  name: Deploy to Trigger.dev (self-hosted)

  on:
    push:
      branches:
        - main

  jobs:
    deploy:
      runs-on: ubuntu-latest

      steps:
        - uses: actions/checkout@v4

        - name: Use Node.js 20.x
          uses: actions/setup-node@v4
          with:
            node-version: "20.x"

        - name: Install dependencies
          run: npm install

        - name: Set up Docker Buildx
          uses: docker/setup-buildx-action@v3
          with:
            version: latest

        - name: Login to DockerHub
          uses: docker/login-action@v3
          with:
            username: ${{ secrets.DOCKERHUB_USERNAME }}
            password: ${{ secrets.DOCKERHUB_TOKEN }}

        - name: 🚀 Deploy Trigger.dev
          env:
            TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
            TRIGGER_API_URL: ${{ secrets.TRIGGER_API_URL }}
          run: |
            npx trigger.dev@latest deploy
  ```
</CodeGroup>


# GitHub integration
Source: https://trigger.dev/docs/github-integration

Automatically deploy your tasks on every push to your GitHub repository.

## How it works

Once you connect a GitHub repository to your project, you can configure tracking branches for the production and staging environments.
Every push to a tracked branch creates a deployment in the corresponding environment. Preview branch deployments are also supported for pull requests.

This eliminates the need to manually run the `trigger.dev deploy` command or set up custom CI/CD workflows.

## Setup

<Steps>
  <Step title="Install our GitHub app">
    Go to your project's settings page and click `Install GitHub app`.
    This will take you to GitHub to authorize the Trigger.dev app for your organization or personal account.
  </Step>

  <Step title="Connect your repository">
    Select a repository to connect to your project.
  </Step>

  <Step title="Configure branch tracking">
    Choose which branches should trigger automatic deployments:

    * **Production**: The branch that deploys to your production environment, e.g., `main`.
    * **Staging**: The branch that deploys to your staging environment.
    * **Preview**: Toggle to enable preview deployments for pull requests
  </Step>

  <Step title="Customize build settings (optional)">
    Configure how your project is built:

    * **Trigger config file**: Path to your `trigger.config.ts` file. By default, we look for it in the root of your repository. The path should be relative to the root of your repository and contain the config file name, e.g., `apps/tasks/trigger.config.ts`.
    * **Install command**: Auto-detected by default, but you can override it if necessary. The command will be run from the root of your repository.
    * **Pre-build command**: Run any commands before building and deploying your project, e.g., `pnpm run prisma:generate`. The command will be run from the root of your repository.
  </Step>
</Steps>

## Branch tracking

Our GitHub integration uses branch tracking to determine when and where to deploy your code.

<img alt="Trigger.dev project git settings" />

### Production and staging branches

When you connect a repository, the default branch of your repository will be used as the production tracking branch, by default.

When you configure a production or staging branch, every push to that branch will trigger a deployment.
Our build server will install the project dependencies, build your project, and deploy it to the corresponding environment.

If there are multiple consecutive pushes to a tracked branch, the later deployments will be queued until the previous deployment completes.

<Note>
  When you connect a repository, the default branch of your repository will be used as the production tracking branch by default.
  You can change this in the git settings of your project.
</Note>

### Pull requests

By default, pull requests will be deployed to preview branch environments, enabling you to test changes before merging.
When the pull request is merged or closed, the preview branch is automatically archived.

The name of the preview branch matches the branch name of the pull request.

<Note>
  Preview branch deployments require the preview environment to be enabled on your project. Learn more about [preview branches](/docs/deployment/preview-branches).
</Note>

## Disconnecting a repository

You can disconnect a repository at any time from your project git settings. This will stop automatic deployments triggered from GitHub.

## Managing repository access

To add or remove repository access for the Trigger.dev GitHub app, follow the link in the `Connect GitHub repository` modal:

<img alt="Trigger.dev prompt to connect a GitHub repository" />

Alternatively, you can follow these steps on GitHub:

1. Go to your GitHub account settings
2. Navigate to **Settings** → **Applications** → **Installed GitHub Apps**
3. Click **Configure** next to `Trigger.dev App`
4. Update repository access under `Repository access`

Changes to repository access will be reflected immediately in your Trigger.dev project settings.

## Environment variables at build time

You can expose environment variables during the build and deployment process by prefixing them with `TRIGGER_BUILD_`.
In the build server, the `TRIGGER_BUILD_` prefix is stripped from the variable name, i.e., `TRIGGER_BUILD_MY_TOKEN` is exposed as `MY_TOKEN`.

Build extensions will also have access to these variables.

<Note>
  Build environment variables only apply to deployments in the environment you set them in.
</Note>

Learn more about managing [environment variables](/docs/deploy-environment-variables).

## Using a private npm registry

If your project uses packages from a private npm registry, you can provide authentication by setting a `TRIGGER_BUILD_NPM_RC` environment variable.

The value should be the contents of your `.npmrc` file including any token credentials, encoded to base64.

### Example

Example `.npmrc` file containing credentials for a private npm registry and a GitHub package registry:

```
//registry.npmjs.org/:_authToken=<YOUR_NPM_TOKEN>
@<YOUR_NAMESPACE>:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:always-auth=true
//npm.pkg.github.com/:_authToken=<YOUR_GITHUB_TOKEN>
```

Encode it to base64:

```bash theme={"theme":"css-variables"}
# Encode your .npmrc file
cat .npmrc | base64
```

Then, set the `TRIGGER_BUILD_NPM_RC` environment variable in your project settings with the encoded value.

<Note>
  The build server will automatically create a `.npmrc` file in the installation directory based on the content of the `TRIGGER_BUILD_NPM_RC` environment variable.
  This enables the build server to authenticate to your private npm registry.
</Note>


# GitHub repo
Source: https://trigger.dev/docs/github-repo



Trigger.dev is [Open Source on GitHub](https://github.com/triggerdotdev/trigger.dev). You can contribute to the project by submitting issues, pull requests, or simply by using it and providing feedback.

You can also [self-host](/docs/self-hosting/overview) the project if you want to run it on your own infrastructure.


# Build a chat agent
Source: https://trigger.dev/docs/guides/ai-agents/chat-agent

Create a durable, multi-turn chat agent with chat.agent(), then add tools to it like any AI SDK agent.

## Overview

Build a **durable, multi-turn chat agent**. A durable session owns the conversation, streams tokens to your UI, and stays alive across many back-and-forth messages. The other guides in this section are one-shot workflows (trigger a task, run a fixed sequence of LLM calls, return a result); a chat agent instead owns the session for its whole lifetime.

[`chat.agent()`](/docs/ai-chat/overview) handles the queuing, retries, resumability and streaming for you. You write the model call, Trigger.dev owns the session. For the full feature set (sessions, fast starts, compaction, sub-agents, the frontend transport), see the [AI chat docs](/docs/ai-chat/overview).

## A minimal agent

Define an agent with `chat.agent()`. The `run` function receives the conversation `messages` (already converted from the frontend's `UIMessage[]`) and an abort `signal`. Return a `StreamTextResult` and it's piped to the frontend automatically.

```typescript trigger/chat.ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { anthropic } from "@ai-sdk/anthropic";
import { streamText, stepCountIs } from "ai";

export const myChat = chat.agent({
  id: "my-chat",
  run: async ({ messages, signal }) => {
    return streamText({
      // Spread chat.toStreamTextOptions() FIRST: it wires up prepareStep
      // (compaction, steering, background injection) and telemetry.
      ...chat.toStreamTextOptions(),
      model: anthropic("claude-sonnet-4-5"),
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

<Warning>
  Always spread `chat.toStreamTextOptions()` into your `streamText` call, and spread it first. It
  wires up the `prepareStep` callback that drives compaction, mid-turn steering and background
  injection. Those features silently no-op if the spread is missing.
</Warning>

## Add tools

A chat agent uses tools exactly like any other AI SDK agent. Declare them on the config so their results survive across turns, then pass the `tools` you receive in `run` straight to `streamText`:

```typescript trigger/chat.ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { anthropic } from "@ai-sdk/anthropic";
import { streamText, stepCountIs, tool } from "ai";
import { z } from "zod";

const getCurrentTime = tool({
  description: "Get the current server time as an ISO string.",
  inputSchema: z.object({}),
  execute: async () => ({ now: new Date().toISOString() }),
});

export const myChat = chat.agent({
  id: "my-chat",
  // Declared here so tool results survive history re-conversion across turns.
  tools: { getCurrentTime },
  run: async ({ messages, tools, signal }) => {
    return streamText({
      // Pass tools INTO toStreamTextOptions (not separately to streamText): it
      // merges them with any auto-injected skill tools and sets streamText's
      // `tools`. Passing tools separately after the spread drops the skill tools.
      ...chat.toStreamTextOptions({ tools }),
      model: anthropic("claude-sonnet-4-5"),
      messages,
      stopWhen: stepCountIs(15),
      abortSignal: signal,
    });
  },
});
```

Swap `getCurrentTime` for whatever your agent needs to do: query a database, call an API, or trigger another Trigger.dev task. See [Tools](/docs/ai-chat/tools) for how tool results are persisted and replayed across turns.

## Wire up the frontend

The browser talks to Trigger.dev directly through the [chat transport](/docs/ai-chat/frontend), so there's no API route to maintain. Expose two server actions (one to start the session, one to mint a session-scoped token) and pass them to `useTriggerChatTransport`, then hand the transport to the AI SDK's `useChat`:

```typescript app/actions.ts theme={"theme":"css-variables"}
"use server";

import { auth } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";

export const startChatSession = chat.createStartSessionAction("my-chat");

export async function mintChatAccessToken(chatId: string) {
  // Authorize the caller for this chatId before minting: confirm the logged-in
  // user owns this session (e.g. look it up in your database). Otherwise anyone
  // who learns a session ID could mint read/write access to it.
  return auth.createPublicToken({
    scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
    expirationTime: "1h",
  });
}
```

See the [Quick Start](/docs/ai-chat/quick-start) for the complete frontend component.

## A full example

For a complete, real-world chat agent, see the ClickHouse chat agent example. It builds on everything above with generative UI, a versioned system prompt, and real tools against a live database.

<CardGroup>
  <Card title="ClickHouse chat agent" icon="chart-column" href="/guides/example-projects/clickhouse-chat-agent">
    A full example project: a chat agent that answers questions about your data with charts, tables
    and maps.
  </Card>

  <Card title="AI chat overview" icon="message-bot" href="/ai-chat/overview">
    How chat agents, sessions and the turn loop work.
  </Card>

  <Card title="Tools" icon="wrench" href="/ai-chat/tools">
    Declaring tools on your agent and how they persist across turns.
  </Card>

  <Card title="Fast starts" icon="bolt" href="/ai-chat/fast-starts">
    Cut first-turn latency with preload and head start.
  </Card>
</CardGroup>


# Claude Agent SDK setup guide
Source: https://trigger.dev/docs/guides/ai-agents/claude-code-trigger

Build AI agents that can read files, run commands, and edit code using the Claude Agent SDK and Trigger.dev.

The [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview) gives you the same tools, agent loop, and context management that power Claude Code. Combined with Trigger.dev, you get durable execution, automatic retries, and full observability for your agents.

## Setup

<Note>
  This guide assumes you are working with an existing [Trigger.dev](https://trigger.dev) project.
  Follow our [quickstart](/docs/quick-start) to get set up if you don't have a project yet.
</Note>

<Steps>
  <Step title="Install the Claude Agent SDK">
    ```bash npm theme={"theme":"css-variables"}
    npm install @anthropic-ai/claude-agent-sdk
    ```
  </Step>

  <Step title="Configure trigger.config.ts">
    Add the SDK to the `external` array so it's not bundled:

    ```ts trigger.config.ts theme={"theme":"css-variables"}
    import { defineConfig } from "@trigger.dev/sdk";

    export default defineConfig({
      project: process.env.TRIGGER_PROJECT_REF!,
      build: {
        external: ["@anthropic-ai/claude-agent-sdk"],
      },
      machine: "small-2x",
    });
    ```

    <Note>
      Adding packages to `external` prevents them from being bundled, which is necessary for the Claude
      Agent SDK. See the [build configuration docs](/docs/config/config-file#external) for more details.
    </Note>
  </Step>

  <Step title="Set your API key">
    Add your Anthropic API key to your environment variables. The SDK reads it automatically.

    ```bash theme={"theme":"css-variables"}
    ANTHROPIC_API_KEY=sk-ant-...
    ```

    You can set this in the [Trigger.dev dashboard](https://cloud.trigger.dev) under **Environment Variables**, or in your `.env` file for local development.
  </Step>

  <Step title="Create your first agent task">
    This example creates a task where Claude generates code in an empty workspace. The agent will create files based on your prompt:

    ```ts trigger/claude-agent.ts theme={"theme":"css-variables"}
    import { query } from "@anthropic-ai/claude-agent-sdk";
    import { schemaTask, logger } from "@trigger.dev/sdk";
    import { mkdtemp, rm, readdir } from "node:fs/promises";
    import { tmpdir } from "node:os";
    import { join } from "node:path";
    import { z } from "zod";

    export const codeGenerator = schemaTask({
      id: "code-generator",
      schema: z.object({
        prompt: z.string(),
      }),
      run: async ({ prompt }, { signal }) => {
        const abortController = new AbortController();
        signal.addEventListener("abort", () => abortController.abort());

        // Create an empty workspace for the agent
        // The agent will create files here based on the prompt
        const workDir = await mkdtemp(join(tmpdir(), "claude-agent-"));
        logger.info("Created workspace", { workDir });

        try {
          const result = query({
            prompt,
            options: {
              model: "claude-sonnet-4-20250514",
              abortController,
              cwd: workDir,
              maxTurns: 10,
              permissionMode: "acceptEdits",
              allowedTools: ["Read", "Edit", "Write", "Glob"],
            },
          });

          for await (const message of result) {
            logger.info("Agent message", { type: message.type });
          }

          // See what files Claude created
          const files = await readdir(workDir, { recursive: true });
          logger.info("Files created", { files });

          return { filesCreated: files };
        } finally {
          await rm(workDir, { recursive: true, force: true });
        }
      },
    });
    ```
  </Step>

  <Step title="Run the dev server">
    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest dev
    ```
  </Step>

  <Step title="Test your agent">
    Go to the Trigger.dev dashboard, find your `code-generator` task, and trigger it with a test payload:

    ```json theme={"theme":"css-variables"}
    {
      "prompt": "Create a Node.js project with a fibonacci.ts file containing a function to calculate fibonacci numbers, and a fibonacci.test.ts file with tests."
    }
    ```
  </Step>
</Steps>

## How it works

The `query()` function runs Claude in an agentic loop where it can:

1. **Read files** - Explore codebases with `Read`, `Grep`, and `Glob` tools
2. **Edit files** - Modify code with `Edit` and `Write` tools
3. **Run commands** - Execute shell commands with `Bash` tool (if enabled)
4. **Think step by step** - Use extended thinking for complex problems

The agent continues until it completes the task or reaches `maxTurns`.

### Permission modes

| Mode                  | What it does                                          |
| --------------------- | ----------------------------------------------------- |
| `"default"`           | Asks for approval on potentially dangerous operations |
| `"acceptEdits"`       | Auto-approves file operations, asks for bash/network  |
| `"bypassPermissions"` | Skips all safety checks (not recommended)             |

### Available tools

```ts theme={"theme":"css-variables"}
allowedTools: [
  "Task", // Planning and task management
  "Glob", // Find files by pattern
  "Grep", // Search file contents
  "Read", // Read file contents
  "Edit", // Edit existing files
  "Write", // Create new files
  "Bash", // Run shell commands
  "TodoRead", // Read todo list
  "TodoWrite", // Update todo list
];
```

## GitHub repo

<Card title="View the Claude Agent SDK + Trigger.dev example" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/claude-agent-sdk-trigger">
  A complete example with two agent patterns: basic safe code generation and advanced with bash
  execution.
</Card>

## Example projects using the Claude Agent SDK

<CardGroup>
  <Card title="Claude changelog generator" icon="scroll" href="/guides/example-projects/claude-changelog-generator">
    Generate changelogs from git commits using custom MCP tools.
  </Card>

  <Card title="Claude GitHub wiki agent" icon="book" href="/guides/example-projects/claude-github-wiki">
    Analyze repositories and answer questions with real-time streaming.
  </Card>
</CardGroup>

## Learn more

* [Claude Agent SDK docs](https://platform.claude.com/docs/en/agent-sdk/overview) – Official Anthropic documentation
* [Trigger.dev Realtime](/docs/realtime/overview) – Stream agent progress to your frontend
* [Waitpoints](/docs/wait) – Add human-in-the-loop approval steps


# Generate and translate copy
Source: https://trigger.dev/docs/guides/ai-agents/generate-translate-copy

Create an AI agent workflow that generates and translates copy

## Overview

**Prompt chaining** is an AI workflow pattern that decomposes a complex task into a sequence of steps, where each LLM call processes the output of the previous one. This approach trades off latency for higher accuracy by making each LLM call an easier, more focused task, with the ability to add programmatic checks between steps to ensure the process remains on track.

<img alt="Generating and translating copy" />

## Example task

In this example, we'll create a workflow that generates and translates copy. This approach is particularly effective when tasks require different models or approaches for different inputs.

**This task:**

* Uses `generateText` from the [AI SDK](https://ai-sdk.dev/) to call Anthropic's Claude models
* Uses `experimental_telemetry` to surface each LLM call on the Run page in the dashboard
* Generates marketing copy based on subject and target word count
* Validates the generated copy meets word count requirements (±10 words)
* Translates the validated copy to the target language while preserving tone

```typescript theme={"theme":"css-variables"}
import { anthropic } from "@ai-sdk/anthropic";
import { task } from "@trigger.dev/sdk";
import { generateText } from "ai";

export interface TranslatePayload {
  marketingSubject: string;
  targetLanguage: string;
  targetWordCount: number;
}

export const generateAndTranslateTask = task({
  id: "generate-and-translate-copy",
  maxDuration: 300, // Stop executing after 5 mins of compute
  run: async (payload: TranslatePayload) => {
    // Step 1: Generate marketing copy
    const generatedCopy = await generateText({
      model: anthropic("claude-sonnet-4-5"),
      messages: [
        {
          role: "system",
          content: "You are an expert copywriter.",
        },
        {
          role: "user",
          content: `Generate as close as possible to ${payload.targetWordCount} words of compelling marketing copy for ${payload.marketingSubject}`,
        },
      ],
      experimental_telemetry: {
        isEnabled: true,
        functionId: "generate-and-translate-copy",
      },
    });

    // Gate: Validate the generated copy meets the word count target
    const wordCount = generatedCopy.text.split(/\s+/).length;

    if (
      wordCount < payload.targetWordCount - 10 ||
      wordCount > payload.targetWordCount + 10
    ) {
      throw new Error(
        `Generated copy length (${wordCount} words) is outside acceptable range of ${
          payload.targetWordCount - 10
        }-${payload.targetWordCount + 10} words`
      );
    }

    // Step 2: Translate to target language
    const translatedCopy = await generateText({
      model: anthropic("claude-sonnet-4-5"),
      messages: [
        {
          role: "system",
          content: `You are an expert translator specializing in marketing content translation into ${payload.targetLanguage}.`,
        },
        {
          role: "user",
          content: `Translate the following marketing copy to ${payload.targetLanguage}, maintaining the same tone and marketing impact:\n\n${generatedCopy.text}`,
        },
      ],
      experimental_telemetry: {
        isEnabled: true,
        functionId: "generate-and-translate-copy",
      },
    });

    return {
      englishCopy: generatedCopy,
      translatedCopy,
    };
  },
});
```

## Run a test

On the Test page in the dashboard, select the `generate-and-translate-copy` task and include a payload like the following:

```json theme={"theme":"css-variables"}
{
  "marketingSubject": "The controversial new Jaguar electric concept car",
  "targetLanguage": "Spanish",
  "targetWordCount": 100
}
```

This example payload generates copy and then translates it using sequential LLM calls. The translation only begins after the generated copy has been validated against the word count requirements.

<video />


# AI agents overview
Source: https://trigger.dev/docs/guides/ai-agents/overview

Real world AI agent example tasks using Trigger.dev

## Example projects using AI agents

<CardGroup>
  <Card icon="scroll" title="Claude changelog generator" href="/guides/example-projects/claude-changelog-generator">
    Automatically generate professional changelogs from git commits using Claude.
  </Card>

  <Card icon="book" title="Claude GitHub wiki agent" href="/guides/example-projects/claude-github-wiki">
    Generate and maintain GitHub wiki documentation with Claude-powered analysis.
  </Card>

  <Card icon="chart-column" title="ClickHouse chat agent" href="/guides/example-projects/clickhouse-chat-agent">
    Build a chat agent that answers questions about your ClickHouse data with charts, tables and maps
    using `chat.agent()` and generative UI.
  </Card>

  <Card icon="hand" title="Human-in-the-loop workflow" href="/guides/example-projects/human-in-the-loop-workflow">
    Create audio summaries of newspaper articles using a human-in-the-loop workflow built with
    ReactFlow and Trigger.dev waitpoint tokens.
  </Card>

  <Card title="Mastra agents with memory" icon="database" href="/guides/example-projects/mastra-agents-with-memory">
    Use Mastra to create a weather agent that can collect live weather data and generate clothing
    recommendations.
  </Card>

  <Card title="OpenAI Agent Python SDK guardrails" icon="snake" href="/guides/example-projects/openai-agent-sdk-guardrails">
    Use the OpenAI Agent SDK to create a guardrails system for your AI agents.
  </Card>

  <Card title="OpenAI Agent TypeScript SDK playground" icon="rocket" href="/guides/example-projects/openai-agents-sdk-typescript-playground">
    A playground containing 7 AI agents using the OpenAI Agent SDK for TypeScript with Trigger.dev.
  </Card>

  <Card title="Vercel AI SDK deep research agent" icon="triangle" href="/guides/example-projects/vercel-ai-sdk-deep-research">
    Use the Vercel AI SDK to generate comprehensive PDF reports using a deep research agent.
  </Card>

  <Card title="Smart Spreadsheet" icon="table" href="/guides/example-projects/smart-spreadsheet">
    Enrich company data using Exa search and Claude with real-time streaming results.
  </Card>
</CardGroup>

## Chat agents

Build a durable, multi-turn chat agent with [`chat.agent()`](/docs/ai-chat/overview). A durable session per conversation, with streaming and resumability handled for you.

<CardGroup>
  <Card title="Chat agent" icon="message-bot" href="/guides/ai-agents/chat-agent">
    Create a durable, multi-turn chat agent with `chat.agent()`, then add tools to it.
  </Card>
</CardGroup>

## Agent fundamentals

These guides will show you how to set up different types of AI agent workflows with Trigger.dev. The examples take inspiration from Anthropic's blog post on [building effective agents](https://www.anthropic.com/research/building-effective-agents).

<CardGroup>
  <Card title="Prompt chaining" href="/guides/ai-agents/generate-translate-copy">
    Chain prompts together to generate and translate marketing copy automatically
  </Card>

  <Card title="Routing" href="/guides/ai-agents/route-question">
    Send questions to different AI models based on complexity analysis
  </Card>

  <Card title="Parallelization" href="/guides/ai-agents/respond-and-check-content">
    Simultaneously check for inappropriate content while responding to customer inquiries
  </Card>

  <Card title="Orchestrator" href="/guides/ai-agents/verify-news-article">
    Coordinate multiple AI workers to verify news article accuracy
  </Card>

  <Card title="Evaluator-optimizer" href="/guides/ai-agents/translate-and-refine">
    Translate text and automatically improve quality through feedback loops
  </Card>
</CardGroup>


# Respond to customer inquiry and check for inappropriate content
Source: https://trigger.dev/docs/guides/ai-agents/respond-and-check-content

Create an AI agent workflow that responds to customer inquiries while checking if their text is inappropriate

## Overview

**Parallelization** is a workflow pattern where multiple tasks or processes run simultaneously instead of sequentially, allowing for more efficient use of resources and faster overall execution. It's particularly valuable when different parts of a task can be handled independently, such as running content analysis and response generation at the same time.

<img alt="Parallelization" />

## Example task

In this example, we'll create a workflow that simultaneously checks content for issues while responding to customer inquiries. This approach is particularly effective when tasks require multiple perspectives or parallel processing streams, with the orchestrator synthesizing the results into a cohesive output.

**This task:**

* Uses `generateText` from the [AI SDK](https://ai-sdk.dev/) to call Anthropic's Claude models
* Uses `experimental_telemetry` to surface each LLM call on the Run page in the dashboard
* Uses [`batch.triggerByTaskAndWait`](/docs/triggering#batch-triggerbytaskandwait) to run customer response and content moderation tasks in parallel
* Answers with `claude-sonnet-4-5` and moderates with the faster, cheaper `claude-haiku-4-5`
* Simultaneously checks for inappropriate content while generating responses

```typescript theme={"theme":"css-variables"}
import { anthropic } from "@ai-sdk/anthropic";
import { batch, task } from "@trigger.dev/sdk";
import { generateText } from "ai";

// Task to generate customer response
export const generateCustomerResponse = task({
  id: "generate-customer-response",
  run: async (payload: { question: string }) => {
    const response = await generateText({
      model: anthropic("claude-sonnet-4-5"),
      messages: [
        {
          role: "system",
          content: "You are a helpful customer service representative.",
        },
        { role: "user", content: payload.question },
      ],
      experimental_telemetry: {
        isEnabled: true,
        functionId: "generate-customer-response",
      },
    });

    return response.text;
  },
});

// Task to check for inappropriate content
export const checkInappropriateContent = task({
  id: "check-inappropriate-content",
  run: async (payload: { text: string }) => {
    const response = await generateText({
      model: anthropic("claude-haiku-4-5"),
      messages: [
        {
          role: "system",
          content:
            "You are a content moderator. Respond with 'true' if the content is inappropriate or contains harmful, threatening, offensive, or explicit content, 'false' otherwise.",
        },
        { role: "user", content: payload.text },
      ],
      experimental_telemetry: {
        isEnabled: true,
        functionId: "check-inappropriate-content",
      },
    });

    return response.text.toLowerCase().includes("true");
  },
});

// Main task that coordinates the parallel execution
export const handleCustomerQuestion = task({
  id: "handle-customer-question",
  run: async (payload: { question: string }) => {
    const {
      runs: [responseRun, moderationRun],
    } = await batch.triggerByTaskAndWait([
      {
        task: generateCustomerResponse,
        payload: { question: payload.question },
      },
      {
        task: checkInappropriateContent,
        payload: { text: payload.question },
      },
    ]);

    // Check moderation result first
    if (moderationRun.ok && moderationRun.output === true) {
      return {
        response:
          "I apologize, but I cannot process this request as it contains inappropriate content.",
        wasInappropriate: true,
      };
    }

    // Return the generated response if everything is ok
    if (responseRun.ok) {
      return {
        response: responseRun.output,
        wasInappropriate: false,
      };
    }

    // Handle any errors
    throw new Error("Failed to process customer question");
  },
});
```

## Run a test

On the Test page in the dashboard, select the `handle-customer-question` task and include a payload like the following:

```json theme={"theme":"css-variables"}
{
  "question": "Can you explain 2FA?"
}
```

When triggered with a question, the task simultaneously generates a response while checking for inappropriate content using two parallel LLM calls. The main task waits for both operations to complete before delivering the final response.

<video />


# Route a question to a different AI model
Source: https://trigger.dev/docs/guides/ai-agents/route-question

Create an AI agent workflow that routes a question to a different AI model depending on its complexity

## Overview

**Routing** is a workflow pattern that classifies an input and directs it to a specialized followup task. This pattern allows for separation of concerns and building more specialized prompts, which is particularly effective when there are distinct categories that are better handled separately. Without routing, optimizing for one kind of input can hurt performance on other inputs.

<img alt="Routing" />

## Example task

In this example, we'll create a workflow that routes a question to a different AI model depending on its complexity. This approach is particularly effective when tasks require different models or approaches for different inputs.

**This task:**

* Uses `generateObject` from the [AI SDK](https://ai-sdk.dev/) to classify the question into a typed routing decision
* Uses `experimental_telemetry` to surface each LLM call on the Run page in the dashboard
* Classifies complexity with a fast, cheap model (`claude-haiku-4-5`)
* Directs simple questions to `claude-haiku-4-5` and complex ones to `claude-sonnet-4-5`
* Returns both the answer and metadata about the routing decision

```typescript theme={"theme":"css-variables"}
import { anthropic } from "@ai-sdk/anthropic";
import { task } from "@trigger.dev/sdk";
import { generateObject, generateText } from "ai";
import { z } from "zod";

// The router's structured decision. generateObject validates the model
// output against this schema, so there's no manual JSON parsing.
const routingSchema = z.object({
  model: z.enum(["claude-haiku-4-5", "claude-sonnet-4-5"]),
  reason: z.string(),
});

export const routeAndAnswerQuestion = task({
  id: "route-and-answer-question",
  run: async (payload: { question: string }) => {
    // Step 1: Classify the question and pick a model
    const { object: routing } = await generateObject({
      model: anthropic("claude-haiku-4-5"),
      schema: routingSchema,
      system:
        "You are a routing assistant. Pick the model best suited to answer the question:\n" +
        "- claude-haiku-4-5 for simple, common, or straightforward questions\n" +
        "- claude-sonnet-4-5 for complex, unusual, or questions needing deep reasoning",
      prompt: payload.question,
      experimental_telemetry: {
        isEnabled: true,
        functionId: "route-question",
      },
    });

    // Step 2: Answer with the selected model
    const answer = await generateText({
      model: anthropic(routing.model),
      prompt: payload.question,
      experimental_telemetry: {
        isEnabled: true,
        functionId: "answer-question",
      },
    });

    return {
      answer: answer.text,
      selectedModel: routing.model,
      routingReason: routing.reason,
    };
  },
});
```

## Run a test

Triggering our task with a simple question shows it routing to the `claude-haiku-4-5` model and returning the answer with reasoning:

```json theme={"theme":"css-variables"}
{
  "question": "How many planets are there in the solar system?"
}
```

<video />


# Translate text and refine it based on feedback
Source: https://trigger.dev/docs/guides/ai-agents/translate-and-refine

This guide will show you how to create a task that translates text and refines it based on feedback.

## Overview

This example is based on the **evaluator-optimizer** pattern, where one LLM generates a response while another provides evaluation and feedback in a loop. This is particularly effective for tasks with clear evaluation criteria where iterative refinement provides better results.

<img alt="Evaluator-optimizer" />

## Example task

This example task translates text into a target language and refines the translation over a number of iterations based on feedback provided by the LLM.

**This task:**

* Uses `generateText` from [Vercel's AI SDK](https://sdk.vercel.ai/docs/introduction) to generate the translation
* Uses `experimental_telemetry` to provide LLM logs on the Run page in the dashboard
* Runs for a maximum of 10 iterations
* Uses `generateText` again to evaluate the translation
* Recursively calls itself to refine the translation based on the feedback

```typescript theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { generateText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

interface TranslationPayload {
  text: string;
  targetLanguage: string;
  previousTranslation?: string;
  feedback?: string;
  rejectionCount?: number;
}

interface TranslationResult {
  finalTranslation: string | undefined;
  iterations: number;
  status: "MAX_ITERATIONS_REACHED" | "APPROVED";
}

export const translateAndRefine = task({
  id: "translate-and-refine",
  // Explicit return type: the task returns its own recursive result, so
  // annotate it to avoid TypeScript's circular-inference error.
  run: async (payload: TranslationPayload): Promise<TranslationResult> => {
    const rejectionCount = payload.rejectionCount || 0;

    // Bail out if we've hit the maximum attempts
    if (rejectionCount >= 10) {
      return {
        finalTranslation: payload.previousTranslation,
        iterations: rejectionCount,
        status: "MAX_ITERATIONS_REACHED",
      };
    }

    // Generate translation (or refinement if we have previous feedback)
    const translationPrompt = payload.feedback
      ? `Previous translation: "${payload.previousTranslation}"\n\nFeedback received: "${payload.feedback}"\n\nPlease provide an improved translation addressing this feedback.`
      : `Translate this text into ${payload.targetLanguage}, preserving style and meaning: "${payload.text}"`;

    const translation = await generateText({
      model: anthropic("claude-sonnet-4-5"),
      messages: [
        {
          role: "system",
          content: `You are an expert literary translator into ${payload.targetLanguage}.
                   Focus on accuracy first, then style and natural flow.`,
        },
        {
          role: "user",
          content: translationPrompt,
        },
      ],
      experimental_telemetry: {
        isEnabled: true,
        functionId: "translate-and-refine",
      },
    });

    // Evaluate the translation
    const evaluation = await generateText({
      model: anthropic("claude-sonnet-4-5"),
      messages: [
        {
          role: "system",
          content: `You are an expert literary critic and translator focused on practical, high-quality translations.
                 Your goal is to ensure translations are accurate and natural, but not necessarily perfect.
                 This is iteration ${
                   rejectionCount + 1
                 } of a maximum 10 iterations.
                 
                 RESPONSE FORMAT:
                 - If the translation meets 90%+ quality: Respond with exactly "APPROVED" (nothing else)
                 - If improvements are needed: Provide only the specific issues that must be fixed
                 
                 Evaluation criteria:
                 - Accuracy of meaning (primary importance)
                 - Natural flow in the target language
                 - Preservation of key style elements
                 
                 DO NOT provide detailed analysis, suggestions, or compliments.
                 DO NOT include the translation in your response.
                 
                 IMPORTANT RULES:
                 - First iteration MUST receive feedback for improvement
                 - Be very strict on accuracy in early iterations
                 - After 3 iterations, lower quality threshold to 85%`,
        },
        {
          role: "user",
          content: `Original: "${payload.text}"
                 Translation: "${translation.text}"
                 Target Language: ${payload.targetLanguage}
                 Iteration: ${rejectionCount + 1}
                 Previous Feedback: ${
                   payload.feedback ? `"${payload.feedback}"` : "None"
                 }
                 
                 ${
                   rejectionCount === 0
                     ? "This is the first attempt. Find aspects to improve."
                     : 'Either respond with exactly "APPROVED" or provide only critical issues that must be fixed.'
                 }`,
        },
      ],
      experimental_telemetry: {
        isEnabled: true,
        functionId: "translate-and-refine",
      },
    });

    // If approved, return the final result
    if (evaluation.text.trim() === "APPROVED") {
      return {
        finalTranslation: translation.text,
        iterations: rejectionCount,
        status: "APPROVED",
      };
    }

    // If not approved, recursively refine with feedback and return the
    // refined result so the final translation propagates back up.
    return await translateAndRefine
      .triggerAndWait({
        text: payload.text,
        targetLanguage: payload.targetLanguage,
        previousTranslation: translation.text,
        feedback: evaluation.text,
        rejectionCount: rejectionCount + 1,
      })
      .unwrap();
  },
});
```

## Run a test

On the Test page in the dashboard, select the `translate-and-refine` task and include a payload like the following:

```json theme={"theme":"css-variables"}
{
  "text": "In the twilight of his years, the old clockmaker's hands, once steady as the timepieces he crafted, now trembled like autumn leaves in the wind.",
  "targetLanguage": "French"
}
```

This example payload translates the text into French and should be suitably difficult to require a few iterations, depending on the model used and the prompt criteria you set.

<video />


# Verify a news article
Source: https://trigger.dev/docs/guides/ai-agents/verify-news-article

Create an AI agent workflow that verifies the facts in a news article

## Overview

This example demonstrates the **orchestrator-workers** pattern, where a central AI agent dynamically breaks down complex tasks and delegates them to specialized worker agents. This pattern is particularly effective when tasks require multiple perspectives or parallel processing streams, with the orchestrator synthesizing the results into a cohesive output.

<img alt="Orchestrator" />

## Example task

Our example task uses multiple LLM calls to extract claims from a news article and analyze them in parallel, combining source verification and historical context to assess their credibility.

**This task:**

* Uses `generateText` from the [AI SDK](https://ai-sdk.dev/) to call Anthropic's Claude models
* Uses `experimental_telemetry` to surface each LLM call on the Run page in the dashboard
* Uses [`batch.triggerByTaskAndWait`](/docs/triggering#batch-triggerbytaskandwait) to orchestrate parallel processing of claims
* Extracts factual claims from news articles using `claude-sonnet-4-5`
* Evaluates claims against recent sources and analyzes historical context in parallel
* Combines results into a structured analysis report

```typescript theme={"theme":"css-variables"}
import { anthropic } from "@ai-sdk/anthropic";
import { batch, logger, task } from "@trigger.dev/sdk";
import { ModelMessage, generateText } from "ai";

// Define types for our workers' outputs
interface Claim {
  id: number;
  text: string;
}

interface SourceVerification {
  claimId: number;
  isVerified: boolean;
  confidence: number;
  explanation: string;
}

interface HistoricalAnalysis {
  claimId: number;
  feasibility: number;
  historicalContext: string;
}

// Worker 1: Claim Extractor
export const extractClaims = task({
  id: "extract-claims",
  run: async ({ article }: { article: string }) => {
    try {
      const messages: ModelMessage[] = [
        {
          role: "system",
          content:
            "Extract distinct factual claims from the news article. Format as numbered claims.",
        },
        {
          role: "user",
          content: article,
        },
      ];

      const response = await generateText({
        model: anthropic("claude-sonnet-4-5"),
        messages,
        experimental_telemetry: {
          isEnabled: true,
          functionId: "extract-claims",
        },
      });

      const claims = response.text
        .split("\n")
        .filter((line: string) => line.trim())
        .map((claim: string, index: number) => ({
          id: index + 1,
          text: claim.replace(/^\d+\.\s*/, ""),
        }));

      logger.info("Extracted claims", { claimCount: claims.length });
      return claims;
    } catch (error) {
      logger.error("Error in claim extraction", {
        error: error instanceof Error ? error.message : "Unknown error",
      });
      throw error;
    }
  },
});

// Worker 2: Source Verifier
export const verifySource = task({
  id: "verify-source",
  run: async (claim: Claim) => {
    const response = await generateText({
      model: anthropic("claude-sonnet-4-5"),
      messages: [
        {
          role: "system",
          content:
            "Verify this claim by considering recent news sources and official statements. Assess reliability.",
        },
        {
          role: "user",
          content: claim.text,
        },
      ],
      experimental_telemetry: {
        isEnabled: true,
        functionId: "verify-source",
      },
    });

    return {
      claimId: claim.id,
      isVerified: false,
      confidence: 0.7,
      explanation: response.text,
    };
  },
});

// Worker 3: Historical Context Analyzer
export const analyzeHistory = task({
  id: "analyze-history",
  run: async (claim: Claim) => {
    const response = await generateText({
      model: anthropic("claude-sonnet-4-5"),
      messages: [
        {
          role: "system",
          content:
            "Analyze this claim in historical context, considering past announcements and technological feasibility.",
        },
        {
          role: "user",
          content: claim.text,
        },
      ],
      experimental_telemetry: {
        isEnabled: true,
        functionId: "analyze-history",
      },
    });

    return {
      claimId: claim.id,
      feasibility: 0.8,
      historicalContext: response.text,
    };
  },
});

// Orchestrator
export const newsFactChecker = task({
  id: "news-fact-checker",
  run: async ({ article }: { article: string }) => {
    // Step 1: Extract claims
    const claimsResult = await batch.triggerByTaskAndWait([
      { task: extractClaims, payload: { article } },
    ]);

    if (!claimsResult.runs[0].ok) {
      logger.error("Failed to extract claims", {
        error: claimsResult.runs[0].error,
        runId: claimsResult.runs[0].id,
      });
      throw new Error(
        `Failed to extract claims: ${claimsResult.runs[0].error}`
      );
    }

    const claims = claimsResult.runs[0].output;

    // Step 2: Process claims in parallel
    const parallelResults = await batch.triggerByTaskAndWait([
      ...claims.map((claim) => ({ task: verifySource, payload: claim })),
      ...claims.map((claim) => ({ task: analyzeHistory, payload: claim })),
    ]);

    // Split and process results
    const verifications = parallelResults.runs
      .filter(
        (run): run is typeof run & { ok: true } =>
          run.ok && run.taskIdentifier === "verify-source"
      )
      .map((run) => run.output as SourceVerification);

    const historicalAnalyses = parallelResults.runs
      .filter(
        (run): run is typeof run & { ok: true } =>
          run.ok && run.taskIdentifier === "analyze-history"
      )
      .map((run) => run.output as HistoricalAnalysis);

    return { claims, verifications, historicalAnalyses };
  },
});
```

## Run a test

On the Test page in the dashboard, select the `news-fact-checker` task and include a payload like the following:

```json theme={"theme":"css-variables"}
{
  "article": "Tesla announced a new breakthrough in battery technology today. The company claims their new batteries will have 50% more capacity and cost 30% less to produce. Elon Musk stated this development will enable electric vehicles to achieve price parity with gasoline cars by 2024. The new batteries are scheduled to enter production next quarter at the Texas Gigafactory."
}
```

This example payload verifies the claims in the news article and provides a report on the results.

<video />


# dotenvx
Source: https://trigger.dev/docs/guides/community/dotenvx

A dotenvx package for Trigger.dev.

This is a community developed package from [dotenvx](https://dotenvx.com/) that enables you to use dotenvx with Trigger.dev.

[View the docs](https://dotenvx.com/docs/background-jobs/triggerdotdev)


# Fatima
Source: https://trigger.dev/docs/guides/community/fatima

A Fatima package for Trigger.dev.

This is a community developed package from [@Fgc17](https://github.com/Fgc17) that enables you to use Fatima with Trigger.dev.

[View the Fatima docs](https://fatimajs.vercel.app/docs/adapters/trigger)

[View the repo](https://github.com/Fgc17/fatima)


# Rate limiter
Source: https://trigger.dev/docs/guides/community/rate-limiter

A rate limiter for Trigger.dev.

This is a community developed package from [@ian](https://github.com/ian) that uses Redis to rate limit Trigger.dev tasks.

[View the repo](https://github.com/ian/trigger-rate-limiting)


# SvelteKit setup guide
Source: https://trigger.dev/docs/guides/community/sveltekit

A plugin for SvelteKit to integrate with Trigger.dev.

This is a community developed Vite plugin from [@cptCrunch\_](https://x.com/cptCrunch_) that enables seamless integration between SvelteKit and Trigger.dev by allowing you to use your SvelteKit functions directly in your Trigger.dev projects.

## Features

* Use SvelteKit functions directly in Trigger.dev tasks
* Automatic function discovery and export
* TypeScript support with type preservation
* Works with Trigger.dev V3
* Configurable directory scanning

## Prerequisites

* Setup a project in&#x20;
* Ensure TypeScript is installed
* [Create a Trigger.dev account](https://cloud.trigger.dev)
* Create a new Trigger.dev project

## Setup

[View setup guide on npm](https://www.npmjs.com/package/triggerkit)

```bash theme={"theme":"css-variables"}
npm i triggerkit
```

<UsefulNextSteps />


# Automated website monitoring with Anchor Browser
Source: https://trigger.dev/docs/guides/example-projects/anchor-browser-web-scraper

Automated web monitoring using Trigger.dev's task scheduling and Anchor Browser's AI-powered browser automation.

<Warning>
  **WEB SCRAPING:** When web scraping, you MUST use a proxy to comply with our terms of service. Direct scraping of third-party websites without the site owner's permission using Trigger.dev Cloud is prohibited and will result in account suspension. See [this example](/docs/guides/examples/puppeteer#scrape-content-from-a-web-page) which uses a proxy.
</Warning>

## Overview

This example demonstrates automated web monitoring using Trigger.dev's task scheduling and Anchor Browser's AI-powered browser automation tools.

The task runs daily at 5pm ET to find the cheapest Broadway tickets available for same-day shows.

**How it works:**

* Trigger.dev schedules and executes the monitoring task
* Anchor Browser spins up a remote browser session with an AI agent
* The AI agent uses computer vision and natural language processing to analyze the TDF website
* AI agent returns the lowest-priced show with specific details: name, price, and showtime

## Tech stack

* **[Node.js](https://nodejs.org)** runtime environment (version 18.2 or higher)
* **[Trigger.dev](https://trigger.dev)** for task scheduling and task orchestration
* **[Anchor Browser](https://anchorbrowser.io/)** for AI-powered browser automation
* **[Playwright](https://playwright.dev/)** for browser automation libraries (handled via external dependencies)

## GitHub repo

<Card title="View the Anchor Browser web scraper repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/anchor-browser-web-scraper">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## Relevant code

### Broadway ticket monitor task

This task runs daily at 5pm ET, in [src/trigger/broadway-monitor.ts](https://github.com/triggerdotdev/examples/tree/main/anchor-browser-web-scraper/src/trigger/broadway-monitor.ts):

```ts theme={"theme":"css-variables"}
import { schedules } from "@trigger.dev/sdk";
import Anchorbrowser from "anchorbrowser";

export const broadwayMonitor = schedules.task({
  id: "broadway-ticket-monitor",
  cron: "0 21 * * *",
  run: async (payload, { ctx }) => {
    const client = new Anchorbrowser({
      apiKey: process.env.ANCHOR_BROWSER_API_KEY!,
    });

    let session;
    try {
      // Create explicit session to get live view URL
      session = await client.sessions.create();
      console.log(`Session ID: ${session.data.id}`);
      console.log(`Live View URL: https://live.anchorbrowser.io?sessionId=${session.data.id}`);

      const response = await client.tools.performWebTask({
        sessionId: session.data.id,
        url: "https://www.tdf.org/discount-ticket-programs/tkts-by-tdf/tkts-live/",
        prompt: `Look for the "Broadway Shows" section on this page. Find the show with the absolute lowest starting price available right now and return the show name, current lowest price, and show time. Be very specific about the current price you see. Format as: Show: [name], Price: [exact current price], Time: [time]`,
      });

      console.log("Raw response:", response);

      const result = response.data.result?.result || response.data.result || response.data;

      if (result && typeof result === "string" && result.includes("Show:")) {
        console.log(`🎭 Best Broadway Deal Found!`);
        console.log(result);

        return {
          success: true,
          bestDeal: result,
          liveViewUrl: `https://live.anchorbrowser.io?sessionId=${session.data.id}`,
        };
      } else {
        console.log("No Broadway deals found today");
        return { success: true, message: "No deals found" };
      }
    } finally {
      if (session?.data?.id) {
        try {
          await client.sessions.delete(session.data.id);
        } catch (cleanupError) {
          console.warn("Failed to cleanup session:", cleanupError);
        }
      }
    }
  },
});
```

### Build configuration

Since Anchor Browser uses browser automation libraries (Playwright) under the hood, we need to configure Trigger.dev to handle these dependencies properly by excluding them from the build bundle in [trigger.config.ts](https://github.com/triggerdotdev/examples/tree/main/anchor-browser-web-scraper/trigger.config.ts):

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "proj_your_project_id_here", // Get from Trigger.dev dashboard
  maxDuration: 3600, // 1 hour - plenty of time for web automation
  dirs: ["./src/trigger"],
  build: {
    external: ["playwright-core", "playwright", "chromium-bidi"],
  },
});
```

## Learn more

* View the [Anchor Browser docs](https://docs.anchorbrowser.io/) to learn more about Anchor Browser's AI-powered browser automation tools.
* Check out the source code for the [Anchor Browser web scraper repo](https://github.com/triggerdotdev/examples/tree/main/anchor-browser-web-scraper) on GitHub.
* Browser our [example projects](/docs/guides/introduction) to see how you can use Trigger.dev with other services.


# Next.js Batch LLM Evaluator
Source: https://trigger.dev/docs/guides/example-projects/batch-llm-evaluator

This example Next.js project evaluates multiple LLM models using the Vercel AI SDK and streams updates to the frontend using Trigger.dev Realtime.

## Overview

This demo is a full stack example that uses the following:

* A [Next.js](https://nextjs.org/) app with [Prisma](https://www.prisma.io/) for the database.
* Trigger.dev [Realtime](https://trigger.dev/launchweek/0/realtime) to stream updates to the frontend.
* Work with multiple LLM models using the Vercel [AI SDK](https://sdk.vercel.ai/docs/introduction). (OpenAI, Anthropic, XAI)
* Distribute tasks across multiple tasks using the new [`batch.triggerByTaskAndWait`](https://trigger.dev/docs/triggering#batch-triggerbytaskandwait) method.

## GitHub repo

<Card title="View the Batch LLM Evaluator repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/batch-llm-evaluator">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## Video

<video />

## Relevant code

* View the Trigger.dev task code in the [src/trigger/batch.ts](https://github.com/triggerdotdev/examples/blob/main/batch-llm-evaluator/src/trigger/batch.ts) file.
* The `evaluateModels` task uses the `batch.triggerByTaskAndWait` method to distribute the task to the different LLM models.
* It then passes the results through to a `summarizeEvals` task that calculates some dummy "tags" for each LLM response.
* We use a [useRealtimeRunsWithTag](/docs/realtime/react-hooks/subscribe#userealtimerunswithtag) hook to subscribe to the different evaluation tasks runs in the [src/components/llm-evaluator.tsx](https://github.com/triggerdotdev/examples/blob/main/batch-llm-evaluator/src/components/llm-evaluator.tsx) file.
* We then pass the relevant run down into three different components for the different models:
  * The `AnthropicEval` component: [src/components/evals/Anthropic.tsx](https://github.com/triggerdotdev/examples/blob/main/batch-llm-evaluator/src/components/evals/Anthropic.tsx)
  * The `XAIEval` component: [src/components/evals/XAI.tsx](https://github.com/triggerdotdev/examples/blob/main/batch-llm-evaluator/src/components/evals/XAI.tsx)
  * The `OpenAIEval` component: [src/components/evals/OpenAI.tsx](https://github.com/triggerdotdev/examples/blob/main/batch-llm-evaluator/src/components/evals/OpenAI.tsx)
* Each of these components then uses [useRealtimeRunWithStreams](/docs/realtime/react-hooks/streams#userealtimerunwithstreams) to subscribe to the different LLM responses.

<Note>
  This example uses the older `useRealtimeRunWithStreams` hook. For new projects, consider using the new [`useRealtimeStream`](/docs/realtime/react-hooks/streams#userealtimestream-recommended) hook (SDK 4.1.0+) for a simpler API and better type safety with defined streams.
</Note>

## Learn more about Trigger.dev Realtime

To learn more, take a look at the following resources:

* [Trigger.dev Realtime](/docs/realtime) - learn more about how to subscribe to runs and get real-time updates
* [Realtime streaming](/docs/realtime/react-hooks/streams) - learn more about streaming data from your tasks
* [Batch Triggering](/docs/triggering#tasks-batchtrigger) - learn more about how to trigger tasks in batches
* [React hooks](/docs/realtime/react-hooks) - learn more about using React hooks to interact with the Trigger.dev API


# Changelog generator using Claude Agent SDK
Source: https://trigger.dev/docs/guides/example-projects/claude-changelog-generator

Automatically generate changelogs from your git commit history using the Claude Agent SDK and Trigger.dev.

## Overview

This demo how to build an AI agent using the Claude Agent SDK that explores GitHub commits, investigates unclear changes by fetching diffs on demand, and generates developer-friendly changelogs.

## Tech stack

* **[Next.js](https://nextjs.org)** – Frontend framework using App Router
* **[Claude Agent SDK](https://docs.anthropic.com/en/docs/agents-and-tools/claude-agent-sdk)** – Anthropic's agent SDK for building AI agents with custom tools
* **[Trigger.dev](https://trigger.dev)** – workflow orchestration with real-time streaming, observability, and deployment
* **[Octokit](https://github.com/octokit/octokit.js)** – GitHub API client for fetching commits and diffs

## Demo video

<video />

## GitHub repo

<Card title="View the changelog generator repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/changelog-generator">
  Click here to view the full open source code for this project in our examples repository on
  GitHub. You can fork it and use it as a starting point for your own project.
</Card>

## How it works

The agent workflow:

1. **Receive request** – User provides a GitHub repo URL and date range
2. **List commits** – Agent calls `list_commits` MCP tool to get all commits
3. **Analyze commits** – Agent categorizes each commit:
   * Skip trivial commits (typos, formatting)
   * Include clear features/improvements directly
   * Investigate unclear commits by fetching their diffs
4. **Generate changelog** – Agent writes a categorized markdown changelog
5. **Stream output** – Changelog streams to the frontend in real-time

## Features

* **Two-phase analysis** – Lists all commits first, then selectively fetches diffs only for ambiguous ones
* **Custom tools** – `list_commits` and `get_commit_diff` called autonomously by Claude
* **Real-time streaming** – Changelog streams to the frontend as it's generated via Trigger.dev Realtime
* **Live observability** – Agent phase, turn count, and tool calls broadcast via run metadata
* **Private repo support** – Optional GitHub token for private repositories

## Relevant code

| File                                                                                                                                                 | Description                            |
| ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| [`trigger/generate-changelog.ts`](https://github.com/triggerdotdev/examples/blob/main/changelog-generator/trigger/generate-changelog.ts)             | Main task with custom tools            |
| [`trigger/changelog-stream.ts`](https://github.com/triggerdotdev/examples/blob/main/changelog-generator/trigger/changelog-stream.ts)                 | Stream definition for real-time output |
| [`app/api/generate-changelog/route.ts`](https://github.com/triggerdotdev/examples/blob/main/changelog-generator/app/api/generate-changelog/route.ts) | API endpoint that triggers the task    |
| [`app/response/[runId]/page.tsx`](https://github.com/triggerdotdev/examples/blob/main/changelog-generator/app/response/%5BrunId%5D/page.tsx)         | Streaming display page                 |

## trigger.config.ts

You need to mark the Claude Agent SDK as external in your trigger.config.ts file.

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: process.env.TRIGGER_PROJECT_REF!,
  runtime: "node",
  logLevel: "log",
  maxDuration: 300,
  build: {
    external: ["@anthropic-ai/claude-agent-sdk"],
  },
  machine: "small-2x",
});
```

<Note>
  Adding packages to `external` prevents them from being bundled, which is necessary for the Claude
  Agent SDK. See the [build configuration docs](/docs/config/config-file#external) for more details.
</Note>

## Learn more

* [**Building agents with Claude Agent SDK**](/docs/guides/ai-agents/claude-code-trigger) – Comprehensive guide for using Claude Agent SDK with Trigger.dev
* [**Realtime**](/docs/realtime/overview) – Stream task progress to your frontend
* [**Scheduled tasks**](/docs/tasks/scheduled) – Automate changelog generation on a schedule


# Claude GitHub wiki
Source: https://trigger.dev/docs/guides/example-projects/claude-github-wiki

Ask questions about any public GitHub repository and get AI-powered analysis using the Claude Agent SDK and Trigger.dev.

## Overview

This demo shows how to build an AI agent using the Claude Agent SDK that clones any public GitHub repo and uses Claude to answer questions about its codebase. The agent explores the code using `Grep` and `Read` tools to provide detailed, accurate answers.

## Tech stack

* **[Next.js](https://nextjs.org/)** – React framework with App Router for the frontend
* **[Claude Agent SDK](https://docs.anthropic.com/en/docs/agents-and-tools/claude-agent-sdk)** – Anthropic's SDK for building AI agents with file system and search tools
* **[Trigger.dev](https://trigger.dev/)** – workflow orchestration with real-time streaming, observability, and deployment

## Demo video

<video />

## GitHub repo

<Card title="View the Claude GitHub wiki agent repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/claude-agent-github-wiki">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## How it works

The agent workflow:

1. **Receive question** – User provides a GitHub URL and question about the repo
2. **Clone repository** – Shallow clone to a temp directory (depth=1 for speed)
3. **Analyze with Claude** – Agent explores the codebase using allowed tools:
   * `Grep` – Search for patterns across files
   * `Read` – Read file contents
4. **Stream response** – Analysis streams to the frontend in real-time
5. **Cleanup** – Temp directory is always deleted, even on failure

## Features

* **Ask anything about any public repo** – Architecture, security vulnerabilities, API endpoints, testing strategies, etc.
* **Claude Agent SDK exploration** – Claude explores the codebase using `Grep` and `Read` tools
* **Cancel anytime** – Abort long-running tasks with proper cleanup
* **Trigger.dev [Realtime](/docs/realtime/overview) streaming** – Watch Claude's analysis stream in as it's generated
* **Progress tracking** – See clone status, analysis progress, and repo size via Trigger.dev metadata

## Relevant code

| File                                                                                                                                              | Description                                                         |
| ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| [`trigger/analyze-repo.ts`](https://github.com/triggerdotdev/examples/blob/main/claude-agent-github-wiki/trigger/analyze-repo.ts)                 | Main task that clones repo, runs Claude agent, and streams response |
| [`trigger/agent-stream.ts`](https://github.com/triggerdotdev/examples/blob/main/claude-agent-github-wiki/trigger/agent-stream.ts)                 | Typed stream definition for real-time text responses                |
| [`app/api/analyze-repo/route.ts`](https://github.com/triggerdotdev/examples/blob/main/claude-agent-github-wiki/app/api/analyze-repo/route.ts)     | API endpoint that triggers the task                                 |
| [`app/response/[runId]/page.tsx`](https://github.com/triggerdotdev/examples/blob/main/claude-agent-github-wiki/app/response/%5BrunId%5D/page.tsx) | Real-time streaming display with progress                           |

## trigger.config.ts

You need to mark the Claude Agent SDK as external in your trigger.config.ts file.

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: process.env.TRIGGER_PROJECT_REF!,
  runtime: "node",
  logLevel: "log",
  maxDuration: 3600, // 60 minutes for large repos
  build: {
    external: ["@anthropic-ai/claude-agent-sdk"],
  },
  machine: "medium-2x",
});
```

<Note>
  Adding packages to `external` prevents them from being bundled, which is necessary for the Claude
  Agent SDK. See the [build configuration docs](/docs/config/config-file#external) for more details.
</Note>

## Learn more

* [**Building agents with Claude Agent SDK**](/docs/guides/ai-agents/claude-code-trigger) – Comprehensive guide for using Claude Agent SDK with Trigger.dev
* [**Trigger.dev Realtime**](/docs/realtime/overview) – Stream task progress to your frontend
* [**Errors and retrying**](/docs/errors-retrying) – Handle failures gracefully


# Claude 3.7 thinking chatbot
Source: https://trigger.dev/docs/guides/example-projects/claude-thinking-chatbot

This example Next.js project uses Vercel's AI SDK and Anthropic's Claude 3.7 model to create a thinking chatbot.

## Overview

This demo is a full stack example that uses the following:

* A [Next.js](https://nextjs.org/) app for the chat interface
* [Trigger.dev Realtime](/docs/realtime/overview) to stream AI responses and thinking/reasoning process to the frontend
* [Claude 3.7 Sonnet](https://www.anthropic.com/claude) for generating AI responses
* [AI SDK](https://sdk.vercel.ai/docs/introduction) for working with the Claude model

## GitHub repo

<Card title="View the Claude thinking chatbot repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/claude-thinking-chatbot">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## Video

<video />

## Relevant code

* **Claude Stream Task**: View the Trigger.dev task code in the [src/trigger/claude-stream.ts](https://github.com/triggerdotdev/examples/tree/main/claude-thinking-chatbot/src/trigger/claude-stream.ts) file, which sets up the streaming connection with Claude.
* **Chat Component**: The main chat interface is in [app/components/claude-chat.tsx](https://github.com/triggerdotdev/examples/tree/main/claude-thinking-chatbot/app/components/claude-chat.tsx), which handles:
  * Message state management
  * User input handling
  * Rendering of message bubbles
  * Integration with Trigger.dev for streaming
* **Stream Response**: The `StreamResponse` component within the chat component handles:
  * Displaying streaming text from Claude
  * Showing/hiding the thinking process with an animated toggle
  * Auto-scrolling as new content arrives

## Learn more about Trigger.dev Realtime

To learn more, take a look at the following resources:

* [Trigger.dev Realtime](/docs/realtime) - learn more about how to subscribe to runs and get real-time updates
* [Realtime streaming](/docs/realtime/react-hooks/streams) - learn more about streaming data from your tasks
* [Batch Triggering](/docs/triggering#tasks-batchtrigger) - learn more about how to trigger tasks in batches
* [React hooks](/docs/realtime/react-hooks) - learn more about using React hooks to interact with the Trigger.dev API


# ClickHouse chat agent
Source: https://trigger.dev/docs/guides/example-projects/clickhouse-chat-agent

Build a chat agent that answers questions about your ClickHouse data with charts, tables and maps instead of text, using chat.agent(), generative UI with json-render, and a Next.js frontend.

## Overview

This example is a fullstack [chat agent](/docs/ai-chat/overview) that answers natural-language questions about the data in a [ClickHouse Cloud](https://clickhouse.com/cloud) database — and presents the answers as **interactive charts, tables, stat cards and maps** instead of walls of text. The agent discovers the schema, writes ClickHouse SQL, runs it through the official [ClickHouse Node.js client](https://clickhouse.com/docs/integrations/javascript), then calls a `renderVisualization` tool with a [json-render](https://json-render.dev) spec that a Next.js chat UI renders live with [shadcn/ui](https://ui.shadcn.com) components.

**Tech stack:**

* **[Trigger.dev AI chat](/docs/ai-chat/overview)** for the agent session, turn loop, streaming and resumability
* **[AI Prompts](/docs/ai/prompts)** for a versioned system prompt with dashboard overrides and per-generation LLM observability
* **[ClickHouse Node.js client](https://clickhouse.com/docs/integrations/javascript)** (`@clickhouse/client`) for queries over HTTPS
* **[AI SDK](https://ai-sdk.dev/)** with Anthropic Claude for the model and tool calling, and `useChat` on the frontend
* **[json-render](https://json-render.dev)** with the [`@json-render/shadcn`](https://www.npmjs.com/package/@json-render/shadcn) component library for generative UI
* **Next.js** chat app using [`useTriggerChatTransport`](/docs/ai-chat/frontend) — the browser talks directly to Trigger.dev, no API route to maintain
* **shadcn charts** (Recharts) and **[mapcn](https://mapcn.dev)** (MapLibre GL, free CARTO tiles) for the chart and map components

**Features:**

* **Generative UI**: a `renderVisualization` tool takes a json-render spec — bar/line/area/pie charts, data tables, stat-card KPI rows and point maps, composed in cards and grids — with the query results inlined. Specs are validated against the component catalog and errors are returned to the model, so it corrects the spec and retries.
* **One shared catalog**: the same module generates the system-prompt component reference and validates tool calls, so the prompt and the renderer can't drift apart
* **Versioned system prompt**: defined with `prompts.define()`, resolvable per-run, overridable from the dashboard without redeploying — and storing it via `chat.prompt.set()` wires up `experimental_telemetry`, so every model call appears in the run trace with token, cost and latency metrics
* **Schema discovery tools**: `listTables` reads table names, engines and row counts from `system.tables`; `describeTable` returns column names and types using bound `Identifier` query params, so table names are never interpolated into SQL strings
* **Read-only query tool**: `runQuery` accepts SELECT-style statements only, enforced in code and backed by ClickHouse settings — `readonly=2`, a 1,000-row result cap, and a 30 second execution timeout
* **Self-correcting SQL**: query errors are returned to the model as tool output, so the agent reads the ClickHouse error, fixes its SQL, and retries

## GitHub repo

<Card title="View the ClickHouse chat agent repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/clickhouse-chat-agent">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## How it works

### The agent

The agent is defined with [`chat.agent()`](/docs/ai-chat/overview). The system prompt is a versioned [AI Prompt](/docs/ai/prompts): the editable analyst guidance lives in the prompt template, while the json-render component reference is generated from the catalog at run time and injected as a template variable. Storing the resolved prompt with `chat.prompt.set()` lets `chat.toStreamTextOptions()` supply the system text, model, config and telemetry:

```ts src/trigger/clickhouse-agent.ts theme={"theme":"css-variables"}
import { prompts } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";
import { anthropic } from "@ai-sdk/anthropic";
import { createProviderRegistry, stepCountIs, streamText } from "ai";
import { z } from "zod";
import { catalogPromptSection } from "../lib/catalog";

const registry = createProviderRegistry({ anthropic });

const systemPrompt = prompts.define({
  id: "clickhouse-analyst",
  model: "anthropic:claude-opus-4-8",
  variables: z.object({ componentReference: z.string() }),
  content: `You are a ClickHouse data analyst. ...

## renderVisualization spec reference

{{componentReference}}`,
});

export const clickhouseAgent = chat.agent({
  id: "clickhouse-agent",
  idleTimeoutInSeconds: 300,
  // Declared on the config so tool results survive history re-conversion across turns
  tools: { listTables, describeTable, runQuery, renderVisualization },

  onChatStart: async () => {
    // Latest prompt version (or an active dashboard override), with the
    // component reference generated from the catalog so it always matches
    // the deployed code.
    const resolved = await systemPrompt.resolve({
      componentReference: catalogPromptSection(),
    });
    chat.prompt.set(resolved);
  },

  run: async ({ messages, tools, signal }) => {
    return streamText({
      // Fallback model only — placed BEFORE the spread so the stored
      // prompt's model (including dashboard overrides) wins when set.
      model: anthropic("claude-opus-4-8"),
      // Wires up prepareStep (compaction, steering, background injection),
      // plus the system prompt + model + config + telemetry from chat.prompt().
      ...chat.toStreamTextOptions({ registry }),
      messages,
      tools,
      stopWhen: stepCountIs(15),
      abortSignal: signal,
    });
  },
});
```

<Warning>
  On AI SDK v5/v6, `experimental_telemetry` comes from the stored prompt via
  `chat.toStreamTextOptions()` — without `chat.prompt.set()`, model calls don't appear as spans in
  the run trace.
</Warning>

### Generative UI with one shared catalog

A single module defines which components the model may use: `Table`, `Card`, `Grid`, `Badge` and friends from `@json-render/shadcn`, plus custom chart components (shadcn charts on Recharts), a `Stat` card, and a `PointMap` built on mapcn. The same catalog produces the system-prompt reference and validates tool calls:

```ts src/lib/catalog.ts theme={"theme":"css-variables"}
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";

export const catalog = defineCatalog(schema, {
  components: {
    // Layout & text from the stock shadcn catalog
    Card: shadcnComponentDefinitions.Card,
    Grid: shadcnComponentDefinitions.Grid,
    Table: shadcnComponentDefinitions.Table,
    // ...plus custom BarChart, LineChart, AreaChart, PieChart, Stat, PointMap
  },
  actions: {},
});

// Generates a component reference (props as JSON schema, from the same zod
// definitions) for the system prompt — the prompt can't drift from the code.
export function catalogPromptSection(): string {
  /* ... */
}

// Validates a spec against the catalog; errors are phrased for the model
// to correct and retry.
export function validateSpec(spec: VisualizationSpec) {
  /* ... */
}
```

The `renderVisualization` tool accepts a flat json-render spec with the data rows inlined from earlier `runQuery` results. Validation failures go back to the model as tool output:

```ts src/trigger/clickhouse-agent.ts theme={"theme":"css-variables"}
const renderVisualization = tool({
  description:
    "Render charts, tables and stat cards for the user, instead of describing data as text.",
  inputSchema: z.object({
    spec: z.object({
      root: z.string(),
      elements: z.record(
        z.string(),
        z.object({
          type: z.string(),
          props: z.record(z.string(), z.unknown()),
          children: z.array(z.string()).optional(),
        })
      ),
    }),
  }),
  execute: async ({ spec }) => {
    const result = validateSpec(spec);
    if (!result.ok) {
      // The model reads these, fixes the spec, and calls the tool again
      return { ok: false, errors: result.errors };
    }
    return { ok: true, note: "Rendered to the user. Add at most a one-sentence takeaway." };
  },
});
```

### The Next.js chat UI

The frontend uses `useChat` with [`useTriggerChatTransport`](/docs/ai-chat/frontend) — the browser subscribes to the session's streams directly, authenticated by two small server actions. `renderVisualization` tool parts in the message stream render through json-render's `<Renderer>` with the shadcn component registry:

```tsx src/components/chat.tsx theme={"theme":"css-variables"}
"use client";

import { useChat } from "@ai-sdk/react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import type { clickhouseAgent } from "@/trigger/clickhouse-agent";
import { mintChatAccessToken, startChatSession } from "@/app/actions";

export function Chat() {
  const transport = useTriggerChatTransport<typeof clickhouseAgent>({
    task: "clickhouse-agent",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }),
  });

  const { messages, sendMessage, stop, status } = useChat({ transport });
  // Render text parts as markdown; render tool-renderVisualization parts
  // with json-render's <Renderer spec={...} registry={registry} />
}
```

The registry maps every catalog component to its React implementation — the stock `@json-render/shadcn` components plus the custom charts and map:

```tsx src/lib/registry.tsx theme={"theme":"css-variables"}
import { defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
import { catalog } from "./catalog";

export const { registry } = defineRegistry(catalog, {
  components: {
    Card: shadcnComponents.Card,
    Table: shadcnComponents.Table,
    // ...
    BarChart: ({ props }) => <BarChartView {...props} />,
    PointMap: ({ props }) => <PointMapView {...props} />,
  },
});
```

### The query tool

`runQuery` guards against writes twice: a statement allowlist in code, and ClickHouse settings on the request itself. Errors are returned to the model instead of thrown, which is what makes the agent self-correct:

```ts src/trigger/clickhouse-agent.ts theme={"theme":"css-variables"}
const READ_ONLY_STATEMENTS = /^\s*(select|with|show|describe|desc|explain|exists)\b/i;

const runQuery = tool({
  description: "Run a read-only SQL query against ClickHouse and get the results as JSON rows.",
  inputSchema: z.object({
    query: z.string().describe("The ClickHouse SQL query to run"),
  }),
  execute: async ({ query }) => {
    if (!READ_ONLY_STATEMENTS.test(query)) {
      return { error: "Only read-only statements are allowed." };
    }
    try {
      const result = await getClickHouse().query({
        query,
        format: "JSONEachRow",
        clickhouse_settings: {
          // readonly=2: reads only (no writes/DDL), but per-query settings
          // like the limits below are still allowed.
          readonly: "2",
          max_result_rows: "1000",
          result_overflow_mode: "break",
          max_execution_time: 30,
        },
      });
      const rows = await result.json();
      return { rowCount: rows.length, rows };
    } catch (error) {
      // Return ClickHouse errors to the model so it can fix the query and retry.
      return { error: error instanceof Error ? error.message : String(error) };
    }
  },
});
```

### Running it

The example needs `CLICKHOUSE_URL` and `ANTHROPIC_API_KEY` set in the Trigger.dev dashboard on the [Environment Variables page](/docs/deploy-environment-variables), and `TRIGGER_PROJECT_REF` plus `TRIGGER_SECRET_KEY` in the local `.env` for the Next.js server actions:

```bash .env theme={"theme":"css-variables"}
TRIGGER_PROJECT_REF=proj_xxxxxxxxxxxxxxxxxxxxxxxx
TRIGGER_SECRET_KEY=tr_dev_xxxxxxxxxxxxxxxxxxxxxxxx
```

Run the agent and the app in two terminals, then open [http://localhost:3000](http://localhost:3000):

```bash theme={"theme":"css-variables"}
pnpm dev:trigger   # the agent
pnpm dev           # the Next.js app
```

With a dataset like [NYC Taxi](https://clickhouse.com/docs/getting-started/example-datasets/nyc-taxi) loaded, asking for a dashboard of daily trip volume, hourly demand and revenue by payment type produces a stat-card KPI row, two charts and a pie in one composed card — and asking "Where do trips start and end?" produces two interactive maps with size-scaled markers.

## Relevant code

* **Agent + tools**: [src/trigger/clickhouse-agent.ts](https://github.com/triggerdotdev/examples/blob/main/clickhouse-chat-agent/src/trigger/clickhouse-agent.ts): the `chat.agent()` definition, the versioned prompt, the four tools, the read-only guards, and the ClickHouse client
* **Shared catalog**: [src/lib/catalog.ts](https://github.com/triggerdotdev/examples/blob/main/clickhouse-chat-agent/src/lib/catalog.ts): component definitions, prompt-reference generation, and spec validation
* **Component registry**: [src/lib/registry.tsx](https://github.com/triggerdotdev/examples/blob/main/clickhouse-chat-agent/src/lib/registry.tsx): maps catalog components to shadcn/Recharts/mapcn implementations
* **Chat UI**: [src/components/chat.tsx](https://github.com/triggerdotdev/examples/blob/main/clickhouse-chat-agent/src/components/chat.tsx): `useChat` + `useTriggerChatTransport`, message parts, and visualization rendering
* **Server actions**: [src/app/actions.ts](https://github.com/triggerdotdev/examples/blob/main/clickhouse-chat-agent/src/app/actions.ts): session creation and token minting

## Learn more

<CardGroup>
  <Card title="AI chat overview" icon="message-bot" href="/ai-chat/overview">
    How chat agents, sessions, and the turn loop work.
  </Card>

  <Card title="Frontend" icon="browser" href="/ai-chat/frontend">
    The chat transport, session tokens, and reconnection.
  </Card>

  <Card title="AI Prompts" icon="file-lines" href="/ai/prompts">
    Versioned prompts with dashboard overrides and generation tracking.
  </Card>

  <Card title="Tools" icon="wrench" href="/ai-chat/tools">
    Declaring tools on your agent and how they persist across turns.
  </Card>
</CardGroup>


# Background Cursor agent using the Cursor CLI
Source: https://trigger.dev/docs/guides/example-projects/cursor-background-agent

Run Cursor's headless CLI agent in a Trigger.dev task and stream the live output to the frontend using Trigger.dev Realtime Streams.

## Overview

This example runs [Cursor's headless CLI](https://cursor.com/cli) in a Trigger.dev task. The agent spawns as a child process, and its NDJSON stdout is parsed and piped to the browser in real-time using [Realtime Streams](/docs/realtime/react-hooks/streams). The result is a live terminal UI that renders each Cursor event (system messages, assistant responses, tool calls, results) as it happens.

**Tech stack:**

* **[Next.js](https://nextjs.org/)** for the web app (App Router with server actions)
* **[Cursor CLI](https://cursor.com/cli)** for the headless AI coding agent
* **[Trigger.dev](https://trigger.dev)** for task orchestration, real-time streaming, and deployment

## Video

<video />

**Features:**

* **Build extensions**: Installs the `cursor-agent` binary into the task container image using `addLayer`, demonstrating how to ship system binaries with your tasks
* **Realtime Streams v2**: NDJSON from a child process stdout is parsed and piped directly to the browser using `streams.define()` and `.pipe()`
* **Live terminal rendering**: Each Cursor event renders as a distinct row with auto-scroll
* **Long-running tasks**: Cursor agent runs for minutes; Trigger.dev handles lifecycle, timeouts, and retries automatically
* **Machine selection**: Uses the `medium-2x` preset for resource-intensive CLI tools
* **LLM model picker**: Switch between models from the UI before triggering a run

## GitHub repo

<Card title="View the Cursor background agent repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/cursor-cli-demo">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## How it works

### Task orchestration

The task spawns the Cursor CLI as a child process and streams its output to the frontend:

1. A Next.js server action triggers the `cursor-agent` task with the user's prompt and selected model
2. The task spawns the Cursor CLI binary using a helper that returns a typed NDJSON stream and a `waitUntilExit()` promise
3. Each line of NDJSON stdout is parsed into typed Cursor events and piped to a Realtime Stream
4. The frontend subscribes to the stream using `useRealtimeRunWithStreams` and renders each event in a terminal UI
5. The task waits for the CLI process to exit and returns the result

### Build extension for system binaries

The example includes a custom build extension that installs `cursor-agent` into the container image using `addLayer`. The official install script is run at build time, then the resolved entry point and its dependencies are copied to a fixed path so the task can invoke them at runtime with the bundled Node binary.

```ts extensions/cursor-cli.ts theme={"theme":"css-variables"}
const CURSOR_AGENT_DIR = "/usr/local/lib/cursor-agent";

export const cursorCli = (): BuildExtension => ({
  name: "cursor-cli",
  onBuildComplete(context) {
    if (context.target === "dev") return;

    context.addLayer({
      id: "cursor-cli",
      image: {
        instructions: [
          "RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*",
          'ENV PATH="/root/.local/bin:$PATH"',
          "RUN curl -fsSL https://cursor.com/install | bash",
          `RUN cp -r $(dirname $(readlink -f /root/.local/bin/cursor-agent)) ${CURSOR_AGENT_DIR}`,
        ],
      },
    });
  },
});
```

### Streaming with Realtime Streams v2

The stream is defined with a typed schema and piped from the child process:

```ts trigger/cursor-stream.ts theme={"theme":"css-variables"}
export const cursorStream = streams.define("cursor", cursorEventSchema);
```

```ts trigger/cursor-agent.ts theme={"theme":"css-variables"}
const { stream, waitUntilExit } = spawnCursorAgent({ prompt, model });
cursorStream.pipe(stream);
await waitUntilExit();
```

On the frontend, the `useRealtimeRunWithStreams` hook subscribes to these events and renders them as they arrive.

## Relevant code

* **Build extension + spawn helper**: [extensions/cursor-cli.ts](https://github.com/triggerdotdev/examples/blob/main/cursor-cli-demo/extensions/cursor-cli.ts): installs the binary and provides a typed NDJSON stream with `waitUntilExit()`
* **Task definition**: [trigger/cursor-agent.ts](https://github.com/triggerdotdev/examples/blob/main/cursor-cli-demo/trigger/cursor-agent.ts): spawns the CLI, pipes the stream, waits for exit
* **Stream definition**: [trigger/cursor-stream.ts](https://github.com/triggerdotdev/examples/blob/main/cursor-cli-demo/trigger/cursor-stream.ts): Realtime Streams v2 stream with typed schema
* **Terminal UI**: [components/terminal.tsx](https://github.com/triggerdotdev/examples/blob/main/cursor-cli-demo/components/terminal.tsx): renders live events using `useRealtimeRunWithStreams`
* **Event types**: [lib/cursor-events.ts](https://github.com/triggerdotdev/examples/blob/main/cursor-cli-demo/lib/cursor-events.ts): TypeScript types and parsers for Cursor NDJSON events
* **Trigger config**: [trigger.config.ts](https://github.com/triggerdotdev/examples/blob/main/cursor-cli-demo/trigger.config.ts): project config with the cursor CLI build extension

## Learn more about Trigger.dev Realtime

To learn more, take a look at the following resources:

* [Trigger.dev Realtime](/docs/realtime) - learn more about how to subscribe to runs and get real-time updates
* [Realtime streaming](/docs/realtime/react-hooks/streams) - learn more about streaming data from your tasks
* [Batch Triggering](/docs/triggering#tasks-batchtrigger) - learn more about how to trigger tasks in batches
* [React hooks](/docs/realtime/react-hooks) - learn more about using React hooks to interact with the Trigger.dev API


# Human-in-the-loop workflow with ReactFlow and Trigger.dev waitpoint tokens
Source: https://trigger.dev/docs/guides/example-projects/human-in-the-loop-workflow

This example project creates audio summaries of newspaper articles using a human-in-the-loop workflow built with ReactFlow and Trigger.dev waitpoint tokens.

## Overview

This demo is a full stack example that uses the following:

* [Next.js](https://nextjs.org/) for the web application
* [ReactFlow](https://reactflow.dev/) for the workflow UI
* [Trigger.dev Realtime](/docs/realtime/overview) to subscribe to task runs and show the real-time status of the workflow steps
* [Trigger.dev waitpoint tokens](/docs/wait-for-token) to create a human-in-the-loop flow with a review step
* [OpenAI API](https://openai.com/api/) to generate article summaries
* [ElevenLabs](https://elevenlabs.io/text-to-speech) to convert text to speech

## GitHub repo

<Card title="View the human-in-the-loop workflow repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/article-summary-workflow">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## Video

<video />

## Relevant code

Each node in the workflow corresponds to a Trigger.dev task. The idea is to enable building flows by composition of different tasks. The output of one task serves as input for another.

* **Trigger.dev task splitting**:
  * The [summarizeArticle](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/trigger/summarizeArticle.ts) task uses the OpenAI API to generate a summary an article.
  * The [convertTextToSpeech](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/trigger/convertTextToSpeech.ts) task uses the ElevenLabs API to convert the summary into an audio stream and upload it to an S3 bucket.
  * The [reviewSummary](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/trigger/reviewSummary.ts) task is a human-in-the-loop step that shows the result and waits for approval of the summary before continuing.
  * [articleWorkflow](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/trigger/articleWorkflow.ts) is the entrypoint that ties the workflow together and orchestrates the tasks. You might choose to approach the orchestration differently, depending on your use case.
* **ReactFlow Nodes**: there are three types of nodes in this example. All of them are custom ReactFlow nodes.
  * The [InputNode](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/components/InputNode.tsx) is the starting node of the workflow. It triggers the workflow by submitting an article URL.
  * The [ActionNode](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/components/ActionNode.tsx) is a node that shows the status of a task run in Trigger.dev, in real-time using the React hooks for Trigger.dev.
  * The [ReviewNode](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/components/ReviewNode.tsx) is a node that shows the summary result and prompts the user for approval before continuing. It uses the Realtime API to fetch details about the review status. Also, it interacts with the Trigger.dev waitpoint API for completing the waitpoint token using Next.js server actions.
* **Workflow orchestration**:
  * The workflow is orchestrated by the [Flow](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/components/Flow.tsx) component. It lays out the nodes, the connections between them, as well as the mapping to the Trigger.dev tasks.
    It also uses the `useRealtimeRunsWithTag` hook to subscribe to task runs associated with the workflow and passes down the run details to the nodes.

The waitpoint token is created in [a Next.js server action](https://github.com/triggerdotdev/examples/blob/main/article-summary-workflow/src/app/actions.ts#L26):

```ts theme={"theme":"css-variables"}
const reviewWaitpointToken = await wait.createToken({
  tags: [workflowTag],
  timeout: "1h",
  idempotencyKey: `review-summary-${workflowTag}`,
});
```

and later completed in another server action in the same file:

```ts theme={"theme":"css-variables"}
await wait.completeToken<ReviewPayload>(
  { id: tokenId },
  {
    approved: true,
    approvedAt: new Date(),
    approvedBy: user,
  }
);
```

While the workflow in this example is static and does not allow changing the connections between nodes in the UI, it serves as a good baseline for understanding how to build completely custom workflow builders using Trigger.dev and ReactFlow.

## Learn more about Trigger.dev Realtime and waitpoint tokens

To learn more, take a look at the following resources:

* [Trigger.dev Realtime](/docs/realtime) - learn more about how to subscribe to runs and get real-time updates
* [Realtime streaming](/docs/realtime/react-hooks/streams) - learn more about streaming data from your tasks
* [React hooks](/docs/realtime/react-hooks) - learn more about using React hooks to interact with the Trigger.dev API
* [Waitpoint tokens](/docs/wait-for-token) - learn about waitpoint tokens in Trigger.dev and human-in-the-loop flows


# Mastra agents with memory sharing + Trigger.dev task orchestration
Source: https://trigger.dev/docs/guides/example-projects/mastra-agents-with-memory

Multi-agent workflow with persistent memory sharing using Mastra and Trigger.dev for clothing recommendations based on weather data.

## Overview

Enter a city and an activity, and get a clothing recommendation generated for you based on today's weather.

![Generated clothing recommendations](https://github.com/user-attachments/assets/edfca304-6b22-4fa8-9362-71ecb3fe4903)

By combining Mastra's persistent memory system and agent orchestration with Trigger.dev's durable task execution, retries and observability, you get production-ready AI workflows that survive failures, scale automatically, and maintain context across long-running operations.

## Tech stack

* **[Node.js](https://nodejs.org)** runtime environment
* **[Mastra](https://mastra.ai)** for AI agent orchestration and memory management (Mastra is a Typescript framework for building AI agents, and uses Vercel's AI Agent SDK under the hood.)
* **[PostgreSQL](https://postgresql.org)** for persistent storage and memory sharing
* **[Trigger.dev](https://trigger.dev)** for task orchestration, batching, and observability
* **[OpenAI GPT-4](https://openai.com)** for natural language processing
* **[Open-Meteo API](https://open-meteo.com)** for weather data (no API key required)
* **[Zod](https://zod.dev)** for schema validation and type safety

## GitHub repo

<Card title="View the Mastra agents with memory repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/mastra-agents">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## Featured patterns

* **[Agent Memory Sharing](https://github.com/triggerdotdev/examples/blob/main/mastra-agents/src/trigger/weather-task.ts)**: Efficient data sharing between agents using Mastra's working memory system
* **[Task Orchestration](https://github.com/triggerdotdev/examples/blob/main/mastra-agents/src/trigger/weather-task.ts)**: Multi-step workflows with `triggerAndWait` for sequential agent execution
* **[Centralized Storage](https://github.com/triggerdotdev/examples/blob/main/mastra-agents/src/mastra/index.ts)**: Single PostgreSQL storage instance shared across all agents to prevent connection duplication
* **[Custom Tools](https://github.com/triggerdotdev/examples/blob/main/mastra-agents/src/mastra/tools/weather-tool.ts)**: External API integration with structured output validation
* **[Agent Specialization](https://github.com/triggerdotdev/examples/blob/main/mastra-agents/src/mastra/agents/)**: Purpose-built agents with specific roles and instructions
* **[Schema Optimization](https://github.com/triggerdotdev/examples/blob/main/mastra-agents/src/mastra/schemas/weather-data.ts)**: Lightweight data structures for performance

## Project Structure

```
src/
├── mastra/
│   ├── agents/
│   │   ├── weather-analyst.ts    # Weather data collection
│   │   ├── clothing-advisor.ts   # Clothing recommendations
│   ├── tools/
│   │   └── weather-tool.ts       # Enhanced weather API tool
│   ├── schemas/
│   │   └── weather-data.ts       # Weather schema
│   └── index.ts                  # Mastra configuration
├── trigger/
│   └── weather-task.ts           # Trigger.dev tasks
```

## Relevant code

* **[Multi-step task orchestration](https://github.com/triggerdotdev/examples/blob/main/mastra-agents/src/trigger/weather-task.ts)**: Multi-step task orchestration with `triggerAndWait` for sequential agent execution and shared memory context
* **[Weather analyst agent](https://github.com/triggerdotdev/examples/blob/main/mastra-agents/src/mastra/agents/weather-analyst.ts)**: Specialized agent for weather data collection with external API integration and memory storage
* **[Clothing advisor agent](https://github.com/triggerdotdev/examples/blob/main/mastra-agents/src/mastra/agents/clothing-advisor.ts)**: Purpose-built agent that reads from working memory and generates natural language responses
* **[Weather tool](https://github.com/triggerdotdev/examples/blob/main/mastra-agents/src/mastra/tools/weather-tool.ts)**: Custom Mastra tool with Zod validation for external API calls and error handling
* **[Weather data schema](https://github.com/triggerdotdev/examples/blob/main/mastra-agents/src/mastra/schemas/weather-data.ts)**: Optimized Zod schema for efficient memory storage and type safety
* **[Mastra configuration](https://github.com/triggerdotdev/examples/blob/main/mastra-agents/src/mastra/index.ts)**: Mastra configuration with PostgreSQL storage and agent registration

## Storage Architecture

This project uses a **centralized PostgreSQL storage** approach where a single database connection is shared across all Mastra agents. This prevents duplicate database connections and ensures efficient memory sharing between the weather analyst and clothing advisor agents.

### Storage Configuration

The storage is configured once in the main Mastra instance (`src/mastra/index.ts`) and automatically inherited by all agent Memory instances. This eliminates the "duplicate database object" warning that can occur with multiple PostgreSQL connections.

The PostgreSQL storage works seamlessly in both local development and serverless environments with any PostgreSQL provider, such as:

* [Local PostgreSQL instance](https://postgresql.org)
* [Supabase](https://supabase.com) - Serverless PostgreSQL
* [Neon](https://neon.tech) - Serverless PostgreSQL
* [Railway](https://railway.app) - Simple PostgreSQL hosting
* [AWS RDS](https://aws.amazon.com/rds/postgresql/) - Managed PostgreSQL

## Learn More

To learn more about the technologies used in this project, check out the following resources:

* [Mastra docs](https://mastra.ai/en/docs) - learn about AI agent orchestration and memory management
* [Mastra working memory](https://mastra.ai/en/docs/memory/overview) - learn about efficient data sharing between agents


# Meme generator with human-in-the-loop approval
Source: https://trigger.dev/docs/guides/example-projects/meme-generator-human-in-the-loop

This example project creates memes using OpenAI's DALL-E 3 with a human-in-the-loop approval workflow built using Trigger.dev waitpoint tokens.

## Overview

This demo is a full stack example that uses the following:

* A [Next.js](https://nextjs.org/) app, with an [endpoint](https://github.com/triggerdotdev/examples/blob/main/meme-generator-human-in-the-loop/src/app/endpoints/\[slug]/page.tsx) for approving the generated memes
* [Trigger.dev](https://trigger.dev) tasks to generate the images and orchestrate the waitpoint workflow
* [OpenAI DALL-E 3](https://platform.openai.com/docs/guides/images) for generating the images
* A [Slack app](https://api.slack.com/quickstart) for the human-in-the-loop step, with the approval buttons linked to the endpoint

## GitHub repo

<Card title="View the meme generator human-in-the-loop example repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/meme-generator-human-in-the-loop">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## Post to Slack

<img alt="Meme Generator with Human-in-the-Loop Approval" />

## Relevant code

* **Meme generator task**:

  * The [memegenerator.ts](https://github.com/triggerdotdev/examples/blob/main/meme-generator-human-in-the-loop/src/trigger/memegenerator.ts) task:
    * Generates two meme variants using DALL-E 3
    * Uses [batchTriggerAndWait](/docs/triggering#yourtask-batchtriggerandwait) to generate multiple meme variants simultaneously (this is because you can only generate 1 image at a time with DALL-E 3)
    * Creates a [waitpoint token](/docs/wait-for-token)
    * Sends the generated images with approval buttons to Slack for review
    * Handles the approval workflow

* **Approval Endpoint**:
  * The waitpoint approval handling is in [page.tsx](https://github.com/triggerdotdev/examples/blob/main/meme-generator-human-in-the-loop/src/app/endpoints/\[slug]/page.tsx), which processes:
    * User selections from Slack buttons
    * Waitpoint completion with the chosen meme variant
    * Success/failure feedback to the approver

## Learn more

To learn more, take a look at the following resources:

* [Waitpoint tokens](/docs/wait-for-token) - learn about waitpoint tokens in Trigger.dev and human-in-the-loop flows
* [OpenAI DALL-E API](https://platform.openai.com/docs/guides/images) - learn about the DALL-E image generation API
* [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API
* [Slack Incoming Webhooks](https://api.slack.com/messaging/webhooks) - learn about integrating with Slack


# OpenAI Agents SDK for Python guardrails
Source: https://trigger.dev/docs/guides/example-projects/openai-agent-sdk-guardrails

This example project demonstrates how to implement different types of guardrails using the OpenAI Agent SDK for Python with Trigger.dev.

## Overview

This demo is a practical guide that demonstrates:

* **Three types of AI guardrails**: Input validation, output checking, and real-time streaming monitoring
* Integration of the [OpenAI Agent SDK for Python](https://openai.github.io/openai-agents-python/) with [Trigger.dev](https://trigger.dev) for production AI workflows
* Triggering Python scripts from tasks using our [Python build extension](/docs/config/extensions/pythonExtension)
* **Educational examples** of implementing guardrails for AI safety and control mechanisms
* Real-world scenarios like math tutoring agents with content validation and complexity monitoring

Guardrails are safety mechanisms that run alongside AI agents to validate input, check output, monitor streaming content in real-time, and prevent unwanted or harmful behavior.

## GitHub repo

<Card title="View the OpenAI Agent SDK Guardrails repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/openai-agent-sdk-guardrails-examples">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## Video

<video />

## Relevant code

### Trigger.dev Tasks

* **[inputGuardrails.ts](https://github.com/triggerdotdev/examples/blob/main/openai-agent-sdk-guardrails-examples/src/trigger/inputGuardrails.ts)** - Passes user prompts to Python script and handles `InputGuardrailTripwireTriggered` exceptions
* **[outputGuardrails.ts](https://github.com/triggerdotdev/examples/blob/main/openai-agent-sdk-guardrails-examples/src/trigger/outputGuardrails.ts)** - Runs agent generation and catches `OutputGuardrailTripwireTriggered` exceptions with detailed error info
* **[streamingGuardrails.ts](https://github.com/triggerdotdev/examples/blob/main/openai-agent-sdk-guardrails-examples/src/trigger/streamingGuardrails.ts)** - Executes streaming Python script and parses JSON output containing guardrail metrics

### Python Implementations

* **[input-guardrails.py](https://github.com/triggerdotdev/examples/blob/main/openai-agent-sdk-guardrails-examples/src/python/input-guardrails.py)** - Agent with `@input_guardrail` decorator that validates user input before processing (example: math tutor that only responds to math questions)
* **[output-guardrails.py](https://github.com/triggerdotdev/examples/blob/main/openai-agent-sdk-guardrails-examples/src/python/output-guardrails.py)** - Agent with `@output_guardrail` decorator that validates generated responses using a separate guardrail agent
* **[streaming-guardrails.py](https://github.com/triggerdotdev/examples/blob/main/openai-agent-sdk-guardrails-examples/src/python/streaming-guardrails.py)** - Processes `ResponseTextDeltaEvent` streams with async guardrail checks at configurable intervals (example: stops streaming if language is too complex for a 10-year-old)

### Configuration

* **[trigger.config.ts](https://github.com/triggerdotdev/examples/blob/main/openai-agent-sdk-guardrails-examples/trigger.config.ts)** - Uses the Trigger.dev Python extension

### Learn more

* [OpenAI Agent SDK documentation](https://openai.github.io/openai-agents-python/)
* [OpenAI Agent SDK guardrails](https://openai.github.io/openai-agents-python/guardrails/)
* Our [Python build extension](/docs/config/extensions/pythonExtension#python)


# OpenAI Agents SDK for Typescript + Trigger.dev playground
Source: https://trigger.dev/docs/guides/example-projects/openai-agents-sdk-typescript-playground

Build production-ready AI agents with OpenAI Agents SDK for Typescript and Trigger.dev. Explore 7 examples covering streaming, multi-agent systems, and tool integration.

## Overview

7 production-ready patterns built with the OpenAI Agents SDK and Trigger.dev. Clone this repo to experiment with everything from basic calls to workflows with tools, streaming, guardrails, handoffs, and more.

By combining the OpenAI Agents SDK with Trigger.dev, you can create durable agents that can be deployed to production and scaled to any size, with retries, queues, and full observability built-in.

## Video

<video />

## Tech stack

* [Node.js](https://nodejs.org) runtime environment
* [OpenAI Agents SDK for Typescript](https://openai.github.io/openai-agents-js/) for creating and managing AI agents
* [Trigger.dev](https://trigger.dev) for task orchestration, batching, scheduling, and workflow management
* [Zod](https://zod.dev) for payload validation

## GitHub repo

<Card title="View the OpenAI Agents SDK TypeScript playground repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/openai-agents-sdk-with-trigger-playground">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## Agent tasks

* **Basic Agent Chat**: Personality-based conversations with strategic model selection
* **Agent with Tools**: A simple agent that can call tools to get weather data
* **Streaming Agent**: Real-time content generation with progress tracking
* **Agent Handoffs**: True multi-agent collaboration using the [handoff pattern](https://openai.github.io/openai-agents-js/guides/handoffs/) where agents can dynamically transfer control to specialists
* **Parallel Agents**: Concurrent agent processing for complex analysis tasks
* **Scheduled Agent**: Time-based agent workflows for continuous monitoring
* **Agent with Guardrails**: Input guardrails for safe AI interactions

## Relevant code

* **[basicAgentChat.ts](https://github.com/triggerdotdev/examples/blob/main/openai-agents-sdk-with-trigger-playground/src/trigger/basicAgentChat.ts)** - Strategic model selection (GPT-4, o1-preview, o1-mini, gpt-4o-mini) mapped to personality types with Trigger.dev task orchestration
* **[agentWithTools.ts](https://github.com/triggerdotdev/examples/blob/main/openai-agents-sdk-with-trigger-playground/src/trigger/agentWithTools.ts)** - OpenAI tool calling with Zod validation integrated into Trigger.dev's retry and error handling mechanisms
* **[streamingAgent.ts](https://github.com/triggerdotdev/examples/blob/main/openai-agents-sdk-with-trigger-playground/src/trigger/streamingAgent.ts)** - Native OpenAI streaming responses with real-time progress tracking via Trigger.dev metadata
* **[scheduledAgent.ts](https://github.com/triggerdotdev/examples/blob/main/openai-agents-sdk-with-trigger-playground/src/trigger/scheduledAgent.ts)** - Cron-scheduled OpenAI agents running every 6 hours with automatic trend analysis
* **[parallelAgents.ts](https://github.com/triggerdotdev/examples/blob/main/openai-agents-sdk-with-trigger-playground/src/trigger/parallelAgents.ts)** - Concurrent OpenAI agent execution using Trigger.dev batch operations (`batch.triggerByTaskAndWait`) for scalable text analysis
* **[agentWithGuardrails.ts](https://github.com/triggerdotdev/examples/blob/main/openai-agents-sdk-with-trigger-playground/src/trigger/agentWithGuardrails.ts)** - OpenAI classification agents as input guardrails with structured validation and exception handling
* **[agentHandoff.ts](https://github.com/triggerdotdev/examples/blob/main/openai-agents-sdk-with-trigger-playground/src/trigger/agentHandoff.ts)** - OpenAI Agents SDK handoff pattern with specialist delegation orchestrated through Trigger.dev workflows

## Learn more

* [OpenAI Agents SDK docs](https://openai.github.io/openai-agents-js/) - learn about creating and managing AI agents
* [OpenAI Agents SDK handoffs](https://openai.github.io/openai-agents-js/guides/handoffs/) - learn about agent-to-agent delegation patterns
* [Batch triggering](/docs/triggering#batch-trigger) - learn about parallel task execution
* [Scheduled tasks (cron)](/docs/tasks/scheduled#scheduled-tasks-cron) - learn about cron-based task scheduling


# Product image generator using Replicate and Trigger.dev
Source: https://trigger.dev/docs/guides/example-projects/product-image-generator

AI-powered product image generator that transforms basic product photos into professional marketing shots using Replicate's image generation models

## Overview

This project demonstrates how to build an AI-powered product image generator that transforms basic product photos into professional marketing shots. Users upload a product image and receive three professionally styled variations: clean product shots, lifestyle scenes, and hero shots with dramatic lighting.

## Video

<video />

## GitHub repo

Clone this repo and follow the instructions in the `README.md` file to get started.

<Card title="View the product image generator repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/product-image-generator">
  Click here to view the full code in our examples repository on GitHub. You can fork it and use it
  as a starting point for your project.
</Card>

## Tech stack

* [**Next.js**](https://nextjs.org/) – frontend React framework
* [**Replicate**](https://replicate.com/docs) – AI image generation using the `google/nano-banana` image-to-image model
* [**UploadThing**](https://uploadthing.com/) – file upload management and server callbacks
* [**Cloudflare R2**](https://developers.cloudflare.com/r2/) – scalable image storage with public URLs

## How it works

The application orchestrates image generation through two main tasks: [`generateImages`](https://github.com/triggerdotdev/examples/blob/main/product-image-generator/app/trigger/generate-images.ts) coordinates batch processing, while [`generateImage`](https://github.com/triggerdotdev/examples/blob/main/product-image-generator/app/trigger/generate-images.ts) handles individual style generation.

Each generation task enhances prompts with style-specific instructions, calls Replicate's `google/nano-banana` image-to-image model, creates waitpoint tokens for async webhook handling, and uploads results to Cloudflare R2. The frontend displays real-time progress updates via React hooks as tasks complete.

Style presets include clean product shots (white background), lifestyle scenes (person holding product), and hero shots (dramatic lighting).

## Relevant code

* **Image generation tasks** – batch processing with waitpoints for Replicate webhook callbacks ([`app/trigger/generate-images.ts`](https://github.com/triggerdotdev/examples/blob/main/product-image-generator/app/trigger/generate-images.ts))
* **Upload handler** – UploadThing integration that triggers batch generation ([`app/api/uploadthing/core.ts`](https://github.com/triggerdotdev/examples/blob/main/product-image-generator/app/api/uploadthing/core.ts))
* **Real-time progress UI** – live task updates using React hooks ([`app/components/GeneratedCard.tsx`](https://github.com/triggerdotdev/examples/blob/main/product-image-generator/app/components/GeneratedCard.tsx))
* **Custom prompt interface** – user-defined style generation ([`app/components/CustomPromptCard.tsx`](https://github.com/triggerdotdev/examples/blob/main/product-image-generator/app/components/CustomPromptCard.tsx))
* **Main app component** – layout and state management ([`app/ProductImageGenerator.tsx`](https://github.com/triggerdotdev/examples/blob/main/product-image-generator/app/ProductImageGenerator.tsx))

## Learn more

* [**Waitpoints**](/docs/wait-for-token) – pause tasks for async webhook callbacks
* [**React hooks**](/docs/realtime/react-hooks/overview) – real-time task updates and frontend integration
* [**Batch operations**](/docs/triggering#tasks-batchtrigger) – parallel task execution patterns
* [**Replicate API**](https://replicate.com/docs/get-started/nextjs) – AI model integration
* [**UploadThing**](https://docs.uploadthing.com/) – file upload handling and server callbacks


# Next.js Realtime CSV Importer
Source: https://trigger.dev/docs/guides/example-projects/realtime-csv-importer

This example Next.js project demonstrates how to use Trigger.dev Realtime to build a CSV Uploader with progress updates streamed to the frontend.

## Overview

The frontend is a Next.js app that allows users to upload a CSV file, which is then processed in the background using Trigger.dev tasks. The progress of the task is streamed back to the frontend in real-time using Trigger.dev Realtime.

* A [Next.js](https://nextjs.org/) app with [Trigger.dev](https://trigger.dev/) for the background tasks.
* [UploadThing](https://uploadthing.com/) to handle CSV file uploads
* Trigger.dev [Realtime](https://trigger.dev/launchweek/0/realtime) to stream updates to the frontend.

## GitHub repo

<Card title="View the Realtime CSV Importer repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/blob/main/realtime-csv-importer/README.md">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## Video

<video />

## Relevant code

* View the Trigger.dev task code in the [src/trigger/csv.ts](https://github.com/triggerdotdev/examples/blob/main/realtime-csv-importer/src/trigger/csv.ts) file.
* The parent task `csvValidator` downloads the CSV file, parses it, and then splits the rows into multiple batches. It then does a `batch.triggerAndWait` to distribute the work the `handleCSVRow` task.
* The `handleCSVRow` task "simulates" checking the row for a valid email address and then updates the progress of the parent task using `metadata.parent`. See the [Trigger.dev docs](/docs/runs/metadata#parent-and-root-updates) for more information on how to use the `metadata.parent` object.
* The `useRealtimeCSVValidator` hook in the [src/hooks/useRealtimeCSVValidator.ts](https://github.com/triggerdotdev/examples/blob/main/realtime-csv-importer/src/hooks/useRealtimeCSVValidator.ts) file handles the call to `useRealtimeRun` to get the progress of the parent task.
* The `CSVProcessor` component in the [src/components/CSVProcessor.tsx](https://github.com/triggerdotdev/examples/blob/main/realtime-csv-importer/src/components/CSVProcessor.tsx) file handles the file upload and displays the progress bar, and uses the `useRealtimeCSVValidator` hook to get the progress updates.

## Learn more about Trigger.dev Realtime

To learn more, take a look at the following resources:

* [Trigger.dev Realtime](/docs/realtime) - learn more about how to subscribe to runs and get real-time updates
* [Realtime streaming](/docs/realtime/react-hooks/streams) - learn more about streaming data from your tasks
* [Batch Triggering](/docs/triggering#tasks-batchtrigger) - learn more about how to trigger tasks in batches
* [React hooks](/docs/realtime/react-hooks) - learn more about using React hooks to interact with the Trigger.dev API


# Image generation with Fal.ai and Trigger.dev Realtime
Source: https://trigger.dev/docs/guides/example-projects/realtime-fal-ai

This example Next.js project generates an image from a prompt using Fal.ai and shows the progress of the task on the frontend using Trigger.dev Realtime.

## Overview

This full stack Next.js project showcases the following:

* A Trigger.dev task which [generates an image from a prompt using Fal.ai](https://github.com/triggerdotdev/examples/blob/main/realtime-fal-ai-image-generation/src/trigger/realtime-generate-image.ts)
* When a [form is submitted](https://github.com/triggerdotdev/examples/blob/main/realtime-fal-ai-image-generation/src/app/page.tsx) in the UI, triggering the task using a [server action](https://github.com/triggerdotdev/examples/blob/main/realtime-fal-ai-image-generation/src/app/actions/process-image.ts)
* Showing the [progress of the task](https://github.com/triggerdotdev/examples/blob/main/realtime-fal-ai-image-generation/src/app/processing/%5Bid%5D/ProcessingContent.tsx) on the frontend using Trigger.dev Realtime. This also includes error handling and a fallback UI
* Once the task is completed, showing the generated image on the frontend next to the original image

## GitHub repo

<Card title="View the project on GitHub" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/realtime-fal-ai-image-generation">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## Walkthrough video

This video walks through the process of creating this task in a Next.js project.

<iframe title="Trigger.dev walkthrough" />

## Learn more about Trigger.dev Realtime

To learn more, take a look at the following resources:

* [Trigger.dev Realtime](/docs/realtime) - learn more about how to subscribe to runs and get real-time updates
* [Realtime streaming](/docs/realtime/react-hooks/streams) - learn more about streaming data from your tasks
* [Batch Triggering](/docs/triggering#tasks-batchtrigger) - learn more about how to trigger tasks in batches
* [React hooks](/docs/realtime/react-hooks) - learn more about using React hooks to interact with the Trigger.dev API


# Smart Spreadsheet
Source: https://trigger.dev/docs/guides/example-projects/smart-spreadsheet

An AI-powered company enrichment tool that uses Exa search and Claude to extract verified company data with source attribution.

## Overview

Smart Spreadsheet is an AI-powered tool that enriches company data on demand. Input a company name or website URL and get verified information including industry, headcount, and funding details; each with source attribution. Results appear in the frontend in real-time as each task completes.

* A [Next.js](https://nextjs.org/) app with [Trigger.dev](https://trigger.dev/) for background tasks
* [Exa](https://exa.ai/) – an AI-native search engine that returns clean, structured content ready for LLM extraction
* [Claude](https://anthropic.com/) via the [Vercel AI SDK](https://sdk.vercel.ai/) for data extraction
* [Supabase](https://supabase.com/) PostgreSQL database for persistence
* Trigger.dev [Realtime](/docs/realtime/overview) for live updates to the frontend

## Video

<video />

## GitHub repo

<Card title="View the Smart Spreadsheet repo" icon="github" href="https://github.com/triggerdotdev/examples/tree/main/smart-spreadsheet">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## How it works

The enrichment workflow:

1. **Trigger enrichment** – User enters a company name or URL in the spreadsheet UI
2. **Parallel data gathering** – Four subtasks run concurrently to fetch basic info, industry, employee count, and funding details
3. **AI extraction** – Each subtask uses Exa search + Claude to extract structured data with source URLs
4. **Real-time updates** – Results appear in the frontend as each subtask completes
5. **Persist results** – Enriched data is saved to Supabase with source attribution

## Features

* **Parallel processing** – All four enrichment categories run simultaneously using [batch.triggerByTaskAndWait](/docs/triggering#batch-trigger-by-task-and-wait)
* **Source attribution** – Every data point includes the URL it was extracted from
* **Live updates** – Results appear in the UI as each task completes using [Realtime](/docs/realtime/overview)
* **Structured extraction** – Zod schemas ensure consistent data output from Claude

## Key code patterns

### Parallel task execution

The main task triggers all four enrichment subtasks simultaneously using `batch.triggerByTaskAndWait`:

```ts src/trigger/enrich-company.ts theme={"theme":"css-variables"}
const { runs } = await batch.triggerByTaskAndWait([
  { task: getBasicInfo, payload: { companyName, companyUrl } },
  { task: getIndustry, payload: { companyName, companyUrl } },
  { task: getEmployeeCount, payload: { companyName, companyUrl } },
  { task: getFundingRound, payload: { companyName, companyUrl } },
]);
```

### Live updates from child tasks

Each subtask uses `metadata.parent.set()` to update the parent's metadata as soon as data is extracted:

```ts src/trigger/get-basic-info.ts theme={"theme":"css-variables"}
// After Claude extracts the data, update the parent task's metadata
metadata.parent.set("website", object.website);
metadata.parent.set("description", object.description);
```

The frontend subscribes to these metadata updates using [Realtime](/docs/realtime/overview), so users see each field populate as it's discovered.

## Relevant code

| File                                                                                                                                           | Description                                                            |
| ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| [`src/trigger/enrich-company.ts`](https://github.com/triggerdotdev/examples/blob/main/smart-spreadsheet/src/trigger/enrich-company.ts)         | Main orchestrator that triggers parallel subtasks and persists results |
| [`src/trigger/get-basic-info.ts`](https://github.com/triggerdotdev/examples/blob/main/smart-spreadsheet/src/trigger/get-basic-info.ts)         | Extracts company website and description                               |
| [`src/trigger/get-industry.ts`](https://github.com/triggerdotdev/examples/blob/main/smart-spreadsheet/src/trigger/get-industry.ts)             | Classifies company industry                                            |
| [`src/trigger/get-employee-count.ts`](https://github.com/triggerdotdev/examples/blob/main/smart-spreadsheet/src/trigger/get-employee-count.ts) | Finds employee headcount                                               |
| [`src/trigger/get-funding-round.ts`](https://github.com/triggerdotdev/examples/blob/main/smart-spreadsheet/src/trigger/get-funding-round.ts)   | Discovers latest funding information                                   |

## Learn more about Trigger.dev Realtime

To learn more, take a look at the following resources:

* [Trigger.dev Realtime](/docs/realtime) - learn more about how to subscribe to runs and get real-time updates
* [Realtime streaming](/docs/realtime/react-hooks/streams) - learn more about streaming data from your tasks
* [Batch Triggering](/docs/triggering#tasks-batchtrigger) - learn more about how to trigger tasks in batches
* [React hooks](/docs/realtime/react-hooks) - learn more about using React hooks to interact with the Trigger.dev API


# Turborepo monorepo with Prisma
Source: https://trigger.dev/docs/guides/example-projects/turborepo-monorepo-prisma

Two example projects demonstrating how to use Prisma and Trigger.dev in a Turborepo monorepo setup.

## Overview

These examples demonstrate two different ways of using Prisma and Trigger.dev in a Turborepo monorepo. In both examples, a task is triggered from a Next.js app using a server action, which uses Prisma to add a user to a database table. The examples differ in how Trigger.dev is installed and configured.

* Example 1: Turborepo monorepo demo with Trigger.dev and Prisma packages
* Example 2: Turborepo monorepo demo with a Prisma package and Trigger.dev installed in a Next.js app

<Note>
  You can either fork the repos below, or simply check out the project structures and code to get an idea of how to set up Trigger.dev in your own monorepos.
</Note>

## Example 1: Turborepo monorepo demo with Trigger.dev and Prisma packages

This simple example demonstrates how to use Trigger.dev and Prisma as packages inside a monorepo created with Turborepo. The Trigger.dev task is triggered by a button click in a Next.js app which triggers the task via a server action.

### GitHub repo

Fork the GitHub repo below to get started with this example project.

<Card title="Check out the Turborepo monorepo demo with Trigger.dev and Prisma packages" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

### Features

* This monorepo has been created using the [Turborepo CLI](https://turbo.build/repo), following the official [Prisma and Turborepo docs](https://www.prisma.io/docs/guides/turborepo), and then adapted for use with Trigger.dev.
* [pnpm](https://pnpm.io/) has been used as the package manager.
* A tasks package (`@repo/tasks`) using [Trigger.dev](https://trigger.dev) is used to create and execute tasks from an app inside the monorepo.
* A database package (`@repo/db`) using [Prisma ORM](https://www.prisma.io/docs/orm/) is used to interact with the database. You can use any popular Postgres database supported by Prisma, e.g. [Supabase](https://supabase.com/), [Neon](https://neon.tech/), etc.
* A [Next.js](https://nextjs.org/) example app (`apps/web`) to show how to trigger the task via a server action.

### Project structure

Simplified project structure for this example:

```
|
| — apps/
|   | — web/                    # Next.js frontend application
|   |   | — app/                # Next.js app router
|   |   |   | — api/
|   |   |   |   | — actions.ts  # Server actions for triggering tasks
|   |   |   | — page.tsx        # Main page with "Add new user" button
|   |   |   | — layout.tsx      # App layout
|   |   | — package.json        # Dependencies including @repo/db and @repo/tasks
|   |
|   | — docs/                   # Documentation app (not fully implemented)
|
| — packages/
|   | — database/               # Prisma database package (@repo/db)
|   |   | — prisma/
|   |   |   | — schema.prisma   # Database schema definition
|   |   | — generated/          # Generated Prisma client (gitignored)
|   |   | — src/
|   |   |   | — index.ts        # Exports from the database package
|   |   | — package.json        # Database package dependencies
|   |
|   | — tasks/                  # Trigger.dev tasks package (@repo/tasks)
|   |   | — src/
|   |   |   | — index.ts        # Exports from the tasks package
|   |   |   | — trigger/
|   |   |       | — index.ts    # Exports the tasks
|   |   |       | — addNewUser.ts # Task implementation for adding users
|   |   | — trigger.config.ts   # Trigger.dev configuration
|   |   | — package.json        # Tasks package dependencies
|   |
|   | — ui/                     # UI components package (referenced but not detailed)
|
| — turbo.json                  # Turborepo configuration
| — package.json                # Root package.json with workspace config
```

### Relevant files and code

#### Database package

* Prisma is added as a package in [`/packages/database`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package/packages/database/) and exported as `@repo/db` in the [`package.json`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package/packages/database/package.json) file.
* The schema is defined in the [`prisma/schema.prisma`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package/packages/database/prisma/schema.prisma) file.

#### Tasks package

<Note>
  to run `pnpm dlx trigger.dev@latest init` in a blank packages folder, you have to add a `package.json` file first, otherwise it will attempt to add Trigger.dev files in the root of your monorepo.
</Note>

* Trigger.dev is added as a package in [`/packages/tasks`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package/packages/tasks) and exported as `@repo/tasks` in the [`package.json`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package/packages/tasks/package.json) file.
* The [`addNewUser.ts`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package/packages/tasks/src/trigger/addNewUser.ts) task adds a new user to the database.
* The [`packages/tasks/src/index.ts`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package/packages/tasks/src/index.ts) file exports values and types from the Trigger.dev SDK, and is exported from the package via the [`package.json`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package/packages/tasks/package.json) file.
* The [`packages/tasks/src/trigger/index.ts`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package/packages/tasks/src/trigger/index.ts) file exports the task from the package. Every task must be exported from the package like this.
* The [`trigger.config.ts`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package/packages/tasks/trigger.config.ts) file configures the Trigger.dev project settings. This is where the Trigger.dev [Prisma build extension](https://trigger.dev/docs/config/extensions/prismaExtension) is added, which is required to use Prisma in the Trigger.dev task.

<Info>
  You must include the version of Prisma you are using in the `trigger.config.ts` file, otherwise the Prisma build extension will not work. Learn more about our [Prisma build extension](/docs/config/extensions/prismaExtension).
</Info>

#### The Next.js app `apps/web`

* The app is a simple Next.js app using the App Router, that uses the `@repo/db` package to interact with the database and the `@repo/tasks` package to trigger the task. These are both added as dependencies in the [`package.json`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package/apps/web/package.json) file.
* The task is triggered from a button click in the app in [`page.tsx`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package/apps/web/app/page.tsx), which uses a server action in [`/app/api/actions.ts`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package/apps/web/app/api/actions.ts) to trigger the task with an example payload.

### Running the example

To run this example, check out the full instructions [in the GitHub repo README file](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package/README.md).

## Example 2: Turborepo monorepo demo with a Prisma package and Trigger.dev installed in a Next.js app

This example demonstrates how to use Trigger.dev and Prisma in a monorepo created with Turborepo. Prisma has been added as a package, and Trigger.dev has been installed in a Next.js app. The task is triggered by a button click in the app via a server action.

### GitHub repo

Fork the GitHub repo below to get started with this example project.

<Card title="Check out the Turborepo monorepo demo with a Prisma package and Trigger.dev installed in a Next.js app" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-trigger">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

### Features

* This monorepo has been created using the [Turborepo CLI](https://turbo.build/repo), following the official [Prisma and Turborepo docs](https://www.prisma.io/docs/guides/turborepo), and then adapted for use with Trigger.dev.
* [pnpm](https://pnpm.io/) has been used as the package manager.
* A database package (`@repo/db`) using [Prisma ORM](https://www.prisma.io/docs/orm/) is used to interact with the database. You can use any popular Postgres database supported by Prisma, e.g. [Supabase](https://supabase.com/), [Neon](https://neon.tech/), etc.
* A [Next.js](https://nextjs.org/) example app (`apps/web`) to show how to trigger the task via a server action.
* Trigger.dev initialized and an `addNewUser` task created in the `web` app.

### Project structure

Simplified project structure for this example:

```
|
| — apps/
|   | — web/                       # Next.js frontend application
|   |   | — app/                   # Next.js app router
|   |   |   | — api/
|   |   |   |   | — actions.ts     # Server actions for triggering tasks
|   |   |   | — page.tsx           # Main page with "Add new user" button
|   |   | — src/
|   |   |   | — trigger/
|   |   |       | — addNewUser.ts  # Task implementation for adding users
|   |   | — trigger.config.ts      # Trigger.dev configuration
|   |   | — package.json           # Dependencies including @repo/db
|   |
|   | — docs/                      # Documentation app
|       | — app/
|           | — page.tsx           # Docs landing page
|
| — packages/
|   | — database/                  # Prisma database package (@repo/db)
|   |   | — prisma/
|   |   |   | — schema.prisma      # Database schema definition
|   |
|   | — ui/                        # UI components package
|
| — turbo.json                     # Turborepo configuration
| — package.json                   # Root package.json with workspace config
```

## Relevant files and code

### Database package (`@repo/db`)

* Located in [`/packages/database/`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-trigger/packages/database/) and exported as `@repo/db`
* Schema defined in [`schema.prisma`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-trigger/packages/database/prisma/schema.prisma)
* Provides database access to other packages and apps

### Next.js app (`apps/web`)

* Contains Trigger.dev configuration in [`trigger.config.ts`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-trigger/apps/web/trigger.config.ts)
* Trigger.dev tasks are defined in [`src/trigger/`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-trigger/apps/web/src/trigger/) (e.g., [`addNewUser.ts`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-trigger/apps/web/src/trigger/addNewUser.ts))
* Demonstrates triggering tasks via server actions in [`app/api/actions.ts`](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-trigger/apps/web/app/api/actions.ts)

### Running the example

To run this example, check out the full instructions [in the GitHub repo README file](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-trigger/README.md).


# Deep research agent using Vercel's AI SDK
Source: https://trigger.dev/docs/guides/example-projects/vercel-ai-sdk-deep-research

Deep research agent which generates comprehensive PDF reports using Vercel's AI SDK.

<Info title="Acknowledgements">
  Acknowledgements: This example project is derived from the brilliant [deep research
  guide](https://aie-feb-25.vercel.app/docs/deep-research) by [Nico
  Albanese](https://x.com/nicoalbanese10).
</Info>

## Overview

This full-stack project is an intelligent deep research agent that autonomously conducts multi-layered web research, generating comprehensive reports which are then converted to PDF and uploaded to storage.

<video />

**Tech stack:**

* **[Next.js](https://nextjs.org/)** for the web app
* **[Vercel's AI SDK](https://sdk.vercel.ai/)** for AI model integration and structured generation
* **[Trigger.dev](https://trigger.dev)** for task orchestration, execution and real-time progress updates
* **[OpenAI's GPT-4o model](https://openai.com/gpt-4)** for intelligent query generation, content analysis, and report creation
* **[Exa API](https://exa.ai/)** for semantic web search with live crawling
* **[LibreOffice](https://www.libreoffice.org/)** for PDF generation
* **[Cloudflare R2](https://developers.cloudflare.com/r2/)** to store the generated reports

**Features:**

* **Recursive research**: AI generates search queries, evaluates their relevance, asks follow-up questions and searches deeper based on initial findings.
* **Real-time progress**: Live updates are shown on the frontend using Trigger.dev Realtime as research progresses.
* **Intelligent source evaluation**: AI evaluates search result relevance before processing.
* **Research report generation**: The completed research is converted to a structured HTML report using a detailed system prompt.
* **PDF creation and uploading to Cloud storage**: The completed reports are then converted to PDF using LibreOffice and uploaded to Cloudflare R2.

## GitHub repo

<Card title="View the Vercel AI SDK deep research agent repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/vercel-ai-sdk-deep-research-agent">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## How the deep research agent works

### Trigger.dev orchestration

The research process is orchestrated through three connected Trigger.dev tasks:

1. `deepResearchOrchestrator` - Main task that coordinates the entire research workflow.
2. `generateReport` - Processes research data into a structured HTML report using OpenAI's GPT-4o model
3. `generatePdfAndUpload` - Converts HTML to PDF using LibreOffice and uploads to R2 cloud storage

Each task uses `triggerAndWait()` to create a dependency chain, ensuring proper sequencing while maintaining isolation and error handling.

### The deep research recursive function

The core research logic uses a recursive depth-first search approach. A query is recursively expanded and the results are collected.

**Key parameters:**

* `depth`: Controls recursion levels (default: 2)
* `breadth`: Number of queries per level (default: 2, halved each recursion)

```
Level 0 (Initial Query): "AI safety in autonomous vehicles"
│
├── Level 1 (depth = 1, breadth = 2):
│   ├── Sub-query 1: "Machine learning safety protocols in self-driving cars"
│   │   ├── → Search Web → Evaluate Relevance → Extract Learnings
│   │   └── → Follow-up: "How do neural networks handle edge cases?"
│   │
│   └── Sub-query 2: "Regulatory frameworks for autonomous vehicle testing"
│       ├── → Search Web → Evaluate Relevance → Extract Learnings
│       └── → Follow-up: "What are current safety certification requirements?"
│
└── Level 2 (depth = 2, breadth = 1):
    ├── From Sub-query 1 follow-up:
    │   └── "Neural network edge case handling in autonomous systems"
    │       └── → Search Web → Evaluate → Extract → DEPTH LIMIT REACHED
    │
    └── From Sub-query 2 follow-up:
        └── "Safety certification requirements for self-driving vehicles"
            └── → Search Web → Evaluate → Extract → DEPTH LIMIT REACHED
```

**Process flow:**

1. **Query generation**: OpenAI's GPT-4o generates multiple search queries from the input
2. **Web search**: Each query searches the web via the Exa API with live crawling
3. **Relevance evaluation**: OpenAI's GPT-4o evaluates if results help answer the query
4. **Learning extraction**: Relevant results are analyzed for key insights and follow-up questions
5. **Recursive deepening**: Follow-up questions become new queries for the next depth level
6. **Accumulation**: All learnings, sources, and queries are accumulated across recursion levels

### Using Trigger.dev Realtime to trigger and subscribe to the deep research task

We use the [`useRealtimeTaskTrigger`](/docs/realtime/react-hooks/triggering#userealtimetasktrigger) React hook to trigger the `deep-research` task and subscribe to it's updates.

**Frontend (React Hook)**:

```typescript theme={"theme":"css-variables"}
const triggerInstance = useRealtimeTaskTrigger<typeof deepResearchOrchestrator>("deep-research", {
  accessToken: triggerToken,
});
const { progress, label } = parseStatus(triggerInstance.run?.metadata);
```

As the research progresses, the metadata is set within the tasks and the frontend is kept updated with every new status:

**Task Metadata**:

```typescript theme={"theme":"css-variables"}
metadata.set("status", {
  progress: 25,
  label: `Searching the web for: "${query}"`,
});
```

## Relevant code

* **Deep research task**: Core logic in [src/trigger/deepResearch.ts](https://github.com/triggerdotdev/examples/blob/main/vercel-ai-sdk-deep-research-agent/src/trigger/deepResearch.ts) - orchestrates the recursive research process. Here you can change the model, the depth and the breadth of the research.
* **Report generation**: [src/trigger/generateReport.ts](https://github.com/triggerdotdev/examples/blob/main/vercel-ai-sdk-deep-research-agent/src/trigger/generateReport.ts) - creates structured HTML reports from research data. The system prompt is defined in the code - this can be updated to be more or less detailed.
* **PDF generation**: [src/trigger/generatePdfAndUpload.ts](https://github.com/triggerdotdev/examples/blob/main/vercel-ai-sdk-deep-research-agent/src/trigger/generatePdfAndUpload.ts) - converts reports to PDF and uploads to R2. This is a simple example of how to use LibreOffice to convert HTML to PDF.
* **Research agent UI**: [src/components/DeepResearchAgent.tsx](https://github.com/triggerdotdev/examples/blob/main/vercel-ai-sdk-deep-research-agent/src/components/DeepResearchAgent.tsx) - handles form submission and real-time progress display using the `useRealtimeTaskTrigger` hook.
* **Progress component**: [src/components/progress-section.tsx](https://github.com/triggerdotdev/examples/blob/main/deep-research-agent/src/components/progress-section.tsx) - displays live research progress.

## Learn more about Trigger.dev Realtime

To learn more, take a look at the following resources:

* [Trigger.dev Realtime](/docs/realtime) - learn more about how to subscribe to runs and get real-time updates
* [Realtime streaming](/docs/realtime/react-hooks/streams) - learn more about streaming data from your tasks
* [Batch Triggering](/docs/triggering#tasks-batchtrigger) - learn more about how to trigger tasks in batches
* [React hooks](/docs/realtime/react-hooks) - learn more about using React hooks to interact with the Trigger.dev API


# Vercel AI SDK image generator
Source: https://trigger.dev/docs/guides/example-projects/vercel-ai-sdk-image-generator

This example Next.js project uses the Vercel AI SDK to generate images from a prompt.

## Overview

This demo is a full stack example that uses the following:

* A [Next.js](https://nextjs.org/) app using [shadcn](https://ui.shadcn.com/) for the UI
* Our 'useRealtimeRun' [React hook](/docs/realtime/react-hooks/subscribe#userealtimerun) to subscribe to the run and show updates on the frontend
* The [Vercel AI SDK](https://sdk.vercel.ai/docs/introduction) to [generate images](https://sdk.vercel.ai/docs/ai-sdk-core/image-generation) using OpenAI's DALL-E models

## GitHub repo

<Card title="View the Vercel AI SDK image generator repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/vercel-ai-sdk-image-generator">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## Video

<video />

## Relevant code

* View the Trigger.dev task code which generates the image using the Vercel AI SDK in [src/trigger/realtime-generate-image.ts](https://github.com/triggerdotdev/examples/tree/main/vercel-ai-sdk-image-generator/src/trigger/realtime-generate-image.ts).
* We use a [useRealtimeRun](/docs/realtime/react-hooks/subscribe#userealtimerun) hook to subscribe to the run in [src/app/processing/\[id\]/ProcessingContent.tsx](https://github.com/triggerdotdev/examples/tree/main/vercel-ai-sdk-image-generator/src/app/processing/\[id]/ProcessingContent.tsx).

## Learn more about Trigger.dev Realtime

To learn more, take a look at the following resources:

* [Trigger.dev Realtime](/docs/realtime) - learn more about how to subscribe to runs and get real-time updates
* [Realtime streaming](/docs/realtime/react-hooks/streams) - learn more about streaming data from your tasks
* [Batch Triggering](/docs/triggering#tasks-batchtrigger) - learn more about how to trigger tasks in batches
* [React hooks](/docs/realtime/react-hooks) - learn more about using React hooks to interact with the Trigger.dev API


# Generate an image using DALL·E 3
Source: https://trigger.dev/docs/guides/examples/dall-e3-generate-image

This example will show you how to generate an image using DALL·E 3 and text using GPT-4o with Trigger.dev.

## Overview

This example demonstrates how to use Trigger.dev to make reliable calls to AI APIs, specifically OpenAI's GPT-4o and DALL-E 3. It showcases automatic retrying with a maximum of 3 attempts, built-in error handling to avoid timeouts, and the ability to trace and monitor API calls.

## Task code

```ts trigger/generateContent.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

type Payload = {
  theme: string;
  description: string;
};

export const generateContent = task({
  id: "generate-content",
  retry: {
    maxAttempts: 3, // Retry up to 3 times
  },
  run: async ({ theme, description }: Payload) => {
    // Generate text
    const textResult = await openai.chat.completions.create({
      model: "gpt-4o",
      messages: generateTextPrompt(theme, description),
    });

    if (!textResult.choices[0]) {
      throw new Error("No content, retrying…");
    }

    // Generate image
    const imageResult = await openai.images.generate({
      model: "dall-e-3",
      prompt: generateImagePrompt(theme, description),
    });

    if (!imageResult.data[0]) {
      throw new Error("No image, retrying…");
    }

    return {
      text: textResult.choices[0],
      image: imageResult.data[0].url,
    };
  },
});

function generateTextPrompt(theme: string, description: string): any {
  return `Theme: ${theme}\n\nDescription: ${description}`;
}

function generateImagePrompt(theme: string, description: string): any {
  return `Theme: ${theme}\n\nDescription: ${description}`;
}
```

## Testing your task

To test this task in the dashboard, you can use the following payload:

```json theme={"theme":"css-variables"}
{
  "theme": "A beautiful sunset",
  "description": "A sunset over the ocean with a tiny yacht in the distance."
}
```


# Transcribe audio using Deepgram
Source: https://trigger.dev/docs/guides/examples/deepgram-transcribe-audio

This example will show you how to transcribe audio using Deepgram's speech recognition API with Trigger.dev.

## Overview

Transcribe audio using [Deepgram's](https://developers.deepgram.com/docs/introduction) speech recognition API.

## Key Features

* Transcribe audio from a URL
* Use the Nova 2 model for transcription

## Task code

```ts trigger/deepgramTranscription.ts theme={"theme":"css-variables"}
import { createClient } from "@deepgram/sdk";
import { logger, task } from "@trigger.dev/sdk";

// Initialize the Deepgram client, using your Deepgram API key (you can find this in your Deepgram account settings).
const deepgram = createClient(process.env.DEEPGRAM_SECRET_KEY);

export const deepgramTranscription = task({
  id: "deepgram-transcribe-audio",
  run: async (payload: { audioUrl: string }) => {
    const { audioUrl } = payload;

    logger.log("Transcribing audio from URL", { audioUrl });

    // Transcribe the audio using Deepgram
    const { result, error } = await deepgram.listen.prerecorded.transcribeUrl(
      {
        url: audioUrl,
      },
      {
        model: "nova-2", // Use the Nova 2 model for the transcription
        smart_format: true, // Automatically format transcriptions to improve readability
        diarize: true, // Recognize speaker changes and assign a speaker to each word in the transcript
      }
    );

    if (error) {
      logger.error("Failed to transcribe audio", { error });
      throw error;
    }

    console.dir(result, { depth: null });

    // Extract the transcription from the result
    const transcription = result.results.channels[0].alternatives[0].paragraphs?.transcript;

    logger.log(`Generated transcription: ${transcription}`);

    return {
      result,
    };
  },
});
```

## Testing your task

To test this task in the dashboard, you can use the following payload:

```json theme={"theme":"css-variables"}
{
  "audioUrl": "https://dpgr.am/spacewalk.wav"
}
```


# Convert an image to a cartoon using Fal.ai
Source: https://trigger.dev/docs/guides/examples/fal-ai-image-to-cartoon

This example task generates an image from a URL using Fal.ai and uploads it to Cloudflare R2.

## Walkthrough

This video walks through the process of creating this task in a Next.js project.

<iframe title="Trigger.dev walkthrough" />

## Prerequisites

* An existing project
* A [Trigger.dev account](https://cloud.trigger.dev) with Trigger.dev [initialized in your project](/docs/quick-start)
* A [Fal.ai](https://fal.ai/) account
* A [Cloudflare](https://developers.cloudflare.com/r2/) account with an R2 bucket setup

## Task code

This task converts an image to a cartoon using Fal.ai, and uploads the result to Cloudflare R2.

```ts trigger/fal-ai-image-to-cartoon.ts theme={"theme":"css-variables"}
import { logger, task } from "@trigger.dev/sdk";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import * as fal from "@fal-ai/serverless-client";
import fetch from "node-fetch";
import { z } from "zod";

// Initialize fal.ai client
fal.config({
  credentials: process.env.FAL_KEY, // Get this from your fal.ai dashboard
});

// Initialize S3-compatible client for Cloudflare R2
const s3Client = new S3Client({
  // How to authenticate to R2: https://developers.cloudflare.com/r2/api/s3/tokens/
  region: "auto",
  endpoint: process.env.R2_ENDPOINT,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
  },
});

export const FalResult = z.object({
  images: z.tuple([z.object({ url: z.string() })]),
});

export const falAiImageToCartoon = task({
  id: "fal-ai-image-to-cartoon",
  run: async (payload: { imageUrl: string; fileName: string }) => {
    logger.log("Converting image to cartoon", payload);

    // Convert image to cartoon using fal.ai
    const result = await fal.subscribe("fal-ai/flux/dev/image-to-image", {
      input: {
        prompt: "Turn the image into a cartoon in the style of a Pixar character",
        image_url: payload.imageUrl,
      },
      onQueueUpdate: (update) => {
        logger.info("Fal.ai processing update", { update });
      },
    });

    const $result = FalResult.parse(result);
    const [{ url: cartoonImageUrl }] = $result.images;

    // Download the cartoon image
    const imageResponse = await fetch(cartoonImageUrl);
    const imageBuffer = await imageResponse.arrayBuffer().then(Buffer.from);

    // Upload to Cloudflare R2
    const r2Key = `cartoons/${payload.fileName}`;
    const uploadParams = {
      Bucket: process.env.R2_BUCKET, // Create a bucket in your Cloudflare dashboard
      Key: r2Key,
      Body: imageBuffer,
      ContentType: "image/png",
    };

    logger.log("Uploading cartoon to R2", { key: r2Key });
    await s3Client.send(new PutObjectCommand(uploadParams));

    logger.log("Cartoon uploaded to R2", { key: r2Key });

    return {
      originalUrl: payload.imageUrl,
      cartoonUrl: `File uploaded to storage at: ${r2Key}`,
    };
  },
});
```

### Testing your task

You can test your task by triggering it from the Trigger.dev dashboard.

```json theme={"theme":"css-variables"}
"imageUrl": "<image-url>", // Replace with the URL of the image you want to convert to a cartoon
"fileName": "<file-name>" // Replace with the name you want to save the file as in Cloudflare R2
```


# Generate an image from a prompt using Fal.ai and Trigger.dev Realtime
Source: https://trigger.dev/docs/guides/examples/fal-ai-realtime

This example task generates an image from a prompt using Fal.ai and shows the progress of the task on the frontend using Trigger.dev Realtime.

## GitHub repo

<Card title="View the project on GitHub" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/realtime-fal-ai-image-generation">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## Walkthrough

This video walks through the process of creating this task in a Next.js project.

<iframe title="Trigger.dev walkthrough" />

## Prerequisites

* An existing project
* A [Trigger.dev account](https://cloud.trigger.dev) with Trigger.dev [initialized in your project](/docs/quick-start)
* A [Fal.ai](https://fal.ai/) account

## Task code

This task generates an image from a prompt using Fal.ai.

```ts trigger/fal-ai-image-from-prompt-realtime.ts theme={"theme":"css-variables"}
import * as fal from "@fal-ai/serverless-client";
import { logger, schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";

export const FalResult = z.object({
  images: z.tuple([z.object({ url: z.string() })]),
});

export const payloadSchema = z.object({
  imageUrl: z.string().url(),
  prompt: z.string(),
});

export const realtimeImageGeneration = schemaTask({
  id: "realtime-image-generation",
  schema: payloadSchema,
  run: async (payload) => {
    const result = await fal.subscribe("fal-ai/flux/dev/image-to-image", {
      input: {
        image_url: payload.imageUrl,
        prompt: payload.prompt,
      },
      onQueueUpdate: (update) => {
        logger.info("Fal.ai processing update", { update });
      },
    });

    const $result = FalResult.parse(result);
    const [{ url: cartoonUrl }] = $result.images;

    return {
      imageUrl: cartoonUrl,
    };
  },
});
```

### Testing your task

You can test your task by triggering it from the Trigger.dev dashboard. Here's an example payload:

```json theme={"theme":"css-variables"}
{
  "imageUrl": "https://static.vecteezy.com/system/resources/previews/005/857/332/non_2x/funny-portrait-of-cute-corgi-dog-outdoors-free-photo.jpg",
  "prompt": "Dress this dog for Christmas"
}
```


# Video processing with FFmpeg
Source: https://trigger.dev/docs/guides/examples/ffmpeg-video-processing

These examples show you how to process videos in various ways using FFmpeg with Trigger.dev.

## Prerequisites

* A project with [Trigger.dev initialized](/docs/quick-start)
* [FFmpeg](https://www.ffmpeg.org/download.html) installed on your machine

### Adding the FFmpeg build extension

To use these example tasks, you'll first need to add our FFmpeg extension to your project configuration like this:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { ffmpeg } from "@trigger.dev/build/extensions/core";
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [ffmpeg()],
  },
});
```

<Note>
  [Build extensions](/docs/config/extensions/overview) allow you to hook into the build system and
  customize the build process or the resulting bundle and container image (in the case of
  deploying). You can use pre-built extensions or create your own.
</Note>

You'll also need to add `@trigger.dev/build` to your `package.json` file under `devDependencies` if you don't already have it there.

If you are modifying this example and using popular FFmpeg libraries like `fluent-ffmpeg` you'll also need to add them to [`external`](/docs/config/config-file#external) in your `trigger.config.ts` file.

## Compress a video using FFmpeg

This task demonstrates how to use FFmpeg to compress a video, reducing its file size while maintaining reasonable quality, and upload the compressed video to R2 storage.

### Key Features

* Fetches a video from a given URL
* Compresses the video using FFmpeg with various compression settings
* Uploads the compressed video to R2 storage

### Task code

```ts trigger/ffmpeg-compress-video.ts theme={"theme":"css-variables"}
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { logger, task } from "@trigger.dev/sdk";
import ffmpeg from "fluent-ffmpeg";
import fs from "fs/promises";
import fetch from "node-fetch";
import { Readable } from "node:stream";
import os from "os";
import path from "path";

// Initialize S3 client
const s3Client = new S3Client({
  // How to authenticate to R2: https://developers.cloudflare.com/r2/api/s3/tokens/
  region: "auto",
  endpoint: process.env.R2_ENDPOINT,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
  },
});

export const ffmpegCompressVideo = task({
  id: "ffmpeg-compress-video",
  run: async (payload: { videoUrl: string }) => {
    const { videoUrl } = payload;

    // Generate temporary file names
    const tempDirectory = os.tmpdir();
    const outputPath = path.join(tempDirectory, `output_${Date.now()}.mp4`);

    // Fetch the video
    const response = await fetch(videoUrl);

    // Compress the video
    await new Promise((resolve, reject) => {
      if (!response.body) {
        return reject(new Error("Failed to fetch video"));
      }

      ffmpeg(Readable.from(response.body))
        .outputOptions([
          "-c:v libx264", // Use H.264 codec
          "-crf 28", // Higher CRF for more compression (28 is near the upper limit for acceptable quality)
          "-preset veryslow", // Slowest preset for best compression
          "-vf scale=iw/2:ih/2", // Reduce resolution to 320p width (height auto-calculated)
          "-c:a aac", // Use AAC for audio
          "-b:a 64k", // Reduce audio bitrate to 64k
          "-ac 1", // Convert to mono audio
        ])
        .output(outputPath)
        .on("end", resolve)
        .on("error", reject)
        .run();
    });

    // Read the compressed video
    const compressedVideo = await fs.readFile(outputPath);
    const compressedSize = compressedVideo.length;

    // Log compression results
    logger.log(`Compressed video size: ${compressedSize} bytes`);
    logger.log(`Temporary compressed video file created`, { outputPath });

    // Create the r2Key for the extracted audio, using the base name of the output path
    const r2Key = `processed-videos/${path.basename(outputPath)}`;

    const uploadParams = {
      Bucket: process.env.R2_BUCKET,
      Key: r2Key,
      Body: compressedVideo,
    };

    // Upload the video to R2 and get the URL
    await s3Client.send(new PutObjectCommand(uploadParams));
    logger.log(`Compressed video saved to your r2 bucket`, { r2Key });

    // Delete the temporary compressed video file
    await fs.unlink(outputPath);
    logger.log(`Temporary compressed video file deleted`, { outputPath });

    // Return the compressed video buffer and r2 key
    return {
      Bucket: process.env.R2_BUCKET,
      r2Key,
    };
  },
});
```

### Testing your task

To test this task, use this payload structure:

```json theme={"theme":"css-variables"}
{
  "videoUrl": "<video-url>" // Replace <a-video-url> with the URL of the video you want to upload
}
```

## Extract audio from a video using FFmpeg

This task demonstrates how to use FFmpeg to extract audio from a video, convert it to WAV format, and upload it to R2 storage.

### Key Features

* Fetches a video from a given URL
* Extracts the audio from the video using FFmpeg
* Converts the extracted audio to WAV format
* Uploads the extracted audio to R2 storage

### Task code

```ts trigger/ffmpeg-extract-audio.ts theme={"theme":"css-variables"}
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { logger, task } from "@trigger.dev/sdk";
import ffmpeg from "fluent-ffmpeg";
import fs from "fs/promises";
import fetch from "node-fetch";
import { Readable } from "node:stream";
import os from "os";
import path from "path";

// Initialize S3 client
const s3Client = new S3Client({
  // How to authenticate to R2: https://developers.cloudflare.com/r2/api/s3/tokens/
  region: "auto",
  endpoint: process.env.R2_ENDPOINT,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
  },
});

export const ffmpegExtractAudio = task({
  id: "ffmpeg-extract-audio",
  run: async (payload: { videoUrl: string }) => {
    const { videoUrl } = payload;

    // Generate temporary file names
    const tempDirectory = os.tmpdir();
    const outputPath = path.join(tempDirectory, `audio_${Date.now()}.wav`);

    // Fetch the video
    const response = await fetch(videoUrl);

    // Extract the audio
    await new Promise((resolve, reject) => {
      if (!response.body) {
        return reject(new Error("Failed to fetch video"));
      }

      ffmpeg(Readable.from(response.body))
        .outputOptions([
          "-vn", // Disable video output
          "-acodec pcm_s16le", // Use PCM 16-bit little-endian encoding
          "-ar 44100", // Set audio sample rate to 44.1 kHz
          "-ac 2", // Set audio channels to stereo
        ])
        .output(outputPath)
        .on("end", resolve)
        .on("error", reject)
        .run();
    });

    // Read the extracted audio
    const audioBuffer = await fs.readFile(outputPath);
    const audioSize = audioBuffer.length;

    // Log audio extraction results
    logger.log(`Extracted audio size: ${audioSize} bytes`);
    logger.log(`Temporary audio file created`, { outputPath });

    // Create the r2Key for the extracted audio, using the base name of the output path
    const r2Key = `extracted-audio/${path.basename(outputPath)}`;

    const uploadParams = {
      Bucket: process.env.R2_BUCKET,
      Key: r2Key,
      Body: audioBuffer,
    };

    // Upload the audio to R2 and get the URL
    await s3Client.send(new PutObjectCommand(uploadParams));
    logger.log(`Extracted audio saved to your R2 bucket`, { r2Key });

    // Delete the temporary audio file
    await fs.unlink(outputPath);
    logger.log(`Temporary audio file deleted`, { outputPath });

    // Return the audio file path, size, and R2 URL
    return {
      Bucket: process.env.R2_BUCKET,
      r2Key,
    };
  },
});
```

### Testing your task

To test this task, use this payload structure:

<Warning>
  Make sure to provide a video URL that contains audio. If the video does not have audio, the task
  will fail.
</Warning>

```json theme={"theme":"css-variables"}
{
  "videoUrl": "<video-url>" // Replace <a-video-url> with the URL of the video you want to upload
}
```

## Generate a thumbnail from a video using FFmpeg

This task demonstrates how to use FFmpeg to generate a thumbnail from a video at a specific time and upload the generated thumbnail to R2 storage.

### Key Features

* Fetches a video from a given URL
* Generates a thumbnail from the video at the 5-second mark
* Uploads the generated thumbnail to R2 storage

### Task code

```ts trigger/ffmpeg-generate-thumbnail.ts theme={"theme":"css-variables"}
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { logger, task } from "@trigger.dev/sdk";
import ffmpeg from "fluent-ffmpeg";
import fs from "fs/promises";
import fetch from "node-fetch";
import { Readable } from "node:stream";
import os from "os";
import path from "path";

// Initialize S3 client
const s3Client = new S3Client({
  // How to authenticate to R2: https://developers.cloudflare.com/r2/api/s3/tokens/
  region: "auto",
  endpoint: process.env.R2_ENDPOINT,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
  },
});

export const ffmpegGenerateThumbnail = task({
  id: "ffmpeg-generate-thumbnail",
  run: async (payload: { videoUrl: string }) => {
    const { videoUrl } = payload;

    // Generate output file name
    const tempDirectory = os.tmpdir();
    const outputPath = path.join(tempDirectory, `thumbnail_${Date.now()}.jpg`);

    // Fetch the video
    const response = await fetch(videoUrl);

    // Generate the thumbnail
    await new Promise((resolve, reject) => {
      if (!response.body) {
        return reject(new Error("Failed to fetch video"));
      }
      ffmpeg(Readable.from(response.body))
        .screenshots({
          count: 1,
          folder: "/tmp",
          filename: path.basename(outputPath),
          size: "320x240",
          timemarks: ["5"], // 5 seconds
        })
        .on("end", resolve)
        .on("error", reject);
    });

    // Read the generated thumbnail
    const thumbnail = await fs.readFile(outputPath);

    // Create the r2Key for the extracted audio, using the base name of the output path
    const r2Key = `thumbnails/${path.basename(outputPath)}`;

    const uploadParams = {
      Bucket: process.env.R2_BUCKET,
      Key: r2Key,
      Body: thumbnail,
    };

    // Upload the thumbnail to R2 and get the URL
    await s3Client.send(new PutObjectCommand(uploadParams));
    const r2Url = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${process.env.R2_BUCKET}/${r2Key}`;
    logger.log("Thumbnail uploaded to R2", { url: r2Url });

    // Delete the temporary file
    await fs.unlink(outputPath);

    // Log thumbnail generation results
    logger.log(`Thumbnail uploaded to S3: ${r2Url}`);

    // Return the thumbnail buffer, path, and R2 URL
    return {
      thumbnailBuffer: thumbnail,
      thumbnailPath: outputPath,
      r2Url,
    };
  },
});
```

### Testing your task

To test this task in the dashboard, you can use the following payload:

```json theme={"theme":"css-variables"}
{
  "videoUrl": "<video-url>" // Replace <a-video-url> with the URL of the video you want to upload
}
```

## Local development

To test this example task locally, be sure to install any packages from the build extensions you added to your `trigger.config.ts` file to your local machine. In this case, you need to install .


# Crawl a URL using Firecrawl
Source: https://trigger.dev/docs/guides/examples/firecrawl-url-crawl

This example demonstrates how to crawl a URL using Firecrawl with Trigger.dev.

## Overview

Firecrawl is a tool for crawling websites and extracting clean markdown that's structured in an LLM-ready format.

Here are two examples of how to use Firecrawl with Trigger.dev:

## Prerequisites

* A project with [Trigger.dev initialized](/docs/quick-start)
* A [Firecrawl](https://firecrawl.dev/) account

## Example 1: crawl an entire website with Firecrawl

This task crawls a website and returns the `crawlResult` object. You can set the `limit` parameter to control the number of URLs that are crawled.

```ts trigger/firecrawl-url-crawl.ts theme={"theme":"css-variables"}
import Firecrawl from "@mendable/firecrawl-js";
import { task } from "@trigger.dev/sdk";

// Initialize the Firecrawl client with your API key
const firecrawlClient = new Firecrawl({
  apiKey: process.env.FIRECRAWL_API_KEY, // Get this from your Firecrawl dashboard
});

export const firecrawlCrawl = task({
  id: "firecrawl-crawl",
  run: async (payload: { url: string }) => {
    const { url } = payload;

    // Crawl: scrapes all the URLs of a web page and return content in LLM-ready format
    const crawlResult = await firecrawlClient.crawl(url, {
      limit: 100, // Limit the number of URLs to crawl
      scrapeOptions: {
        formats: ["markdown", "html"],
      },
    });

    if (crawlResult.status === "failed") {
      throw new Error(`Failed to crawl: ${url}`);
    }

    return {
      data: crawlResult,
    };
  },
});
```

### Testing your task

You can test your task by triggering it from the Trigger.dev dashboard.

```json theme={"theme":"css-variables"}
"url": "<url-to-crawl>" // Replace with the URL you want to crawl
```

## Example 2: scrape a single URL with Firecrawl

This task scrapes a single URL and returns the `scrapeResult` object.

```ts trigger/firecrawl-url-scrape.ts theme={"theme":"css-variables"}
import Firecrawl from "@mendable/firecrawl-js";
import { task } from "@trigger.dev/sdk";

// Initialize the Firecrawl client with your API key
const firecrawlClient = new Firecrawl({
  apiKey: process.env.FIRECRAWL_API_KEY, // Get this from your Firecrawl dashboard
});

export const firecrawlScrape = task({
  id: "firecrawl-scrape",
  run: async (payload: { url: string }) => {
    const { url } = payload;

    // Scrape: scrapes a URL and get its content in LLM-ready format (markdown, structured data via LLM Extract, screenshot, html)
    const scrapeResult = await firecrawlClient.scrape(url, {
      formats: ["markdown", "html"],
    });

    return {
      data: scrapeResult,
    };
  },
});
```

### Testing your task

You can test your task by triggering it from the Trigger.dev dashboard.

```json theme={"theme":"css-variables"}
"url": "<url-to-scrape>" // Replace with the URL you want to scrape
```


# Trigger tasks from Hookdeck webhooks
Source: https://trigger.dev/docs/guides/examples/hookdeck-webhook

This example demonstrates how to use Hookdeck to receive webhooks and trigger Trigger.dev tasks.

## Overview

This example shows how to use [Hookdeck](https://hookdeck.com) as your webhook infrastructure to trigger Trigger.dev tasks. Hookdeck receives webhooks from external services, and forwards them directly to the Trigger.dev API. This gives you the best of both worlds: Hookdeck's webhook management, logging, and replay capabilities, combined with Trigger.dev's reliable task execution.

## Key features

* Use Hookdeck as your webhook endpoint for external services
* Hookdeck forwards webhooks directly to Trigger.dev tasks via the API
* All webhooks are logged and replayable in Hookdeck

## Setting up Hookdeck

You'll configure everything in the [Hookdeck dashboard](https://dashboard.hookdeck.com). No code changes needed in your app.

### 1. Create a destination

In Hookdeck, create a new [destination](https://hookdeck.com/docs/destinations) with the following settings:

* **URL**: `https://api.trigger.dev/api/v1/tasks/<task-id>/trigger` (replace `<task-id>` with your task ID)
* **Method**: POST
* **Authentication**: Bearer token (use your `TRIGGER_SECRET_KEY` from Trigger.dev)

### 2. Add a transformation

Create a [transformation](https://hookdeck.com/docs/transformations) to wrap the webhook body in the `payload` field that Trigger.dev expects:

```javascript theme={"theme":"css-variables"}
addHandler("transform", (request, context) => {
  request.body = { payload: { ...request.body } };
  return request;
});
```

### 3. Create a connection

Create a [connection](https://hookdeck.com/docs/connections) that links your source (where webhooks come from) to the destination and transformation you created above.

## Task code

This task will be triggered when Hookdeck forwards a webhook to the Trigger.dev API.

```ts trigger/webhook-handler.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

export const webhookHandler = task({
  id: "webhook-handler",
  run: async (payload: Record<string, unknown>) => {
    // The payload contains the original webhook data from the external service
    console.log("Received webhook:", payload);

    // Add your custom logic here
  },
});
```

## Testing your setup

To test everything is working:

1. Set up your destination, transformation, and connection in [Hookdeck](https://dashboard.hookdeck.com)
2. Send a test webhook to your Hookdeck source URL (use the Hookdeck Console or cURL)
3. Check the Hookdeck dashboard to verify the webhook was received and forwarded
4. Check the [Trigger.dev dashboard](https://cloud.trigger.dev) to see the successful run of your task

For more information on setting up Hookdeck, refer to the [Hookdeck Documentation](https://hookdeck.com/docs).


# Convert documents to PDF using LibreOffice
Source: https://trigger.dev/docs/guides/examples/libreoffice-pdf-conversion

This example demonstrates how to convert documents to PDF using LibreOffice with Trigger.dev.

## Prerequisites

* A project with [Trigger.dev initialized](/docs/quick-start)
* [LibreOffice](https://www.libreoffice.org/download/download-libreoffice/) installed on your machine
* A [Cloudflare R2](https://developers.cloudflare.com) account and bucket

### Using our `aptGet` build extension to add the LibreOffice package

To deploy this task, you'll need to add LibreOffice to your project configuration, like this:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { aptGet } from "@trigger.dev/build/extensions/core";
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [
      aptGet({
        packages: ["libreoffice"],
      }),
    ],
  },
});
```

<Note>
  [Build extensions](/docs/config/extensions/overview) allow you to hook into the build system and
  customize the build process or the resulting bundle and container image (in the case of
  deploying). You can use pre-built extensions or create your own.
</Note>

You'll also need to add `@trigger.dev/build` to your `package.json` file under `devDependencies` if you don't already have it there.

## Convert a document to PDF using LibreOffice and upload to R2

This task demonstrates how to use LibreOffice to convert a document (.doc or .docx) to PDF and upload the PDF to an R2 storage bucket.

### Key Features

* Fetches a document from a given URL
* Converts the document to PDF
* Uploads the PDF to R2 storage

### Task code

```ts trigger/libreoffice-pdf-convert.ts theme={"theme":"css-variables"}
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { task } from "@trigger.dev/sdk";
import libreoffice from "libreoffice-convert";
import { promisify } from "node:util";
import path from "path";
import fs from "fs";

const convert = promisify(libreoffice.convert);

// Initialize S3 client
const s3Client = new S3Client({
  // How to authenticate to R2: https://developers.cloudflare.com/r2/api/s3/tokens/
  region: "auto",
  endpoint: process.env.R2_ENDPOINT,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
  },
});

export const libreOfficePdfConvert = task({
  id: "libreoffice-pdf-convert",
  run: async (payload: { documentUrl: string }, { ctx }) => {
    // Set LibreOffice path for production environment
    if (ctx.environment.type !== "DEVELOPMENT") {
      process.env.LIBREOFFICE_PATH = "/usr/bin/libreoffice";
    }

    try {
      // Create temporary file paths
      const inputPath = path.join(process.cwd(), `input_${Date.now()}.docx`);
      const outputPath = path.join(process.cwd(), `output_${Date.now()}.pdf`);

      // Download file from URL
      const response = await fetch(payload.documentUrl);
      const buffer = Buffer.from(await response.arrayBuffer());
      fs.writeFileSync(inputPath, buffer);

      const inputFile = fs.readFileSync(inputPath);
      // Convert to PDF using LibreOffice
      const pdfBuffer = await convert(inputFile, ".pdf", undefined);
      fs.writeFileSync(outputPath, pdfBuffer);

      // Upload to R2
      const key = `converted-pdfs/output_${Date.now()}.pdf`;
      await s3Client.send(
        new PutObjectCommand({
          Bucket: process.env.R2_BUCKET,
          Key: key,
          Body: fs.readFileSync(outputPath),
        })
      );

      // Cleanup temporary files
      fs.unlinkSync(inputPath);
      fs.unlinkSync(outputPath);

      return { pdfLocation: key };
    } catch (error) {
      console.error("Error converting PDF:", error);
      throw error;
    }
  },
});
```

### Testing your task

To test this task, use this payload structure:

```json theme={"theme":"css-variables"}
{
  "documentUrl": "<a-document-url>" // Replace <a-document-url> with the URL of the document you want to convert
}
```

## Local development

To test this example task locally, be sure to install any packages from the build extensions you added to your `trigger.config.ts` file to your local machine. In this case, you need to install .


# Lightpanda examples with Trigger.dev
Source: https://trigger.dev/docs/guides/examples/lightpanda

These examples demonstrate how to use Lightpanda with Trigger.dev.

## Overview

Lightpanda is a purpose-built browser for AI and automation workflows. It is 10x faster, uses 10x less RAM than Chrome headless.

Here are a few examples of how to use Lightpanda with Trigger.dev.

<Warning>
  **WEB SCRAPING:** When web scraping, you MUST use a proxy to comply with our terms of service. Direct scraping of third-party websites without the site owner's permission using Trigger.dev Cloud is prohibited and will result in account suspension. See [this example](/docs/guides/examples/puppeteer#scrape-content-from-a-web-page) which uses a proxy.
</Warning>

## Limitations

* Lightpanda does not support the `puppeteer` screenshot feature.

## Using Lightpanda Cloud

### Prerequisites

* A [Lightpanda](https://lightpanda.io/) cloud token

### Get links from a website

In this task we use Lightpanda browser to get links from a provided URL. You will have to pass the URL as a payload when triggering the task.

Make sure to add `LIGHTPANDA_TOKEN` to your Trigger.dev dashboard on the Environment Variables page:

```bash theme={"theme":"css-variables"}
LIGHTPANDA_TOKEN="<your-token>"
```

```ts trigger/lightpanda-cloud-puppeteer.ts theme={"theme":"css-variables"}
import { logger, task } from "@trigger.dev/sdk";
import puppeteer from "puppeteer-core";

export const lightpandaCloudPuppeteer = task({
  id: "lightpanda-cloud-puppeteer",
  machine: {
    preset: "micro",
  },
  run: async (payload: { url: string }, { ctx }) => {
    logger.log("Lets get a page's links with Lightpanda!", { payload, ctx });

    if (!payload.url) {
      logger.warn("Please define the payload url");
      throw new Error("payload.url is undefined");
    }

    const token = process.env.LIGHTPANDA_TOKEN;
    if (!token) {
      logger.warn("Please define the env variable LIGHTPANDA_TOKEN");
      throw new Error("LIGHTPANDA_TOKEN is undefined");
    }

    // Connect to Lightpanda's cloud
    const browser = await puppeteer.connect({
      browserWSEndpoint: `wss://cloud.lightpanda.io/ws?browser=lightpanda&token=${token}`,
    });
    const context = await browser.createBrowserContext();
    const page = await context.newPage();

    // Dump all the links from the page.
    await page.goto(payload.url);

    const links = await page.evaluate(() => {
      return Array.from(document.querySelectorAll("a")).map((row) => {
        return row.getAttribute("href");
      });
    });

    logger.info("Processing done, shutting down…");

    await page.close();
    await context.close();
    await browser.disconnect();

    logger.info("✅ Completed");

    return {
      links,
    };
  },
});
```

### Proxies

Proxies can be used with your browser via the proxy query string parameter. By default, the proxy used is "datacenter" which is a pool of shared datacenter IPs.
`datacenter` accepts an optional `country` query string parameter which is an [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) country code.

```bash theme={"theme":"css-variables"}
# This example will use a German IP
wss://cloud.lightpanda.io/ws?proxy=datacenter&country=de&token=${token}
```

### Session

A session is alive until you close it or the connection is closed. The max duration of a session is 15 minutes.

## Using Lightpanda browser directly

### Prerequisites

* Setup the [Lightpanda build extension](/docs/config/extensions/lightpanda)

### Get the HTML of a webpage

This task will dump the HTML of a provided URL using the Lightpanda browser binary. You will have to pass the URL as a payload when triggering the task.

```ts trigger/lightpanda-fetch.ts theme={"theme":"css-variables"}
import { logger, task } from "@trigger.dev/sdk";
import { execSync } from "node:child_process";

export const lightpandaFetch = task({
  id: "lightpanda-fetch",
  machine: {
    preset: "micro",
  },
  run: async (payload: { url: string }, { ctx }) => {
    logger.log("Lets get a page's content with Lightpanda!", { payload, ctx });

    if (!payload.url) {
      logger.warn("Please define the payload url");
      throw new Error("payload.url is undefined");
    }

    const buffer = execSync(`lightpanda fetch --dump ${payload.url}`);

    logger.info("✅ Completed");

    return {
      message: buffer.toString(),
    };
  },
});
```

### Lightpanda CDP with Puppeteer

This task initializes a Lightpanda CDP server and uses it with `puppeteer-core` to scrape a provided URL.

```ts trigger/lightpanda-cdp.ts theme={"theme":"css-variables"}
import { logger, task } from "@trigger.dev/sdk";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import puppeteer from "puppeteer-core";

const spawnLightpanda = async (host: string, port: string) =>
  new Promise<ChildProcessWithoutNullStreams>((resolve, reject) => {
    const child = spawn("lightpanda", [
      "serve",
      "--host",
      host,
      "--port",
      port,
      "--log_level",
      "info",
    ]);

    child.on("spawn", async () => {
      logger.info("Running Lightpanda's CDP server…", {
        pid: child.pid,
      });

      await new Promise((resolve) => setTimeout(resolve, 250));
      resolve(child);
    });
    child.on("error", (e) => reject(e));
  });

export const lightpandaCDP = task({
  id: "lightpanda-cdp",
  machine: {
    preset: "micro",
  },
  run: async (payload: { url: string }, { ctx }) => {
    logger.log("Lets get a page's links with Lightpanda!", { payload, ctx });

    if (!payload.url) {
      logger.warn("Please define the payload url");
      throw new Error("payload.url is undefined");
    }

    const host = process.env.LIGHTPANDA_CDP_HOST ?? "127.0.0.1";
    const port = process.env.LIGHTPANDA_CDP_PORT ?? "9222";

    // Launch Lightpanda's CDP server
    const lpProcess = await spawnLightpanda(host, port);

    const browser = await puppeteer.connect({
      browserWSEndpoint: `ws://${host}:${port}`,
    });
    const context = await browser.createBrowserContext();
    const page = await context.newPage();

    // Dump all the links from the page.
    await page.goto(payload.url);

    const links = await page.evaluate(() => {
      return Array.from(document.querySelectorAll("a")).map((row) => {
        return row.getAttribute("href");
      });
    });

    logger.info("Processing done");
    logger.info("Shutting down…");

    // Close Puppeteer instance
    await browser.close();

    // Stop Lightpanda's CDP Server
    lpProcess.kill();

    logger.info("✅ Completed");

    return {
      links,
    };
  },
});
```


# Call OpenAI with retrying
Source: https://trigger.dev/docs/guides/examples/open-ai-with-retrying

This example will show you how to call OpenAI with retrying using Trigger.dev.

## Overview

Sometimes OpenAI calls can take a long time to complete, or they can fail. This task will retry if the API call fails completely or if the response is empty.

## Task code

```ts trigger/openai.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

export const openaiTask = task({
  id: "openai-task",
  //specifying retry options overrides the defaults defined in your trigger.config file
  retry: {
    maxAttempts: 10,
    factor: 1.8,
    minTimeoutInMs: 500,
    maxTimeoutInMs: 30_000,
    randomize: false,
  },
  run: async (payload: { prompt: string }) => {
    //if this fails, it will throw an error and retry
    const chatCompletion = await openai.chat.completions.create({
      messages: [{ role: "user", content: payload.prompt }],
      model: "gpt-3.5-turbo",
    });

    if (chatCompletion.choices[0]?.message.content === undefined) {
      //sometimes OpenAI returns an empty response, let's retry by throwing an error
      throw new Error("OpenAI call failed");
    }

    return chatCompletion.choices[0].message.content;
  },
});
```

## Testing your task

To test this task in the dashboard, you can use the following payload:

```json theme={"theme":"css-variables"}
{
  "prompt": "What is the meaning of life?"
}
```


# Turn a PDF into an image using MuPDF
Source: https://trigger.dev/docs/guides/examples/pdf-to-image

This example will show you how to turn a PDF into an image using MuPDF and Trigger.dev.

## Overview

This example demonstrates how to use Trigger.dev to turn a PDF into a series of images using MuPDF and upload them to Cloudflare R2.

## Update your build configuration

To use this example, add these build settings below to your `trigger.config.ts` file. They ensure that the `mutool` and `curl` packages are installed when you deploy your task. You can learn more about this and see more build settings [here](/docs/config/extensions/aptGet).

```ts trigger.config.ts theme={"theme":"css-variables"}
export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [aptGet({ packages: ["mupdf-tools", "curl"] })],
  },
});
```

## Task code

```ts trigger/pdfToImage.ts theme={"theme":"css-variables"}
import { logger, task } from "@trigger.dev/sdk";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { execSync } from "child_process";
import fs from "fs";
import path from "path";

// Initialize S3 client
const s3Client = new S3Client({
  region: "auto",
  endpoint: process.env.S3_ENDPOINT,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
  },
});

export const pdfToImage = task({
  id: "pdf-to-image",
  run: async (payload: { pdfUrl: string; documentId: string }) => {
    logger.log("Converting PDF to images", payload);

    const pdfPath = `/tmp/${payload.documentId}.pdf`;
    const outputDir = `/tmp/${payload.documentId}`;

    // Download PDF and convert to images using MuPDF
    execSync(`curl -s -o ${pdfPath} ${payload.pdfUrl}`);
    fs.mkdirSync(outputDir, { recursive: true });
    execSync(`mutool convert -o ${outputDir}/page-%d.png ${pdfPath}`);

    // Upload images to R2
    const uploadedUrls = [];
    for (const file of fs.readdirSync(outputDir)) {
      const s3Key = `images/${payload.documentId}/${file}`;
      const uploadParams = {
        Bucket: process.env.S3_BUCKET,
        Key: s3Key,
        Body: fs.readFileSync(path.join(outputDir, file)),
        ContentType: "image/png",
      };

      logger.log("Uploading to R2", uploadParams);

      await s3Client.send(new PutObjectCommand(uploadParams));
      const s3Url = `https://${process.env.S3_BUCKET}.r2.cloudflarestorage.com/${s3Key}`;
      uploadedUrls.push(s3Url);
      logger.log("Image uploaded to R2", { url: s3Url });
    }

    // Clean up
    fs.rmSync(outputDir, { recursive: true, force: true });
    fs.unlinkSync(pdfPath);

    logger.log("All images uploaded to R2", { urls: uploadedUrls });

    return {
      imageUrls: uploadedUrls,
    };
  },
});
```

## Testing your task

To test this task in the dashboard, you can use the following payload:

```json theme={"theme":"css-variables"}
{
  "pdfUrl": "https://pdfobject.com/pdf/sample.pdf",
  "documentId": "unique-document-id"
}
```

## Local development

To test this example task locally, be sure to install any packages from the build extensions you added to your `trigger.config.ts` file to your local machine. In this case, you need to install .


# Puppeteer examples with Trigger.dev
Source: https://trigger.dev/docs/guides/examples/puppeteer

These examples demonstrate how to use Puppeteer with Trigger.dev.

## Prerequisites

* A project with [Trigger.dev initialized](/docs/quick-start)
* [Puppeteer](https://pptr.dev/guides/installation) installed on your machine

## Overview

There are 3 example tasks to follow on this page:

1. [Basic example](/docs/guides/examples/puppeteer#basic-example)
2. [Generate a PDF from a web page](/docs/guides/examples/puppeteer#generate-a-pdf-from-a-web-page)
3. [Scrape content from a web page](/docs/guides/examples/puppeteer#scrape-content-from-a-web-page)

<Warning>
  **WEB SCRAPING:** When web scraping, you MUST use a proxy to comply with our terms of service. Direct scraping of third-party websites without the site owner's permission using Trigger.dev Cloud is prohibited and will result in account suspension. See [this example](/docs/guides/examples/puppeteer#scrape-content-from-a-web-page) which uses a proxy.
</Warning>

## Build configuration

To use all examples on this page, you'll first need to add these build settings to your `trigger.config.ts` file:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { puppeteer } from "@trigger.dev/build/extensions/puppeteer";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    // This is required to use the Puppeteer library
    extensions: [puppeteer()],
  },
});
```

Learn more about the [trigger.config.ts](/docs/config/config-file) file including setting default retry settings, customizing the build environment, and more.

## Set an environment variable

Set the following environment variable in your [Trigger.dev dashboard](/docs/deploy-environment-variables) or [using the SDK](/docs/deploy-environment-variables#in-your-code):

```bash theme={"theme":"css-variables"}
PUPPETEER_EXECUTABLE_PATH: "/usr/bin/google-chrome-stable",
```

## Basic example

### Overview

In this example we use [Puppeteer](https://pptr.dev/) to log out the title of a web page, in this case from the [Trigger.dev](https://trigger.dev) landing page.

### Task code

```ts trigger/puppeteer-basic-example.ts theme={"theme":"css-variables"}
import { logger, task } from "@trigger.dev/sdk";
import puppeteer from "puppeteer";

export const puppeteerTask = task({
  id: "puppeteer-log-title",
  run: async () => {
    const browser = await puppeteer.launch();
    const page = await browser.newPage();

    await page.goto("https://trigger.dev");

    const content = await page.title();
    logger.info("Content", { content });

    await browser.close();
  },
});
```

### Testing your task

There's no payload required for this task so you can just click "Run test" from the Test page in the dashboard. Learn more about testing tasks [here](/docs/run-tests).

## Generate a PDF from a web page

### Overview

In this example we use [Puppeteer](https://pptr.dev/) to generate a PDF from the [Trigger.dev](https://trigger.dev) landing page and upload it to [Cloudflare R2](https://developers.cloudflare.com/r2/).

### Task code

```ts trigger/puppeteer-generate-pdf.ts theme={"theme":"css-variables"}
import { logger, task } from "@trigger.dev/sdk";
import puppeteer from "puppeteer";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";

// Initialize S3 client
const s3Client = new S3Client({
  region: "auto",
  endpoint: process.env.S3_ENDPOINT,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
  },
});

export const puppeteerWebpageToPDF = task({
  id: "puppeteer-webpage-to-pdf",
  run: async () => {
    const browser = await puppeteer.launch();
    const page = await browser.newPage();
    const response = await page.goto("https://trigger.dev");
    const url = response?.url() ?? "No URL found";

    // Generate PDF from the web page
    const generatePdf = await page.pdf();

    logger.info("PDF generated from URL", { url });

    await browser.close();

    // Upload to R2
    const s3Key = `pdfs/test.pdf`;
    const uploadParams = {
      Bucket: process.env.S3_BUCKET,
      Key: s3Key,
      Body: generatePdf,
      ContentType: "application/pdf",
    };

    logger.log("Uploading to R2 with params", uploadParams);

    // Upload the PDF to R2 and return the URL.
    await s3Client.send(new PutObjectCommand(uploadParams));
    const s3Url = `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${s3Key}`;
    logger.log("PDF uploaded to R2", { url: s3Url });
    return { pdfUrl: s3Url };
  },
});
```

### Testing your task

There's no payload required for this task so you can just click "Run test" from the Test page in the dashboard. Learn more about testing tasks [here](/docs/run-tests).

## Scrape content from a web page

### Overview

In this example we use [Puppeteer](https://pptr.dev/) with a [BrowserBase](https://www.browserbase.com/) proxy to scrape the GitHub stars count from the [Trigger.dev](https://trigger.dev) landing page and log it out. See [this list](/docs/guides/examples/puppeteer#proxying) for more proxying services we recommend.

<Warning>
  When web scraping, you MUST use the technique below which uses a proxy with Puppeteer. Direct
  scraping without using `browserWSEndpoint` is prohibited and will result in account suspension.
  Screenshots are also prohibited when scraping.
</Warning>

### Task code

```ts trigger/scrape-website.ts theme={"theme":"css-variables"}
import { logger, task } from "@trigger.dev/sdk";
import puppeteer from "puppeteer-core";

export const puppeteerScrapeWithProxy = task({
  id: "puppeteer-scrape-with-proxy",
  run: async () => {
    const browser = await puppeteer.connect({
      browserWSEndpoint: `wss://connect.browserbase.com?apiKey=${process.env.BROWSERBASE_API_KEY}`,
    });

    const page = await browser.newPage();

    try {
      // Navigate to the target website
      await page.goto("https://trigger.dev", { waitUntil: "networkidle0" });

      // Scrape the GitHub stars count
      const starCount = await page.evaluate(() => {
        const starElement = document.querySelector(".github-star-count");
        const text = starElement?.textContent ?? "0";
        const numberText = text.replace(/[^0-9]/g, "");
        return parseInt(numberText);
      });

      logger.info("GitHub star count", { starCount });

      return { starCount };
    } catch (error) {
      logger.error("Error during scraping", {
        error: error instanceof Error ? error.message : String(error),
      });
      throw error;
    } finally {
      await browser.close();
    }
  },
});
```

### Testing your task

There's no payload required for this task so you can just click "Run test" from the Test page in the dashboard. Learn more about testing tasks [here](/docs/run-tests).

## Local development

To test this example task locally, be sure to install any packages from the build extensions you added to your `trigger.config.ts` file to your local machine. In this case, you need to install .

## Proxying

If you're using Trigger.dev Cloud and Puppeteer or any other tool to scrape content from websites you don't own, you'll need to proxy your requests. **If you don't you'll risk getting our IP address blocked and we will ban you from our service. You must always have permission from the website owner to scrape their content.**

Here are a list of proxy services we recommend:

* [Browserbase](https://www.browserbase.com/)
* [Brightdata](https://brightdata.com/)
* [Browserless](https://browserless.io/)
* [Oxylabs](https://oxylabs.io/)
* [ScrapingBee](https://scrapingbee.com/)
* [Smartproxy](https://smartproxy.com/)


# Send emails using React Email
Source: https://trigger.dev/docs/guides/examples/react-email

Learn how to send beautiful emails using React Email and Trigger.dev.

## Overview

This example demonstrates how to use Trigger.dev to send emails using [React Email](https://react.email/).

<Note>
  This example uses [Resend](https://resend.com) as the email provider. You can use other email
  providers like [Loops](https://loops.so) or [SendGrid](https://sendgrid.com) etc. Full list of
  their integrations can be found [here](https://react.email/docs/introduction#integrations).
</Note>

## Task code

<Warning>
  This email is built using React components. To use React components in your task, it must be a
  .tsx file.
</Warning>

```tsx trigger/sendReactEmail.tsx theme={"theme":"css-variables"}
import { Body, Button, Container, Head, Heading, Html, Preview } from "@react-email/components";
import { logger, task } from "@trigger.dev/sdk";
import { Resend } from "resend";

// Initialize Resend client
const resend = new Resend(process.env.RESEND_API_KEY);

// React Email template component
const EmailTemplate = ({ name, message }: { name: string; message: string }) => (
  <Html lang="en">
    <Head />
    <Preview>New message from {name}</Preview>
    <Body style={{ fontFamily: "Arial, sans-serif", margin: "0", padding: "0" }}>
      <Container style={{ padding: "20px", maxWidth: "600px" }}>
        <Heading>Hello from Acme Inc.</Heading>
        <p>Hi {name},</p>
        <p>{message}</p>
        <Button
          href="https://trigger.dev"
          style={{
            backgroundColor: "#0070f3",
            color: "white",
            padding: "12px 20px",
            borderRadius: "8px",
          }}
        >
          Go to Acme Inc.
        </Button>
      </Container>
    </Body>
  </Html>
);

export const sendEmail = task({
  id: "send-react-email",
  run: async (payload: {
    to: string;
    name: string;
    message: string;
    subject: string;
    from?: string;
  }) => {
    try {
      logger.info("Sending email using React.email and Resend", {
        to: payload.to,
      });

      // Send the email using Resend
      const { data, error } = await resend.emails.send({
        // The from address needs to be a verified email address you own
        from: payload.from || "email@acmeinc.com", // Default from address
        to: payload.to,
        subject: payload.subject,
        react: <EmailTemplate name={payload.name} message={payload.message} />,
      });

      if (error) {
        logger.error("Failed to send email", { error });
        throw new Error(`Failed to send email: ${error.message}`);
      }

      logger.info("Email sent successfully", { emailId: data?.id });

      // Return the response from Resend
      return {
        id: data?.id,
        status: "sent",
      };
    } catch (error) {
      logger.error("Unexpected error sending email", { error });
      throw error;
    }
  },
});
```

## The email

This example email should look like this:

<img alt="React Email" />

This is just a simple implementation, you can customize the email to be as complex as you want. Check out the [React email templates](https://react.email/templates) for more inspiration.

## Testing your task

To test this task in the [dashboard](https://cloud.trigger.dev), you can use the following payload:

```json theme={"theme":"css-variables"}
{
  "to": "recipient@example.com",
  "name": "Jane Doe",
  "message": "Thank you for signing up for our service!",
  "subject": "Welcome to Acme Inc."
}
```

## Deploying your task

Deploy the task to production using the Trigger.dev CLI `deploy` command.

## Using Cursor / AI to build your emails

In this video you can see how we use Cursor to build a welcome email.

We recommend using our [Cursor rules](https://trigger.dev/changelog/cursor-rules-writing-tasks/) to help you build your tasks and emails.

#### Video: creating a new email template using Cursor

<video />

#### The generated email template

<img alt="Cursor" />

#### The generated code

```tsx emails/trigger-welcome-email.tsx theme={"theme":"css-variables"}
import {
  Body,
  Button,
  Container,
  Head,
  Heading,
  Hr,
  Html,
  Img,
  Link,
  Preview,
  Section,
  Text,
} from "@react-email/components";

const baseUrl = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "";

export interface TriggerWelcomeEmailProps {
  name: string;
}

export const TriggerWelcomeEmail = ({ name }: TriggerWelcomeEmailProps) => (
  <Html>
    <Head />
    <Preview>Welcome to Trigger.dev - Your background jobs platform!</Preview>
    <Body style={main}>
      <Container style={container}>
        <Section style={box}>
          <Img
            src="https://trigger.dev/assets/triggerdev-lockup--light.svg"
            width="150"
            height="40"
            alt="Trigger.dev"
          />
          <Hr style={hr} />
          <Heading>Welcome, {name}!</Heading>
          <Text style={paragraph}>
            Thanks for signing up for Trigger.dev! You're now ready to start creating powerful
            background jobs and workflows.
          </Text>
          <Text style={paragraph}>
            You can monitor your jobs, view runs, and manage your projects right from your
            dashboard.
          </Text>
          <Button style={button} href="https://cloud.trigger.dev/dashboard">
            View your Trigger.dev Dashboard
          </Button>
          <Hr style={hr} />
          <Text style={paragraph}>
            To help you get started, check out our{" "}
            <Link style={anchor} href="https://trigger.dev/docs">
              documentation
            </Link>{" "}
            and{" "}
            <Link style={anchor} href="https://trigger.dev/docs/quickstart">
              quickstart guide
            </Link>
            .
          </Text>
          <Text style={paragraph}>
            You can create your first job using our SDK, set up integrations, and configure triggers
            to automate your workflows. Take a look at our{" "}
            <Link style={anchor} href="https://trigger.dev/docs/examples">
              examples
            </Link>{" "}
            for inspiration.
          </Text>
          <Text style={paragraph}>
            Join our{" "}
            <Link style={anchor} href="https://discord.gg/kA47vcd8Qr">
              Discord community
            </Link>{" "}
            to connect with other developers and get help when you need it.
          </Text>
          <Text style={paragraph}>
            We're here to help you build amazing things. If you have any questions, check out our{" "}
            <Link style={anchor} href="https://trigger.dev/docs">
              documentation
            </Link>{" "}
            or reach out to us on Discord.
          </Text>
          <Text style={paragraph}>— The Trigger.dev team</Text>
          <Hr style={hr} />
          <Text style={footer}>Trigger.dev Inc.</Text>
        </Section>
      </Container>
    </Body>
  </Html>
);

export default TriggerWelcomeEmail;

const main = {
  backgroundColor: "#0E0C15",
  fontFamily:
    '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};

const container = {
  backgroundColor: "#1D1B27",
  margin: "0 auto",
  padding: "20px 0 48px",
  marginBottom: "64px",
};

const box = {
  padding: "0 48px",
};

const hr = {
  borderColor: "#2D2B3B",
  margin: "20px 0",
};

const paragraph = {
  color: "#E1E1E3",
  fontSize: "16px",
  lineHeight: "24px",
  textAlign: "left" as const,
};

const anchor = {
  color: "#A78BFA",
};

const button = {
  backgroundColor: "#7C3AED",
  borderRadius: "6px",
  color: "#fff",
  fontSize: "16px",
  fontWeight: "bold",
  textDecoration: "none",
  textAlign: "center" as const,
  display: "block",
  width: "100%",
  padding: "12px",
};

const footer = {
  color: "#9CA3AF",
  fontSize: "12px",
  lineHeight: "16px",
};
```

And then to trigger the email, you can use the following task:

```tsx trigger/triggerWelcomeEmail.tsx theme={"theme":"css-variables"}
import { logger, task } from "@trigger.dev/sdk";
import { Resend } from "resend";
import TriggerWelcomeEmail from "emails/trigger-welcome-email";

// Initialize Resend client
const resend = new Resend(process.env.RESEND_API_KEY);

export const sendEmail = task({
  id: "trigger-welcome-email",
  run: async (payload: { to: string; name: string; subject: string; from?: string }) => {
    try {
        to: payload.to,
      });

      const { data, error } = await resend.emails.send({
        // The from address needs to be a verified email address
        from: payload.from || "email@acmeinc.com", // Default from address
        to: payload.to,
        subject: payload.subject,
        react: <TriggerWelcomeEmail name={payload.name} />,
      });

      if (error) {
        logger.error("Failed to send email", { error });
        throw new Error(`Failed to send email: ${error.message}`);
      }

      logger.info("Email sent successfully", { emailId: data?.id });

      return {
        id: data?.id,
        status: "sent",
      };
    } catch (error) {
      logger.error("Unexpected error sending email", { error });
      throw error;
    }
  },
});
```

## Troubleshooting

If you see this error when using `react-email` packages:

```
reactDOMServer.renderToPipeableStream is not a function
```

See our [common problems guide](/docs/troubleshooting#reactdomserver-rendertopipeablestream-is-not-a-function-when-using-react-email) for more information.

## Learn more

### React Email docs

Check out the [React Email docs](https://react.email/docs) and learn how to set up and use React Email, including how to preview your emails locally.

<CardGroup>
  <Card title="Components" icon="puzzle-piece" href="https://react.email/components">
    Pre-built components you can copy and paste into your emails.
  </Card>

  <Card title="Templates" icon="rectangle-list" href="https://react.email/templates">
    Extensive pre-built templates ready to use.
  </Card>
</CardGroup>


# Generate a PDF using react-pdf and save it to R2
Source: https://trigger.dev/docs/guides/examples/react-pdf

This example will show you how to generate a PDF using Trigger.dev.

## Overview

This example demonstrates how to use Trigger.dev to generate a PDF using [react-pdf](https://react-pdf.org/) and save it to Cloudflare R2.

## Task code

<Info> This example must be a .tsx file to use React components.</Info>

```ts trigger/generateResumePDF.tsx theme={"theme":"css-variables"}
import { logger, task } from "@trigger.dev/sdk";
import { renderToBuffer, Document, Page, Text, View } from "@react-pdf/renderer";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";

// Initialize R2 client
const r2Client = new S3Client({
  // How to authenticate to R2: https://developers.cloudflare.com/r2/api/s3/tokens/
  region: "auto",
  endpoint: process.env.R2_ENDPOINT,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
  },
});

export const generateResumePDF = task({
  id: "generate-resume-pdf",
  run: async (payload: { text: string }) => {
    // Log the payload
    logger.log("Generating PDF resume", payload);

    // Render the ResumeDocument component to a PDF buffer
    const pdfBuffer = await renderToBuffer(
      <Document>
        <Page size="A4">
          <View>
            <Text>{payload.text}</Text>
          </View>
        </Page>
      </Document>
    );

    // Generate a unique filename based on the text and current timestamp
    const filename = `${payload.text.replace(/\s+/g, "-").toLowerCase()}-${Date.now()}.pdf`;

    // Set the R2 key for the PDF file
    const r2Key = `resumes/${filename}`;

    // Set the upload parameters for R2
    const uploadParams = {
      Bucket: process.env.R2_BUCKET,
      Key: r2Key,
      Body: pdfBuffer,
      ContentType: "application/pdf",
    };

    // Log the upload parameters
    logger.log("Uploading to R2 with params", uploadParams);

    // Upload the PDF to R2
    await r2Client.send(new PutObjectCommand(uploadParams));

    // Return the Bucket and R2 key for the uploaded PDF
    return {
      Bucket: process.env.R2_BUCKET,
      Key: r2Key,
    };
  },
});
```

## Testing your task

To test this task in the dashboard, you can use the following payload:

```json theme={"theme":"css-variables"}
{
  "text": "Hello, world!"
}
```


# Image-to-image generation using Replicate and nano-banana
Source: https://trigger.dev/docs/guides/examples/replicate-image-generation

Learn how to generate images from source image URLs using Replicate and Trigger.dev.

## Overview

This example demonstrates how to use Trigger.dev to generate images from source image URLs using [Replicate](https://replicate.com/), the [nano-banana](https://replicate.com/google/nano-banana) model.

## Task code

```tsx trigger/generateImage.tsx theme={"theme":"css-variables"}
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { task, wait } from "@trigger.dev/sdk";
import Replicate, { Prediction } from "replicate";

// Initialize clients
const replicate = new Replicate({
  auth: process.env.REPLICATE_API_TOKEN,
});

const s3Client = new S3Client({
  region: "auto",
  endpoint: process.env.R2_ENDPOINT,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
  },
});

const model = "google/nano-banana";

export const generateImageAndUploadToR2 = task({
  id: "generate-image-and-upload-to-r2",
  run: async (payload: { prompt: string; imageUrl: string }) => {
    const { prompt, imageUrl } = payload;

    const token = await wait.createToken({
      timeout: "10m",
    });

    // Use Flux with structured prompt
    const output = await replicate.predictions.create({
      model: model,
      input: { prompt, image_input: [imageUrl] },
      // pass the provided URL to Replicate's webhook, so they can "callback"
      webhook: token.url,
      webhook_events_filter: ["completed"],
    });

    const result = await wait.forToken<Prediction>(token).unwrap();
    // unwrap() throws a timeout error or returns the result 👆

    if (!result.ok) {
      throw new Error("Failed to create prediction");
    }

    const generatedImageUrl = result.output.output;

    const image = await fetch(generatedImageUrl);
    const imageBuffer = Buffer.from(await image.arrayBuffer());

    const base64Image = Buffer.from(imageBuffer).toString("base64");

    const timestamp = Date.now();
    const filename = `generated-${timestamp}.png`;

    // Generate unique key for R2
    const sanitizedFileName = filename.replace(/[^a-zA-Z0-9.-]/g, "_");
    const r2Key = `uploaded-images/${timestamp}-${sanitizedFileName}`;

    const uploadParams = {
      Bucket: process.env.R2_BUCKET,
      Key: r2Key,
      Body: imageBuffer,
      ContentType: "image/png",
      // Add cache control for better performance
      CacheControl: "public, max-age=31536000", // 1 year
    };

    const uploadResult = await s3Client.send(new PutObjectCommand(uploadParams));

    // Construct the public URL using the R2_PUBLIC_URL env var
    const publicUrl = `${process.env.R2_PUBLIC_URL}/${r2Key}`;

    return {
      success: true,
      publicUrl,
      originalPrompt: prompt,
      sourceImageUrl: imageUrl,
    };
  },
});
```

## Environment variables

You will need to set the following environment variables:

```
TRIGGER_SECRET_KEY=<your-trigger-secret-key>
REPLICATE_API_TOKEN=<your-replicate-api-token>
R2_ENDPOINT=<your-r2-endpoint>
R2_ACCESS_KEY_ID=<your-r2-access-key-id>
R2_SECRET_ACCESS_KEY=<your-r2-secret-access-key>
R2_BUCKET=<your-r2-bucket>
R2_PUBLIC_URL=<your-r2-public-url>
```


# Send a sequence of emails using Resend
Source: https://trigger.dev/docs/guides/examples/resend-email-sequence

This example will show you how to send a sequence of emails over several days using Resend with Trigger.dev.

## Overview

Each email is wrapped in retry.onThrow. This will retry the block of code if an error is thrown. This is useful when you don’t want to retry the whole task, but just a part of it. The entire task will use the default retrying, so can also retry.

Additionally this task uses wait.for to wait for a certain amount of time before sending the next email. During the waiting time, the task will be paused and will not consume any resources.

## Task code

```ts trigger/email-sequence.ts theme={"theme":"css-variables"}
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_ASP_KEY);

export const emailSequence = task({
  id: "email-sequence",
  run: async (payload: { userId: string; email: string; name: string }) => {
    console.log(`Start email sequence for user ${payload.userId}`, payload);

    // Send the first email immediately
    const firstEmailResult = await retry.onThrow(
      async ({ attempt }) => {
        const { data, error } = await resend.emails.send({
          from: "hello@trigger.dev",
          to: payload.email,
          subject: "Welcome to Trigger.dev",
          html: `<p>Hello ${payload.name},</p><p>Welcome to Trigger.dev</p>`,
        });

        if (error) {
          // Throwing an error will trigger a retry of this block
          throw error;
        }

        return data;
      },
      { maxAttempts: 3 }
    );

    // Then wait 3 days
    await wait.for({ days: 3 });

    // Send the second email
    const secondEmailResult = await retry.onThrow(
      async ({ attempt }) => {
        const { data, error } = await resend.emails.send({
          from: "hello@trigger.dev",
          to: payload.email,
          subject: "Some tips for you",
          html: `<p>Hello ${payload.name},</p><p>Here are some tips for you…</p>`,
        });

        if (error) {
          // Throwing an error will trigger a retry of this block
          throw error;
        }

        return data;
      },
      { maxAttempts: 3 }
    );

    //etc...
  },
});
```

## Testing your task

To test this task in the dashboard, you can use the following payload:

```json theme={"theme":"css-variables"}
{
  "userId": "123",
  "email": "<your-test-email>", // Replace with your test email
  "name": "Alice Testington"
}
```


# Generate OG Images using Satori
Source: https://trigger.dev/docs/guides/examples/satori

Learn how to generate dynamic Open Graph images using Satori and Trigger.dev.

## Overview

This example demonstrates how to use Trigger.dev to generate dynamic Open Graph (OG) images using Vercel's [Satori](https://github.com/vercel/satori). The task takes a title and image URL as input and generates a beautiful OG image with text overlay.

This can be customized and extended however you like, full list of options can be found [here](https://github.com/vercel/satori).

## Task code

```tsx trigger/generateOgImage.ts theme={"theme":"css-variables"}
import { schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";
import satori from "satori";
import sharp from "sharp";
import { join } from "path";
import fs from "fs/promises";

export const generateOgImage = schemaTask({
  id: "generate-og-image",
  schema: z.object({
    width: z.number().optional(),
    height: z.number().optional(),
    title: z.string(),
    imageUrl: z.string().url(),
  }),
  run: async (payload) => {
    // Load font
    const fontResponse = await fetch(
      "https://github.com/googlefonts/roboto/raw/main/src/hinted/Roboto-Regular.ttf"
    ).then((res) => res.arrayBuffer());

    // Fetch and convert image to base64
    const imageResponse = await fetch(payload.imageUrl);
    const imageBuffer = await imageResponse.arrayBuffer();
    const imageBase64 = `data:${
      imageResponse.headers.get("content-type") || "image/jpeg"
    };base64,${Buffer.from(imageBuffer).toString("base64")}`;

    const markup = (
      <div
        style={{
          width: payload.width ?? 1200,
          height: payload.height ?? 630,
          display: "flex",
          backgroundColor: "#121317",
          position: "relative",
          fontFamily: "Roboto",
        }}
      >
        <img
          src={imageBase64}
          width={payload.width ?? 1200}
          height={payload.height ?? 630}
          style={{
            objectFit: "cover",
          }}
        />
        <h1
          style={{
            fontSize: "60px",
            fontWeight: "bold",
            color: "#fff",
            margin: 0,
            position: "absolute",
            top: "50%",
            transform: "translateY(-50%)",
            left: "48px",
            maxWidth: "60%",
            textShadow: "0 2px 4px rgba(0,0,0,0.5)",
          }}
        >
          {payload.title}
        </h1>
      </div>
    );

    const svg = await satori(markup, {
      width: payload.width ?? 1200,
      height: payload.height ?? 630,
      fonts: [
        {
          name: "Roboto",
          data: fontResponse,
          weight: 400,
          style: "normal",
        },
      ],
    });

    const fileName = `og-${Date.now()}.jpg`;
    const tempDir = join(process.cwd(), "tmp");
    await fs.mkdir(tempDir, { recursive: true });
    const outputPath = join(tempDir, fileName);

    await sharp(Buffer.from(svg))
      .jpeg({
        quality: 90,
        mozjpeg: true,
      })
      .toFile(outputPath);

    return {
      filePath: outputPath,
      width: payload.width,
      height: payload.height,
    };
  },
});
```

## Image example

This image was generated using the above task.

<img alt="OG Image" />

## Testing your task

To test this task in the [dashboard](https://cloud.trigger.dev), you can use the following payload:

```json theme={"theme":"css-variables"}
{
  "title": "My Awesome OG image",
  "imageUrl": "<your-image-url>",
  "width": 1200, // optional, defaults to 1200
  "height": 630 // optional, defaults to 630
}
```


# Scrape the top 3 articles from Hacker News and email yourself a summary every weekday
Source: https://trigger.dev/docs/guides/examples/scrape-hacker-news

This example demonstrates how to scrape the top 3 articles from Hacker News using BrowserBase and Puppeteer, summarize them with ChatGPT and send a nicely formatted email summary to yourself every weekday using Resend.

<iframe title="YouTube video player" />

## Overview

In this example we'll be using a number of different tools and features to:

1. Scrape the content of the top 3 articles from Hacker News
2. Summarize each article
3. Email the summaries to yourself

And we'll be using the following tools and features:

* [Schedules](/docs/tasks/scheduled) to run the task every weekday at 9 AM
* [Batch Triggering](/docs/triggering#yourtask-batchtriggerandwait) to run separate child tasks for each article while the parent task waits for them all to complete
* [idempotencyKey](/docs/triggering#idempotencykey) to prevent tasks being triggered multiple times
* [BrowserBase](https://browserbase.com/) to proxy the scraping of the Hacker News articles
* [Puppeteer](https://pptr.dev/) to scrape the articles linked from Hacker News
* [OpenAI](https://platform.openai.com/docs/overview) to summarize the articles
* [Resend](https://resend.com/) to send a nicely formatted email summary

<Warning>
  **WEB SCRAPING:** When web scraping, you MUST use a proxy to comply with our terms of service. Direct scraping of third-party websites without the site owner's permission using Trigger.dev Cloud is prohibited and will result in account suspension. See [this example](/docs/guides/examples/puppeteer#scrape-content-from-a-web-page) which uses a proxy.
</Warning>

## Prerequisites

* A project with [Trigger.dev initialized](/docs/quick-start)
* [Puppeteer](https://pptr.dev/guides/installation) installed on your machine
* A [BrowserBase](https://browserbase.com/) account
* An [OpenAI](https://platform.openai.com/docs/overview) account
* A [Resend](https://resend.com/) account

## Build configuration

First up, add these build settings to your `trigger.config.ts` file:

```tsx trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { puppeteer } from "@trigger.dev/build/extensions/puppeteer";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    // This is required to use the Puppeteer library
    extensions: [puppeteer()],
  },
});
```

Learn more about the [trigger.config.ts](/docs/config/config-file) file including setting default retry settings, customizing the build environment, and more.

### Environment variables

Set the following environment variable in your local `.env` file to run this task locally. And before deploying your task, set them in the [Trigger.dev dashboard](/docs/deploy-environment-variables) or [using the SDK](/docs/deploy-environment-variables#in-your-code):

```bash theme={"theme":"css-variables"}
BROWSERBASE_API_KEY: "<your BrowserBase API key>"
OPENAI_API_KEY: "<your OpenAI API key>"
RESEND_API_KEY: "<your Resend API key>"
```

### Task code

```ts trigger/scrape-hacker-news.ts theme={"theme":"css-variables"}
import { render } from "@react-email/render";
import { logger, schedules, task, wait } from "@trigger.dev/sdk";
import { OpenAI } from "openai";
import puppeteer from "puppeteer-core";
import { Resend } from "resend";
import { HNSummaryEmail } from "./summarize-hn-email";

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const resend = new Resend(process.env.RESEND_API_KEY);

// Parent task (scheduled to run 9AM every weekday)
export const summarizeHackerNews = schedules.task({
  id: "summarize-hacker-news",
  cron: {
    pattern: "0 9 * * 1-5",
    timezone: "Europe/London",
  }, // Run at 9 AM, Monday to Friday
  run: async () => {
    // Connect to BrowserBase to proxy the scraping of the Hacker News articles
    const browser = await puppeteer.connect({
      browserWSEndpoint: `wss://connect.browserbase.com?apiKey=${process.env.BROWSERBASE_API_KEY}`,
    });
    logger.info("Connected to Browserbase");

    const page = await browser.newPage();

    // Navigate to Hacker News and scrape top 3 articles
    await page.goto("https://news.ycombinator.com/news", {
      waitUntil: "networkidle0",
    });
    logger.info("Navigated to Hacker News");

    const articles = await page.evaluate(() => {
      const items = document.querySelectorAll(".athing");
      return Array.from(items)
        .slice(0, 3)
        .map((item) => {
          const titleElement = item.querySelector(".titleline > a");
          const link = titleElement?.getAttribute("href");
          const title = titleElement?.textContent;
          return { title, link };
        });
    });
    logger.info("Scraped top 3 articles", { articles });

    await browser.close();
    await wait.for({ seconds: 5 });

    // Use batchTriggerAndWait to process articles
    const summaries = await scrapeAndSummarizeArticle
      .batchTriggerAndWait(
        articles.map((article) => ({
          payload: { title: article.title!, link: article.link! },
        }))
      )
      .then((batch) => batch.runs.filter((run) => run.ok).map((run) => run.output));

    // Send email using Resend
    await resend.emails.send({
      from: "Hacker News Summary <hi@demo.tgr.dev>",
      to: ["james@trigger.dev"],
      subject: "Your morning HN summary",
      html: render(<HNSummaryEmail articles={summaries} />),
    });

    logger.info("Email sent successfully");
  },
});

// Child task for scraping and summarizing individual articles
export const scrapeAndSummarizeArticle = task({
  id: "scrape-and-summarize-articles",
  retry: {
    maxAttempts: 3,
    minTimeoutInMs: 5000,
    maxTimeoutInMs: 10000,
    factor: 2,
    randomize: true,
  },
  run: async ({ title, link }: { title: string; link: string }) => {
    logger.info(`Summarizing ${title}`);

    const browser = await puppeteer.connect({
      browserWSEndpoint: `wss://connect.browserbase.com?apiKey=${process.env.BROWSERBASE_API_KEY}`,
    });
    const page = await browser.newPage();

    // Prevent all assets from loading, images, stylesheets etc
    await page.setRequestInterception(true);
    page.on("request", (request) => {
      if (["script", "stylesheet", "image", "media", "font"].includes(request.resourceType())) {
        request.abort();
      } else {
        request.continue();
      }
    });

    await page.goto(link, { waitUntil: "networkidle0" });
    logger.info(`Navigated to article: ${title}`);

    // Extract the main content of the article
    const content = await page.evaluate(() => {
      const articleElement = document.querySelector("article") || document.body;
      return articleElement.innerText.trim().slice(0, 1500); // Limit to 1500 characters
    });

    await browser.close();

    logger.info(`Extracted content for article: ${title}`, { content });

    // Summarize the content using ChatGPT
    const response = await openai.chat.completions.create({
      model: "gpt-4o",
      messages: [
        {
          role: "user",
          content: `Summarize this article in 2-3 concise sentences:\n\n${content}`,
        },
      ],
    });

    logger.info(`Generated summary for article: ${title}`);

    return {
      title,
      link,
      summary: response.choices[0].message.content,
    };
  },
});
```

## Create your email template using React Email

To prevent the main example from becoming too cluttered, we'll create a separate file for our email template. It's formatted using [React Email](https://react.email/docs/introduction) components so you'll need to install the package to use it.

Notice how this file is imported into the main task code and passed to Resend to send the email.

```tsx summarize-hn-email.tsx theme={"theme":"css-variables"}
import { Html, Head, Body, Container, Section, Heading, Text, Link } from "@react-email/components";

interface Article {
  title: string;
  link: string;
  summary: string | null;
}

export const HNSummaryEmail: React.FC<{ articles: Article[] }> = ({ articles }) => (
  <Html>
    <Head />
    <Body style={{ fontFamily: "Arial, sans-serif", padding: "20px" }}>
      <Container>
        <Heading as="h1">Your Morning HN Summary</Heading>
        {articles.map((article, index) => (
          <Section key={index} style={{ marginBottom: "20px" }}>
            <Heading as="h3">
              <Link href={article.link}>{article.title}</Link>
            </Heading>
            <Text>{article.summary || "No summary available"}</Text>
          </Section>
        ))}
      </Container>
    </Body>
  </Html>
);
```

## Local development

To test this example task locally, be sure to install any packages from the build extensions you added to your `trigger.config.ts` file to your local machine. In this case, you need to install .

## Testing your task

To test this task in the dashboard, use the Test page and set the schedule date to "Now" to ensure the task triggers immediately. Then click "Run test" and wait for the task to complete.


# Track errors with Sentry
Source: https://trigger.dev/docs/guides/examples/sentry-error-tracking

This example demonstrates how to track errors with Sentry using Trigger.dev.

## Overview

Automatically send errors and source maps to your Sentry project from your Trigger.dev tasks. Sending source maps to Sentry allows for more detailed stack traces when errors occur, as Sentry can map the minified code back to the original source code.

## Prerequisites

* A [Sentry](https://sentry.io) account and project
* A [Trigger.dev](https://trigger.dev) account and project

## Setup

This setup involves two files:

1. **`trigger.config.ts`** - Configures the build to upload source maps to Sentry during deployment
2. **`trigger/init.ts`** - Initializes Sentry and registers the error tracking hook at runtime

<Note>
  You will need to set the `SENTRY_AUTH_TOKEN` and `SENTRY_DSN` environment variables. You can find
  the `SENTRY_AUTH_TOKEN` in your Sentry dashboard, in settings -> developer settings -> auth tokens
  and the `SENTRY_DSN` in your Sentry dashboard, in settings -> projects -> your project -> client
  keys (DSN). Add these to your `.env` file, and in your [Trigger.dev
  dashboard](https://cloud.trigger.dev), under environment variables in your project's sidebar.
</Note>

### Build configuration

Add this build configuration to your `trigger.config.ts` file. This uses the Sentry esbuild plugin to upload source maps every time you deploy your project.

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { esbuildPlugin } from "@trigger.dev/build/extensions";
import { sentryEsbuildPlugin } from "@sentry/esbuild-plugin";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [
      esbuildPlugin(
        sentryEsbuildPlugin({
          org: "<your-sentry-org>",
          project: "<your-sentry-project>",
          // Find this auth token in settings -> developer settings -> auth tokens
          authToken: process.env.SENTRY_AUTH_TOKEN,
        }),
        { placement: "last", target: "deploy" }
      ),
    ],
  },
});
```

<Note>
  [Build extensions](/docs/config/extensions/overview) allow you to hook into the build system and
  customize the build process or the resulting bundle and container image (in the case of
  deploying). You can use pre-built extensions or create your own.
</Note>

### Bun runtime

If you are using the Bun runtime, esbuild's bundling of `@sentry/node`'s CJS entry can cause a runtime error in the local dev environment. Add the following extension to mark `@sentry/node` as external in dev only:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { esbuildPlugin } from "@trigger.dev/build/extensions";
import { sentryEsbuildPlugin } from "@sentry/esbuild-plugin";

export default defineConfig({
  project: "<project ref>",
  runtime: "bun",
  build: {
    extensions: [
      {
        name: "sentry-external-dev",
        externalsForTarget: (target) => (target === "dev" ? ["@sentry/node"] : []),
      },
      esbuildPlugin(
        sentryEsbuildPlugin({
          org: "<your-sentry-org>",
          project: "<your-sentry-project>",
          authToken: process.env.SENTRY_AUTH_TOKEN,
        }),
        { placement: "last", target: "deploy" }
      ),
    ],
  },
});
```

This lets Bun resolve `@sentry/node` directly from `node_modules` during dev, while still bundling it normally for deployment.

### Runtime initialization

Create a `trigger/init.ts` file to initialize Sentry and register the global `onFailure` hook. This file is automatically loaded when your tasks execute.

```ts trigger/init.ts theme={"theme":"css-variables"}
import { tasks } from "@trigger.dev/sdk";
import * as Sentry from "@sentry/node";

// Initialize Sentry
Sentry.init({
  defaultIntegrations: false,
  // The Data Source Name (DSN) is a unique identifier for your Sentry project.
  dsn: process.env.SENTRY_DSN,
  // Update this to match the environment you want to track errors for
  environment: process.env.NODE_ENV === "production" ? "production" : "development",
});

// Register a global onFailure hook to capture errors
tasks.onFailure(({ payload, error, ctx }) => {
  Sentry.captureException(error, {
    extra: {
      payload,
      ctx,
    },
  });
});
```

<Note>
  Learn more about [global lifecycle hooks](/docs/tasks/overview#global-lifecycle-hooks) and the
  [`init.ts` file](/docs/tasks/overview#init-ts).
</Note>

## Testing that errors are being sent to Sentry

To test that errors are being sent to Sentry, you need to create a task that will fail.

This task takes no payload, and will throw an error.

```ts trigger/sentry-error-test.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

export const sentryErrorTest = task({
  id: "sentry-error-test",
  retry: {
    // Only retry once
    maxAttempts: 1,
  },
  run: async () => {
    const error = new Error("This is a custom error that Sentry will capture");
    error.cause = { additionalContext: "This is additional context" };
    throw error;
  },
});
```

After creating the task, deploy your project.

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest deploy
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest deploy
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest deploy
  ```
</CodeGroup>

Once deployed, open the `sentry-error-test` task in your [Trigger.dev dashboard](https://cloud.trigger.dev) (make sure you're in your `prod` environment) and press the "Test" button to open the test page for it.

Run a test task with an empty payload by clicking the `Run test` button.

Your run should then fail, and if everything is set up correctly, you will see an error in the Sentry project dashboard shortly after.


# Process images using Sharp
Source: https://trigger.dev/docs/guides/examples/sharp-image-processing

This example demonstrates how to process images using the Sharp library with Trigger.dev.

## Overview

This task processes and watermarks an image using the Sharp library, and then uploads it to R2 storage.

## Prerequisites

* A project with [Trigger.dev initialized](/docs/quick-start)
* The [Sharp](https://sharp.pixelplumbing.com/install) library installed on your machine
* An R2-compatible object storage service, such as [Cloudflare R2](https://developers.cloudflare.com/r2)

## Adding the build configuration

To use this example, you'll first need to add these build settings to your `trigger.config.ts` file:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    // This is required to use the Sharp library
    external: ["sharp"],
  },
});
```

<Note>
  Any packages that install or build a native binary should be added to external, as native binaries
  cannot be bundled.
</Note>

## Key features

* Resizes a JPEG image to 800x800 pixels
* Adds a watermark to the image, positioned in the bottom-right corner, using a PNG image
* Uploads the processed image to R2 storage

## Task code

```ts trigger/sharp-image-processing.ts theme={"theme":"css-variables"}
import { S3Client } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { logger, task } from "@trigger.dev/sdk";
import fs from "fs/promises";
import os from "os";
import path from "path";
import sharp from "sharp";

// Initialize R2 client using your R2 account details
const r2Client = new S3Client({
  region: "auto",
  endpoint: process.env.R2_ENDPOINT,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
  },
});

export const sharpProcessImage = task({
  id: "sharp-process-image",
  retry: { maxAttempts: 1 },
  run: async (payload: { imageUrl: string; watermarkUrl: string }) => {
    const { imageUrl, watermarkUrl } = payload;
    const outputPath = path.join(os.tmpdir(), `output_${Date.now()}.jpg`);

    const [imageResponse, watermarkResponse] = await Promise.all([
      fetch(imageUrl),
      fetch(watermarkUrl),
    ]);
    const imageBuffer = await imageResponse.arrayBuffer();
    const watermarkBuffer = await watermarkResponse.arrayBuffer();

    await sharp(Buffer.from(imageBuffer))
      .resize(800, 800) // Resize the image to 800x800px
      .composite([
        {
          input: Buffer.from(watermarkBuffer),
          gravity: "southeast", // Position the watermark in the bottom-right corner
        },
      ])
      .jpeg() // Convert to jpeg
      .toBuffer() // Convert to buffer
      .then(async (outputBuffer) => {
        await fs.writeFile(outputPath, outputBuffer); // Write the buffer to file

        const r2Key = `processed-images/${path.basename(outputPath)}`;
        const uploadParams = {
          Bucket: process.env.R2_BUCKET,
          Key: r2Key,
          Body: await fs.readFile(outputPath),
        };

        const upload = new Upload({
          client: r2Client,
          params: uploadParams,
        });

        await upload.done();
        logger.log("Image uploaded to R2 storage.", {
          path: `/${process.env.R2_BUCKET}/${r2Key}`,
        });

        await fs.unlink(outputPath); // Clean up the temporary file
        return { r2Key };
      });
  },
});
```

## Testing your task

To test this task in the dashboard, you can use the following payload:

```json theme={"theme":"css-variables"}
{
  "imageUrl": "<an-image-url.jpg>", // Replace with a URL to a JPEG image
  "watermarkUrl": "<an-image-url.png>" // Replace with a URL to a PNG watermark image
}
```

## Local development

To test this example task locally, be sure to install any packages from the build extensions you added to your `trigger.config.ts` file to your local machine. In this case, you need to install .


# Trigger a task from Stripe webhook events
Source: https://trigger.dev/docs/guides/examples/stripe-webhook

This example demonstrates how to handle Stripe webhook events using Trigger.dev.

## Overview

This example shows how to set up a webhook handler in your existing app for incoming Stripe events. The handler triggers a task when a `checkout.session.completed` event is received. This is easily customisable to handle other Stripe events.

## Key features

* Shows how to create a Stripe webhook handler in your app
* Triggers a task from your backend when a `checkout.session.completed` event is received

## Environment variables

You'll need to configure the following environment variables for this example to work:

* `STRIPE_WEBHOOK_SECRET` The secret key used to verify the Stripe webhook signature.
* `TRIGGER_API_URL` Your Trigger.dev API url: `https://api.trigger.dev`
* `TRIGGER_SECRET_KEY` Your Trigger.dev secret key

## Setting up the Stripe webhook handler

First you'll need to create a [Stripe webhook](https://stripe.com/docs/webhooks) handler route that listens for POST requests and verifies the Stripe signature.

Here are examples of how you can set up a handler using different frameworks:

<CodeGroup>
  ```ts Next.js theme={"theme":"css-variables"}
  // app/api/stripe-webhook/route.ts
  import { NextResponse } from "next/server";
  import { tasks } from "@trigger.dev/sdk";
  import Stripe from "stripe";
  import type { stripeCheckoutCompleted } from "@/trigger/stripe-checkout-completed";
  //     👆 **type-only** import

  export async function POST(request: Request) {
    const signature = request.headers.get("stripe-signature");
    const payload = await request.text();

    if (!signature || !payload) {
      return NextResponse.json(
        { error: "Invalid Stripe payload/signature" },
        {
          status: 400,
        }
      );
    }

    const event = Stripe.webhooks.constructEvent(
      payload,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET as string
    );

    // Perform the check based on the event type
    switch (event.type) {
      case "checkout.session.completed": {
        // Trigger the task only if the event type is "checkout.session.completed"
        const { id } = await tasks.trigger<typeof stripeCheckoutCompleted>(
          "stripe-checkout-completed",
          event.data.object
        );
        return NextResponse.json({ runId: id });
      }
      default: {
        // Return a response indicating that the event is not handled
        return NextResponse.json(
          { message: "Event not handled" },
          {
            status: 200,
          }
        );
      }
    }
  }
  ```

  ```ts Remix theme={"theme":"css-variables"}
  // app/webhooks.stripe.ts
  import { type ActionFunctionArgs, json } from "@remix-run/node";
  import type { stripeCheckoutCompleted } from "src/trigger/stripe-webhook";
  //     👆 **type-only** import
  import { tasks } from "@trigger.dev/sdk";
  import Stripe from "stripe";

  export async function action({ request }: ActionFunctionArgs) {
    // Validate the Stripe webhook payload
    const signature = request.headers.get("stripe-signature");
    const payload = await request.text();

    if (!signature || !payload) {
      return json({ error: "Invalid Stripe payload/signature" }, { status: 400 });
    }

    const event = Stripe.webhooks.constructEvent(
      payload,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET as string
    );

    // Perform the check based on the event type
    switch (event.type) {
      case "checkout.session.completed": {
        // Trigger the task only if the event type is "checkout.session.completed"
        const { id } = await tasks.trigger<typeof stripeCheckoutCompleted>(
          "stripe-checkout-completed",
          event.data.object
        );
        return json({ runId: id });
      }
      default: {
        // Return a response indicating that the event is not handled
        return json({ message: "Event not handled" }, { status: 200 });
      }
    }
  }
  ```
</CodeGroup>

## Task code

This task is triggered when a `checkout.session.completed` event is received from Stripe.

```ts trigger/stripe-checkout-completed.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import type stripe from "stripe";

export const stripeCheckoutCompleted = task({
  id: "stripe-checkout-completed",
  run: async (payload: stripe.Checkout.Session) => {
    // Add your custom logic for handling the checkout.session.completed event here
  },
});
```

## Testing your task locally

To test everything is working you can use the Stripe CLI to send test events to your endpoint:

1. Install the [Stripe CLI](https://stripe.com/docs/stripe-cli#install), and login
2. Follow the instructions to [test your handler](https://docs.stripe.com/webhooks#test-webhook). This will include a temporary `STRIPE_WEBHOOK_SECRET` that you can use for testing.
3. When triggering the event, use the `checkout.session.completed` event type. With the Stripe CLI: `stripe trigger checkout.session.completed`
4. If your endpoint is set up correctly, you should see the Stripe events logged in your console with a status of `200`.
5. Then, check the [Trigger.dev](https://cloud.trigger.dev) dashboard and you should see the successful run of the `stripe-webhook` task.

For more information on setting up and testing Stripe webhooks, refer to the [Stripe Webhook Documentation](https://stripe.com/docs/webhooks).


# Supabase database operations using Trigger.dev
Source: https://trigger.dev/docs/guides/examples/supabase-database-operations

These examples demonstrate how to run basic CRUD operations on a table in a Supabase database using Trigger.dev.

## Add a new user to a table in a Supabase database

This is a basic task which inserts a new row into a table from a Trigger.dev task.

### Key features

* Shows how to set up a Supabase client using the `@supabase/supabase-js` library
* Shows how to add a new row to a table using `insert`

### Prerequisites

* A [Supabase account](https://supabase.com/dashboard/) and a project set up
* In your Supabase project, create a table called `user_subscriptions`.
* In your `user_subscriptions` table, create a new column:
  * `user_id`, with the data type: `text`

### Task code

```ts trigger/supabase-database-insert.ts theme={"theme":"css-variables"}
import { createClient } from "@supabase/supabase-js";
import { task } from "@trigger.dev/sdk";
import jwt from "jsonwebtoken";
// Generate the Typescript types using the Supabase CLI: https://supabase.com/docs/guides/api/rest/generating-types
import { Database } from "database.types";

export const supabaseDatabaseInsert = task({
  id: "add-new-user",
  run: async (payload: { userId: string }) => {
    const { userId } = payload;

    // Get JWT secret from env vars
    const jwtSecret = process.env.SUPABASE_JWT_SECRET;
    if (!jwtSecret) {
      throw new Error("SUPABASE_JWT_SECRET is not defined in environment variables");
    }

    // Create JWT token for the user
    const token = jwt.sign({ sub: userId }, jwtSecret, { expiresIn: "1h" });

    // Initialize Supabase client with JWT
    const supabase = createClient<Database>(
      process.env.SUPABASE_URL as string,
      process.env.SUPABASE_ANON_KEY as string,
      {
        global: {
          headers: {
            Authorization: `Bearer ${token}`,
          },
        },
      }
    );

    // Insert a new row into the user_subscriptions table with the provided userId
    const { error } = await supabase.from("user_subscriptions").insert({
      user_id: userId,
    });

    // If there was an error inserting the new user, throw an error
    if (error) {
      throw new Error(`Failed to insert new user: ${error.message}`);
    }

    return {
      message: `New user added successfully: ${userId}`,
    };
  },
});
```

<Note>
  To learn more about how to properly configure Supabase auth for Trigger.dev tasks, please refer to
  our [Supabase Authentication guide](/docs/guides/frameworks/supabase-authentication). It demonstrates
  how to use JWT authentication for user-specific operations or your service role key for
  admin-level access.
</Note>

### Testing your task

To test this task in the [Trigger.dev dashboard](https://cloud.trigger.dev), you can use the following payload:

```json theme={"theme":"css-variables"}
{
  "userId": "user_12345"
}
```

If the task completes successfully, you will see a new row in your `user_subscriptions` table with the `user_id` set to `user_12345`.

## Update a user's subscription on a table in a Supabase database

This task shows how to update a user's subscription on a table. It checks if the user already has a subscription and either inserts a new row or updates an existing row with the new plan.

This type of task is useful for managing user subscriptions, updating user details, or performing other operations you might need to do on a database table.

### Key features

* Shows how to set up a Supabase client using the `@supabase/supabase-js` library
* Adds a new row to the table if the user doesn't exist using `insert`
* Checks if the user already has a plan, and if they do updates the existing row using `update`
* Demonstrates how to use [AbortTaskRunError](https://trigger.dev/docs/errors-retrying#using-aborttaskrunerror) to stop the task run without retrying if an invalid plan type is provided

### Prerequisites

* A [Supabase account](https://supabase.com/dashboard/) and a project set up
* In your Supabase project, create a table called `user_subscriptions` (if you haven't already)
* In your `user_subscriptions` table, create these columns (if they don't already exist):

  * `user_id`, with the data type: `text`
  * `plan`, with the data type: `text`
  * `updated_at`, with the data type: `timestamptz`

### Task code

```ts trigger/supabase-update-user-subscription.ts theme={"theme":"css-variables"}
import { createClient } from "@supabase/supabase-js";
import { AbortTaskRunError, task } from "@trigger.dev/sdk";
// Generate the Typescript types using the Supabase CLI: https://supabase.com/docs/guides/api/rest/generating-types
import { Database } from "database.types";

// Define the allowed plan types
type PlanType = "hobby" | "pro" | "enterprise";

// Create a single Supabase client for interacting with your database
// 'Database' supplies the type definitions to supabase-js
const supabase = createClient<Database>(
  // These details can be found in your Supabase project settings under `API`
  process.env.SUPABASE_PROJECT_URL as string, // e.g. https://abc123.supabase.co - replace 'abc123' with your project ID
  process.env.SUPABASE_SERVICE_ROLE_KEY as string // Your service role secret key
);

export const supabaseUpdateUserSubscription = task({
  id: "update-user-subscription",
  run: async (payload: { userId: string; newPlan: PlanType }) => {
    const { userId, newPlan } = payload;

    // Abort the task run without retrying if the new plan type is invalid
    if (!["hobby", "pro", "enterprise"].includes(newPlan)) {
      throw new AbortTaskRunError(
        `Invalid plan type: ${newPlan}. Allowed types are 'hobby', 'pro', or 'enterprise'.`
      );
    }

    // Query the user_subscriptions table to check if the user already has a subscription
    const { data: existingSubscriptions } = await supabase
      .from("user_subscriptions")
      .select("user_id")
      .eq("user_id", userId);

    if (!existingSubscriptions || existingSubscriptions.length === 0) {
      // If there are no existing users with the provided userId and plan, insert a new row
      const { error: insertError } = await supabase.from("user_subscriptions").insert({
        user_id: userId,
        plan: newPlan,
        updated_at: new Date().toISOString(),
      });

      // If there was an error inserting the new subscription, throw an error
      if (insertError) {
        throw new Error(`Failed to insert user subscription: ${insertError.message}`);
      }
    } else {
      // If the user already has a subscription, update their existing row
      const { error: updateError } = await supabase
        .from("user_subscriptions")
        // Set the plan to the new plan and update the timestamp
        .update({ plan: newPlan, updated_at: new Date().toISOString() })
        .eq("user_id", userId);

      // If there was an error updating the subscription, throw an error
      if (updateError) {
        throw new Error(`Failed to update user subscription: ${updateError.message}`);
      }
    }

    // Return an object with the userId and newPlan
    return {
      userId,
      newPlan,
    };
  },
});
```

<Note>
  This task uses your service role secret key to bypass Row Level Security. There are different ways
  of configuring your [RLS
  policies](https://supabase.com/docs/guides/database/postgres/row-level-security), so always make
  sure you have the correct permissions set up for your project.
</Note>

### Testing your task

To test this task in the [Trigger.dev dashboard](https://cloud.trigger.dev), you can use the following payload:

```json theme={"theme":"css-variables"}
{
  "userId": "user_12345",
  "newPlan": "pro"
}
```

If the task completes successfully, you will see a new row in your `user_subscriptions` table with the `user_id` set to `user_12345`, the `plan` set to `pro`, and the `updated_at` timestamp updated to the current time.

## Learn more about Supabase and Trigger.dev

### Full walkthrough guides from development to deployment

<CardGroup>
  <Card title="Edge function hello world guide" icon="book" href="/guides/frameworks/supabase-edge-functions-basic">
    Learn how to trigger a task from a Supabase edge function when a URL is visited.
  </Card>

  <Card title="Database webhooks guide" icon="book" href="/guides/frameworks/supabase-edge-functions-database-webhooks">
    Learn how to trigger a task from a Supabase edge function when an event occurs in your database.
  </Card>

  <Card title="Supabase authentication guide" icon="book" href="/guides/frameworks/supabase-authentication">
    Learn how to authenticate Supabase tasks using JWTs for Row Level Security (RLS) or service role
    keys for admin access.
  </Card>
</CardGroup>

### Task examples with code you can copy and paste

<CardGroup>
  <Card title="Supabase database operations" icon="bolt" href="/guides/examples/supabase-database-operations">
    Run basic CRUD operations on a table in a Supabase database using Trigger.dev.
  </Card>

  <Card title="Supabase Storage upload" icon="bolt" href="/guides/examples/supabase-storage-upload">
    Download a video from a URL and upload it to Supabase Storage using S3.
  </Card>
</CardGroup>


# Uploading files to Supabase Storage
Source: https://trigger.dev/docs/guides/examples/supabase-storage-upload

This example demonstrates how to upload files to Supabase Storage using Trigger.dev.

## Overview

This example shows how to upload a video file to Supabase Storage using two different methods.

* [Upload to Supabase Storage using the Supabase client](/docs/guides/examples/supabase-storage-upload#example-1-upload-to-supabase-storage-using-the-supabase-storage-client)
* [Upload to Supabase Storage using the AWS S3 client](/docs/guides/examples/supabase-storage-upload#example-2-upload-to-supabase-storage-using-the-aws-s3-client)

## Upload to Supabase Storage using the Supabase client

This task downloads a video from a provided URL and uploads it to Supabase Storage using the Supabase client.

### Task code

```ts trigger/supabase-storage-upload.ts theme={"theme":"css-variables"}
import { createClient } from "@supabase/supabase-js";
import { logger, task } from "@trigger.dev/sdk";
import fetch from "node-fetch";

// Initialize Supabase client
const supabase = createClient(
  process.env.SUPABASE_PROJECT_URL ?? "",
  process.env.SUPABASE_SERVICE_ROLE_KEY ?? ""
);

export const supabaseStorageUpload = task({
  id: "supabase-storage-upload",
  run: async (payload: { videoUrl: string }) => {
    const { videoUrl } = payload;

    const bucket = "my_bucket"; // Replace "my_bucket" with your bucket name
    const objectKey = `video_${Date.now()}.mp4`;

    // Download video data as a buffer
    const response = await fetch(videoUrl);

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const videoBuffer = await response.buffer();

    // Upload the video directly to Supabase Storage
    const { error } = await supabase.storage.from(bucket).upload(objectKey, videoBuffer, {
      contentType: "video/mp4",
      upsert: true,
    });

    if (error) {
      throw new Error(`Error uploading video: ${error.message}`);
    }

    logger.log(`Video uploaded to Supabase Storage bucket`, { objectKey });

    // Return the video object key and bucket
    return {
      objectKey,
      bucket: bucket,
    };
  },
});
```

### Testing your task

To test this task in the dashboard, you can use the following payload:

```json theme={"theme":"css-variables"}
{
  "videoUrl": "<a-video-url>" // Replace <a-video-url> with the URL of the video you want to upload
}
```

## Upload to Supabase Storage using the AWS S3 client

This task downloads a video from a provided URL, saves it to a temporary file, and then uploads the video file to Supabase Storage using the AWS S3 client.

### Key features

* Fetches a video from a provided URL
* Uploads the video file to Supabase Storage using S3

### Task code

```ts trigger/supabase-storage-upload-s3.ts theme={"theme":"css-variables"}
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { logger, task } from "@trigger.dev/sdk";
import fetch from "node-fetch";

// Initialize S3 client for Supabase Storage
const s3Client = new S3Client({
  region: process.env.SUPABASE_REGION, // Your Supabase project's region e.g. "us-east-1"
  endpoint: `https://${process.env.SUPABASE_PROJECT_ID}.supabase.co/storage/v1/s3`,
  credentials: {
    // These credentials can be found in your supabase storage settings, under 'S3 access keys'
    accessKeyId: process.env.SUPABASE_ACCESS_KEY_ID ?? "",
    secretAccessKey: process.env.SUPABASE_SECRET_ACCESS_KEY ?? "",
  },
});

export const supabaseStorageUploadS3 = task({
  id: "supabase-storage-upload-s3",
  run: async (payload: { videoUrl: string }) => {
    const { videoUrl } = payload;

    // Fetch the video as an ArrayBuffer
    const response = await fetch(videoUrl);
    const videoArrayBuffer = await response.arrayBuffer();
    const videoBuffer = Buffer.from(videoArrayBuffer);

    const bucket = "my_bucket"; // Replace "my_bucket" with your bucket name
    const objectKey = `video_${Date.now()}.mp4`;

    // Upload the video directly to Supabase Storage
    await s3Client.send(
      new PutObjectCommand({
        Bucket: bucket,
        Key: objectKey,
        Body: videoBuffer,
      })
    );
    logger.log(`Video uploaded to Supabase Storage bucket`, { objectKey });

    // Return the video object key
    return {
      objectKey,
      bucket: bucket,
    };
  },
});
```

<Note>
  To learn more about how to properly configure Supabase auth for Trigger.dev tasks, please refer to
  our [Supabase Authentication guide](/docs/guides/frameworks/supabase-authentication). It demonstrates
  how to use JWT authentication for user-specific operations or your service role key for
  admin-level access.
</Note>

### Testing your task

To test this task in the dashboard, you can use the following payload:

```json theme={"theme":"css-variables"}
{
  "videoUrl": "<a-video-url>" // Replace <a-video-url> with the URL of the video you want to upload
}
```

## Learn more about Supabase and Trigger.dev

### Full walkthrough guides from development to deployment

<CardGroup>
  <Card title="Edge function hello world guide" icon="book" href="/guides/frameworks/supabase-edge-functions-basic">
    Learn how to trigger a task from a Supabase edge function when a URL is visited.
  </Card>

  <Card title="Database webhooks guide" icon="book" href="/guides/frameworks/supabase-edge-functions-database-webhooks">
    Learn how to trigger a task from a Supabase edge function when an event occurs in your database.
  </Card>

  <Card title="Supabase authentication guide" icon="book" href="/guides/frameworks/supabase-authentication">
    Learn how to authenticate Supabase tasks using JWTs for Row Level Security (RLS) or service role
    keys for admin access.
  </Card>
</CardGroup>

### Task examples with code you can copy and paste

<CardGroup>
  <Card title="Supabase database operations" icon="bolt" href="/guides/examples/supabase-database-operations">
    Run basic CRUD operations on a table in a Supabase database using Trigger.dev.
  </Card>

  <Card title="Supabase Storage upload" icon="bolt" href="/guides/examples/supabase-storage-upload">
    Download a video from a URL and upload it to Supabase Storage using S3.
  </Card>
</CardGroup>


# Using the Vercel AI SDK
Source: https://trigger.dev/docs/guides/examples/vercel-ai-sdk

This example demonstrates how to use the Vercel AI SDK with Trigger.dev.

## Overview

The [Vercel AI SDK](https://www.npmjs.com/package/ai) is a simple way to use AI models from many different providers, including OpenAI, Microsoft Azure, Google Generative AI, Anthropic, Amazon Bedrock, Groq, Perplexity and [more](https://sdk.vercel.ai/providers/ai-sdk-providers).

It provides a consistent interface to interact with the different AI models, so you can easily switch between them without needing to change your code.

## Generate text using OpenAI

This task shows how to use the Vercel AI SDK to generate text from a prompt with OpenAI.

### Task code

```ts trigger/vercel-ai-sdk-openai.ts theme={"theme":"css-variables"}
import { logger, task } from "@trigger.dev/sdk";
import { generateText } from "ai";
// Install the package of the AI model you want to use, in this case OpenAI
import { openai } from "@ai-sdk/openai"; // Ensure OPENAI_API_KEY environment variable is set

export const openaiTask = task({
  id: "openai-text-generate",

  run: async (payload: { prompt: string }) => {
    const chatCompletion = await generateText({
      model: openai("gpt-4-turbo"),
      // Add a system message which will be included with the prompt
      system: "You are a friendly assistant!",
      // The prompt passed in from the payload
      prompt: payload.prompt,
    });

    // Log the generated text
    logger.log("chatCompletion text:" + chatCompletion.text);

    return chatCompletion;
  },
});
```

## Testing your task

To test this task in the dashboard, you can use the following payload:

```json theme={"theme":"css-variables"}
{
  "prompt": "What is the meaning of life?"
}
```

## Learn more about Next.js and Trigger.dev

### Walk-through guides from development to deployment

<CardGroup>
  <Card title="Next.js - setup guide" icon="N" href="/guides/frameworks/nextjs">
    Learn how to setup Trigger.dev with Next.js, using either the pages or app router.
  </Card>

  <Card title="Next.js - triggering tasks using webhooks" icon="N" href="/guides/frameworks/nextjs-webhooks">
    Learn how to create a webhook handler for incoming webhooks in a Next.js app, and trigger a task from it.
  </Card>
</CardGroup>

### Task examples

<CardGroup>
  <Card title="Fal.ai with Realtime in Next.js" href="/guides/examples/fal-ai-realtime">
    Generate an image from a prompt using Fal.ai and Trigger.dev Realtime.
  </Card>

  <Card title="Generate a cartoon using Fal.ai in Next.js" href="/guides/examples/fal-ai-image-to-cartoon">
    Convert an image to a cartoon using Fal.ai.
  </Card>

  <Card title="Vercel sync environment variables" icon="code" href="/guides/examples/vercel-sync-env-vars">
    Learn how to automatically sync environment variables from your Vercel projects to Trigger.dev.
  </Card>

  <Card title="Vercel AI SDK" icon="code" href="/guides/examples/vercel-ai-sdk">
    Learn how to use the Vercel AI SDK, which is a simple way to use AI models from different
    providers, including OpenAI, Anthropic, Amazon Bedrock, Groq, Perplexity etc.
  </Card>
</CardGroup>


# Syncing environment variables from your Vercel projects
Source: https://trigger.dev/docs/guides/examples/vercel-sync-env-vars

This example demonstrates how to sync environment variables from your Vercel project to Trigger.dev.

<Warning>
  **Deprecated when using the Vercel integration.** If you are using the [Vercel
  integration](/docs/vercel-integration), do not use `syncVercelEnvVars` — the integration handles env
  var syncing natively and using both together can cause env vars to be incorrectly populated.

  If you are **not** using the Vercel integration, `syncVercelEnvVars` is still supported. Continue
  with the configuration below.
</Warning>

## Build configuration

If you are not using the [Vercel integration](/docs/vercel-integration), you can sync environment variables manually by adding the `syncVercelEnvVars` build extension to your `trigger.config.ts` file. This extension will run automatically every time you deploy your Trigger.dev project.

<Note>
  You need to set the `VERCEL_ACCESS_TOKEN` and `VERCEL_PROJECT_ID` environment variables, or pass
  in the token and project ID as arguments to the `syncVercelEnvVars` build extension. If you're
  working with a team project, you'll also need to set `VERCEL_TEAM_ID`, which can be found in your
  team settings. You can find / generate the `VERCEL_ACCESS_TOKEN` in your Vercel
  [dashboard](https://vercel.com/account/settings/tokens). Make sure the scope of the token covers
  the project with the environment variables you want to sync.
</Note>

<Note>
  When running the build from a Vercel build environment (e.g., during a Vercel deployment), the
  environment variable values will be read from `process.env` instead of fetching them from the
  Vercel API. This is determined by checking if the `VERCEL` environment variable is present. The
  API is still used to determine which environment variables are configured for your project, but
  the actual values come from the local environment.
</Note>

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { syncVercelEnvVars } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    // Add the syncVercelEnvVars build extension
    extensions: [
      syncVercelEnvVars({
        // A personal access token created in your Vercel account settings
        // Used to authenticate API requests to Vercel
        // Generate at: https://vercel.com/account/tokens
        vercelAccessToken: process.env.VERCEL_ACCESS_TOKEN,
        // The unique identifier of your Vercel project
        // Found in Project Settings > General > Project ID
        projectId: process.env.VERCEL_PROJECT_ID,
        // Optional: The ID of your Vercel team
        // Only required for team projects
        // Found in Team Settings > General > Team ID
        vercelTeamId: process.env.VERCEL_TEAM_ID,
      }),
    ],
  },
});
```

<Note>
  [Build extensions](/docs/config/extensions/overview) allow you to hook into the build system and
  customize the build process or the resulting bundle and container image (in the case of
  deploying). You can use pre-built extensions or create your own.
</Note>

## Running the sync operation

To sync the environment variables, all you need to do is run our `deploy` command. You should see some output in the console indicating that the environment variables have been synced, and they should now be available in your Trigger.dev dashboard.

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest deploy
```

## Learn more about Next.js and Trigger.dev

### Walk-through guides from development to deployment

<CardGroup>
  <Card title="Next.js - setup guide" icon="N" href="/guides/frameworks/nextjs">
    Learn how to setup Trigger.dev with Next.js, using either the pages or app router.
  </Card>

  <Card title="Next.js - triggering tasks using webhooks" icon="N" href="/guides/frameworks/nextjs-webhooks">
    Learn how to create a webhook handler for incoming webhooks in a Next.js app, and trigger a task from it.
  </Card>
</CardGroup>

### Task examples

<CardGroup>
  <Card title="Fal.ai with Realtime in Next.js" href="/guides/examples/fal-ai-realtime">
    Generate an image from a prompt using Fal.ai and Trigger.dev Realtime.
  </Card>

  <Card title="Generate a cartoon using Fal.ai in Next.js" href="/guides/examples/fal-ai-image-to-cartoon">
    Convert an image to a cartoon using Fal.ai.
  </Card>

  <Card title="Vercel sync environment variables" icon="code" href="/guides/examples/vercel-sync-env-vars">
    Learn how to automatically sync environment variables from your Vercel projects to Trigger.dev.
  </Card>

  <Card title="Vercel AI SDK" icon="code" href="/guides/examples/vercel-ai-sdk">
    Learn how to use the Vercel AI SDK, which is a simple way to use AI models from different
    providers, including OpenAI, Anthropic, Amazon Bedrock, Groq, Perplexity etc.
  </Card>
</CardGroup>


# Bun guide
Source: https://trigger.dev/docs/guides/frameworks/bun

This guide will show you how to setup Trigger.dev in your existing Bun project, test an example task, and view the run.

<Warning>
  The trigger.dev CLI does not yet support Bun. So you will need to run the CLI using Node.js. Bun
  will still be used to execute your tasks, even in the `dev` environment.
</Warning>

<Note>
  **Supported Bun version:** Deployed tasks run on Bun 1.3.3. For local development, use Bun 1.3.x
  for compatibility.
</Note>

## Prerequisites

* Setup a project in&#x20;
* Ensure TypeScript is installed
* [Create a Trigger.dev account](https://cloud.trigger.dev)
* Create a new Trigger.dev project

## Known issues

* Certain OpenTelemetry instrumentation will not work with Bun, because Bun does not support Node's `register` hook. This means that some libraries that rely on this hook will not work with Bun.
* If Bun is installed via Homebrew (e.g. `/opt/homebrew/bin/bun`), you may see an `ENOENT: spawn /Users/<you>/.bun/bin/bun` error because the CLI expects Bun at the default install path. **Workaround:** create a symlink:
  ```bash theme={"theme":"css-variables"}
  mkdir -p ~/.bun/bin && ln -s $(which bun) ~/.bun/bin/bun
  ```
* Bun's WebSocket client does not handle the `101 Switching Protocols` upgrade response correctly, so connecting to a remote browser via `puppeteer.connect()` / `playwright.connectOverCDP()` (e.g. BrowserBase, Browserless) fails silently — typically with an empty `{}` `ErrorEvent`. The remote session opens and immediately drops. **Workaround:** set `runtime: "node"` in `trigger.config.ts` for tasks that connect to a remote browser.

## Initial setup

<Steps>
  <Step title="Run the CLI `init` command">
    The easiest way to get started is to use the CLI. It will add Trigger.dev to your existing project, create a `/trigger` folder and give you an example task.

    Run this command in the root of your project to get started:

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest init --runtime bun
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest init --runtime bun
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest init --runtime bun
      ```
    </CodeGroup>

    It will do a few things:

    1. Log you into the CLI if you're not already logged in.
    2. Create a `trigger.config.ts` file in the root of your project.
    3. Ask where you'd like to create the `/trigger` directory.
    4. Create the `/src/trigger` directory with an example task, `/src/trigger/example.[ts/js]`.

    Install the "Hello World" example task when prompted. We'll use this task to test the setup.
  </Step>

  <Step title="Update example.ts to use Bun">
    Open the `/src/trigger/example.ts` file and replace the contents with the following:

    ```ts example.ts theme={"theme":"css-variables"}
    import { Database } from "bun:sqlite";
    import { task } from "@trigger.dev/sdk";

    export const bunTask = task({
      id: "bun-task",
      run: async (payload: { query: string }) => {
        const db = new Database(":memory:");
        const query = db.query("select 'Hello world' as message;");
        console.log(query.get()); // => { message: "Hello world" }

        return {
          message: "Query executed",
        };
      },
    });

    ```
  </Step>

  <Step title="Run the CLI `dev` command">
    The CLI `dev` command runs a server for your tasks. It watches for changes in your `/trigger` directory and communicates with the Trigger.dev platform to register your tasks, perform runs, and send data back and forth.

    It can also update your `@trigger.dev/*` packages to prevent version mismatches and failed deploys. You will always be prompted first.

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest dev
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest dev
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest dev
      ```
    </CodeGroup>
  </Step>

  <Step title="Perform a test run using the dashboard">
    The CLI `dev` command spits out various useful URLs, including a link to the dashboard. Open it, find your Example task on the Tasks page, and press the "Test" button to open its test page.

    Most tasks have a "payload" which you enter in the JSON editor, but our example task doesn't need any input. You can also configure run options, pre-populate the form from recent runs, and save run templates.

    Press the "Run test" button.

    <img alt="Test page" />
  </Step>

  <Step title="View your run">
    Congratulations, you should see the run page which will live reload showing you the current state of the run.

    <img alt="Run page" />

    If you go back to your terminal you'll see that the dev command also shows the task status and links to the run log.

    <img alt="Terminal showing completed run" />
  </Step>
</Steps>


# Drizzle setup guide
Source: https://trigger.dev/docs/guides/frameworks/drizzle

This guide will show you how to set up Drizzle ORM with Trigger.dev

## Overview

This guide will show you how to set up [Drizzle ORM](https://orm.drizzle.team/) with Trigger.dev, test and view an example task run.

## Prerequisites

* An existing Node.js project with a `package.json` file
* Ensure TypeScript is installed
* A [PostgreSQL](https://www.postgresql.org/) database server running locally, or accessible via a connection string
* Drizzle ORM [installed and initialized](https://orm.drizzle.team/docs/get-started) in your project
* A `DATABASE_URL` environment variable set in your `.env` file, pointing to your PostgreSQL database (e.g. `postgresql://user:password@localhost:5432/dbname`)

<Tip>
  If your Postgres lives in a private AWS VPC (e.g. RDS without a public endpoint), connect it via
  [Private networking](/docs/private-networking/overview) instead of opening it to the public internet
  (Pro and Enterprise plans).
</Tip>

## Initial setup (optional)

Follow these steps if you don't already have Trigger.dev set up in your project.

<Steps>
  <Step title="Run the CLI `init` command">
    The easiest way to get started is to use the CLI. It will add Trigger.dev to your existing project, create a `/trigger` folder and give you an example task.

    Run this command in the root of your project to get started:

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest init
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest init
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest init
      ```
    </CodeGroup>

    It will do a few things:

    <Tip title="MCP Server">
      Our [Trigger.dev MCP server](/docs/mcp-introduction) gives your AI assistant direct access to Trigger.dev tools; search docs, trigger tasks, deploy projects, and monitor runs. We recommend installing it for the best developer experience.
    </Tip>

    1. Ask if you want to install the [Trigger.dev MCP server](/docs/mcp-introduction) for your AI assistant.
    2. Log you into the CLI if you're not already logged in.
    3. Ask you to select your project.
    4. Install the required SDK packages.
    5. Ask where you'd like to create the `/trigger` directory and create it with an example task.
    6. Create a `trigger.config.ts` file in the root of your project.

    Install the "Hello World" example task when prompted. We'll use this task to test the setup.
  </Step>

  <Step title="Run the CLI `dev` command">
    The CLI `dev` command runs a server for your tasks. It watches for changes in your `/trigger` directory and communicates with the Trigger.dev platform to register your tasks, perform runs, and send data back and forth.

    It can also update your `@trigger.dev/*` packages to prevent version mismatches and failed deploys. You will always be prompted first.

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest dev
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest dev
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest dev
      ```
    </CodeGroup>
  </Step>

  <Step title="Perform a test run using the dashboard">
    The CLI `dev` command spits out various useful URLs, including a link to the dashboard. Open it, find your Example task on the Tasks page, and press the "Test" button to open its test page.

    Most tasks have a "payload" which you enter in the JSON editor, but our example task doesn't need any input. You can also configure run options, pre-populate the form from recent runs, and save run templates.

    Press the "Run test" button.

    <img alt="Test page" />
  </Step>

  <Step title="View your run">
    Congratulations, you should see the run page which will live reload showing you the current state of the run.

    <img alt="Run page" />

    If you go back to your terminal you'll see that the dev command also shows the task status and links to the run log.

    <img alt="Terminal showing completed run" />
  </Step>
</Steps>

## Creating a task using Drizzle and deploying it to production

<Steps>
  <Step title="The task using Drizzle">
    First, create a new task file in your `trigger` folder.

    This is a simple task that will add a new user to your database, we will call it `drizzle-add-new-user`.

    <Note>
      For this task to work correctly, you will need to have a `users` table schema defined with Drizzle
      that includes `name`, `age` and `email` fields.
    </Note>

    ```ts /trigger/drizzle-add-new-user.ts theme={"theme":"css-variables"}
    import { eq } from "drizzle-orm";
    import { task } from "@trigger.dev/sdk";
    import { users } from "src/db/schema";
    import { drizzle } from "drizzle-orm/node-postgres";

    // Initialize Drizzle client
    const db = drizzle(process.env.DATABASE_URL!);

    export const addNewUser = task({
      id: "drizzle-add-new-user",
      run: async (payload: typeof users.$inferInsert) => {
        // Create new user
        const [user] = await db.insert(users).values(payload).returning();

        return {
          createdUser: user,
          message: "User created and updated successfully",
        };
      },
    });
    ```
  </Step>

  <Step title="Configuring the build">
    Next, in your `trigger.config.js` file, add `pg` to the `externals` array. `pg` is a non-blocking PostgreSQL client for Node.js.

    It is marked as an external to ensure that it is not bundled into the task's bundle, and instead will be installed and loaded from `node_modules` at runtime.

    ```js /trigger.config.js theme={"theme":"css-variables"}
    import { defineConfig } from "@trigger.dev/sdk";

    export default defineConfig({
      project: "<project ref>", // Your project reference
      // Your other config settings...
      build: {
        externals: ["pg"],
      },
    });
    ```
  </Step>

  <Step title="Deploying your task">
    Once the build configuration is added, you can now deploy your task using the Trigger.dev CLI.

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest deploy
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest deploy
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest deploy
      ```
    </CodeGroup>
  </Step>

  <Step title="Adding your DATABASE_URL environment variable to Trigger.dev">
    In your Trigger.dev dashboard sidebar click "Environment Variables" <Icon icon="circle-1" />, and then the "New environment variable" button <Icon icon="circle-2" />.

    <img alt="Environment variables page" />

    You can add values for your local dev environment, staging and prod. in this case we will add the `DATABASE_URL` for the production environment.

    <img
      alt="Environment variables
page"
    />
  </Step>

  <Step title="Running your task">
    To test this task, go to the 'test' page in the Trigger.dev dashboard and run the task with the following payload:

    ```json theme={"theme":"css-variables"}
    {
      "name": "<a-name>", // e.g. "John Doe"
      "age": "<an-age>", // e.g. 25
      "email": "<an-email>" // e.g. "john@doe.test"
    }
    ```

    Congratulations! You should now see a new completed run, and a new user with the credentials you provided should be added to your database.
  </Step>
</Steps>

## Useful next steps

<CardGroup>
  <Card title="Tasks overview" icon="diagram-subtask" href="/tasks/overview">
    Learn what tasks are and their options
  </Card>

  <Card title="Writing tasks" icon="pen-nib" href="/writing-tasks-introduction">
    Learn how to write your own tasks
  </Card>

  <Card title="Deploy using the CLI" icon="terminal" href="/cli-deploy-commands">
    Learn how to deploy your task manually using the CLI
  </Card>

  <Card title="Deploy using GitHub actions" icon="github" href="/github-actions">
    Learn how to deploy your task using GitHub actions
  </Card>
</CardGroup>


# Nango OAuth with Trigger.dev
Source: https://trigger.dev/docs/guides/frameworks/nango

Use Nango to authenticate API calls inside a Trigger.dev task, no token management required.

[Nango](https://www.nango.dev/) handles OAuth for 250+ APIs, storing and automatically refreshing access tokens on your behalf. This makes it a natural fit for Trigger.dev tasks that need to call third-party APIs on behalf of your users.

In this guide you'll build a task that:

1. Receives a Nango `connectionId` from your frontend
2. Fetches a fresh GitHub access token from Nango inside the task
3. Calls the GitHub API to retrieve the user's open pull requests
4. Uses Claude to summarize what's being worked on

This pattern works for any API Nango supports. Swap GitHub for HubSpot, Slack, Notion, or any other provider.

## Prerequisites

* A Next.js project with [Trigger.dev installed](/docs/guides/frameworks/nextjs)
* A [Nango](https://app.nango.dev/) account
* An [Anthropic](https://console.anthropic.com/) API key

## How it works

```mermaid theme={"theme":"css-variables"}
sequenceDiagram
  participant User
  participant Frontend
  participant API as Next.js API
  participant TD as Trigger.dev task
  participant Nango
  participant GH as GitHub API
  participant Claude

  User->>Frontend: Clicks "Analyze my PRs"
  Frontend->>API: POST /api/nango-session
  API->>Nango: POST /connect/sessions (secret key)
  Nango-->>API: session token (30 min TTL)
  API-->>Frontend: session token
  Frontend->>Nango: OAuth connect (frontend SDK + session token)
  Nango-->>Frontend: connectionId
  Frontend->>API: POST /api/analyze-prs { connectionId, repo }
  API->>TD: tasks.trigger(...)
  TD->>Nango: getConnection(connectionId)
  Nango-->>TD: access_token
  TD->>GH: GET /repos/:repo/pulls
  GH-->>TD: open pull requests
  TD->>Claude: Summarize PRs
  Claude-->>TD: Summary
```

## Step 1: Connect GitHub in Nango

<Steps>
  <Step title="Create a GitHub integration in Nango">
    1. In your [Nango dashboard](https://app.nango.dev/), go to **Integrations** and click **Set up new integration**.
    2. Search for **GitHub** and select GitHub (User OAuth).
    3. Create and add a test connection
  </Step>

  <Step title="Add the Nango frontend SDK">
    Install the Nango frontend SDK in your Next.js project:

    ```bash theme={"theme":"css-variables"}
    npm install @nangohq/frontend
    ```

    The frontend SDK requires a short-lived **connect session token** issued by your backend. Add an API route that creates the session:

    ```ts app/api/nango-session/route.ts theme={"theme":"css-variables"}
    import { NextResponse } from "next/server";

    export async function POST(req: Request) {
      const { userId } = await req.json();

      if (!userId || typeof userId !== "string") {
        return NextResponse.json({ error: "Missing or invalid userId" }, { status: 400 });
      }

      const response = await fetch("https://api.nango.dev/connect/sessions", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.NANGO_SECRET_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          end_user: { id: userId },
        }),
      });

      if (!response.ok) {
        const text = await response.text();
        console.error("Nango error:", response.status, text);
        return NextResponse.json({ error: text }, { status: response.status });
      }

      const { data } = await response.json();
      return NextResponse.json({ token: data.token });
    }
    ```

    Then add a connect button to your UI that fetches the token and opens the Nango OAuth flow:

    ```tsx app/page.tsx theme={"theme":"css-variables"}
    "use client";

    import Nango from "@nangohq/frontend";

    export default function Page() {
      async function connectGitHub() {
        // Get a short-lived session token from your backend
        const sessionRes = await fetch("/api/nango-session", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ userId: "user_123" }), // replace with your actual user ID
        });
        const { token } = await sessionRes.json();

        const nango = new Nango({ connectSessionToken: token });
        // Use the exact integration slug from your Nango dashboard
        const result = await nango.auth("<your-integration-slug>");

        // result.connectionId is what you pass to your task
        await fetch("/api/analyze-prs", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            connectionId: result.connectionId,
            repo: "triggerdotdev/trigger.dev",
          }),
        });
      }

      return <button onClick={connectGitHub}>Analyze my PRs</button>;
    }
    ```
  </Step>
</Steps>

## Step 2: Create the Trigger.dev task

Install the required packages:

```bash theme={"theme":"css-variables"}
npm install @nangohq/node @anthropic-ai/sdk
```

Create the task:

<CodeGroup>
  ```ts trigger/analyze-prs.ts theme={"theme":"css-variables"}
  import { task } from "@trigger.dev/sdk";
  import { Nango } from "@nangohq/node";
  import Anthropic from "@anthropic-ai/sdk";

  const nango = new Nango({ secretKey: process.env.NANGO_SECRET_KEY! });
  const anthropic = new Anthropic();

  export const analyzePRs = task({
    id: "analyze-prs",
    run: async (payload: { connectionId: string; repo: string }) => {
      const { connectionId, repo } = payload;

      // Fetch a fresh access token from Nango. It handles refresh automatically.
      // Use the exact integration slug from your Nango dashboard, e.g. "github-getting-started"
      const connection = await nango.getConnection("<your-integration-slug>", connectionId);

      if (connection.credentials.type !== "OAUTH2") {
        throw new Error(`Unexpected credentials type: ${connection.credentials.type}`);
      }

      const accessToken = connection.credentials.access_token;
      // Call the GitHub API on behalf of the user
      const response = await fetch(
        `https://api.github.com/repos/${repo}/pulls?state=open&per_page=20`,
        {
          headers: {
            Authorization: `Bearer ${accessToken}`,
            Accept: "application/vnd.github.v3+json",
          },
        }
      );

      if (!response.ok) {
        throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
      }

      const prs = await response.json();

      if (prs.length === 0) {
        return { summary: "No open pull requests found.", prCount: 0 };
      }

      // Use Claude to summarize what's being worked on
      const prList = prs
        .map(
          (pr: { number: number; title: string; user: { login: string }; body: string | null }) =>
            `#${pr.number} by @${pr.user.login}: ${pr.title}\n${pr.body?.slice(0, 200) ?? ""}`
        )
        .join("\n\n");

      const message = await anthropic.messages.create({
        model: "claude-opus-4-6",
        max_tokens: 1024,
        messages: [
          {
            role: "user",
            content: `Here are the open pull requests for ${repo}. Give a concise summary of what's being worked on, grouped by theme where possible.\n\n${prList}`,
          },
        ],
      });

      const summary = message.content[0].type === "text" ? message.content[0].text : "";

      return { summary, prCount: prs.length };
    },
  });
  ```
</CodeGroup>

## Step 3: Create the API route

Add a route handler that receives the `connectionId` from your frontend and triggers the task:

```ts app/api/analyze-prs/route.ts theme={"theme":"css-variables"}
import { analyzePRs } from "@/trigger/analyze-prs";
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const { connectionId, repo } = await req.json();

  if (!connectionId || !repo) {
    return NextResponse.json({ error: "Missing connectionId or repo" }, { status: 400 });
  }

  const handle = await analyzePRs.trigger({ connectionId, repo });

  return NextResponse.json(handle);
}
```

## Step 4: Set environment variables

Add the following to your `.env.local` file:

```bash theme={"theme":"css-variables"}
NANGO_SECRET_KEY=      # From Nango dashboard → Environment → Secret key
TRIGGER_SECRET_KEY=    # From Trigger.dev dashboard → API keys
ANTHROPIC_API_KEY=     # From Anthropic console
```

Add `NANGO_SECRET_KEY` and `ANTHROPIC_API_KEY` as [environment variables](/docs/deploy-environment-variables) in your Trigger.dev project too. These are used inside the task at runtime.

## Test it

<Steps>
  <Step title="Start your dev servers">
    ```bash theme={"theme":"css-variables"}
    npm run dev
    npx trigger.dev@latest dev
    ```
  </Step>

  <Step title="Connect GitHub and trigger the task">
    Open your app, click **Analyze my PRs**, and complete the GitHub OAuth flow. The task will be triggered automatically.
  </Step>

  <Step title="Watch the run in Trigger.dev">
    Open your [Trigger.dev dashboard](https://cloud.trigger.dev/) and navigate to **Runs** to see the task execute. You'll see the PR count and Claude's summary in the output.
  </Step>
</Steps>

<Check>
  Your task is now fetching a fresh GitHub token from Nango, calling the GitHub API on behalf of the
  user, and using Claude to summarize their open PRs. No token storage or refresh logic required.
</Check>

## Next steps

* **Reuse the `connectionId`**: Once a user has connected, store their `connectionId` and pass it in future task payloads. No need to re-authenticate.
* **Add retries**: If the GitHub API returns a transient error, Trigger.dev [retries](/docs/errors-retrying) will handle it automatically.
* **Switch providers**: The same pattern works for any Nango-supported API. Change `"github"` to `"hubspot"`, `"slack"`, `"notion"`, or any other provider.
* **Stream the analysis**: Use [Trigger.dev Realtime](/docs/realtime/overview) to stream Claude's response back to your frontend as it's generated.


# Next.js setup guide
Source: https://trigger.dev/docs/guides/frameworks/nextjs

This guide will show you how to setup Trigger.dev in your existing Next.js project, test an example task, and view the run.

<Note>This guide can be followed for both App and Pages router as well as Server Actions.</Note>

## Prerequisites

* Setup a project in&#x20;
* Ensure TypeScript is installed
* [Create a Trigger.dev account](https://cloud.trigger.dev)
* Create a new Trigger.dev project

## Initial setup

<Steps>
  <Step title="Run the CLI `init` command">
    The easiest way to get started is to use the CLI. It will add Trigger.dev to your existing project, create a `/trigger` folder and give you an example task.

    Run this command in the root of your project to get started:

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest init
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest init
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest init
      ```
    </CodeGroup>

    It will do a few things:

    <Tip title="MCP Server">
      Our [Trigger.dev MCP server](/docs/mcp-introduction) gives your AI assistant direct access to Trigger.dev tools; search docs, trigger tasks, deploy projects, and monitor runs. We recommend installing it for the best developer experience.
    </Tip>

    1. Ask if you want to install the [Trigger.dev MCP server](/docs/mcp-introduction) for your AI assistant.
    2. Log you into the CLI if you're not already logged in.
    3. Ask you to select your project.
    4. Install the required SDK packages.
    5. Ask where you'd like to create the `/trigger` directory and create it with an example task.
    6. Create a `trigger.config.ts` file in the root of your project.

    Install the "Hello World" example task when prompted. We'll use this task to test the setup.
  </Step>

  <Step title="Run the CLI `dev` command">
    The CLI `dev` command runs a server for your tasks. It watches for changes in your `/trigger` directory and communicates with the Trigger.dev platform to register your tasks, perform runs, and send data back and forth.

    It can also update your `@trigger.dev/*` packages to prevent version mismatches and failed deploys. You will always be prompted first.

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest dev
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest dev
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest dev
      ```
    </CodeGroup>
  </Step>

  <Step title="Perform a test run using the dashboard">
    The CLI `dev` command spits out various useful URLs, including a link to the dashboard. Open it, find your Example task on the Tasks page, and press the "Test" button to open its test page.

    Most tasks have a "payload" which you enter in the JSON editor, but our example task doesn't need any input. You can also configure run options, pre-populate the form from recent runs, and save run templates.

    Press the "Run test" button.

    <img alt="Test page" />
  </Step>

  <Step title="View your run">
    Congratulations, you should see the run page which will live reload showing you the current state of the run.

    <img alt="Run page" />

    If you go back to your terminal you'll see that the dev command also shows the task status and links to the run log.

    <img alt="Terminal showing completed run" />
  </Step>
</Steps>

<Tip>
  Instead of running your Next.js app and Trigger.dev dev server in separate terminals, you can run them concurrently. First, add these scripts to your `package.json`:

  ```json theme={"theme":"css-variables"}
  {
    "scripts": {
      "trigger:dev": "npx trigger.dev@latest dev",
      "dev": "npx concurrently --kill-others --names \"next,trigger\" --prefix-colors \"yellow,blue\" \"next dev\" \"npm run trigger:dev\""
    }
  }
  ```

  Then, in your terminal, you can start both servers with a single command:

  ```bash theme={"theme":"css-variables"}
  npm run dev
  ```

  This will run both your Next.js app and Trigger.dev dev server in the same terminal window, with color-coded output to distinguish between them.
</Tip>

## Set your secret key locally

Set your `TRIGGER_SECRET_KEY` environment variable in your `.env.local` file if using the Next.js App router or `.env` file if using Pages router. This key is used to authenticate with Trigger.dev, so you can trigger runs from your Next.js app. Visit the API Keys page in the dashboard and select the DEV secret key.

<img alt="How to find your secret key" />

For more information on authenticating with Trigger.dev, see the [API keys page](/docs/apikeys).

## Triggering your task in Next.js

Here are the steps to trigger your task in the Next.js App and Pages router and Server Actions.

<Tabs>
  <Tab title="App Router">
    <Steps>
      <Step title="Create a Route Handler">
        Add a Route Handler by creating a `route.ts` file (or `route.js` file) in the `app/api` directory like this: `app/api/hello-world/route.ts`.
      </Step>

      <Step title="Add your task">
        Add this code to your `route.ts` file which imports your task along with `NextResponse` to handle the API route response:

        ```ts app/api/hello-world/route.ts theme={"theme":"css-variables"}
        // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
        import type { helloWorldTask } from "@/trigger/example";
        import { tasks } from "@trigger.dev/sdk";
        import { NextResponse } from "next/server";

        //tasks.trigger also works with the edge runtime
        //export const runtime = "edge";

        export async function GET() {
          const handle = await tasks.trigger<typeof helloWorldTask>(
            "hello-world",
            "James"
          );

          return NextResponse.json(handle);
        }
        ```
      </Step>

      <Step title="Trigger your task">
        Run your Next.js app:

        <CodeGroup>
          ```bash npm theme={"theme":"css-variables"}
          npm run dev
          ```

          ```bash pnpm theme={"theme":"css-variables"}
          pnpm run dev
          ```

          ```bash yarn theme={"theme":"css-variables"}
          yarn dev
          ```
        </CodeGroup>

        Run the dev server from Step 2. of the [Initial Setup](/docs/guides/frameworks/nextjs#initial-setup) section above if it's not already running:

        <CodeGroup>
          ```bash npm theme={"theme":"css-variables"}
          npx trigger.dev@latest dev
          ```

          ```bash pnpm theme={"theme":"css-variables"}
          pnpm dlx trigger.dev@latest dev
          ```

          ```bash yarn theme={"theme":"css-variables"}
          yarn dlx trigger.dev@latest dev
          ```
        </CodeGroup>

        Now visit the URL in your browser to trigger the task. Ensure the port number is the same as the one you're running your Next.js app on. For example, if you're running your Next.js app on port 3000, visit:

        ```bash theme={"theme":"css-variables"}
        http://localhost:3000/api/hello-world
        ```

        You should see the CLI log the task run with a link to view the logs in the dashboard.

        <img alt="Trigger.dev CLI showing a successful run" />

        Visit the [Trigger.dev dashboard](https://cloud.trigger.dev) to see your run.
      </Step>
    </Steps>
  </Tab>

  <Tab title="App Router (Server Actions)">
    <Steps>
      <Step title="Create an `actions.ts` file">
        Create an `actions.ts` file in the `app/api` directory and add this code which imports your `helloWorldTask()` task. Make sure to include `"use server";` at the top of the file.

        ```ts app/api/actions.ts theme={"theme":"css-variables"}
          "use server";

          import type { helloWorldTask } from "@/trigger/example";
          import { tasks } from "@trigger.dev/sdk";

          export async function myTask() {
            try {
              const handle = await tasks.trigger<typeof helloWorldTask>(
                "hello-world",
                "James"
              );

              return { handle };
            } catch (error) {
              console.error(error);
              return {
                error: "something went wrong",
              };
            }
          }
        ```
      </Step>

      <Step title="Create a button to trigger your task">
        For the purposes of this guide, we'll create a button with an `onClick` event that triggers your task. We'll add this to the `page.tsx` file so we can trigger the task by clicking the button. Make sure to import your task and include `"use client";` at the top of your file.

        ```ts app/page.tsx theme={"theme":"css-variables"}
        "use client";

        import { myTask } from "./actions";

        export default function Home() {
          return (
            <main className="flex min-h-screen flex-col items-center justify-center p-24">
              <button
                onClick={async () => {
                  await myTask();
                }}
              >
                Trigger my task
              </button>
            </main>
          );
        }
        ```
      </Step>

      <Step title="Trigger your task">
        Run your Next.js app:

        <CodeGroup>
          ```bash npm theme={"theme":"css-variables"}
          npm run dev
          ```

          ```bash pnpm theme={"theme":"css-variables"}
          pnpm run dev
          ```

          ```bash yarn theme={"theme":"css-variables"}
          yarn dev
          ```
        </CodeGroup>

        Open your app in a browser, making sure the port number is the same as the one you're running your Next.js app on. For example, if you're running your Next.js app on port 3000, visit:

        ```bash theme={"theme":"css-variables"}
        http://localhost:3000
        ```

        Run the dev server from Step 2. of the [Initial Setup](/docs/guides/frameworks/nextjs#initial-setup) section above if it's not already running:

        <CodeGroup>
          ```bash npm theme={"theme":"css-variables"}
          npx trigger.dev@latest dev
          ```

          ```bash pnpm theme={"theme":"css-variables"}
          pnpm dlx trigger.dev@latest dev
          ```

          ```bash yarn theme={"theme":"css-variables"}
          yarn dlx trigger.dev@latest dev
          ```
        </CodeGroup>

        Then click the button we created in your app to trigger the task. You should see the CLI log the task run with a link to view the logs.

        <img alt="Trigger.dev CLI showing a successful run" />

        Visit the [Trigger.dev dashboard](https://cloud.trigger.dev) to see your run.
      </Step>
    </Steps>
  </Tab>

  <Tab title="Pages Router">
    <Steps>
      <Step title="Create an API route">
        Create an API route in the `pages/api` directory. Then create a `hello-world .ts` (or `hello-world.js`) file for your task and copy this code example:

        ```ts pages/api/hello-world.ts theme={"theme":"css-variables"}
        // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
        import { helloWorldTask } from "@/trigger/example";
        import { tasks } from "@trigger.dev/sdk";
        import type { NextApiRequest, NextApiResponse } from "next";

        export default async function handler(
          req: NextApiRequest,
          res: NextApiResponse<{ id: string }>
        ) {
          const handle = await tasks.trigger<typeof helloWorldTask>(
          "hello-world",
          "James"
          );

          res.status(200).json(handle);
        }
        ```
      </Step>

      <Step title="Trigger your task">
        Run your Next.js app:

        <CodeGroup>
          ```bash npm theme={"theme":"css-variables"}
          npm run dev
          ```

          ```bash pnpm theme={"theme":"css-variables"}
          pnpm run dev
          ```

          ```bash yarn theme={"theme":"css-variables"}
          yarn dev
          ```
        </CodeGroup>

        Run the dev server from Step 2. of the [Initial Setup](/docs/guides/frameworks/nextjs#initial-setup) section above if it's not already running:

        <CodeGroup>
          ```bash npm theme={"theme":"css-variables"}
          npx trigger.dev@latest dev
          ```

          ```bash pnpm theme={"theme":"css-variables"}
          pnpm dlx trigger.dev@latest dev
          ```

          ```bash yarn theme={"theme":"css-variables"}
          yarn dlx trigger.dev@latest dev
          ```
        </CodeGroup>

        Now visit the URL in your browser to trigger the task. Ensure the port number is the same as the one you're running your Next.js app on. For example, if you're running your Next.js app on port 3000, visit:

        ```bash theme={"theme":"css-variables"}
        http://localhost:3000/api/hello-world
        ```

        You should see the CLI log the task run with a link to view the logs in the dashboard.

        <img alt="Trigger.dev CLI showing a successful run" />

        Visit the [Trigger.dev dashboard](https://cloud.trigger.dev) to see your run.
      </Step>
    </Steps>
  </Tab>
</Tabs>

## Automatically sync environment variables from your Vercel project (optional)

If you want to automatically sync environment variables from your Vercel project to Trigger.dev, you can add our `syncVercelEnvVars` build extension to your `trigger.config.ts` file.

<Note>
  You need to set the `VERCEL_ACCESS_TOKEN` and `VERCEL_PROJECT_ID` environment variables, or pass
  in the token and project ID as arguments to the `syncVercelEnvVars` build extension. If you're
  working with a team project, you'll also need to set `VERCEL_TEAM_ID`, which can be found in your
  team settings. You can find / generate the `VERCEL_ACCESS_TOKEN` in your Vercel
  [dashboard](https://vercel.com/account/settings/tokens). Make sure the scope of the token covers
  the project with the environment variables you want to sync.
</Note>

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { syncVercelEnvVars } from "@trigger.dev/build/extensions/core";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [syncVercelEnvVars()],
  },
});
```

<Note>
  For more information, see our [Vercel sync environment
  variables](/docs/guides/examples/vercel-sync-env-vars) guide.
</Note>

## Manually add your environment variables (optional)

If you have any environment variables in your tasks, be sure to add them in the dashboard so deployed code runs successfully. In Node.js, these environment variables are accessed in your code using `process.env.MY_ENV_VAR`.

In the sidebar select the "Environment Variables" page, then press the "New environment variable"
button. <img alt="Environment variables page" />

You can add values for your local dev environment, staging and prod. <img alt="Environment variables
page" />

You can also add environment variables in code by following the steps on the [Environment Variables page](/docs/deploy-environment-variables#in-your-code).

## Deploying your task to Trigger.dev

For this guide, we'll manually deploy your task by running the [CLI deploy command](/docs/cli-deploy-commands) below. Other ways to deploy are listed in the next section.

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest deploy
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest deploy
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest deploy
  ```
</CodeGroup>

### Other ways to deploy

<Tabs>
  <Tab title="GitHub Actions">
    Use GitHub Actions to automatically deploy your tasks whenever new code is pushed and when the `trigger` directory has changes in it. Follow [this guide](/docs/github-actions) to set up GitHub Actions.
  </Tab>

  <Tab title="Vercel Integration">
    We're working on adding an official [Vercel integration](/docs/vercel-integration) which you can follow the progress of [here](https://feedback.trigger.dev/p/vercel-integration-3).
  </Tab>
</Tabs>

## Troubleshooting & extra resources

### Revalidation from your Trigger.dev tasks

[Revalidation](https://vercel.com/docs/incremental-static-regeneration/quickstart#on-demand-revalidation) allows you to purge the cache for an ISR route. To revalidate an ISR route from a Trigger.dev task, you have to set up a handler for the `revalidate` event. This is an API route that you can add to your Next.js app.

This handler will run the `revalidatePath` function from Next.js, which purges the cache for the given path.

The handlers are slightly different for the App and Pages router:

#### Revalidation handler: App Router

If you are using the App router, create a new revalidation route at `app/api/revalidate/path/route.ts`:

```ts app/api/revalidate/path/route.ts theme={"theme":"css-variables"}
import { NextRequest, NextResponse } from "next/server";
import { revalidatePath } from "next/cache";

export async function POST(request: NextRequest) {
  try {
    const { path, type, secret } = await request.json();
    // Create a REVALIDATION_SECRET and set it in your environment variables
    if (secret !== process.env.REVALIDATION_SECRET) {
      return NextResponse.json({ message: "Invalid secret" }, { status: 401 });
    }

    if (!path) {
      return NextResponse.json({ message: "Path is required" }, { status: 400 });
    }

    revalidatePath(path, type);

    return NextResponse.json({ revalidated: true });
  } catch (err) {
    console.error("Error revalidating path:", err);
    return NextResponse.json({ message: "Error revalidating path" }, { status: 500 });
  }
}
```

#### Revalidation handler: Pages Router

If you are using the Pages router, create a new revalidation route at `pages/api/revalidate/path.ts`:

```ts pages/api/revalidate/path.ts theme={"theme":"css-variables"}
import type { NextApiRequest, NextApiResponse } from "next";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  try {
    if (req.method !== "POST") {
      return res.status(405).json({ message: "Method not allowed" });
    }

    const { path, secret } = req.body;

    if (secret !== process.env.REVALIDATION_SECRET) {
      return res.status(401).json({ message: "Invalid secret" });
    }

    if (!path) {
      return res.status(400).json({ message: "Path is required" });
    }

    await res.revalidate(path);

    return res.json({ revalidated: true });
  } catch (err) {
    console.error("Error revalidating path:", err);
    return res.status(500).json({ message: "Error revalidating path" });
  }
}
```

#### Revalidation task

This task takes a `path` as a payload and will revalidate the path you specify, using the handler you set up previously.

<Note>
  To run this task locally you will need to set the `REVALIDATION_SECRET` environment variable in your `.env.local` file (or `.env` file if using Pages router).

  To run this task in production, you will need to set the `REVALIDATION_SECRET` environment variable in Vercel, in your project settings, and also in your environment variables in the Trigger.dev dashboard.
</Note>

```ts trigger/revalidate-path.ts theme={"theme":"css-variables"}
import { logger, task } from "@trigger.dev/sdk";

const NEXTJS_APP_URL = process.env.NEXTJS_APP_URL; // e.g. "http://localhost:3000" or "https://my-nextjs-app.vercel.app"
const REVALIDATION_SECRET = process.env.REVALIDATION_SECRET; // Create a REVALIDATION_SECRET and set it in your environment variables

export const revalidatePath = task({
  id: "revalidate-path",
  run: async (payload: { path: string }) => {
    const { path } = payload;

    try {
      const response = await fetch(`${NEXTJS_APP_URL}/api/revalidate/path`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          path: `${NEXTJS_APP_URL}/${path}`,
          secret: REVALIDATION_SECRET,
        }),
      });

      if (response.ok) {
        logger.log("Path revalidation successful", { path });
        return { success: true };
      } else {
        logger.error("Path revalidation failed", {
          path,
          statusCode: response.status,
          statusText: response.statusText,
        });
        return {
          success: false,
          error: `Revalidation failed with status ${response.status}: ${response.statusText}`,
        };
      }
    } catch (error) {
      logger.error("Path revalidation encountered an error", {
        path,
        error: error instanceof Error ? error.message : String(error),
      });
      return {
        success: false,
        error: `Failed to revalidate path due to an unexpected error`,
      };
    }
  },
});
```

#### Testing the revalidation task

You can test your revalidation task in the Trigger.dev dashboard on the testing page, using the following payload.

```json theme={"theme":"css-variables"}
{
  "path": "<path-to-revalidate>" // e.g. "blog"
}
```

### Next.js build failing due to missing API key in GitHub CI

This issue occurs during the Next.js app build process on GitHub CI where the Trigger.dev SDK is expecting the TRIGGER\_SECRET\_KEY environment variable to be set at build time. Next.js attempts to compile routes and creates static pages, which can cause issues with SDKs that require runtime environment variables. The solution is to mark the relevant pages as dynamic to prevent Next.js from trying to make them static. You can do this by adding the following line to the route file:

```ts theme={"theme":"css-variables"}
export const dynamic = "force-dynamic";
```

### Correctly passing event handlers to React components

An issue can sometimes arise when you try to pass a function directly to the `onClick` prop. This is because the function may require specific arguments or context that are not available when the event occurs. By wrapping the function call in an arrow function, you ensure that the handler is called with the correct context and any necessary arguments. For example:

This works:

```tsx theme={"theme":"css-variables"}
<Button onClick={() => myTask()}>Trigger my task</Button>
```

Whereas this does not work:

```tsx theme={"theme":"css-variables"}
<Button onClick={myTask}>Trigger my task</Button>
```

## Realtime updates with React hooks

The `@trigger.dev/react-hooks` package lets you subscribe to task runs from your React components. Show progress bars, stream AI responses, or display run status in real time.

<CardGroup>
  <Card title="React hooks" icon="react" href="/realtime/react-hooks/overview">
    Hooks for subscribing to runs, streaming data, and triggering tasks from the frontend.
  </Card>

  <Card title="Streams" icon="wave-pulse" href="/tasks/streams">
    Pipe continuous data (like AI completions) from your tasks to the client while they run.
  </Card>
</CardGroup>

## Learn more about Next.js and Trigger.dev

### Walk-through guides from development to deployment

<CardGroup>
  <Card title="Next.js - setup guide" icon="N" href="/guides/frameworks/nextjs">
    Learn how to setup Trigger.dev with Next.js, using either the pages or app router.
  </Card>

  <Card title="Next.js - triggering tasks using webhooks" icon="N" href="/guides/frameworks/nextjs-webhooks">
    Learn how to create a webhook handler for incoming webhooks in a Next.js app, and trigger a task from it.
  </Card>
</CardGroup>

### Task examples

<CardGroup>
  <Card title="Fal.ai with Realtime in Next.js" href="/guides/examples/fal-ai-realtime">
    Generate an image from a prompt using Fal.ai and Trigger.dev Realtime.
  </Card>

  <Card title="Generate a cartoon using Fal.ai in Next.js" href="/guides/examples/fal-ai-image-to-cartoon">
    Convert an image to a cartoon using Fal.ai.
  </Card>

  <Card title="Vercel sync environment variables" icon="code" href="/guides/examples/vercel-sync-env-vars">
    Learn how to automatically sync environment variables from your Vercel projects to Trigger.dev.
  </Card>

  <Card title="Vercel AI SDK" icon="code" href="/guides/examples/vercel-ai-sdk">
    Learn how to use the Vercel AI SDK, which is a simple way to use AI models from different
    providers, including OpenAI, Anthropic, Amazon Bedrock, Groq, Perplexity etc.
  </Card>
</CardGroup>

## Useful next steps

<CardGroup>
  <Card title="Tasks overview" icon="diagram-subtask" href="/tasks/overview">
    Learn what tasks are and their options
  </Card>

  <Card title="Writing tasks" icon="pen-nib" href="/writing-tasks-introduction">
    Learn how to write your own tasks
  </Card>

  <Card title="Deploy using the CLI" icon="terminal" href="/cli-deploy-commands">
    Learn how to deploy your task manually using the CLI
  </Card>

  <Card title="Deploy using GitHub actions" icon="github" href="/github-actions">
    Learn how to deploy your task using GitHub actions
  </Card>
</CardGroup>


# Triggering tasks with webhooks in Next.js
Source: https://trigger.dev/docs/guides/frameworks/nextjs-webhooks

Learn how to trigger a task from a webhook in a Next.js app.

## Prerequisites

* [A Next.js project, set up with Trigger.dev](/docs/guides/frameworks/nextjs)
* [cURL](https://curl.se/) installed on your local machine. This will be used to send a POST request to your webhook handler.

## GitHub repo

<Card title="View the project on GitHub" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/nextjs-webhooks/my-app">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## Adding the webhook handler

The webhook handler in this guide will be an API route.

This will be different depending on whether you are using the Next.js pages router or the app router.

### Pages router: creating the webhook handler

Create a new file `pages/api/webhook-handler.ts` or `pages/api/webhook-hander.js`.

In your new file, add the following code:

```ts /pages/api/webhook-handler.ts theme={"theme":"css-variables"}
import { helloWorldTask } from "@/trigger/example";
import { tasks } from "@trigger.dev/sdk";
import type { NextApiRequest, NextApiResponse } from "next";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  // Parse the webhook payload
  const payload = req.body;

  // Trigger the helloWorldTask with the webhook data as the payload
  await tasks.trigger<typeof helloWorldTask>("hello-world", payload);

  res.status(200).json({ message: "OK" });
}
```

This code will handle the webhook payload and trigger the 'Hello World' task.

### App router: creating the webhook handler

Create a new file in the `app/api/webhook-handler/route.ts` or `app/api/webhook-handler/route.js`.

In your new file, add the following code:

```ts /app/api/webhook-handler/route.ts theme={"theme":"css-variables"}
import type { helloWorldTask } from "@/trigger/example";
import { tasks } from "@trigger.dev/sdk";
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  // Parse the webhook payload
  const payload = await req.json();

  // Trigger the helloWorldTask with the webhook data as the payload
  await tasks.trigger<typeof helloWorldTask>("hello-world", payload);

  return NextResponse.json("OK", { status: 200 });
}
```

This code will handle the webhook payload and trigger the 'Hello World' task.

## Triggering the task locally

Now that you have your webhook handler set up, you can trigger the 'Hello World' task from it. We will do this locally using cURL.

<Steps>
  <Step title="Run your Next.js app and the Trigger.dev dev server">
    First, run your Next.js app.

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npm run dev
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm run dev
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dev
      ```
    </CodeGroup>

    Then, open up a second terminal window and start the Trigger.dev dev server:

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest dev
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest dev
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest dev
      ```
    </CodeGroup>
  </Step>

  <Step title="Trigger the webhook with some dummy data">
    To send a POST request to your webhook handler, open up a terminal window on your local machine and run the following command:

    <Tip>
      If `http://localhost:3000` isn't the URL of your locally running Next.js app, replace the URL in
      the below command with that URL instead.
    </Tip>

    ```bash theme={"theme":"css-variables"}
    curl -X POST -H "Content-Type: application/json" -d '{"Name": "John Doe", "Age": "87"}' http://localhost:3000/api/webhook-handler
    ```

    This will send a POST request to your webhook handler, with a JSON payload.
  </Step>

  <Step title="Check the task ran successfully">
    After running the command, you should see a successful dev run and a 200 response in your terminals.

    If you now go to your [Trigger.dev dashboard](https://cloud.trigger.dev), you should also see a successful run for the 'Hello World' task, with the payload you sent, in this case; `{"name": "John Doe", "age": "87"}`.
  </Step>
</Steps>

## Learn more about Next.js and Trigger.dev

### Walk-through guides from development to deployment

<CardGroup>
  <Card title="Next.js - setup guide" icon="N" href="/guides/frameworks/nextjs">
    Learn how to setup Trigger.dev with Next.js, using either the pages or app router.
  </Card>

  <Card title="Next.js - triggering tasks using webhooks" icon="N" href="/guides/frameworks/nextjs-webhooks">
    Learn how to create a webhook handler for incoming webhooks in a Next.js app, and trigger a task from it.
  </Card>
</CardGroup>

### Task examples

<CardGroup>
  <Card title="Fal.ai with Realtime in Next.js" href="/guides/examples/fal-ai-realtime">
    Generate an image from a prompt using Fal.ai and Trigger.dev Realtime.
  </Card>

  <Card title="Generate a cartoon using Fal.ai in Next.js" href="/guides/examples/fal-ai-image-to-cartoon">
    Convert an image to a cartoon using Fal.ai.
  </Card>

  <Card title="Vercel sync environment variables" icon="code" href="/guides/examples/vercel-sync-env-vars">
    Learn how to automatically sync environment variables from your Vercel projects to Trigger.dev.
  </Card>

  <Card title="Vercel AI SDK" icon="code" href="/guides/examples/vercel-ai-sdk">
    Learn how to use the Vercel AI SDK, which is a simple way to use AI models from different
    providers, including OpenAI, Anthropic, Amazon Bedrock, Groq, Perplexity etc.
  </Card>
</CardGroup>


# Node.js setup guide
Source: https://trigger.dev/docs/guides/frameworks/nodejs

This guide will show you how to setup Trigger.dev in your existing Node.js project, test an example task, and view the run.

## Prerequisites

* Setup a project in&#x20;
* Ensure TypeScript is installed
* [Create a Trigger.dev account](https://cloud.trigger.dev)
* Create a new Trigger.dev project

## Initial setup

<Steps>
  <Step title="Run the CLI `init` command">
    The easiest way to get started is to use the CLI. It will add Trigger.dev to your existing project, create a `/trigger` folder and give you an example task.

    Run this command in the root of your project to get started:

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest init
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest init
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest init
      ```
    </CodeGroup>

    It will do a few things:

    <Tip title="MCP Server">
      Our [Trigger.dev MCP server](/docs/mcp-introduction) gives your AI assistant direct access to Trigger.dev tools; search docs, trigger tasks, deploy projects, and monitor runs. We recommend installing it for the best developer experience.
    </Tip>

    1. Ask if you want to install the [Trigger.dev MCP server](/docs/mcp-introduction) for your AI assistant.
    2. Log you into the CLI if you're not already logged in.
    3. Ask you to select your project.
    4. Install the required SDK packages.
    5. Ask where you'd like to create the `/trigger` directory and create it with an example task.
    6. Create a `trigger.config.ts` file in the root of your project.

    Install the "Hello World" example task when prompted. We'll use this task to test the setup.
  </Step>

  <Step title="Run the CLI `dev` command">
    The CLI `dev` command runs a server for your tasks. It watches for changes in your `/trigger` directory and communicates with the Trigger.dev platform to register your tasks, perform runs, and send data back and forth.

    It can also update your `@trigger.dev/*` packages to prevent version mismatches and failed deploys. You will always be prompted first.

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest dev
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest dev
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest dev
      ```
    </CodeGroup>
  </Step>

  <Step title="Perform a test run using the dashboard">
    The CLI `dev` command spits out various useful URLs, including a link to the dashboard. Open it, find your Example task on the Tasks page, and press the "Test" button to open its test page.

    Most tasks have a "payload" which you enter in the JSON editor, but our example task doesn't need any input. You can also configure run options, pre-populate the form from recent runs, and save run templates.

    Press the "Run test" button.

    <img alt="Test page" />
  </Step>

  <Step title="View your run">
    Congratulations, you should see the run page which will live reload showing you the current state of the run.

    <img alt="Run page" />

    If you go back to your terminal you'll see that the dev command also shows the task status and links to the run log.

    <img alt="Terminal showing completed run" />
  </Step>
</Steps>

## Useful next steps

<CardGroup>
  <Card title="Tasks overview" icon="diagram-subtask" href="/tasks/overview">
    Learn what tasks are and their options
  </Card>

  <Card title="Writing tasks" icon="pen-nib" href="/writing-tasks-introduction">
    Learn how to write your own tasks
  </Card>

  <Card title="Deploy using the CLI" icon="terminal" href="/cli-deploy-commands">
    Learn how to deploy your task manually using the CLI
  </Card>

  <Card title="Deploy using GitHub actions" icon="github" href="/github-actions">
    Learn how to deploy your task using GitHub actions
  </Card>
</CardGroup>


# Prisma setup guide
Source: https://trigger.dev/docs/guides/frameworks/prisma

This guide will show you how to set up Prisma with Trigger.dev

## Overview

This guide will show you how to set up [Prisma](https://www.prisma.io/) with Trigger.dev, test and view an example task run.

## Prerequisites

* An existing Node.js project with a `package.json` file
* Ensure TypeScript is installed
* A [PostgreSQL](https://www.postgresql.org/) database server running locally, or accessible via a connection string
* Prisma ORM [installed and initialized](https://www.prisma.io/docs/getting-started/quickstart) in your project
* A `DATABASE_URL` environment variable set in your `.env` file, pointing to your PostgreSQL database (e.g. `postgresql://user:password@localhost:5432/dbname`)

## Initial setup (optional)

Follow these steps if you don't already have Trigger.dev set up in your project.

<Steps>
  <Step title="Run the CLI `init` command">
    The easiest way to get started is to use the CLI. It will add Trigger.dev to your existing project, create a `/trigger` folder and give you an example task.

    Run this command in the root of your project to get started:

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest init
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest init
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest init
      ```
    </CodeGroup>

    It will do a few things:

    <Tip title="MCP Server">
      Our [Trigger.dev MCP server](/docs/mcp-introduction) gives your AI assistant direct access to Trigger.dev tools; search docs, trigger tasks, deploy projects, and monitor runs. We recommend installing it for the best developer experience.
    </Tip>

    1. Ask if you want to install the [Trigger.dev MCP server](/docs/mcp-introduction) for your AI assistant.
    2. Log you into the CLI if you're not already logged in.
    3. Ask you to select your project.
    4. Install the required SDK packages.
    5. Ask where you'd like to create the `/trigger` directory and create it with an example task.
    6. Create a `trigger.config.ts` file in the root of your project.

    Install the "Hello World" example task when prompted. We'll use this task to test the setup.
  </Step>

  <Step title="Run the CLI `dev` command">
    The CLI `dev` command runs a server for your tasks. It watches for changes in your `/trigger` directory and communicates with the Trigger.dev platform to register your tasks, perform runs, and send data back and forth.

    It can also update your `@trigger.dev/*` packages to prevent version mismatches and failed deploys. You will always be prompted first.

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest dev
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest dev
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest dev
      ```
    </CodeGroup>
  </Step>

  <Step title="Perform a test run using the dashboard">
    The CLI `dev` command spits out various useful URLs, including a link to the dashboard. Open it, find your Example task on the Tasks page, and press the "Test" button to open its test page.

    Most tasks have a "payload" which you enter in the JSON editor, but our example task doesn't need any input. You can also configure run options, pre-populate the form from recent runs, and save run templates.

    Press the "Run test" button.

    <img alt="Test page" />
  </Step>

  <Step title="View your run">
    Congratulations, you should see the run page which will live reload showing you the current state of the run.

    <img alt="Run page" />

    If you go back to your terminal you'll see that the dev command also shows the task status and links to the run log.

    <img alt="Terminal showing completed run" />
  </Step>
</Steps>

## Creating a task using Prisma and deploying it to production

<Steps>
  <Step title="Writing the Prisma task">
    First, create a new task file in your `trigger` folder.

    This is a simple task that will add a new user to the database.

    <Note>
      For this task to work correctly, you will need to have a `user` model in your Prisma schema with
      an `id` field, a `name` field, and an `email` field.
    </Note>

    ```ts /trigger/prisma-add-new-user.ts theme={"theme":"css-variables"}
    import { PrismaClient } from "@prisma/client";
    import { task } from "@trigger.dev/sdk";

    // Initialize Prisma client
    const prisma = new PrismaClient();

    export const addNewUser = task({
      id: "prisma-add-new-user",
      run: async (payload: { name: string; email: string; id: number }) => {
        const { name, email, id } = payload;

        // This will create a new user in the database
        const user = await prisma.user.create({
          data: {
            name: name,
            email: email,
            id: id,
          },
        });

        return {
          message: `New user added successfully: ${user.id}`,
        };
      },
    });
    ```
  </Step>

  <Step title="Configuring the build extension">
    Next, configure the Prisma [build extension](https://trigger.dev/docs/config/extensions/overview) in the `trigger.config.js` file to include the Prisma client in the build.

    This will ensure that the Prisma client is available when the task runs.

    ```js /trigger.config.js theme={"theme":"css-variables"}
    export default defineConfig({
      project: "<project ref>", // Your project reference
      // Your other config settings...
      build: {
        extensions: [
          prismaExtension({
            mode: "legacy", // required
            version: "5.20.0", // optional, we'll automatically detect the version if not provided
            schema: "prisma/schema.prisma", // update this to the path of your Prisma schema file
          }),
        ],
      },
    });
    ```

    The `prismaExtension` requires a `mode` parameter. For standard Prisma setups, use `"legacy"`
    mode. See the [Prisma extension documentation](/docs/config/extensions/prismaExtension) for other modes
    and full configuration options.

    <Note>
      [Build extensions](/docs/config/extensions/overview) allow you to hook into the build system and
      customize the build process or the resulting bundle and container image (in the case of
      deploying). You can use pre-built extensions or create your own.
    </Note>
  </Step>

  <Step title="Optional: adding Prisma instrumentation">
    We use OpenTelemetry to [instrument](https://trigger.dev/docs/config/config-file#instrumentations) our tasks and collect telemetry data.

    If you want to automatically log all Prisma queries and mutations, you can use the Prisma instrumentation extension.

    ```js /trigger.config.js theme={"theme":"css-variables"}
    import { defineConfig } from "@trigger.dev/sdk";
    import { PrismaInstrumentation } from "@prisma/instrumentation";
    import { OpenAIInstrumentation } from "@traceloop/instrumentation-openai";

    export default defineConfig({
      //..other stuff
      instrumentations: [new PrismaInstrumentation(), new OpenAIInstrumentation()],
    });
    ```

    This provides much more detailed information about your tasks with minimal effort.
  </Step>

  <Step title="Deploying your task">
    With the build extension and task configured, you can now deploy your task using the Trigger.dev CLI.

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest deploy
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest deploy
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest deploy
      ```
    </CodeGroup>
  </Step>

  <Step title="Adding your DATABASE_URL environment variable to Trigger.dev">
    In your Trigger.dev dashboard sidebar click "Environment Variables" <Icon icon="circle-1" />, and then the "New environment variable" button <Icon icon="circle-2" />.

    You can add values for your local dev environment, staging and prod. in this case we will add the `DATABASE_URL` for the production environment.

    <img
      alt="Environment variables
page"
    />
  </Step>

  <Step title="Running your task">
    To test this task, go to the 'test' page in the Trigger.dev dashboard and run the task with the following payload:

    ```json theme={"theme":"css-variables"}
    {
      "name": "<a-name>", // e.g. "John Doe"
      "email": "<a-email>", // e.g. "john@doe.test"
      "id": <a-number> // e.g. 12345
    }
    ```

    Congratulations! You should now see a new completed run, and a new user with the credentials you provided should be added to your database.
  </Step>
</Steps>

## Useful next steps

<CardGroup>
  <Card title="Tasks overview" icon="diagram-subtask" href="/tasks/overview">
    Learn what tasks are and their options
  </Card>

  <Card title="Writing tasks" icon="pen-nib" href="/writing-tasks-introduction">
    Learn how to write your own tasks
  </Card>

  <Card title="Deploy using the CLI" icon="terminal" href="/cli-deploy-commands">
    Learn how to deploy your task manually using the CLI
  </Card>

  <Card title="Deploy using GitHub actions" icon="github" href="/github-actions">
    Learn how to deploy your task using GitHub actions
  </Card>
</CardGroup>


# Remix setup guide
Source: https://trigger.dev/docs/guides/frameworks/remix

This guide will show you how to setup Trigger.dev in your existing Remix project, test an example task, and view the run.

## Prerequisites

* Setup a project in&#x20;
* Ensure TypeScript is installed
* [Create a Trigger.dev account](https://cloud.trigger.dev)
* Create a new Trigger.dev project

## Initial setup

<Steps>
  <Step title="Run the CLI `init` command">
    The easiest way to get started is to use the CLI. It will add Trigger.dev to your existing project, create a `/trigger` folder and give you an example task.

    Run this command in the root of your project to get started:

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest init
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest init
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest init
      ```
    </CodeGroup>

    It will do a few things:

    <Tip title="MCP Server">
      Our [Trigger.dev MCP server](/docs/mcp-introduction) gives your AI assistant direct access to Trigger.dev tools; search docs, trigger tasks, deploy projects, and monitor runs. We recommend installing it for the best developer experience.
    </Tip>

    1. Ask if you want to install the [Trigger.dev MCP server](/docs/mcp-introduction) for your AI assistant.
    2. Log you into the CLI if you're not already logged in.
    3. Ask you to select your project.
    4. Install the required SDK packages.
    5. Ask where you'd like to create the `/trigger` directory and create it with an example task.
    6. Create a `trigger.config.ts` file in the root of your project.

    Install the "Hello World" example task when prompted. We'll use this task to test the setup.
  </Step>

  <Step title="Run the CLI `dev` command">
    The CLI `dev` command runs a server for your tasks. It watches for changes in your `/trigger` directory and communicates with the Trigger.dev platform to register your tasks, perform runs, and send data back and forth.

    It can also update your `@trigger.dev/*` packages to prevent version mismatches and failed deploys. You will always be prompted first.

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest dev
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest dev
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest dev
      ```
    </CodeGroup>
  </Step>

  <Step title="Perform a test run using the dashboard">
    The CLI `dev` command spits out various useful URLs, including a link to the dashboard. Open it, find your Example task on the Tasks page, and press the "Test" button to open its test page.

    Most tasks have a "payload" which you enter in the JSON editor, but our example task doesn't need any input. You can also configure run options, pre-populate the form from recent runs, and save run templates.

    Press the "Run test" button.

    <img alt="Test page" />
  </Step>

  <Step title="View your run">
    Congratulations, you should see the run page which will live reload showing you the current state of the run.

    <img alt="Run page" />

    If you go back to your terminal you'll see that the dev command also shows the task status and links to the run log.

    <img alt="Terminal showing completed run" />
  </Step>
</Steps>

## Set your secret key locally

Set your `TRIGGER_SECRET_KEY` environment variable in your `.env` file. This key is used to authenticate with Trigger.dev, so you can trigger runs from your Remix app. Visit the API Keys page in the dashboard and select the DEV secret key.

<img alt="How to find your secret key" />

For more information on authenticating with Trigger.dev, see the [API keys page](/docs/apikeys).

## Triggering your task in Remix

<Steps>
  <Step title="Create an API route">
    Create a new file called `api.hello-world.ts` (or `api.hello-world.js`) in the `app/routes` directory like this: `app/routes/api.hello-world.ts`.
  </Step>

  <Step title="Add your task">
    Add this code to your `api.hello-world.ts` file which imports your task:

    ```ts app/routes/api.hello-world.ts theme={"theme":"css-variables"}
    import type { helloWorldTask } from "../../src/trigger/example";
    import { tasks } from "@trigger.dev/sdk";

    export async function loader() {
      const handle = await tasks.trigger<typeof helloWorldTask>("hello-world", "James");

      return new Response(JSON.stringify(handle), {
        headers: { "Content-Type": "application/json" },
      });
    }
    ```
  </Step>

  <Step title="Trigger your task">
    Run your Remix app:

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npm run dev
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm run dev
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dev
      ```
    </CodeGroup>

    Run the dev server from Step 2. of the [Initial Setup](/docs/guides/frameworks/remix#initial-setup) section above if it's not already running:

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest dev
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest dev
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest dev
      ```
    </CodeGroup>

    Now visit the URL in your browser to trigger the task. Ensure the port number is the same as the one you're running your Remix app on. For example, if you're running your Remix app on port 3000, visit:

    ```bash theme={"theme":"css-variables"}
    http://localhost:3000/api/trigger
    ```

    You should see the CLI log the task run with a link to view the logs in the dashboard.

    <img alt="Trigger.dev CLI showing a successful run" />

    Visit the [Trigger.dev dashboard](https://cloud.trigger.dev) to see your run.
  </Step>
</Steps>

## Manually add your environment variables (optional)

If you have any environment variables in your tasks, be sure to add them in the dashboard so deployed code runs successfully. In Node.js, these environment variables are accessed in your code using `process.env.MY_ENV_VAR`.

In the sidebar select the "Environment Variables" page, then press the "New environment variable"
button. <img alt="Environment variables page" />

You can add values for your local dev environment, staging and prod. <img alt="Environment variables
page" />

You can also add environment variables in code by following the steps on the [Environment Variables page](/docs/deploy-environment-variables#in-your-code).

## Deploying your task to Trigger.dev

For this guide, we'll manually deploy your task by running the [CLI deploy command](/docs/cli-deploy-commands) below. Other ways to deploy are listed in the next section.

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest deploy
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest deploy
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest deploy
  ```
</CodeGroup>

### Other ways to deploy

<Tabs>
  <Tab title="GitHub Actions">
    Use GitHub Actions to automatically deploy your tasks whenever new code is pushed and when the `trigger` directory has changes in it. Follow [this guide](/docs/github-actions) to set up GitHub Actions.
  </Tab>

  <Tab title="Vercel Integration">
    We're working on adding an official [Vercel integration](/docs/vercel-integration) which you can follow the progress of [here](https://feedback.trigger.dev/p/vercel-integration-3).
  </Tab>
</Tabs>

## Deploying to Vercel Edge Functions

Before we start, it's important to note that:

* We'll be using a type-only import for the task to ensure compatibility with the edge runtime.
* The `@trigger.dev/sdk` package supports the edge runtime out of the box.

There are a few extra steps to follow to deploy your `/api/hello-world` API endpoint to Vercel Edge Functions.

<Steps>
  <Step title="Update your API route">
    Update your API route to use the `runtime: "edge"` option and change it to an `action()` so we can trigger the task from a curl request later on.

    ```ts app/routes/api.hello-world.ts theme={"theme":"css-variables"}
    import { tasks } from "@trigger.dev/sdk";
    import type { helloWorldTask } from "../../src/trigger/example";
    //      👆 **type-only** import

    // include this at the top of your API route file
    export const config = {
      runtime: "edge",
    };
    export async function action({ request }: { request: Request }) {
      // This is where you'd authenticate the request
      const payload = await request.json();
      const handle = await tasks.trigger<typeof helloWorldTask>("hello-world", payload);
      return new Response(JSON.stringify(handle), {
        headers: { "Content-Type": "application/json" },
      });
    }
    ```
  </Step>

  <Step title="Update the Vercel configuration">
    Create or update the `vercel.json` file with the following:

    ```json vercel.json theme={"theme":"css-variables"}
    {
      "buildCommand": "npm run vercel-build",
      "devCommand": "npm run dev",
      "framework": "remix",
      "installCommand": "npm install",
      "outputDirectory": "build/client"
    }
    ```
  </Step>

  <Step title="Update package.json scripts">
    Update your `package.json` to include the following scripts:

    ```json package.json theme={"theme":"css-variables"}
    "scripts": {
        "build": "remix vite:build",
        "dev": "remix vite:dev",
        "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
        "start": "remix-serve ./build/server/index.js",
        "typecheck": "tsc",
        "vercel-build": "remix vite:build && cp -r ./public ./build/client"
    },
    ```
  </Step>

  <Step title="Deploy to Vercel">
    Push your code to a Git repository and create a new project in the Vercel dashboard. Select your repository and follow the prompts to complete the deployment.
  </Step>

  <Step title="Add your Vercel environment variables">
    In the Vercel project settings, add your Trigger.dev secret key:

    ```bash theme={"theme":"css-variables"}
    TRIGGER_SECRET_KEY=your-secret-key
    ```

    You can find this key in the Trigger.dev dashboard under API Keys and select the environment key you want to use.

    <img alt="How to find your secret key" />
  </Step>

  <Step title="Deploy your project">
    Once you've added the environment variable, deploy your project to Vercel.

    <Note>
      Ensure you have also deployed your Trigger.dev task. See [deploy your task
      step](/docs/guides/frameworks/remix#deploying-your-task-to-trigger-dev).
    </Note>
  </Step>

  <Step title="Test your task in production">
    After deployment, you can test your task in production by running this curl command:

    ```bash theme={"theme":"css-variables"}
    curl -X POST https://your-app.vercel.app/api/hello-world \
    -H "Content-Type: application/json" \
    -d '{"name": "James"}'
    ```

    This sends a POST request to your API endpoint with a JSON payload.
  </Step>
</Steps>

### Additional notes

The `vercel-build` script in `package.json` is specific to Remix projects on Vercel, ensuring that static assets are correctly copied to the build output.

The `runtime: "edge"` configuration in the API route allows for better performance on Vercel's Edge Network.

## Realtime updates with React hooks

The `@trigger.dev/react-hooks` package lets you subscribe to task runs from your React components. Show progress bars, stream AI responses, or display run status in real time.

<CardGroup>
  <Card title="React hooks" icon="react" href="/realtime/react-hooks/overview">
    Hooks for subscribing to runs, streaming data, and triggering tasks from the frontend.
  </Card>

  <Card title="Streams" icon="wave-pulse" href="/tasks/streams">
    Pipe continuous data (like AI completions) from your tasks to the client while they run.
  </Card>
</CardGroup>

## Additional resources for Remix

<Card title="Remix - triggering tasks using webhooks" icon="R" href="/guides/frameworks/remix-webhooks">
  How to create a webhook handler in a Remix app, and trigger a task from it.
</Card>

## Useful next steps

<CardGroup>
  <Card title="Tasks overview" icon="diagram-subtask" href="/tasks/overview">
    Learn what tasks are and their options
  </Card>

  <Card title="Writing tasks" icon="pen-nib" href="/writing-tasks-introduction">
    Learn how to write your own tasks
  </Card>

  <Card title="Deploy using the CLI" icon="terminal" href="/cli-deploy-commands">
    Learn how to deploy your task manually using the CLI
  </Card>

  <Card title="Deploy using GitHub actions" icon="github" href="/github-actions">
    Learn how to deploy your task using GitHub actions
  </Card>
</CardGroup>


# Triggering tasks with webhooks in Remix
Source: https://trigger.dev/docs/guides/frameworks/remix-webhooks

Learn how to trigger a task from a webhook in a Remix app.

## Prerequisites

* [A Remix project, set up with Trigger.dev](/docs/guides/frameworks/remix)
* [cURL](https://curl.se/) installed on your local machine. This will be used to send a POST request to your webhook handler.

## GitHub repo

<Card title="View the project on GitHub" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/remix-webhooks">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## Adding the webhook handler

The webhook handler in this guide will be an API route. Create a new file `app/routes/api.webhook-handler.ts` or `app/routes/api.webhook-handler.js`.

In your new file, add the following code:

```ts /api/webhook-handler.ts theme={"theme":"css-variables"}
import type { ActionFunctionArgs } from "@remix-run/node";
import { tasks } from "@trigger.dev/sdk";
import { helloWorldTask } from "src/trigger/example";

export async function action({ request }: ActionFunctionArgs) {
  const payload = await request.json();

  // Trigger the helloWorldTask with the webhook data as the payload
  await tasks.trigger<typeof helloWorldTask>("hello-world", payload);

  return new Response("OK", { status: 200 });
}
```

This code will handle the webhook payload and trigger the 'Hello World' task.

## Triggering the task locally

Now that you have a webhook handler set up, you can trigger the 'Hello World' task from it. We will do this locally using cURL.

<Steps>
  <Step title="Run your Remix app and the Trigger.dev dev server">
    First, run your Remix app.

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npm run dev
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm run dev
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dev
      ```
    </CodeGroup>

    Then, open up a second terminal window and start the Trigger.dev dev server:

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest dev
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest dev
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest dev
      ```
    </CodeGroup>
  </Step>

  <Step title="Trigger the webhook with some dummy data">
    To send a POST request to your webhook handler, open up a terminal window on your local machine and run the following command:

    <Tip>
      If `http://localhost:5173` isn't the URL of your locally running Remix app, replace the URL in the
      below command with that URL instead.
    </Tip>

    ```bash theme={"theme":"css-variables"}
    curl -X POST -H "Content-Type: application/json" -d '{"Name": "John Doe", "Age": "87"}' http://localhost:5173/api/webhook-handler
    ```

    This will send a POST request to your webhook handler, with a JSON payload.
  </Step>

  <Step title="Check the task ran successfully">
    After running the command, you should see a successful dev run and a 200 response in your terminals.

    If you now go to your [Trigger.dev dashboard](https://cloud.trigger.dev), you should also see a successful run for the 'Hello World' task, with the payload you sent, in this case; `{"name": "John Doe", "age": "87"}`.
  </Step>
</Steps>


# Sequin database triggers
Source: https://trigger.dev/docs/guides/frameworks/sequin

This guide will show you how to trigger tasks from database changes using Sequin

[Sequin](https://sequinstream.com) allows you to trigger tasks from database changes. Sequin captures every insert, update, and delete on a table and then ensures a task is triggered for each change.

Often, task runs coincide with database changes. For instance, you might want to use a Trigger.dev task to generate an embedding for each post in your database:

<img alt="Sequin and Trigger.dev Overview" />

In this guide, you'll learn how to use Sequin to trigger Trigger.dev tasks from database changes.

## Prerequisites

You are about to create a [regular Trigger.dev task](/docs/tasks-regular) that you will execute when ever a post is inserted or updated in your database. Sequin will detect all the changes on the `posts` table and then send the payload of the post to an API endpoint that will call `tasks.trigger()` to create the embedding and update the database.

As long as you create an HTTP endpoint that Sequin can deliver webhooks to, you can use any web framework or edge function (e.g. Supabase Edge Functions, Vercel Functions, Cloudflare Workers, etc.) to invoke your Trigger.dev task. In this guide, we'll show you how to setup Trigger.dev tasks using Next.js API Routes.

You'll need the following to follow this guide:

* A Next.js project with [Trigger.dev](https://trigger.dev) installed
  <Info>
    If you don't have one already, follow [Trigger.dev's Next.js setup
    guide](/docs/guides/frameworks/nextjs) to setup your project. You can return to this guide when
    you're ready to write your first Trigger.dev task.
  </Info>
* A [Sequin](https://console.sequinstream.com/register) account
* A Postgres database (Sequin works with any Postgres database version 12 and up) with a `posts` table.

## Create a Trigger.dev task

Start by creating a new Trigger.dev task that takes in a Sequin change event as a payload, creates an embedding, and then inserts the embedding into the database:

<Steps>
  <Step title="Create a `create-embedding-for-post` task">
    In your `src/trigger/tasks` directory, create a new file called `create-embedding-for-post.ts` and add the following code:

    <CodeGroup>
      ```ts trigger/create-embedding-for-post.ts theme={"theme":"css-variables"}
      import { task } from "@trigger.dev/sdk";
      import { OpenAI } from "openai";
      import { upsertEmbedding } from "../util";

      const openai = new OpenAI({
        apiKey: process.env.OPENAI_API_KEY,
      });

      export const createEmbeddingForPost = task({
        id: "create-embedding-for-post",
        run: async (payload: {
          record: {
            id: number;
            title: string;
            body: string;
            author: string;
            createdAt: string;
            embedding: string | null;
          },
          metadata: {
            table_schema: string,
            table_name: string,
            consumer: {
              id: string;
              name: string;
            };
          };
        }) => {
          // Create an embedding using the title and body of payload.record
          const content = `${payload.record.title}\n\n${payload.record.body}`;
          const embedding = (await openai.embeddings.create({
            model: "text-embedding-ada-002",
            input: content,
          })).data[0].embedding;

          // Upsert the embedding in the database. See utils.ts for the implementation -> ->
          await upsertEmbedding(embedding, payload.record.id);

          // Return the updated record
          return {
            ...payload.record,
            embedding: JSON.stringify(embedding),
          };
        }
      });
      ```

      ```ts utils.ts theme={"theme":"css-variables"}
      import pg from "pg";

      export async function upsertEmbedding(embedding: number[], id: number) {
        const client = new pg.Client({
          connectionString: process.env.DATABASE_URL,
        });
        await client.connect();

        try {
          const query = `
            INSERT INTO post_embeddings (id, embedding)
            VALUES ($2, $1)
            ON CONFLICT (id)
            DO UPDATE SET embedding = $1
          `;
          const values = [JSON.stringify(embedding), id];

          const result = await client.query(query, values);
          console.log(`Updated record in database. Rows affected: ${result.rowCount}`);

          return result.rowCount;
        } catch (error) {
          console.error("Error updating record in database:", error);
          throw error;
        } finally {
          await client.end();
        }
      }
      ```
    </CodeGroup>

    This task takes in a Sequin record event, creates an embedding, and then upserts the embedding into a `post_embeddings` table.
  </Step>

  <Step title="Add the task to your Trigger.dev project">
    Register the `create-embedding-for-post` task to your Trigger.dev cloud project by running the following command:

    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest dev
    ```

    In the Trigger.dev dashboard, you should now see the `create-embedding-for-post` task:

    <Frame>
      <img alt="Task added" />
    </Frame>
  </Step>
</Steps>

<Check>
  You've successfully created a Trigger.dev task that will create an embedding for each post in your
  database. In the next step, you'll create an API endpoint that Sequin can deliver records to.
</Check>

## Setup API route

You'll now create an API endpoint that will receive posts from Sequin and then trigger the `create-embedding-for-post` task.

<Info>
  This guide covers how to setup an API endpoint using the Next.js App Router. You can find examples
  for Next.js Server Actions and Pages Router in the [Trigger.dev
  documentation](https://trigger.dev/docs/guides/frameworks/nextjs).
</Info>

<Steps>
  <Step title="Create a route handler">
    Add a route handler by creating a new `route.ts` file in a `/app/api/create-embedding-for-post` directory:

    ```ts app/api/create-embedding-for-post/route.ts theme={"theme":"css-variables"}
    import type { createEmbeddingForPost } from "@/trigger/create-embedding-for-post";
    import { tasks } from "@trigger.dev/sdk";
    import { NextResponse } from "next/server";

    export async function POST(req: Request) {
      const authHeader = req.headers.get("authorization");
      if (!authHeader || authHeader !== `Bearer ${process.env.SEQUIN_WEBHOOK_SECRET}`) {
        return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
      }
      const payload = await req.json();
      const handle = await tasks.trigger<typeof createEmbeddingForPost>(
        "create-embedding-for-post",
        payload
      );

      return NextResponse.json(handle);
    }
    ```

    This route handler will receive records from Sequin, parse them, and then trigger the `create-embedding-for-post` task.
  </Step>

  <Step title="Set secret keys">
    You'll need to set four secret keys in a `.env.local` file:

    ```bash theme={"theme":"css-variables"}
    SEQUIN_WEBHOOK_SECRET=your-secret-key
    TRIGGER_SECRET_KEY=secret-from-trigger-dev
    OPENAI_API_KEY=sk-proj-asdfasdfasdf
    DATABASE_URL=postgresql://
    ```

    The `SEQUIN_WEBHOOK_SECRET` ensures that only Sequin can access your API endpoint.

    The `TRIGGER_SECRET_KEY` is used to authenticate requests to Trigger.dev and can be found in the **API keys** tab of the Trigger.dev dashboard.

    The `OPENAI_API_KEY` and `DATABASE_URL` are used to create an embedding using OpenAI and connect to your database. Be sure to add these as [environment variables](https://trigger.dev/docs/deploy-environment-variables) in Trigger.dev as well.
  </Step>
</Steps>

<Check>
  You've successfully created an API endpoint that can receive record payloads from Sequin and
  trigger a Trigger.dev task. In the next step, you'll setup Sequin to trigger the endpoint.
</Check>

## Create Sequin consumer

You'll now configure Sequin to send every row in your `posts` table to your Trigger.dev task.

<Steps>
  <Step title="Connect Sequin to your database">
    1. Login to your Sequin account and click the **Add New Database** button.
    2. Enter the connection details for your Postgres database.

    <Info>
      If you need to connect to a local dev database, flip the **use localhost** switch and follow the instructions to create a tunnel using the [Sequin CLI](https://sequinstream.com/docs/cli).
    </Info>

    3. Follow the instructions to create a publication and a replication slot by running two SQL commands in your database:

    ```sql theme={"theme":"css-variables"}
    create publication sequin_pub for all tables;
    select pg_create_logical_replication_slot('sequin_slot', 'pgoutput');
    ```

    4. Name your database and click the **Connect Database** button.

    Sequin will connect to your database and ensure that it's configured properly.

    <Note>
      If you need step-by-step connection instructions to connect Sequin to your database, check out our [quickstart guide](https://sequinstream.com/docs/quickstart).
    </Note>
  </Step>

  <Step title="Tunnel to your local endpoint">
    Now, create a tunnel to your local endpoint so Sequin can deliver change payloads to your local API:

    1. In the Sequin console, open the **HTTP Endpoint** tab and click the **Create HTTP Endpoint** button.
    2. Enter a name for your endpoint (i.e. `local_endpoint`) and flip the **Use localhost** switch. Follow the instructions in the Sequin console to [install the Sequin CLI](https://sequinstream.com/docs/cli), then run:

    ```bash theme={"theme":"css-variables"}
    sequin tunnel --ports=3001:local_endpoint
    ```

    3. Now, click **Add encryption header** and set the key to `Authorization` and the value to `Bearer SEQUIN_WEBHOOK_SECRET`.
    4. Click **Create HTTP Endpoint**.
  </Step>

  <Step title="Create a Push Consumer">
    Create a push consumer that will capture posts from your database and deliver them to your local endpoint:

    1. Navigate to the **Consumers** tab and click the **Create Consumer** button.
    2. Select your `posts` table (i.e `public.posts`).
    3. You want to ensure that every post receives an embedding - and that embeddings are updated as posts are updated. To do this, select to process **Rows** and click **Continue**.

    <Note>
      You can also use **changes** for this particular use case, but **rows** comes with some nice replay and backfill features.
    </Note>

    4. You'll now set the sort and filter for the consumer. For this guide, we'll sort by `updated_at` and start at the beginning of the table. We won't apply any filters:

    <Frame>
      <img alt="Consumer Sort and Filter" />
    </Frame>

    5. On the next screen, select **Push** to have Sequin send the events to your webhook URL. Click **Continue**.
    6. Now, give your consumer a name (i.e. `posts_push_consumer`) and in the **HTTP Endpoint** section select the `local_endpoint` you created above. Add the exact API route you created in the previous step (i.e. `/api/create-embedding-for-post`):

    <Frame>
      <img alt="Consumer Endpoint" />
    </Frame>

    7. Click the **Create Consumer** button.
  </Step>
</Steps>

<Check>Your Sequin consumer is now created and ready to send events to your API endpoint.</Check>

## Test end-to-end

<Steps>
  <Step title="Spin up you dev environment">
    1. The Next.js app is running: `npm run dev`
    2. The Trigger.dev dev server is running `npx trigger.dev@latest dev`
    3. The Sequin tunnel is running: `sequin tunnel --ports=3001:local_endpoint`
  </Step>

  <Step title="Create a new post in your database">
    ```sql theme={"theme":"css-variables"}
    insert into
    posts (title, body, author)
    values
      (
        'The Future of AI',
        'An insightful look into how artificial intelligence is shaping the future of technology and society.',
        'Alice H Johnson'
      );
    ```
  </Step>

  <Step title="Trace the change in the Sequin dashboard">
    In the Sequin console, navigate to the [**Trace**](https://console.sequinstream.com/trace) tab and confirm that Sequin delivered the event to your local endpoint:

    <Frame>
      <img alt="Trace Event" />
    </Frame>
  </Step>

  <Step title="Confirm the event was received by your endpoint">
    In your local terminal, you should see a `200` response in your Next.js app:

    ```bash theme={"theme":"css-variables"}
    POST /api/create-embedding-for-post 200 in 262ms
    ```
  </Step>

  <Step title="Observe the task run in the Trigger.dev dashboard">
    Finally, in the [**Trigger.dev dashboard**](https://cloud.trigger.dev/), navigate to the Runs page and confirm that the task run completed successfully:

    <Frame>
      <img alt="Task run" />
    </Frame>
  </Step>
</Steps>

<Check>
  Every time a post is created or updated, Sequin will deliver the row payload to your API endpoint
  and Trigger.dev will run the `create-embedding-for-post` task.
</Check>

## Next steps

With Sequin and Trigger.dev, every post in your database will now have an embedding. This is a simple example of how you can trigger long-running tasks on database changes.

From here, add error handling and deploy to production:

* Add [retries](/docs/errors-retrying) to your Trigger.dev task to ensure that any errors are captured and logged.
* Deploy to [production](/docs/guides/frameworks/nextjs#deploying-your-task-to-trigger-dev) and update your Sequin consumer to point to your production database and endpoint.


# Authenticating Supabase tasks: JWTs and service roles
Source: https://trigger.dev/docs/guides/frameworks/supabase-authentication

Learn how to authenticate Supabase tasks using JWTs for Row Level Security (RLS) or service role keys for admin access.

There are two ways to authenticate your Supabase client in Trigger.dev tasks:

### 1. Using JWT Authentication (Recommended for User-Specific Operations)

A JWT (JSON Web Token) is a string-formatted data container that typically stores user identity and permissions data. Row Level Security policies are based on the information present in JWTs. Supabase JWT docs can be found [here](https://supabase.com/docs/guides/auth/jwts).

To use JWTs with Supabase, you'll need to add the `SUPABASE_JWT_SECRET` environment variable in your project. This secret is used to sign the JWTs. This can be found in your Supabase project settings under `Data API`.

This example code shows how to create a JWT token for a user and initialize a Supabase client with that token for authentication, allowing the task to perform database operations as that specific user. You can adapt this code to fit your own use case.

```ts theme={"theme":"css-variables"}

// The rest of your task code
async run(payload: { user_id: string }) {
    const { user_id } = payload;

    // Optional error handling
    const jwtSecret = process.env.SUPABASE_JWT_SECRET;
    if (!jwtSecret) {
      throw new Error(
        "SUPABASE_JWT_SECRET is not defined in environment variables"
      );
    }

    // Create a JWT token for the user that expires in 1 hour
    const token = jwt.sign({ sub: user_id }, jwtSecret, { expiresIn: "1h" });

    // Initialize the Supabase client with the JWT token
    const supabase = createClient(
      // These details can be found in your Supabase project settings under `Data API`
      process.env.SUPABASE_URL as string,
      process.env.SUPABASE_ANON_KEY as string,
      {
        global: {
          headers: {
            Authorization: `Bearer ${token}`,
          },
        },
      }
    );
// The rest of your task code
```

Using JWTs to authenticate Supabase operations is more secure than using service role keys because it respects Row Level Security policies, maintains user-specific audit trails, and follows the principle of least privileged access.

### 2. Using Service Role Key (For Admin-Level Access)

<Warning>
  The service role key has unlimited access and bypasses all security checks. Only use it when you
  need admin-level privileges, and never expose it client-side.
</Warning>

This example code creates a Supabase client with admin-level privileges using a service role key, bypassing all Row Level Security policies to allow unrestricted database access.

```ts theme={"theme":"css-variables"}
// Create a single Supabase client for interacting with your database
// 'Database' supplies the type definitions to supabase-js
const supabase = createClient<Database>(
  // These details can be found in your Supabase project settings under `API`
  process.env.SUPABASE_PROJECT_URL as string, // e.g. https://abc123.supabase.co - replace 'abc123' with your project ID
  process.env.SUPABASE_SERVICE_ROLE_KEY as string // Your service role secret key
);

// Your task
```

## Learn more about Supabase and Trigger.dev

### Full walkthrough guides from development to deployment

<CardGroup>
  <Card title="Edge function hello world guide" icon="book" href="/guides/frameworks/supabase-edge-functions-basic">
    Learn how to trigger a task from a Supabase edge function when a URL is visited.
  </Card>

  <Card title="Database webhooks guide" icon="book" href="/guides/frameworks/supabase-edge-functions-database-webhooks">
    Learn how to trigger a task from a Supabase edge function when an event occurs in your database.
  </Card>

  <Card title="Supabase authentication guide" icon="book" href="/guides/frameworks/supabase-authentication">
    Learn how to authenticate Supabase tasks using JWTs for Row Level Security (RLS) or service role
    keys for admin access.
  </Card>
</CardGroup>

### Task examples with code you can copy and paste

<CardGroup>
  <Card title="Supabase database operations" icon="bolt" href="/guides/examples/supabase-database-operations">
    Run basic CRUD operations on a table in a Supabase database using Trigger.dev.
  </Card>

  <Card title="Supabase Storage upload" icon="bolt" href="/guides/examples/supabase-storage-upload">
    Download a video from a URL and upload it to Supabase Storage using S3.
  </Card>
</CardGroup>


# Triggering tasks from Supabase edge functions
Source: https://trigger.dev/docs/guides/frameworks/supabase-edge-functions-basic

This guide will show you how to trigger a task from a Supabase edge function, and then view the run in our dashboard.

## Overview

Supabase edge functions allow you to trigger tasks either when an event is sent from a third party (e.g. when a new Stripe payment is processed, when a new user signs up to a service, etc), or when there are any changes or updates to your Supabase database.

This guide shows you how to set up and deploy a simple Supabase edge function example that triggers a task when an edge function URL is accessed.

## Prerequisites

* Ensure you have the [Supabase CLI](https://supabase.com/docs/guides/cli/getting-started) installed
* Since Supabase CLI version 1.123.4, you must have [Docker Desktop installed](https://supabase.com/docs/guides/functions/deploy#deploy-your-edge-functions) to deploy Edge Functions
* Ensure TypeScript is installed
* [Create a Trigger.dev account](https://cloud.trigger.dev)
* Create a new Trigger.dev project

## GitHub repo

<Card title="View the project on GitHub" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/supabase-edge-functions">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## Initial setup

<Steps>
  <Step title="Optional step 1: create a new Supabase project">
    <Info> If you already have a Supabase project on your local machine you can skip this step.</Info>

    You can create a new project by running the following command in your terminal using the Supabase CLI:

    ```bash theme={"theme":"css-variables"}
    supabase init
    ```

    <Note>
      If you are using VS Code, ensure to answer 'y' when asked to generate VS Code settings for Deno,
      and install any recommended extensions.
    </Note>
  </Step>

  <Step title="Optional step 2: create a package.json file">
    If your project does not already have `package.json` file (e.g. if you are using Deno), create it manually in your project's root folder.

    <Info> If your project has a `package.json` file you can skip this step.</Info>

    This is required for the Trigger.dev SDK to work correctly.

    ```ts package.json theme={"theme":"css-variables"}
    {
      "devDependencies": {
        "typescript": "^5.6.2"
      }
    }
    ```

    <Note> Update your Typescript version to the latest version available. </Note>
  </Step>

  <Step title="Run the CLI `init` command">
    The easiest way to get started is to use the CLI. It will add Trigger.dev to your existing project, create a `/trigger` folder and give you an example task.

    Run this command in the root of your project to get started:

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest init
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest init
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest init
      ```
    </CodeGroup>

    It will do a few things:

    <Tip title="MCP Server">
      Our [Trigger.dev MCP server](/docs/mcp-introduction) gives your AI assistant direct access to Trigger.dev tools; search docs, trigger tasks, deploy projects, and monitor runs. We recommend installing it for the best developer experience.
    </Tip>

    1. Ask if you want to install the [Trigger.dev MCP server](/docs/mcp-introduction) for your AI assistant.
    2. Log you into the CLI if you're not already logged in.
    3. Ask you to select your project.
    4. Install the required SDK packages.
    5. Ask where you'd like to create the `/trigger` directory and create it with an example task.
    6. Create a `trigger.config.ts` file in the root of your project.

    Install the "Hello World" example task when prompted. We'll use this task to test the setup.
  </Step>

  <Step title="Run the CLI `dev` command">
    The CLI `dev` command runs a server for your tasks. It watches for changes in your `/trigger` directory and communicates with the Trigger.dev platform to register your tasks, perform runs, and send data back and forth.

    It can also update your `@trigger.dev/*` packages to prevent version mismatches and failed deploys. You will always be prompted first.

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest dev
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest dev
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest dev
      ```
    </CodeGroup>
  </Step>

  <Step title="Perform a test run using the dashboard">
    The CLI `dev` command spits out various useful URLs, including a link to the dashboard. Open it, find your Example task on the Tasks page, and press the "Test" button to open its test page.

    Most tasks have a "payload" which you enter in the JSON editor, but our example task doesn't need any input. You can also configure run options, pre-populate the form from recent runs, and save run templates.

    Press the "Run test" button.

    <img alt="Test page" />
  </Step>

  <Step title="View your run">
    Congratulations, you should see the run page which will live reload showing you the current state of the run.

    <img alt="Run page" />

    If you go back to your terminal you'll see that the dev command also shows the task status and links to the run log.

    <img alt="Terminal showing completed run" />
  </Step>
</Steps>

## Create a new Supabase edge function and deploy it

<Steps>
  <Step title="Create a new Supabase edge function">
    We'll call this example `edge-function-trigger`.

    In your project, run the following command in the terminal using the Supabase CLI:

    ```bash theme={"theme":"css-variables"}
    supabase functions new edge-function-trigger
    ```
  </Step>

  <Step title="Update the edge function code">
    Replace the placeholder code in your `edge-function-trigger/index.ts` file with the following:

    ```ts functions/edge-function-trigger/index.ts theme={"theme":"css-variables"}
    // Setup type definitions for built-in Supabase Runtime APIs
    import "jsr:@supabase/functions-js/edge-runtime.d.ts";
    // Import the Trigger.dev SDK - replace "<your-sdk-version>" with the version of the SDK you are using, e.g. "3.0.0". You can find this in your package.json file.
    import { tasks } from "npm:@trigger.dev/sdk@3.0.0";
    // Import your task type from your /trigger folder
    import type { helloWorldTask } from "../../../src/trigger/example.ts";
    //     👆 **type-only** import

    Deno.serve(async () => {
      await tasks.trigger<typeof helloWorldTask>(
        // Your task id
        "hello-world",
        // Your task payload
        "Hello from a Supabase Edge Function!"
      );
      return new Response("OK");
    });
    ```

    <Note>You can only import the `type` from the task.</Note>

    <Note>
      Tasks in the `trigger` folder use Node, so they must stay in there or they will not run,
      especially if you are using a different runtime like Deno. Also do not add "`npm:`" to imports
      inside your task files, for the same reason.
    </Note>
  </Step>

  <Step title="Deploy your edge function using the Supabase CLI">
    You can now deploy your edge function with the following command in your terminal:

    ```bash theme={"theme":"css-variables"}
    supabase functions deploy edge-function-trigger --no-verify-jwt
    ```

    <Warning>
      `--no-verify-jwt` removes the JSON Web Tokens requirement from the authorization header. By
      default this should be on, but it is not strictly required for this hello world example.
    </Warning>

    <Note>
      To learn more about how to properly configure Supabase auth for Trigger.dev tasks, please refer to
      our [Supabase Authentication guide](/docs/guides/frameworks/supabase-authentication). It demonstrates
      how to use JWT authentication for user-specific operations or your service role key for
      admin-level access.
    </Note>

    Follow the CLI instructions and once complete you should now see your new edge function deployment in your Supabase edge functions dashboard.

    There will be a link to the dashboard in your terminal output, or you can find it at this URL:

    `https://supabase.com/dashboard/project/<your-project-id>/functions`

    <Note>Replace `your-project-id` with your actual project ID.</Note>
  </Step>
</Steps>

## Set your Trigger.dev prod secret key in the Supabase dashboard

To trigger a task from your edge function, you need to set your Trigger.dev secret key in the Supabase dashboard.

To do this, first go to your Trigger.dev [project dashboard](https://cloud.trigger.dev) and copy the `prod` secret key from the API keys page.

<img alt="How to find your prod secret key" />

Then, in [Supabase](https://supabase.com/dashboard/projects), select your project, navigate to 'Project settings' <Icon icon="circle-1" />, click 'Edge functions' <Icon icon="circle-2" /> in the configurations menu, and then click the 'Add new secret' <Icon icon="circle-3" /> button.

Add `TRIGGER_SECRET_KEY` <Icon icon="circle-4" /> with the pasted value of your Trigger.dev `prod` secret key.

<img alt="Add secret key in Supabase" />

## Deploy your task and trigger it from your edge function

<Steps>
  <Step title="Deploy your 'Hello World' task">
    Next, deploy your `hello-world` task to [Trigger.dev cloud](https://cloud.trigger.dev).

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest deploy
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest deploy
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest deploy
      ```
    </CodeGroup>
  </Step>

  <Step title="Trigger a prod run from your deployed edge function">
    To do this all you need to do is simply open the `edge-function-trigger` URL.

    `https://supabase.com/dashboard/project/<your-project-id>/functions`

    <Note>Replace `your-project-id` with your actual project ID.</Note>

    In your Supabase project, go to your Edge function dashboard, find `edge-function-trigger`, copy the URL, and paste it into a new window in your browser.

    Once loaded you should see ‘OK’ on the new screen.

    <img alt="Edge function URL" />

    The task will be triggered when your edge function URL is accessed.

    Check your [cloud.trigger.dev](https://cloud.trigger.dev) dashboard and you should see a successful `hello-world` task.

    **Congratulations, you have run a simple Hello World task from a Supabase edge function!**
  </Step>
</Steps>

### If you see a runtime error when calling tasks.trigger()

If you see `TypeError: Cannot read properties of undefined (reading 'toString')` when calling `tasks.trigger()` from your edge function, the SDK is hitting a dependency that expects Node-style APIs not available in the Supabase Edge (Deno) runtime. Use the [Tasks API](/docs/management/tasks/trigger) with `fetch` instead of the SDK—that avoids loading the SDK in Deno:

```ts theme={"theme":"css-variables"}
const response = await fetch(
  `https://api.trigger.dev/api/v1/tasks/your-task-id/trigger`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${Deno.env.get("TRIGGER_SECRET_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ payload: { your: "payload" } }),
  }
);
```

See [Trigger task via API](/docs/management/tasks/trigger) for full request/response details and optional fields (e.g. `delay`, `idempotencyKey`).

## Learn more about Supabase and Trigger.dev

### Full walkthrough guides from development to deployment

<CardGroup>
  <Card title="Edge function hello world guide" icon="book" href="/guides/frameworks/supabase-edge-functions-basic">
    Learn how to trigger a task from a Supabase edge function when a URL is visited.
  </Card>

  <Card title="Database webhooks guide" icon="book" href="/guides/frameworks/supabase-edge-functions-database-webhooks">
    Learn how to trigger a task from a Supabase edge function when an event occurs in your database.
  </Card>

  <Card title="Supabase authentication guide" icon="book" href="/guides/frameworks/supabase-authentication">
    Learn how to authenticate Supabase tasks using JWTs for Row Level Security (RLS) or service role
    keys for admin access.
  </Card>
</CardGroup>

### Task examples with code you can copy and paste

<CardGroup>
  <Card title="Supabase database operations" icon="bolt" href="/guides/examples/supabase-database-operations">
    Run basic CRUD operations on a table in a Supabase database using Trigger.dev.
  </Card>

  <Card title="Supabase Storage upload" icon="bolt" href="/guides/examples/supabase-storage-upload">
    Download a video from a URL and upload it to Supabase Storage using S3.
  </Card>
</CardGroup>


# Triggering tasks from Supabase Database Webhooks
Source: https://trigger.dev/docs/guides/frameworks/supabase-edge-functions-database-webhooks

This guide shows you how to trigger a transcribing task when a row is added to a table in a Supabase database, using a Database Webhook and Edge Function.

## Overview

Supabase and Trigger.dev can be used together to create powerful workflows triggered by real-time changes in your database tables:

* A Supabase Database Webhook triggers an Edge Function when a row including a video URL is inserted into a table
* The Edge Function triggers a Trigger.dev task, passing the `video_url` column data from the new table row as the payload
* The Trigger.dev task then:

  * Uses [FFmpeg](https://www.ffmpeg.org/) to extract the audio track from a video URL
  * Uses [Deepgram](https://deepgram.com) to transcribe the extracted audio
  * Updates the original table row using the `record.id` in Supabase with the new transcription using `update`

## Prerequisites

* Ensure you have the [Supabase CLI](https://supabase.com/docs/guides/cli/getting-started) installed
* Since Supabase CLI version 1.123.4, you must have [Docker Desktop installed](https://supabase.com/docs/guides/functions/deploy#deploy-your-edge-functions) to deploy Edge Functions
* Ensure TypeScript is installed
* [Create a Trigger.dev account](https://cloud.trigger.dev)
* Create a new Trigger.dev project
* [Create a new Deepgram account](https://deepgram.com/) and get your API key from the dashboard

## GitHub repo

<Card title="View the project on GitHub" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/supabase-edge-functions">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## Initial setup

<Steps>
  <Step title="Optional step 1: create a new Supabase project">
    <Info> If you already have a Supabase project on your local machine you can skip this step.</Info>

    You can create a new project by running the following command in your terminal using the Supabase CLI:

    ```bash theme={"theme":"css-variables"}
    supabase init
    ```

    <Note>
      If you are using VS Code, ensure to answer 'y' when asked to generate VS Code settings for Deno,
      and install any recommended extensions.
    </Note>
  </Step>

  <Step title="Optional step 2: create a package.json file">
    If your project does not already have `package.json` file (e.g. if you are using Deno), create it manually in your project's root folder.

    <Info> If your project has a `package.json` file you can skip this step.</Info>

    This is required for the Trigger.dev SDK to work correctly.

    ```ts package.json theme={"theme":"css-variables"}
    {
      "devDependencies": {
        "typescript": "^5.6.2"
      }
    }
    ```

    <Note> Update your Typescript version to the latest version available. </Note>
  </Step>

  <Step title="Run the CLI `init` command">
    The easiest way to get started is to use the CLI. It will add Trigger.dev to your existing project, create a `/trigger` folder and give you an example task.

    Run this command in the root of your project to get started:

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest init
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest init
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest init
      ```
    </CodeGroup>

    It will do a few things:

    1. Log you into the CLI if you're not already logged in.
    2. Create a `trigger.config.ts` file in the root of your project.
    3. Ask where you'd like to create the `/trigger` directory.
    4. Create the `/trigger` directory with an example task, `/trigger/example.[ts/js]`.

    Choose "None" when prompted to install an example task. We will create a new task for this guide.
  </Step>
</Steps>

## Create a new table in your Supabase database

First, in the Supabase project dashboard, you'll need to create a new table to store the video URL and transcription.

To do this, click on 'Table Editor' <Icon icon="circle-1" /> in the left-hand menu and create a new table. <Icon icon="circle-2" />

<img alt="How to create a new Supabase table" />

Call your table `video_transcriptions`. <Icon icon="circle-1" />

Add two new columns, one called `video_url` with the type `text` <Icon icon="circle-2" />, and another called `transcription`, also with the type `text` <Icon icon="circle-3" />.

<img alt="How to create a new Supabase table 2" />

## Create and deploy the Trigger.dev task

### Generate the Database type definitions

To allow you to use TypeScript to interact with your table, you need to [generate the type definitions](https://supabase.com/docs/guides/api/rest/generating-types) for your Supabase table using the Supabase CLI.

```bash theme={"theme":"css-variables"}
supabase gen types --lang=typescript --project-id <project-ref> --schema public > database.types.ts
```

<Note> Replace `<project-ref>` with your Supabase project reference ID. This can be found in your Supabase project settings under 'General'. </Note>

### Create the transcription task

Create a new task file in your `/trigger` folder. Call it `videoProcessAndUpdate.ts`.

This task takes a video from a public video url, extracts the audio using FFmpeg and transcribes the audio using Deepgram. The transcription summary will then be updated back to the original row in the `video_transcriptions` table in Supabase.

You will need to install some additional dependencies for this task:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npm install @deepgram/sdk @supabase/supabase-js fluent-ffmpeg
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm install @deepgram/sdk @supabase/supabase-js fluent-ffmpeg
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn install @deepgram/sdk @supabase/supabase-js fluent-ffmpeg
  ```
</CodeGroup>

These dependencies will allow you to interact with the Deepgram and Supabase APIs and extract audio from a video using FFmpeg.

<Warning>
  When updating your tables from a Trigger.dev task which has been triggered by a database change,
  be extremely careful to not cause an infinite loop. Ensure you have the correct conditions in
  place to prevent this.
</Warning>

```ts /trigger/videoProcessAndUpdate.ts theme={"theme":"css-variables"}
// Install any missing dependencies below
import { createClient as createDeepgramClient } from "@deepgram/sdk";
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
import { logger, task } from "@trigger.dev/sdk";
import ffmpeg from "fluent-ffmpeg";
import fs from "fs";
import { Readable } from "node:stream";
import os from "os";
import path from "path";
import { Database } from "../../database.types";

// Create a single Supabase client for interacting with your database
// 'Database' supplies the type definitions to supabase-js
const supabase = createSupabaseClient<Database>(
  // These details can be found in your Supabase project settings under `API`
  process.env.SUPABASE_PROJECT_URL as string, // e.g. https://abc123.supabase.co - replace 'abc123' with your project ID
  process.env.SUPABASE_SERVICE_ROLE_KEY as string // Your service role secret key
);

// Your DEEPGRAM_SECRET_KEY can be found in your Deepgram dashboard
const deepgram = createDeepgramClient(process.env.DEEPGRAM_SECRET_KEY);

export const videoProcessAndUpdate = task({
  id: "video-process-and-update",
  run: async (payload: { videoUrl: string; id: number }) => {
    const { videoUrl, id } = payload;

    logger.log(`Processing video at URL: ${videoUrl}`);

    // Generate temporary file names
    const tempDirectory = os.tmpdir();
    const outputPath = path.join(tempDirectory, `audio_${Date.now()}.wav`);

    const response = await fetch(videoUrl);

    // Extract the audio using FFmpeg
    await new Promise((resolve, reject) => {
      if (!response.body) {
        return reject(new Error("Failed to fetch video"));
      }

      ffmpeg(Readable.from(response.body))
        .outputOptions([
          "-vn", // Disable video output
          "-acodec pcm_s16le", // Use PCM 16-bit little-endian encoding
          "-ar 44100", // Set audio sample rate to 44.1 kHz
          "-ac 2", // Set audio channels to stereo
        ])
        .output(outputPath)
        .on("end", resolve)
        .on("error", reject)
        .run();
    });

    logger.log(`Audio extracted from video`, { outputPath });

    // Transcribe the audio using Deepgram
    const { result, error } = await deepgram.listen.prerecorded.transcribeFile(
      fs.readFileSync(outputPath),
      {
        model: "nova-2", // Use the Nova 2 model
        smart_format: true, // Automatically format the transcription
        diarize: true, // Enable speaker diarization
      }
    );

    if (error) {
      throw error;
    }

    const transcription = result.results.channels[0].alternatives[0].paragraphs?.transcript;

    logger.log(`Transcription: ${transcription}`);

    // Delete the temporary audio file
    fs.unlinkSync(outputPath);
    logger.log(`Temporary audio file deleted`, { outputPath });

    const { error: updateError } = await supabase
      .from("video_transcriptions")
      // Update the transcription column
      .update({ transcription: transcription })
      // Find the row by its ID
      .eq("id", id);

    if (updateError) {
      throw new Error(`Failed to update transcription: ${updateError.message}`);
    }

    return {
      message: `Summary of the audio: ${transcription}`,
      result,
    };
  },
});
```

<Warning>
  This task uses your service role secret key to bypass Row Level Security. This is not recommended
  for production use as it has unlimited access and bypasses all security checks.
</Warning>

<Note>
  To learn more about how to properly configure Supabase auth for Trigger.dev tasks, please refer to
  our [Supabase Authentication guide](/docs/guides/frameworks/supabase-authentication). It demonstrates
  how to use JWT authentication for user-specific operations or your service role key for
  admin-level access.
</Note>

### Adding the FFmpeg build extension

Before you can deploy the task, you'll need to add the FFmpeg build extension to your `trigger.config.ts` file.

```ts trigger.config.ts theme={"theme":"css-variables"}
// Add this import
import { ffmpeg } from "@trigger.dev/build/extensions/core";
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>", // Replace with your project ref
  // Your other config settings...
  build: {
    // Add the FFmpeg build extension
    extensions: [ffmpeg()],
  },
});
```

<Note>
  [Build extensions](/docs/config/extensions/overview) allow you to hook into the build system and
  customize the build process or the resulting bundle and container image (in the case of
  deploying). You can use pre-built extensions or create your own.
</Note>

<Note>
  You'll also need to add `@trigger.dev/build` to your `package.json` file under `devDependencies`
  if you don't already have it there.
</Note>

If you are modifying this example and using popular FFmpeg libraries like `fluent-ffmpeg` you'll also need to add them to [`external`](/docs/config/config-file#external) in your `trigger.config.ts` file.

### Add your Deepgram and Supabase environment variables to your Trigger.dev project

You will need to add your `DEEPGRAM_SECRET_KEY`, `SUPABASE_PROJECT_URL` and `SUPABASE_SERVICE_ROLE_KEY` as environment variables in your Trigger.dev project. This can be done in the 'Environment Variables' page in your project dashboard.

<img alt="Adding environment variables" />

### Deploying your task

Now you can now deploy your task using the following command:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npx trigger.dev@latest deploy
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest deploy
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest deploy
  ```
</CodeGroup>

## Create and deploy the Supabase Edge Function

### Add your Trigger.dev prod secret key to the Supabase dashboard

Go to your Trigger.dev [project dashboard](https://cloud.trigger.dev) and copy the `prod` secret key from the API keys page.

<img alt="How to find your prod secret key" />

Then, in [Supabase](https://supabase.com/dashboard/projects), select the project you want to use, navigate to 'Project settings' <Icon icon="circle-1" />, click 'Edge Functions' <Icon icon="circle-2" /> in the configurations menu, and then click the 'Add new secret' <Icon icon="circle-3" /> button.

Add `TRIGGER_SECRET_KEY` <Icon icon="circle-4" /> with the pasted value of your Trigger.dev `prod` secret key.

<img alt="Add secret key in Supabase" />

### Create a new Edge Function using the Supabase CLI

Now create an Edge Function using the Supabase CLI. Call it `video-processing-handler`. This function will be triggered by the Database Webhook.

```bash theme={"theme":"css-variables"}
supabase functions new video-processing-handler
```

```ts functions/video-processing-handler/index.ts theme={"theme":"css-variables"}
// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { tasks } from "npm:@trigger.dev/sdk@latest";
// Import the videoProcessAndUpdate task from the trigger folder
import type { videoProcessAndUpdate } from "../../../src/trigger/videoProcessAndUpdate.ts";
//     👆 type only import

// Sets up a Deno server that listens for incoming JSON requests
Deno.serve(async (req) => {
  const payload = await req.json();

  // This payload will contain the video url and id from the new row in the table
  const videoUrl = payload.record.video_url;
  const id = payload.record.id;

  // Trigger the videoProcessAndUpdate task with the videoUrl payload
  await tasks.trigger<typeof videoProcessAndUpdate>("video-process-and-update", { videoUrl, id });
  console.log(payload ?? "No name provided");

  return new Response("ok");
});
```

<Note>
  Tasks in the `trigger` folder use Node, so they must stay in there or they will not run,
  especially if you are using a different runtime like Deno. Also do not add "`npm:`" to imports
  inside your task files, for the same reason.
</Note>

### Deploy the Edge Function

Now deploy your new Edge Function with the following command:

```bash theme={"theme":"css-variables"}
supabase functions deploy video-processing-handler
```

Follow the CLI instructions, selecting the same project you added your `prod` secret key to, and once complete you should see your new Edge Function deployment in your Supabase Edge Functions dashboard.

There will be a link to the dashboard in your terminal output.

## Create the Database Webhook

In your Supabase project dashboard, click 'Project settings' <Icon icon="circle-1" />, then the 'API' tab <Icon icon="circle-2" />, and copy the `anon` `public` API key from the table <Icon icon="circle-3" />.

<img alt="How to find your Supabase API keys" />

Then, go to 'Database' <Icon icon="circle-1" /> click on 'Webhooks' <Icon icon="circle-2" />, and then click 'Create a new hook' <Icon icon="circle-3" />.

<img alt="How to create a new webhook" />

<Icon icon="circle-1" /> Call the hook `edge-function-hook`.

<Icon icon="circle-2" /> Select the new table you have created:
`public` `video_transcriptions`.

<Icon icon="circle-3" /> Choose the `insert` event.

<img alt="How to create a new webhook 2" />

<Icon icon="circle-4" /> Under 'Webhook configuration', select
'Supabase Edge Functions'

<Icon icon="circle-5" /> Under 'Edge Function', choose `POST`
and select the Edge Function you have created: `video-processing-handler`.

<Icon icon="circle-6" /> Under 'HTTP Headers', add a new header with the key `Authorization` and the value `Bearer <your-api-key>` (replace `<your-api-key>` with the `anon` `public` API key you copied earlier).

<Info>
  Supabase Edge Functions require a JSON Web Token [JWT](https://supabase.com/docs/guides/auth/jwts)
  in the authorization header. This is to ensure that only authorized users can access your edge
  functions.
</Info>

<Icon icon="circle-7" /> Click 'Create webhook'.

<img alt="How to create a new webhook 3" />

Your Database Webhook is now ready to use.

## Triggering the entire workflow

Your `video-processing-handler` Edge Function is now set up to trigger the `videoProcessAndUpdate` task every time a new row is inserted into your `video_transcriptions` table.

To do this, go back to your Supabase project dashboard, click on 'Table Editor' <Icon icon="circle-1" /> in the left-hand menu, click on the `video_transcriptions` table <Icon icon="circle-2" /> , and then click 'Insert', 'Insert Row' <Icon icon="circle-3" />.

<img alt="How to insert a new row 1" />

Add a new item under `video_url`, with a public video url. <Icon icon="circle-1" />.

You can use the following public video URL for testing: `https://content.trigger.dev/Supabase%20Edge%20Functions%20Quickstart.mp4`.

<img alt="How to insert a new row 2" />

Once the new table row has been inserted, check your [cloud.trigger.dev](https://cloud.trigger.dev) project 'Runs' list <Icon icon="circle-1" /> and you should see a processing `videoProcessAndUpdate` task <Icon icon="circle-2" /> which has been triggered when you added a new row with the video url to your `video_transcriptions` table.

<img alt="Supabase successful run" />

Once the run has completed successfully, go back to your Supabase `video_transcriptions` table, and you should see that in the row containing the original video URL, the transcription has now been added to the `transcription` column.

<img alt="Supabase successful table update" />

**Congratulations! You have completed the full workflow from Supabase to Trigger.dev and back again.**

## Learn more about Supabase and Trigger.dev

### Full walkthrough guides from development to deployment

<CardGroup>
  <Card title="Edge function hello world guide" icon="book" href="/guides/frameworks/supabase-edge-functions-basic">
    Learn how to trigger a task from a Supabase edge function when a URL is visited.
  </Card>

  <Card title="Database webhooks guide" icon="book" href="/guides/frameworks/supabase-edge-functions-database-webhooks">
    Learn how to trigger a task from a Supabase edge function when an event occurs in your database.
  </Card>

  <Card title="Supabase authentication guide" icon="book" href="/guides/frameworks/supabase-authentication">
    Learn how to authenticate Supabase tasks using JWTs for Row Level Security (RLS) or service role
    keys for admin access.
  </Card>
</CardGroup>

### Task examples with code you can copy and paste

<CardGroup>
  <Card title="Supabase database operations" icon="bolt" href="/guides/examples/supabase-database-operations">
    Run basic CRUD operations on a table in a Supabase database using Trigger.dev.
  </Card>

  <Card title="Supabase Storage upload" icon="bolt" href="/guides/examples/supabase-storage-upload">
    Download a video from a URL and upload it to Supabase Storage using S3.
  </Card>
</CardGroup>


# Supabase overview
Source: https://trigger.dev/docs/guides/frameworks/supabase-guides-overview

Guides and examples for using Supabase with Trigger.dev.

## Learn more about Supabase and Trigger.dev

### Full walkthrough guides from development to deployment

<CardGroup>
  <Card title="Edge function hello world guide" icon="book" href="/guides/frameworks/supabase-edge-functions-basic">
    Learn how to trigger a task from a Supabase edge function when a URL is visited.
  </Card>

  <Card title="Database webhooks guide" icon="book" href="/guides/frameworks/supabase-edge-functions-database-webhooks">
    Learn how to trigger a task from a Supabase edge function when an event occurs in your database.
  </Card>

  <Card title="Supabase authentication guide" icon="book" href="/guides/frameworks/supabase-authentication">
    Learn how to authenticate Supabase tasks using JWTs for Row Level Security (RLS) or service role
    keys for admin access.
  </Card>
</CardGroup>

### Task examples with code you can copy and paste

<CardGroup>
  <Card title="Supabase database operations" icon="bolt" href="/guides/examples/supabase-database-operations">
    Run basic CRUD operations on a table in a Supabase database using Trigger.dev.
  </Card>

  <Card title="Supabase Storage upload" icon="bolt" href="/guides/examples/supabase-storage-upload">
    Download a video from a URL and upload it to Supabase Storage using S3.
  </Card>
</CardGroup>


# Using webhooks with Trigger.dev
Source: https://trigger.dev/docs/guides/frameworks/webhooks-guides-overview

Guides for using webhooks with Trigger.dev.

## Overview

Webhooks are a way to send and receive events from external services. Triggering tasks using webhooks allow you to add real-time, event driven functionality to your app.

A webhook handler is code that executes in response to an event. They can be endpoints in your framework's routing which can be triggered by an external service.

## Webhook guides

<CardGroup>
  <Card title="Next.js - triggering tasks using webhooks" icon="N" href="/guides/frameworks/nextjs-webhooks">
    How to create a webhook handler in a Next.js app, and trigger a task from it.
  </Card>

  <Card title="Remix - triggering tasks using webhooks" icon="R" href="/guides/frameworks/remix-webhooks">
    How to create a webhook handler in a Remix app, and trigger a task from it.
  </Card>

  <Card title="Stripe webhooks" icon="webhook" href="/guides/examples/stripe-webhook">
    How to create a Stripe webhook handler and trigger a task when a 'checkout session completed'
    event is received.
  </Card>

  <Card title="Hookdeck webhooks" icon="webhook" href="/guides/examples/hookdeck-webhook">
    Use Hookdeck to receive webhooks and forward them to Trigger.dev tasks with logging and replay
    capabilities.
  </Card>

  <Card title="Supabase database webhooks guide" icon="webhook" href="/guides/frameworks/supabase-edge-functions-database-webhooks">
    Learn how to trigger a task from a Supabase edge function when an event occurs in your database.
  </Card>
</CardGroup>


# Frameworks, guides and examples
Source: https://trigger.dev/docs/guides/introduction

A growing list of guides and examples to get the most out of Trigger.dev.

## Frameworks

<CardGroup>
  <Card title="Bun" href="/guides/frameworks/bun" />

  <Card title="Next.js" href="/guides/frameworks/nextjs" />

  <Card title="Node.js" href="/guides/frameworks/nodejs" />

  <Card title="Remix" href="/guides/frameworks/remix" />

  <Card title="SvelteKit" href="/guides/community/sveltekit" />
</CardGroup>

## Guides

Get set up fast using our detailed walk-through guides.

| Guide                                                                                      | Description                                                            |
| :----------------------------------------------------------------------------------------- | :--------------------------------------------------------------------- |
| [AI Agent: Chat agent](/docs/guides/ai-agents/chat-agent)                                       | Build a durable, multi-turn chat agent with `chat.agent()`             |
| [AI Agent: Content moderation](/docs/guides/ai-agents/respond-and-check-content)                | Parallel check content while responding to customers                   |
| [AI Agent: Generate and translate copy](/docs/guides/ai-agents/generate-translate-copy)         | Chain prompts to generate and translate content                        |
| [AI Agent: News verification](/docs/guides/ai-agents/verify-news-article)                       | Orchestrate fact checking of news articles                             |
| [AI Agent: Route questions](/docs/guides/ai-agents/route-question)                              | Route questions to different models based on complexity                |
| [AI Agent: Translation refinement](/docs/guides/ai-agents/translate-and-refine)                 | Evaluate and refine translations with feedback                         |
| [Claude Agent SDK](/docs/guides/ai-agents/claude-code-trigger)                                  | Build AI agents that read files, run commands, and edit code           |
| [Cursor rules](/docs/guides/cursor-rules)                                                       | Use Cursor rules to help write Trigger.dev tasks                       |
| [Prisma](/docs/guides/frameworks/prisma)                                                        | How to setup Prisma with Trigger.dev                                   |
| [Python image processing](/docs/guides/python/python-image-processing)                          | Use Python and Pillow to process images                                |
| [Python document to markdown](/docs/guides/python/python-doc-to-markdown)                       | Use Python and MarkItDown to convert documents to markdown             |
| [Python PDF form extractor](/docs/guides/python/python-pdf-form-extractor)                      | Use Python, PyMuPDF and Trigger.dev to extract data from a PDF form    |
| [Python web crawler](/docs/guides/python/python-crawl4ai)                                       | Use Python, Crawl4AI and Playwright to create a headless web crawler   |
| [Sequin database triggers](/docs/guides/frameworks/sequin)                                      | Trigger tasks from database changes using Sequin                       |
| [Stripe webhooks](/docs/guides/examples/stripe-webhook)                                         | Trigger tasks from incoming Stripe webhook events                      |
| [Hookdeck webhooks](/docs/guides/examples/hookdeck-webhook)                                     | Use Hookdeck to receive webhooks and forward them to Trigger.dev tasks |
| [Supabase database webhooks](/docs/guides/frameworks/supabase-edge-functions-database-webhooks) | Trigger tasks using Supabase database webhooks                         |
| [Supabase edge function hello world](/docs/guides/frameworks/supabase-edge-functions-basic)     | Trigger tasks from Supabase edge function                              |
| [Using webhooks in Next.js](/docs/guides/frameworks/nextjs-webhooks)                            | Trigger tasks from a webhook in Next.js                                |
| [Using webhooks in Remix](/docs/guides/frameworks/remix-webhooks)                               | Trigger tasks from a webhook in Remix                                  |

## Featured use cases

<CardGroup>
  <Card title="Data processing & ETL workflows" icon="database" href="/guides/use-cases/data-processing-etl">
    Build complex data pipelines that process large datasets without timeouts.
  </Card>

  <Card title="Media processing workflows" icon="film" href="/guides/use-cases/media-processing">
    Batch process videos, images, audio, and documents with no execution time limits.
  </Card>

  <Card title="AI media generation workflows" icon="wand-magic-sparkles" href="/guides/use-cases/media-generation">
    Generate images, videos, audio, documents and other media using AI models.
  </Card>

  <Card title="Marketing workflows" icon="bullhorn" href="/guides/use-cases/marketing">
    Build drip campaigns, create marketing content, and orchestrate multi-channel campaigns.
  </Card>
</CardGroup>

## Example projects

Example projects are full projects with example repos you can fork and use. These are a great way of learning how to use Trigger.dev in your projects.

| Example project                                                                                                 | Description                                                                                                                               | Framework | GitHub                                                                                                         |
| :-------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | :-------- | :------------------------------------------------------------------------------------------------------------- |
| [Anchor Browser web scraper](/docs/guides/example-projects/anchor-browser-web-scraper)                               | Monitor a website and find the cheapest tickets for a show.                                                                               | —         | [View the repo](https://github.com/triggerdotdev/examples/tree/main/anchor-browser-web-scraper)                |
| [Batch LLM Evaluator](/docs/guides/example-projects/batch-llm-evaluator)                                             | Evaluate multiple LLM models and stream the results to the frontend.                                                                      | Next.js   | [View the repo](https://github.com/triggerdotdev/examples/tree/main/batch-llm-evaluator)                       |
| [Claude changelog generator](/docs/guides/example-projects/claude-changelog-generator)                               | Automatically generate professional changelogs from git commits using Claude.                                                             | —         | [View the repo](https://github.com/triggerdotdev/examples/tree/main/changelog-generator)                       |
| [Claude GitHub wiki agent](/docs/guides/example-projects/claude-github-wiki)                                         | Generate and maintain GitHub wiki documentation with Claude-powered analysis.                                                             | —         | [View the repo](https://github.com/triggerdotdev/examples/tree/main/claude-agent-github-wiki)                  |
| [Claude thinking chatbot](/docs/guides/example-projects/claude-thinking-chatbot)                                     | Use Vercel's AI SDK and Anthropic's Claude 3.7 model to create a thinking chatbot.                                                        | Next.js   | [View the repo](https://github.com/triggerdotdev/examples/tree/main/claude-thinking-chatbot)                   |
| [ClickHouse chat agent](/docs/guides/example-projects/clickhouse-chat-agent)                                         | Build a chat agent that answers questions about your ClickHouse data with charts, tables and maps using `chat.agent()` and generative UI. | Next.js   | [View the repo](https://github.com/triggerdotdev/examples/tree/main/clickhouse-chat-agent)                     |
| [Cursor background agent](/docs/guides/example-projects/cursor-background-agent)                                     | Run Cursor's headless CLI agent as a background task, streaming live output to the browser.                                               | Next.js   | [View the repo](https://github.com/triggerdotdev/examples/tree/main/cursor-cli-demo)                           |
| [Human-in-the-loop workflow](/docs/guides/example-projects/human-in-the-loop-workflow)                               | Create audio summaries of newspaper articles using a human-in-the-loop workflow built with ReactFlow and Trigger.dev waitpoint tokens.    | Next.js   | [View the repo](https://github.com/triggerdotdev/examples/tree/main/article-summary-workflow)                  |
| [Mastra agents with memory](/docs/guides/example-projects/mastra-agents-with-memory)                                 | Use Mastra to create a weather agent that can collect live weather data and generate clothing recommendations.                            | —         | [View the repo](https://github.com/triggerdotdev/examples/tree/main/mastra-agents)                             |
| [OpenAI Agents SDK for Python guardrails](/docs/guides/example-projects/openai-agent-sdk-guardrails)                 | Use the OpenAI Agents SDK for Python to create a guardrails system for your AI agents.                                                    | —         | [View the repo](https://github.com/triggerdotdev/examples/tree/main/openai-agent-sdk-guardrails-examples)      |
| [OpenAI Agents SDK for TypeScript playground](/docs/guides/example-projects/openai-agents-sdk-typescript-playground) | A playground containing 7 AI agents using the OpenAI Agents SDK for TypeScript with Trigger.dev.                                          | —         | [View the repo](https://github.com/triggerdotdev/examples/tree/main/openai-agents-sdk-with-trigger-playground) |
| [Product image generator](/docs/guides/example-projects/product-image-generator)                                     | Transform basic product photos into professional marketing shots using Replicate's image generation models.                               | Next.js   | [View the repo](https://github.com/triggerdotdev/examples/tree/main/product-image-generator)                   |
| [Python web crawler](/docs/guides/python/python-crawl4ai)                                                            | Use Python, Crawl4AI and Playwright to create a headless web crawler with Trigger.dev.                                                    | —         | [View the repo](https://github.com/triggerdotdev/examples/tree/main/python-crawl4ai)                           |
| [Realtime CSV Importer](/docs/guides/example-projects/realtime-csv-importer)                                         | Upload a CSV file and see the progress of the task streamed to the frontend.                                                              | Next.js   | [View the repo](https://github.com/triggerdotdev/examples/tree/main/realtime-csv-importer)                     |
| [Realtime Fal.ai image generation](/docs/guides/example-projects/realtime-fal-ai)                                    | Generate an image from a prompt using Fal.ai and show the progress of the task on the frontend using Realtime.                            | Next.js   | [View the repo](https://github.com/triggerdotdev/examples/tree/main/realtime-fal-ai-image-generation)          |
| [Smart Spreadsheet](/docs/guides/example-projects/smart-spreadsheet)                                                 | Enrich company data using Exa search and Claude with real-time streaming results.                                                         | Next.js   | [View the repo](https://github.com/triggerdotdev/examples/tree/main/smart-spreadsheet)                         |
| [Turborepo monorepo with Prisma](/docs/guides/example-projects/turborepo-monorepo-prisma)                            | Use Prisma in a Turborepo monorepo with Trigger.dev.                                                                                      | Next.js   | [View the repo](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package)  |
| [Vercel AI SDK image generator](/docs/guides/example-projects/vercel-ai-sdk-image-generator)                         | Use the Vercel AI SDK to generate images from a prompt.                                                                                   | Next.js   | [View the repo](https://github.com/triggerdotdev/examples/tree/main/vercel-ai-sdk-image-generator)             |
| [Vercel AI SDK deep research agent](/docs/guides/example-projects/vercel-ai-sdk-deep-research)                       | Use the Vercel AI SDK to generate comprehensive PDF reports using a deep research agent.                                                  | Next.js   | [View the repo](https://github.com/triggerdotdev/examples/tree/main/vercel-ai-sdk-deep-research-agent)         |

## Example tasks

Task code you can copy and paste to use in your project. They can all be extended and customized to fit your needs.

| Example task                                                                  | Description                                                                                                                                          |
| :---------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------- |
| [DALL·E 3 image generation](/docs/guides/examples/dall-e3-generate-image)          | Use OpenAI's GPT-4o and DALL·E 3 to generate an image and text.                                                                                      |
| [Deepgram audio transcription](/docs/guides/examples/deepgram-transcribe-audio)    | Transcribe audio using Deepgram's speech recognition API.                                                                                            |
| [Fal.ai image to cartoon](/docs/guides/examples/fal-ai-image-to-cartoon)           | Convert an image to a cartoon using Fal.ai, and upload the result to Cloudflare R2.                                                                  |
| [Fal.ai with Realtime](/docs/guides/examples/fal-ai-realtime)                      | Generate an image from a prompt using Fal.ai and show the progress of the task on the frontend using Realtime.                                       |
| [FFmpeg video processing](/docs/guides/examples/ffmpeg-video-processing)           | Use FFmpeg to process a video in various ways and save it to Cloudflare R2.                                                                          |
| [Firecrawl URL crawl](/docs/guides/examples/firecrawl-url-crawl)                   | Learn how to use Firecrawl to crawl a URL and return LLM-ready markdown.                                                                             |
| [LibreOffice PDF conversion](/docs/guides/examples/libreoffice-pdf-conversion)     | Convert a document to PDF using LibreOffice.                                                                                                         |
| [Lightpanda](/docs/guides/examples/lightpanda)                                     | Use Lightpanda browser (or cloud version) to get a webpage's content.                                                                                |
| [OpenAI with retrying](/docs/guides/examples/open-ai-with-retrying)                | Create a reusable OpenAI task with custom retry options.                                                                                             |
| [PDF to image](/docs/guides/examples/pdf-to-image)                                 | Use `MuPDF` to turn a PDF into images and save them to Cloudflare R2.                                                                                |
| [Puppeteer](/docs/guides/examples/puppeteer)                                       | Use Puppeteer to generate a PDF or scrape a webpage.                                                                                                 |
| [React email](/docs/guides/examples/react-email)                                   | Send an email using React Email.                                                                                                                     |
| [React to PDF](/docs/guides/examples/react-pdf)                                    | Use `react-pdf` to generate a PDF and save it to Cloudflare R2.                                                                                      |
| [Resend email sequence](/docs/guides/examples/resend-email-sequence)               | Send a sequence of emails over several days using Resend with Trigger.dev.                                                                           |
| [Replicate image generation](/docs/guides/examples/replicate-image-generation)     | Learn how to generate images from source image URLs using Replicate and Trigger.dev.                                                                 |
| [Satori](/docs/guides/examples/satori)                                             | Generate OG images using React Satori.                                                                                                               |
| [Scrape Hacker News](/docs/guides/examples/scrape-hacker-news)                     | Scrape Hacker News using BrowserBase and Puppeteer, summarize the articles with ChatGPT and send an email of the summary every weekday using Resend. |
| [Sentry error tracking](/docs/guides/examples/sentry-error-tracking)               | Automatically send errors to Sentry from your tasks.                                                                                                 |
| [Sharp image processing](/docs/guides/examples/sharp-image-processing)             | Use Sharp to process an image and save it to Cloudflare R2.                                                                                          |
| [Supabase database operations](/docs/guides/examples/supabase-database-operations) | Run basic CRUD operations on a table in a Supabase database using Trigger.dev.                                                                       |
| [Supabase Storage upload](/docs/guides/examples/supabase-storage-upload)           | Download a video from a URL and upload it to Supabase Storage using S3.                                                                              |
| [Vercel AI SDK](/docs/guides/examples/vercel-ai-sdk)                               | Use Vercel AI SDK to generate text using OpenAI.                                                                                                     |
| [Vercel sync environment variables](/docs/guides/examples/vercel-sync-env-vars)    | Automatically sync environment variables from your Vercel projects to Trigger.dev.                                                                   |

<Note>
  If you would like to see a guide for your framework, or an example task for your use case, please
  request it in our [Discord server](https://trigger.dev/discord) and we'll add it to the list.
</Note>


# Python headless browser web crawler example
Source: https://trigger.dev/docs/guides/python/python-crawl4ai

Learn how to use Python, Crawl4AI and Playwright to create a headless browser web crawler with Trigger.dev.

## Overview

This demo showcases how to use Trigger.dev with Python to build a web crawler that uses a headless browser to navigate websites and extract content.

## Prerequisites

* A project with [Trigger.dev initialized](/docs/quick-start)
* [Python](https://www.python.org/) installed on your local machine

## Features

* [Trigger.dev](https://trigger.dev) for background task orchestration
* Our [Python build extension](/docs/config/extensions/pythonExtension) to install the dependencies and run the Python script
* [Crawl4AI](https://github.com/unclecode/crawl4ai), an open source LLM friendly web crawler
* A custom [Playwright extension](https://playwright.dev/) to create a headless chromium browser
* Proxy support

## Using Proxies

<Warning>
  **WEB SCRAPING:** When web scraping, you MUST use a proxy to comply with our terms of service. Direct scraping of third-party websites without the site owner's permission using Trigger.dev Cloud is prohibited and will result in account suspension. See [this example](/docs/guides/examples/puppeteer#scrape-content-from-a-web-page) which uses a proxy.
</Warning>

Some popular proxy services are:

* [Smartproxy](https://smartproxy.com/)
* [Bright Data](https://brightdata.com/)
* [Browserbase](https://browserbase.com/)
* [Oxylabs](https://oxylabs.io/)
* [ScrapingBee](https://scrapingbee.com/)

Once you have a proxy service, set the following environment variables in your Trigger.dev .env file, and add them in the Trigger.dev dashboard:

* `PROXY_URL`: The URL of your proxy server (e.g., `http://proxy.example.com:8080`)
* `PROXY_USERNAME`: Username for authenticated proxies (optional)
* `PROXY_PASSWORD`: Password for authenticated proxies (optional)

## GitHub repo

<Card title="View the project on GitHub" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/python-crawl4ai">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## The code

### Build configuration

After you've initialized your project with Trigger.dev, add these build settings to your `trigger.config.ts` file:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { pythonExtension } from "@trigger.dev/python/extension";
import type { BuildContext, BuildExtension } from "@trigger.dev/core/build";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    extensions: [
      // This is required to use the Python extension
      pythonExtension(),
      // This is required to create a headless chromium browser with Playwright
      installPlaywrightChromium(),
    ],
  },
});

// This is a custom build extension to install Playwright and Chromium
export function installPlaywrightChromium(): BuildExtension {
  return {
    name: "InstallPlaywrightChromium",
    onBuildComplete(context: BuildContext) {
      const instructions = [
        // Base and Chromium dependencies
        `RUN apt-get update && apt-get install -y --no-install-recommends \
          curl unzip npm libnspr4 libatk1.0-0 libatk-bridge2.0-0 libatspi2.0-0 \
          libasound2 libnss3 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
          libgbm1 libxkbcommon0 \
          && apt-get clean && rm -rf /var/lib/apt/lists/*`,

        // Install Playwright and Chromium
        `RUN npm install -g playwright`,
        `RUN mkdir -p /ms-playwright`,
        `RUN PLAYWRIGHT_BROWSERS_PATH=/ms-playwright python -m playwright install --with-deps chromium`,
      ];

      context.addLayer({
        id: "playwright",
        image: { instructions },
        deploy: {
          env: {
            PLAYWRIGHT_BROWSERS_PATH: "/ms-playwright",
            PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1",
            PLAYWRIGHT_SKIP_BROWSER_VALIDATION: "1",
          },
          override: true,
        },
      });
    },
  };
}
```

<Info>
  Learn more about executing scripts in your Trigger.dev project using our Python build extension
  [here](/docs/config/extensions/pythonExtension).
</Info>

### Task code

This task uses the `python.runScript` method to run the `crawl-url.py` script with the given URL as an argument. You can see the original task in our examples repository [here](https://github.com/triggerdotdev/examples/blob/main/python-crawl4ai/src/trigger/pythonTasks.ts).

```ts src/trigger/pythonTasks.ts theme={"theme":"css-variables"}
import { logger, schemaTask, task } from "@trigger.dev/sdk";
import { python } from "@trigger.dev/python";
import { z } from "zod";

export const convertUrlToMarkdown = schemaTask({
  id: "convert-url-to-markdown",
  schema: z.object({
    url: z.string().url(),
  }),
  run: async (payload) => {
    // Pass through any proxy environment variables
    const env = {
      PROXY_URL: process.env.PROXY_URL,
      PROXY_USERNAME: process.env.PROXY_USERNAME,
      PROXY_PASSWORD: process.env.PROXY_PASSWORD,
    };

    const result = await python.runScript("./src/python/crawl-url.py", [payload.url], { env });

    logger.debug("convert-url-to-markdown", {
      url: payload.url,
      result,
    });

    return result.stdout;
  },
});
```

### Add a requirements.txt file

Add the following to your `requirements.txt` file. This is required in Python projects to install the dependencies.

```txt requirements.txt theme={"theme":"css-variables"}
crawl4ai
playwright
urllib3<2.0.0
```

### The Python script

The Python script is a simple script using Crawl4AI that takes a URL and returns the markdown content of the page. You can see the original script in our examples repository [here](https://github.com/triggerdotdev/examples/blob/main/python-crawl4ai/src/python/crawl-url.py).

```python src/python/crawl-url.py theme={"theme":"css-variables"}
import asyncio
import sys
import os
from crawl4ai import *
from crawl4ai.async_configs import BrowserConfig

async def main(url: str):
    # Get proxy configuration from environment variables
    proxy_url = os.environ.get("PROXY_URL")
    proxy_username = os.environ.get("PROXY_USERNAME")
    proxy_password = os.environ.get("PROXY_PASSWORD")

    # Configure the proxy
    browser_config = None
    if proxy_url:
        if proxy_username and proxy_password:
            # Use authenticated proxy
            proxy_config = {
                "server": proxy_url,
                "username": proxy_username,
                "password": proxy_password
            }
            browser_config = BrowserConfig(proxy_config=proxy_config)
        else:
            # Use simple proxy
            browser_config = BrowserConfig(proxy=proxy_url)
    else:
        browser_config = BrowserConfig()

    async with AsyncWebCrawler(config=browser_config) as crawler:
        result = await crawler.arun(
            url=url,
        )
        print(result.markdown)

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python crawl-url.py <url>")
        sys.exit(1)
    url = sys.argv[1]
    asyncio.run(main(url))
```

## Testing your task

1. Create a virtual environment `python -m venv venv`
2. Activate the virtual environment, depending on your OS: On Mac/Linux: `source venv/bin/activate`, on Windows: `venv\Scripts\activate`
3. Install the Python dependencies `pip install -r requirements.txt`
4. If you haven't already, copy your project ref from your [Trigger.dev dashboard](https://cloud.trigger.dev) and add it to the `trigger.config.ts` file.
5. Run the Trigger.dev CLI `dev` command (it may ask you to authorize the CLI if you haven't already).
6. Test the task in the dashboard, using a URL of your choice.

<Warning>
  **WEB SCRAPING:** When web scraping, you MUST use a proxy to comply with our terms of service. Direct scraping of third-party websites without the site owner's permission using Trigger.dev Cloud is prohibited and will result in account suspension. See [this example](/docs/guides/examples/puppeteer#scrape-content-from-a-web-page) which uses a proxy.
</Warning>

## Deploying your task

Deploy the task to production using the Trigger.dev CLI `deploy` command.

## Learn more about using Python with Trigger.dev

<Card title="Python build extension" icon="code" href="/config/extensions/pythonExtension">
  Learn how to use our built-in Python build extension to install dependencies and run your Python
  code.
</Card>


# Convert documents to markdown using Python and MarkItDown
Source: https://trigger.dev/docs/guides/python/python-doc-to-markdown

Learn how to use Trigger.dev with Python to convert documents to markdown using MarkItDown.

## Overview

Convert documents to markdown using Microsoft's [MarkItDown](https://github.com/microsoft/markitdown) library. This can be especially useful for preparing documents in a structured format for AI applications.

## Prerequisites

* A project with [Trigger.dev initialized](/docs/quick-start)
* [Python](https://www.python.org/) installed on your local machine. *This example requires Python 3.10 or higher.*

## Features

* A Trigger.dev task which downloads a document from a URL and runs the Python script which converts it to markdown
* A Python script to convert documents to markdown using Microsoft's [MarkItDown](https://github.com/microsoft/markitdown) library
* Uses our [Python build extension](/docs/config/extensions/pythonExtension) to install dependencies and run Python scripts

## GitHub repo

<Card title="View the project on GitHub" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/python-doc-to-markdown-converter">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## The code

### Build configuration

After you've initialized your project with Trigger.dev, add these build settings to your `trigger.config.ts` file:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { pythonExtension } from "@trigger.dev/python/extension";
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  runtime: "node",
  project: "<your-project-ref>",
  // Your other config settings...
  build: {
    extensions: [
      pythonExtension({
        // The path to your requirements.txt file
        requirementsFile: "./requirements.txt",
        // The path to your Python binary
        devPythonBinaryPath: `venv/bin/python`,
        // The paths to your Python scripts to run
        scripts: ["src/python/**/*.py"],
      }),
    ],
  },
});
```

<Info>
  Learn more about executing scripts in your Trigger.dev project using our Python build extension
  [here](/docs/config/extensions/pythonExtension).
</Info>

### Task code

This task uses the `python.runScript` method to run the `markdown-converter.py` script with the given document URL as an argument.

```ts src/trigger/convertToMarkdown.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { python } from "@trigger.dev/python";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";

export const convertToMarkdown = task({
  id: "convert-to-markdown",
  run: async (payload: { url: string }) => {
    const { url } = payload;

    // STEP 1: Create temporary file with unique name
    const tempDir = os.tmpdir();
    const fileName = `doc-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`;
    const urlPath = new URL(url).pathname;
    const extension = path.extname(urlPath) || ".docx";
    const tempFilePath = path.join(tempDir, `${fileName}${extension}`);

    // STEP 2: Download file from URL
    const response = await fetch(url);
    const buffer = await response.arrayBuffer();
    await fs.promises.writeFile(tempFilePath, Buffer.from(buffer));

    // STEP 3: Run Python script to convert document to markdown
    const pythonResult = await python.runScript("./src/python/markdown-converter.py", [
      JSON.stringify({ file_path: tempFilePath }),
    ]);

    // STEP 4: Clean up temporary file
    fs.unlink(tempFilePath, () => {});

    // STEP 5: Process result
    if (pythonResult.stdout) {
      const result = JSON.parse(pythonResult.stdout);
      return {
        url,
        markdown: result.status === "success" ? result.markdown : null,
        error: result.status === "error" ? result.error : null,
        success: result.status === "success",
      };
    }

    return {
      url,
      markdown: null,
      error: "No output from Python script",
      success: false,
    };
  },
});
```

### Add a requirements.txt file

Add the following to your `requirements.txt` file. This is required in Python projects to install the dependencies.

```txt requirements.txt theme={"theme":"css-variables"}
markitdown[all]
```

### The Python script

The Python script uses MarkItDown to convert documents to Markdown format.

```python src/python/markdown-converter.py theme={"theme":"css-variables"}
import json
import sys
import os
from markitdown import MarkItDown

def convert_to_markdown(file_path):
    """Convert a file to markdown format using MarkItDown"""
    # Check if file exists
    if not os.path.exists(file_path):
        raise FileNotFoundError(f"File not found: {file_path}")

    # Initialize MarkItDown
    md = MarkItDown()

    # Convert the file
    try:
        result = md.convert(file_path)
        return result.text_content
    except Exception as e:
        raise Exception(f"Error converting file: {str(e)}")

def process_trigger_task(file_path):
    """Process a file and convert to markdown"""
    try:
        markdown_result = convert_to_markdown(file_path)
        return {
            "status": "success",
            "markdown": markdown_result
        }
    except Exception as e:
        return {
            "status": "error",
            "error": str(e)
        }

if __name__ == "__main__":
    # Get the file path from command line arguments
    if len(sys.argv) < 2:
        print(json.dumps({"status": "error", "error": "No file path provided"}))
        sys.exit(1)

    try:
        config = json.loads(sys.argv[1])
        file_path = config.get("file_path")

        if not file_path:
            print(json.dumps({"status": "error", "error": "No file path specified in config"}))
            sys.exit(1)

        result = process_trigger_task(file_path)
        print(json.dumps(result))
    except Exception as e:
        print(json.dumps({"status": "error", "error": str(e)}))
        sys.exit(1)
```

## Testing your task

1. Create a virtual environment `python -m venv venv`
2. Activate the virtual environment, depending on your OS: On Mac/Linux: `source venv/bin/activate`, on Windows: `venv\Scripts\activate`
3. Install the Python dependencies `pip install -r requirements.txt`. *Make sure you have Python 3.10 or higher installed.*
4. Copy the project ref from your [Trigger.dev dashboard](https://cloud.trigger.dev) and add it to the `trigger.config.ts` file.
5. Run the Trigger.dev CLI `dev` command (it may ask you to authorize the CLI if you haven't already).
6. Test the task in the dashboard by providing a valid document URL.
7. Deploy the task to production using the Trigger.dev CLI `deploy` command.

## MarkItDown Conversion Capabilities

* Convert various file formats to Markdown:
  * Office formats (Word, PowerPoint, Excel)
  * PDFs
  * Images (with optional LLM-generated descriptions)
  * HTML, CSV, JSON, XML
  * Audio files (with optional transcription)
  * ZIP archives
  * And more
* Preserve document structure (headings, lists, tables, etc.)
* Handle multiple input methods (file paths, URLs, base64 data)
* Optional Azure Document Intelligence integration for better PDF and image conversion

## Learn more about using Python with Trigger.dev

<Card title="Python build extension" icon="code" href="/config/extensions/pythonExtension">
  Learn how to use our built-in Python build extension to install dependencies and run your Python
  code.
</Card>


# Python image processing example
Source: https://trigger.dev/docs/guides/python/python-image-processing

Learn how to use Trigger.dev with Python to process images from URLs and upload them to S3.

## Overview

This demo showcases how to use Trigger.dev with Python to process an image using Pillow (PIL) from a URL and upload it to S3-compatible storage bucket.

## Prerequisites

* A project with [Trigger.dev initialized](/docs/quick-start)
* [Python](https://www.python.org/) installed on your local machine

## Features

* A [Trigger.dev](https://trigger.dev) task to trigger the image processing Python script, and then upload the processed image to S3-compatible storage
* The [Trigger.dev Python build extension](https://trigger.dev/docs/config/extensions/pythonExtension) to install dependencies and run Python scripts
* [Pillow (PIL)](https://pillow.readthedocs.io/) for powerful image processing capabilities
* [AWS SDK v3](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3/) for S3 uploads
* S3-compatible storage support (AWS S3, Cloudflare R2, etc.)

## GitHub repo

<Card title="View the project on GitHub" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/python-image-processing">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## The code

### Build configuration

After you've initialized your project with Trigger.dev, add these build settings to your `trigger.config.ts` file:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { pythonExtension } from "@trigger.dev/python/extension";
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  runtime: "node",
  project: "<your-project-ref>",
  // Your other config settings...
  build: {
    extensions: [
      pythonExtension({
        // The path to your requirements.txt file
        requirementsFile: "./requirements.txt",
        // The path to your Python binary
        devPythonBinaryPath: `venv/bin/python`,
        // The paths to your Python scripts to run
        scripts: ["src/python/**/*.py"],
      }),
    ],
  },
});
```

<Info>
  Learn more about executing scripts in your Trigger.dev project using our Python build extension
  [here](/docs/config/extensions/pythonExtension).
</Info>

### Task code

This task uses the `python.runScript` method to run the `image-processing.py` script with the given image URL as an argument. You can adjust the image processing parameters in the payload, with options such as height, width, quality, output format, etc.

```ts src/trigger/processImage.ts theme={"theme":"css-variables"}
import { schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";
import { python } from "@trigger.dev/python";
import { promises as fs } from "fs";
import { S3Client } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";

// Initialize S3 client
const s3Client = new S3Client({
  region: "auto",
  endpoint: process.env.S3_ENDPOINT,
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
  },
});

// Define the input schema with Zod
const imageProcessingSchema = z.object({
  imageUrl: z.string().url(),
  height: z.number().positive().optional().default(800),
  width: z.number().positive().optional().default(600),
  quality: z.number().min(1).max(100).optional().default(85),
  maintainAspectRatio: z.boolean().optional().default(true),
  outputFormat: z.enum(["jpeg", "png", "webp", "gif", "avif"]).optional().default("jpeg"),
  brightness: z.number().optional(),
  contrast: z.number().optional(),
  sharpness: z.number().optional(),
  grayscale: z.boolean().optional().default(false),
});

// Define the output schema
const outputSchema = z.object({
  url: z.string().url(),
  key: z.string(),
  format: z.string(),
  originalSize: z.object({
    width: z.number(),
    height: z.number(),
  }),
  newSize: z.object({
    width: z.number(),
    height: z.number(),
  }),
  fileSizeBytes: z.number(),
  exitCode: z.number(),
});

export const processImage = schemaTask({
  id: "process-image",
  schema: imageProcessingSchema,
  run: async (payload, io) => {
    const {
      imageUrl,
      height,
      width,
      quality,
      maintainAspectRatio,
      outputFormat,
      brightness,
      contrast,
      sharpness,
      grayscale,
    } = payload;

    try {
      // Run the Python script
      const result = await python.runScript("./src/python/image-processing.py", [
        imageUrl,
        height.toString(),
        width.toString(),
        quality.toString(),
        maintainAspectRatio.toString(),
        outputFormat,
        brightness?.toString() || "null",
        contrast?.toString() || "null",
        sharpness?.toString() || "null",
        grayscale.toString(),
      ]);

      const { outputPath, format, originalSize, newSize, fileSizeBytes } = JSON.parse(
        result.stdout
      );

      // Read file once
      const fileContent = await fs.readFile(outputPath);

      try {
        // Upload to S3
        const key = `processed-images/${Date.now()}-${outputPath.split("/").pop()}`;
        await new Upload({
          client: s3Client,
          params: {
            Bucket: process.env.S3_BUCKET!,
            Key: key,
            Body: fileContent,
            ContentType: `image/${format}`,
          },
        }).done();

        return {
          url: `${process.env.S3_PUBLIC_URL}/${key}`,
          key,
          format,
          originalSize,
          newSize,
          fileSizeBytes,
          exitCode: result.exitCode,
        };
      } finally {
        // Always clean up the temp file
        await fs.unlink(outputPath).catch(console.error);
      }
    } catch (error) {
      throw new Error(
        `Processing failed: ${error instanceof Error ? error.message : "Unknown error"}`
      );
    }
  },
});
```

### Add a requirements.txt file

Add the following to your `requirements.txt` file. This is required in Python projects to install the dependencies.

```txt requirements.txt theme={"theme":"css-variables"}
# Core dependencies
Pillow==10.2.0            # Image processing library
python-dotenv==1.0.0      # Environment variable management
requests==2.31.0          # HTTP requests
numpy==1.26.3             # Numerical operations (for advanced processing)

# Optional enhancements
opencv-python==4.8.1.78   # For more advanced image processing
```

### The Python script

The Python script uses Pillow (PIL) to process an image. You can see the original script in our examples repository [here](https://github.com/triggerdotdev/examples/blob/main/python-image-processing/src/python/image-processing.py).

```python src/python/image-processing.py theme={"theme":"css-variables"}
from PIL import Image, ImageOps, ImageEnhance
import io
from io import BytesIO
import os
from typing import Tuple, List, Dict, Optional, Union
import logging
import sys
import json
import requests

# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

class ImageProcessor:
    """Image processing utility for resizing, optimizing, and converting images."""

    # Supported formats for conversion
    SUPPORTED_FORMATS = ['JPEG', 'PNG', 'WEBP', 'GIF', 'AVIF']

    @staticmethod
    def open_image(image_data: Union[bytes, str]) -> Image.Image:
        """Open an image from bytes or file path."""
        try:
            if isinstance(image_data, bytes):
                return Image.open(io.BytesIO(image_data))
            else:
                return Image.open(image_data)
        except Exception as e:
            logger.error(f"Failed to open image: {e}")
            raise ValueError(f"Could not open image: {e}")

    @staticmethod
    def resize_image(
        img: Image.Image,
        width: Optional[int] = None,
        height: Optional[int] = None,
        maintain_aspect_ratio: bool = True
    ) -> Image.Image:
        """
        Resize an image to specified dimensions.

        Args:
            img: PIL Image object
            width: Target width (None to auto-calculate from height)
            height: Target height (None to auto-calculate from width)
            maintain_aspect_ratio: Whether to maintain the original aspect ratio

        Returns:
            Resized PIL Image
        """
        if width is None and height is None:
            return img  # No resize needed

        original_width, original_height = img.size

        if maintain_aspect_ratio:
            if width and height:
                # Calculate the best fit while maintaining aspect ratio
                ratio = min(width / original_width, height / original_height)
                new_width = int(original_width * ratio)
                new_height = int(original_height * ratio)
            elif width:
                # Calculate height based on width
                ratio = width / original_width
                new_width = width
                new_height = int(original_height * ratio)
            else:
                # Calculate width based on height
                ratio = height / original_height
                new_width = int(original_width * ratio)
                new_height = height
        else:
            # Force exact dimensions
            new_width = width if width else original_width
            new_height = height if height else original_height

        return img.resize((new_width, new_height), Image.LANCZOS)

    @staticmethod
    def optimize_image(
        img: Image.Image,
        quality: int = 85,
        format: Optional[str] = None
    ) -> Tuple[bytes, str]:
        """
        Optimize an image for web delivery.

        Args:
            img: PIL Image object
            quality: JPEG/WebP quality (0-100)
            format: Output format (JPEG, PNG, WEBP, etc.)

        Returns:
            Tuple of (image_bytes, format)
        """
        if format is None:
            format = img.format or 'JPEG'

        format = format.upper()
        if format not in ImageProcessor.SUPPORTED_FORMATS:
            format = 'JPEG'  # Default to JPEG if unsupported format

        # Convert mode if needed
        if format == 'JPEG' and img.mode in ('RGBA', 'P'):
            img = img.convert('RGB')

        # Save to bytes
        buffer = io.BytesIO()

        if format == 'JPEG':
            img.save(buffer, format=format, quality=quality, optimize=True)
        elif format == 'PNG':
            img.save(buffer, format=format, optimize=True)
        elif format == 'WEBP':
            img.save(buffer, format=format, quality=quality)
        elif format == 'AVIF':
            img.save(buffer, format=format, quality=quality)
        else:
            img.save(buffer, format=format)

        buffer.seek(0)
        return buffer.getvalue(), format.lower()

    @staticmethod
    def apply_filters(
        img: Image.Image,
        brightness: Optional[float] = None,
        contrast: Optional[float] = None,
        sharpness: Optional[float] = None,
        grayscale: bool = False
    ) -> Image.Image:
        """
        Apply various filters and enhancements to an image.

        Args:
            img: PIL Image object
            brightness: Brightness factor (0.0-2.0, 1.0 is original)
            contrast: Contrast factor (0.0-2.0, 1.0 is original)
            sharpness: Sharpness factor (0.0-2.0, 1.0 is original)
            grayscale: Convert to grayscale if True

        Returns:
            Processed PIL Image
        """
        # Apply grayscale first if requested
        if grayscale:
            img = ImageOps.grayscale(img)
            # Convert back to RGB if other filters will be applied
            if any(x is not None for x in [brightness, contrast, sharpness]):
                img = img.convert('RGB')

        # Apply enhancements
        if brightness is not None:
            img = ImageEnhance.Brightness(img).enhance(brightness)

        if contrast is not None:
            img = ImageEnhance.Contrast(img).enhance(contrast)

        if sharpness is not None:
            img = ImageEnhance.Sharpness(img).enhance(sharpness)

        return img

    @staticmethod
    def process_image(
        image_data: Union[bytes, str],
        width: Optional[int] = None,
        height: Optional[int] = None,
        maintain_aspect_ratio: bool = True,
        quality: int = 85,
        output_format: Optional[str] = None,
        brightness: Optional[float] = None,
        contrast: Optional[float] = None,
        sharpness: Optional[float] = None,
        grayscale: bool = False
    ) -> Dict:
        """
        Process an image with all available options.

        Args:
            image_data: Image bytes or file path
            width: Target width
            height: Target height
            maintain_aspect_ratio: Whether to maintain aspect ratio
            quality: Output quality
            output_format: Output format
            brightness: Brightness adjustment
            contrast: Contrast adjustment
            sharpness: Sharpness adjustment
            grayscale: Convert to grayscale

        Returns:
            Dict with processed image data and metadata
        """
        # Open the image
        img = ImageProcessor.open_image(image_data)
        original_format = img.format
        original_size = img.size

        # Apply filters
        img = ImageProcessor.apply_filters(
            img,
            brightness=brightness,
            contrast=contrast,
            sharpness=sharpness,
            grayscale=grayscale
        )

        # Resize if needed
        if width or height:
            img = ImageProcessor.resize_image(
                img,
                width=width,
                height=height,
                maintain_aspect_ratio=maintain_aspect_ratio
            )

        # Optimize and get bytes
        processed_bytes, actual_format = ImageProcessor.optimize_image(
            img,
            quality=quality,
            format=output_format
        )

        # Return result with metadata
        return {
            "processed_image": processed_bytes,
            "format": actual_format,
            "original_format": original_format,
            "original_size": original_size,
            "new_size": img.size,
            "file_size_bytes": len(processed_bytes)
        }

def process_image(url, height, width, quality):
    # Download image from URL
    response = requests.get(url)
    img = Image.open(BytesIO(response.content))

    # Resize
    img = img.resize((int(width), int(height)), Image.Resampling.LANCZOS)

    # Save with quality setting
    output_path = f"/tmp/processed_{width}x{height}.jpg"
    img.save(output_path, "JPEG", quality=int(quality))

    return output_path

if __name__ == "__main__":
    url = sys.argv[1]
    height = int(sys.argv[2])
    width = int(sys.argv[3])
    quality = int(sys.argv[4])
    maintain_aspect_ratio = sys.argv[5].lower() == 'true'
    output_format = sys.argv[6]
    brightness = float(sys.argv[7]) if sys.argv[7] != 'null' else None
    contrast = float(sys.argv[8]) if sys.argv[8] != 'null' else None
    sharpness = float(sys.argv[9]) if sys.argv[9] != 'null' else None
    grayscale = sys.argv[10].lower() == 'true'

    processor = ImageProcessor()
    result = processor.process_image(
        requests.get(url).content,
        width=width,
        height=height,
        maintain_aspect_ratio=maintain_aspect_ratio,
        quality=quality,
        output_format=output_format,
        brightness=brightness,
        contrast=contrast,
        sharpness=sharpness,
        grayscale=grayscale
    )

    output_path = f"/tmp/processed_{width}x{height}.{result['format']}"
    with open(output_path, 'wb') as f:
        f.write(result['processed_image'])

    print(json.dumps({
        "outputPath": output_path,
        "format": result['format'],
        "originalSize": result['original_size'],
        "newSize": result['new_size'],
        "fileSizeBytes": result['file_size_bytes']
    }))
```

## Testing your task

1. Create a virtual environment `python -m venv venv`
2. Activate the virtual environment, depending on your OS: On Mac/Linux: `source venv/bin/activate`, on Windows: `venv\Scripts\activate`
3. Install the Python dependencies `pip install -r requirements.txt`
4. Set up your S3-compatible storage credentials in your environment variables, in .env for local development, or in the Trigger.dev dashboard for production:
   ```
   S3_ENDPOINT=https://your-endpoint.com
   S3_ACCESS_KEY_ID=your-access-key
   S3_SECRET_ACCESS_KEY=your-secret-key
   S3_BUCKET=your-bucket-name
   S3_PUBLIC_URL=https://your-public-url.com
   ```
5. Copy the project ref from your [Trigger.dev dashboard](https://cloud.trigger.dev) and add it to the `trigger.config.ts` file.
6. Run the Trigger.dev CLI `dev` command (it may ask you to authorize the CLI if you haven't already).
7. Test the task in the dashboard by providing a valid image URL and processing options.
8. Deploy the task to production using the Trigger.dev CLI `deploy` command.

## Example Payload

These are all optional parameters that can be passed to the `image-processing.py` Python script from the `processImage.ts` task.

```json theme={"theme":"css-variables"}
{
  "imageUrl": "<your-image-url>",
  "height": 1200,
  "width": 900,
  "quality": 90,
  "maintainAspectRatio": true,
  "outputFormat": "webp",
  "brightness": 1.2,
  "contrast": 1.1,
  "sharpness": 1.3,
  "grayscale": false
}
```

## Deploying your task

Deploy the task to production using the CLI command `npx trigger.dev@latest deploy`

## Learn more about using Python with Trigger.dev

<Card title="Python build extension" icon="code" href="/config/extensions/pythonExtension">
  Learn how to use our built-in Python build extension to install dependencies and run your Python
  code.
</Card>


# Python PDF form extractor example
Source: https://trigger.dev/docs/guides/python/python-pdf-form-extractor

Learn how to use Trigger.dev with Python to extract form data from PDF files.

## Overview

This demo showcases how to use Trigger.dev with Python to extract structured form data from a PDF file available at a URL.

## Prerequisites

* A project with [Trigger.dev initialized](/docs/quick-start)
* [Python](https://www.python.org/) installed on your local machine

## Features

* A [Trigger.dev](https://trigger.dev) task to trigger the Python script
* [Trigger.dev Python build extension](https://trigger.dev/docs/config/extensions/pythonExtension) to install the dependencies and run the Python script
* [PyMuPDF](https://pymupdf.readthedocs.io/en/latest/) to extract form data from PDF files
* [Requests](https://docs.python-requests.org/en/master/) to download PDF files from URLs

## GitHub repo

<Card title="View the project on GitHub" icon="GitHub" href="https://github.com/triggerdotdev/examples/edit/main/python-pdf-form-extractor/">
  Click here to view the full code for this project in our examples repository on GitHub. You can
  fork it and use it as a starting point for your own project.
</Card>

## The code

### Build configuration

After you've initialized your project with Trigger.dev, add these build settings to your `trigger.config.ts` file:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { pythonExtension } from "@trigger.dev/python/extension";
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  runtime: "node",
  project: "<your-project-ref>",
  // Your other config settings...
  build: {
    extensions: [
      pythonExtension({
        // The path to your requirements.txt file
        requirementsFile: "./requirements.txt",
        // The path to your Python binary
        devPythonBinaryPath: `venv/bin/python`,
        // The paths to your Python scripts to run
        scripts: ["src/python/**/*.py"],
      }),
    ],
  },
});
```

<Info>
  Learn more about executing scripts in your Trigger.dev project using our Python build extension
  [here](/docs/config/extensions/pythonExtension).
</Info>

### Task code

This task uses the `python.runScript` method to run the `image-processing.py` script with the given image URL as an argument. You can adjust the image processing parameters in the payload, with options such as height, width, quality, output format, etc.

```ts src/trigger/pythonPdfTask.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { python } from "@trigger.dev/python";

export const processPdfForm = task({
  id: "process-pdf-form",
  run: async (payload: { pdfUrl: string }, io: any) => {
    const { pdfUrl } = payload;
    const args = [pdfUrl];

    const result = await python.runScript("./src/python/extract-pdf-form.py", args);

    // Parse the JSON output from the script
    let formData;
    try {
      formData = JSON.parse(result.stdout);
    } catch (error) {
      throw new Error(`Failed to parse JSON output: ${result.stdout}`);
    }

    return {
      formData,
      stderr: result.stderr,
      exitCode: result.exitCode,
    };
  },
});
```

### Add a requirements.txt file

Add the following to your `requirements.txt` file. This is required in Python projects to install the dependencies.

```txt requirements.txt theme={"theme":"css-variables"}
PyMuPDF==1.23.8
requests==2.31.0
```

### The Python script

The Python script uses PyMuPDF to extract form data from a PDF file. You can see the original script in our examples repository [here](https://github.com/triggerdotdev/examples/blob/main/python-pdf-form-extractor/src/python/extract-pdf-form.py).

```python src/python/extract-pdf-form.py theme={"theme":"css-variables"}
import fitz  # PyMuPDF
import requests
import os
import json
import sys
from urllib.parse import urlparse

def download_pdf(url):
    """Download PDF from URL to a temporary file"""
    response = requests.get(url)
    response.raise_for_status()

    # Get filename from URL or use default
    filename = os.path.basename(urlparse(url).path) or "downloaded.pdf"
    filepath = os.path.join("/tmp", filename)

    with open(filepath, 'wb') as f:
        f.write(response.content)
    return filepath

def extract_form_data(pdf_path):
    """Extract form data from a PDF file."""
    doc = fitz.open(pdf_path)
    form_data = {}

    for page_num, page in enumerate(doc):
        fields = page.widgets()
        for field in fields:
            field_name = field.field_name or f"unnamed_field_{page_num}_{len(form_data)}"
            field_type = field.field_type_string
            field_value = field.field_value

            # For checkboxes, convert to boolean
            if field_type == "CheckBox":
                field_value = field_value == "Yes"

            form_data[field_name] = {
                "type": field_type,
                "value": field_value,
                "page": page_num + 1
            }

    return form_data

def main():
    if len(sys.argv) < 2:
        print(json.dumps({"error": "PDF URL is required as an argument"}), file=sys.stderr)
        return 1

    url = sys.argv[1]

    try:
        pdf_path = download_pdf(url)
        form_data = extract_form_data(pdf_path)

        # Convert to JSON for structured output
        structured_output = json.dumps(form_data, indent=2)
        print(structured_output)
        return 0
    except Exception as e:
        print(json.dumps({"error": str(e)}), file=sys.stderr)
        return 1

if __name__ == "__main__":
    sys.exit(main())
```

## Testing your task

1. Create a virtual environment `python -m venv venv`
2. Activate the virtual environment, depending on your OS: On Mac/Linux: `source venv/bin/activate`, on Windows: `venv\Scripts\activate`
3. Install the Python dependencies `pip install -r requirements.txt`
4. Copy the project ref from your [Trigger.dev dashboard](https://cloud.trigger.dev) and add it to the `trigger.config.ts` file.
5. Run the Trigger.dev CLI `dev` command (it may ask you to authorize the CLI if you haven't already).
6. Test the task in the dashboard by providing a valid PDF URL.
7. Deploy the task to production using the Trigger.dev CLI `deploy` command.

## Learn more about using Python with Trigger.dev

<Card title="Python build extension" icon="code" href="/config/extensions/pythonExtension">
  Learn how to use our built-in Python build extension to install dependencies and run your Python
  code.
</Card>


# Data processing & ETL workflows
Source: https://trigger.dev/docs/guides/use-cases/data-processing-etl

Learn how to use Trigger.dev for data processing and ETL (Extract, Transform, Load), including web scraping, database synchronization, batch enrichment and more.

## Overview

Build complex data pipelines that process large datasets without timeouts. Handle streaming analytics, batch enrichment, web scraping, database sync, and file processing with automatic retries and progress tracking.

## Featured examples

<CardGroup>
  <Card title="Realtime CSV importer" icon="book" href="/guides/example-projects/realtime-csv-importer">
    Import CSV files with progress streamed live to frontend.
  </Card>

  <Card title="Web scraper with BrowserBase" icon="book" href="/guides/examples/scrape-hacker-news">
    Scrape websites using BrowserBase and Puppeteer.
  </Card>

  <Card title="Supabase database webhooks" icon="book" href="/guides/frameworks/supabase-edge-functions-database-webhooks">
    Trigger tasks from Supabase database webhooks.
  </Card>
</CardGroup>

## Benefits of using Trigger.dev for data processing & ETL workflows

**Process datasets for hours without timeouts:** Handle multi-hour transformations, large file processing, or complete database exports. No execution time limits.

**Parallel processing with built-in rate limiting:** Process thousands of records simultaneously while respecting API rate limits. Scale efficiently without overwhelming downstream services.

**Stream progress to your users in real-time:** Show row-by-row processing status updating live in your dashboard. Users see exactly where processing is and how long remains.

## Production use cases

<CardGroup>
  <Card title="MagicSchool AI customer story" href="https://trigger.dev/customers/magicschool-ai-customer-story">
    Read how MagicSchool AI uses Trigger.dev to generate insights from millions of student interactions.
  </Card>

  <Card title="Comp AI customer story" href="https://trigger.dev/customers/comp-ai-customer-story">
    Read how Comp AI uses Trigger.dev to automate evidence collection at scale, powering their open source, AI-driven compliance platform.
  </Card>

  <Card title="Midday customer story" href="https://trigger.dev/customers/midday-customer-story">
    Read how Midday use Trigger.dev to sync large volumes of bank transactions in their financial management platform.
  </Card>
</CardGroup>

## Example workflow patterns

<Tabs>
  <Tab title="CSV file import">
    Simple CSV import pipeline. Receives file upload, parses CSV rows, validates data, imports to database with progress tracking.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[importCSV] --> B[parseCSVFile]
          B --> C[validateRows]
          C --> D[bulkInsertToDB]
          D --> E[notifyCompletion]
      ```
    </div>
  </Tab>

  <Tab title="Multi-source ETL pipeline">
    **Coordinator pattern with parallel extraction**. Batch triggers parallel extraction from multiple sources (APIs, databases, S3), transforms and validates data, loads to data warehouse with monitoring.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[runETLPipeline] --> B[coordinateExtraction]
          B --> C[batchTriggerAndWait]

          C --> D[extractFromAPI]
          C --> E[extractFromDatabase]
          C --> F[extractFromS3]

          D --> G[transformData]
          E --> G
          F --> G

          G --> H[validateData]
          H --> I[loadToWarehouse]
      ```
    </div>
  </Tab>

  <Tab title="Parallel web scraping">
    **Coordinator pattern with browser automation**. Launches headless browsers in parallel to scrape multiple pages, extracts structured data, cleans and normalizes content, stores in database.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[scrapeSite] --> B[coordinateScraping]
          B --> C[batchTriggerAndWait]

          C --> D[scrapePage1]
          C --> E[scrapePage2]
          C --> F[scrapePageN]

          D --> G[cleanData]
          E --> G
          F --> G

          G --> H[normalizeData]
          H --> I[storeInDatabase]
      ```
    </div>
  </Tab>

  <Tab title="Batch data enrichment">
    **Coordinator pattern with rate limiting**. Fetches records needing enrichment, batch triggers parallel API calls with configurable concurrency to respect rate limits, validates enriched data, updates database.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[enrichRecords] --> B[fetchRecordsToEnrich]
          B --> C[coordinateEnrichment]
          C --> D[batchTriggerAndWait]

          D --> E[enrichRecord1]
          D --> F[enrichRecord2]
          D --> G[enrichRecordN]

          E --> H[validateEnrichedData]
          F --> H
          G --> H

          H --> I[updateDatabase]
      ```
    </div>
  </Tab>
</Tabs>

## Featured use cases

<CardGroup>
  <Card title="Data processing & ETL workflows" icon="database" href="/guides/use-cases/data-processing-etl">
    Build complex data pipelines that process large datasets without timeouts.
  </Card>

  <Card title="Media processing workflows" icon="film" href="/guides/use-cases/media-processing">
    Batch process videos, images, audio, and documents with no execution time limits.
  </Card>

  <Card title="AI media generation workflows" icon="wand-magic-sparkles" href="/guides/use-cases/media-generation">
    Generate images, videos, audio, documents and other media using AI models.
  </Card>

  <Card title="Marketing workflows" icon="bullhorn" href="/guides/use-cases/marketing">
    Build drip campaigns, create marketing content, and orchestrate multi-channel campaigns.
  </Card>
</CardGroup>


# Marketing workflows
Source: https://trigger.dev/docs/guides/use-cases/marketing

Learn how to use Trigger.dev for marketing workflows, including drip campaigns, behavioral triggers, personalization engines, and AI-powered content workflows

## Overview

Build marketing workflows from email drip sequences to orchestrating full multi-channel campaigns. Handle multi-day sequences, behavioral triggers, dynamic content generation, and build live analytics dashboards.

## Featured examples

<CardGroup>
  <Card title="Email sequences with Resend" icon="book" href="/guides/examples/resend-email-sequence">
    Send multi-day email sequences with wait delays between messages.
  </Card>

  <Card title="Product image generator" icon="book" href="/guides/example-projects/product-image-generator">
    Transform product photos into professional marketing images using Replicate.
  </Card>

  <Card title="Human-in-the-loop workflow" icon="book" href="/guides/example-projects/human-in-the-loop-workflow">
    Approve marketing content using a human-in-the-loop workflow.
  </Card>
</CardGroup>

## Benefits of using Trigger.dev for marketing workflows

**Delays without idle costs:** Wait hours or weeks between steps. Waits over 5 seconds are automatically checkpointed and don't count towards compute usage. Perfect for drip campaigns and scheduled follow-ups.

**Guaranteed delivery:** Messages send exactly once, even after retries. Personalized content isn't regenerated on failure.

**Scale without limits:** Process thousands in parallel while respecting rate limits. Send to entire segments without overwhelming APIs.

## Production use cases

<Card title="Icon customer story" href="https://trigger.dev/customers/icon-customer-story">
  Read how Icon uses Trigger.dev to process and generate thousands of videos per month for their AI-driven video creation platform.
</Card>

## Example workflow patterns

<Tabs>
  <Tab title="Drip email campaign">
    Simple drip campaign. User signs up, waits specified delay, sends personalized email, tracks engagement.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[userCreateAccount] --> B[sendWelcomeEmail]
          B --> C[wait.for 24h]
          C --> D[sendProductTipsEmail]
          D --> E[wait.for 7d]
          E --> F[sendFeedbackEmail]

      ```
    </div>
  </Tab>

  <Tab title="Multi-channel campaigns">
    **Router pattern with delay orchestration**. User action triggers campaign, router selects channel based on preferences (email/SMS/push), coordinates multi-day sequence with delays between messages, tracks engagement across channels.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[startCampaign] --> B[fetchUserProfile]
          B --> C[selectChannel]
          C --> D{Preferred<br/>Channel?}

          D -->|Email| E[sendEmail1]
          D -->|SMS| F[sendSMS1]
          D -->|Push| G[sendPush1]

          E --> H[wait.for 2d]
          F --> H
          G --> H

          H --> I[sendFollowUp]
          I --> J[trackConversion]
      ```
    </div>
  </Tab>

  <Tab title="AI content with approval">
    **Supervisor pattern with approval gate**. Generates AI marketing content (images, copy, assets), pauses with wait.forToken for human review, applies revisions if needed, publishes to channels after approval.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[createCampaignAssets] --> B[generateAIContent]
          B --> C[wait.forToken approval]
          C --> D{Approved?}

          D -->|Yes| E[publishToChannels]
          D -->|Needs revision| F[applyFeedback]
          F --> B
      ```
    </div>
  </Tab>

  <Tab title="Survey response enrichment">
    **Coordinator pattern with enrichment**. User completes survey, batch triggers parallel enrichment from CRM/analytics, analyzes and scores responses, updates customer profiles, triggers personalized follow-up campaigns.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[processSurveyResponse] --> B[coordinateEnrichment]
          B --> C[batchTriggerAndWait]

          C --> D[fetchCRMData]
          C --> E[fetchAnalytics]
          C --> F[fetchBehaviorData]

          D --> G[analyzeAndScore]
          E --> G
          F --> G

          G --> H[updateCRMProfile]
          H --> I[triggerFollowUp]
      ```
    </div>
  </Tab>
</Tabs>

## Featured use cases

<CardGroup>
  <Card title="Data processing & ETL workflows" icon="database" href="/guides/use-cases/data-processing-etl">
    Build complex data pipelines that process large datasets without timeouts.
  </Card>

  <Card title="Media processing workflows" icon="film" href="/guides/use-cases/media-processing">
    Batch process videos, images, audio, and documents with no execution time limits.
  </Card>

  <Card title="AI media generation workflows" icon="wand-magic-sparkles" href="/guides/use-cases/media-generation">
    Generate images, videos, audio, documents and other media using AI models.
  </Card>

  <Card title="Marketing workflows" icon="bullhorn" href="/guides/use-cases/marketing">
    Build drip campaigns, create marketing content, and orchestrate multi-channel campaigns.
  </Card>
</CardGroup>


# AI media generation workflows
Source: https://trigger.dev/docs/guides/use-cases/media-generation

Learn how to use Trigger.dev for AI media generation including image creation, video synthesis, audio generation, and multi-modal content workflows

## Overview

Build AI media generation pipelines that handle unpredictable API latencies and long-running operations. Generate images, videos, audio, and multi-modal content with automatic retries, progress tracking, and no timeout limits.

## Featured examples

<CardGroup>
  <Card title="Product image generator" icon="book" href="/guides/example-projects/product-image-generator">
    Transform product photos into professional marketing images using Replicate.
  </Card>

  <Card title="Meme generator (human-in-the-loop)" icon="book" href="/guides/example-projects/meme-generator-human-in-the-loop">
    Generate memes with DALL·E 3 and add human approval steps.
  </Card>

  <Card title="Vercel AI SDK image generation" icon="book" href="/guides/example-projects/vercel-ai-sdk-image-generator">
    Generate images from text prompts using the Vercel AI SDK.
  </Card>
</CardGroup>

## Benefits of using Trigger.dev for AI media generation workflows

**Pay only for active compute, not AI inference time:** Checkpoint-resume pauses during AI API calls. Generate content that takes minutes or hours without paying for idle inference time.

**No timeout limits for long generations:** Handle generations that take minutes or hours without execution limits. Perfect for high-quality video synthesis and complex multi-modal workflows.

**Human approval gates for brand safety:** Add review steps before publishing AI-generated content. Pause workflows for human approval using waitpoint tokens.

## Production use cases

<CardGroup>
  <Card title="Icon customer story" href="https://trigger.dev/customers/icon-customer-story">
    Read how Icon uses Trigger.dev to process and generate thousands of videos per month for their AI-driven video creation platform.
  </Card>

  <Card title="Papermark customer story" href="https://trigger.dev/customers/papermark-customer-story">
    Read how Papermark process thousands of documents per month using Trigger.dev.
  </Card>
</CardGroup>

## Example workflow patterns

<Tabs>
  <Tab title="AI content with approval">
    **Supervisor pattern with approval gate**. Generates AI content, pauses execution with wait.forToken to allow human review, applies feedback if needed, publishes approved content.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[generateContent] --> B[createWithAI]
          B --> C[wait.forToken approval]
          C --> D{Approved?}

          D -->|Yes| E[publishContent]
          D -->|Needs revision| F[applyFeedback]
          F --> B
      ```
    </div>
  </Tab>

  <Tab title="AI image generation">
    Simple AI image generation. Receives prompt and parameters, calls OpenAI DALL·E 3, post-processes result, uploads to storage.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[generateImage] --> B[optimizeImage]
          B --> C[uploadToStorage]
          C --> D[updateDatabase]
      ```
    </div>
  </Tab>

  <Tab title="Batch image generation">
    **Coordinator pattern with rate limiting**. Receives batch of generation requests, coordinates parallel processing with configurable concurrency to respect API rate limits, validates outputs, stores results.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[processBatch] --> B[coordinateGeneration]
          B --> C[batchTriggerAndWait]

          C --> D[generateImage1]
          C --> E[generateImage2]
          C --> F[generateImageN]

          D --> G[validateResults]
          E --> G
          F --> G

          G --> H[storeResults]
          H --> I[notifyCompletion]
      ```
    </div>
  </Tab>

  <Tab title="Multi-step image enhancement">
    **Coordinator pattern with sequential processing**. Generates initial content with AI, applies style transfer or enhancement, upscales resolution, optimizes and compresses for delivery.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[processCreative] --> B[generateWithAI]
          B --> C[applyStyleTransfer]
          C --> D[upscaleResolution]
          D --> E[optimizeAndCompress]
          E --> F[uploadToStorage]
      ```
    </div>
  </Tab>
</Tabs>

## Featured use cases

<CardGroup>
  <Card title="Data processing & ETL workflows" icon="database" href="/guides/use-cases/data-processing-etl">
    Build complex data pipelines that process large datasets without timeouts.
  </Card>

  <Card title="Media processing workflows" icon="film" href="/guides/use-cases/media-processing">
    Batch process videos, images, audio, and documents with no execution time limits.
  </Card>

  <Card title="AI media generation workflows" icon="wand-magic-sparkles" href="/guides/use-cases/media-generation">
    Generate images, videos, audio, documents and other media using AI models.
  </Card>

  <Card title="Marketing workflows" icon="bullhorn" href="/guides/use-cases/marketing">
    Build drip campaigns, create marketing content, and orchestrate multi-channel campaigns.
  </Card>
</CardGroup>


# Media processing workflows
Source: https://trigger.dev/docs/guides/use-cases/media-processing

Learn how to use Trigger.dev for media processing including video transcoding, image optimization, audio transformation, and document conversion.

## Overview

Build media processing pipelines that handle large files and long-running operations. Process videos, images, audio, and documents with automatic retries, progress tracking, and no timeout limits.

## Featured examples

<CardGroup>
  <Card title="FFmpeg video processing" icon="book" href="/guides/examples/ffmpeg-video-processing">
    Process videos and upload results to R2 storage using FFmpeg.
  </Card>

  <Card title="Product image generator" icon="book" href="/guides/example-projects/product-image-generator">
    Transform product photos into professional marketing images using Replicate.
  </Card>

  <Card title="LibreOffice PDF conversion" icon="book" href="/guides/examples/libreoffice-pdf-conversion">
    Convert documents to PDF using LibreOffice.
  </Card>
</CardGroup>

## Benefits of using Trigger.dev for media processing workflows

**Process multi-hour videos without timeouts:** Transcode videos, extract frames, or run CPU-intensive operations for hours. No execution time limits.

**Stream progress to users in real-time:** Show processing status updating live in your UI. Users see exactly where encoding is and how long remains.

**Parallel processing with resource control:** Process hundreds of files simultaneously with configurable concurrency limits. Control resource usage without overwhelming infrastructure.

## Example workflow patterns

<Tabs>
  <Tab title="Video transcode">
    Simple video transcoding pipeline. Downloads video from storage, batch triggers parallel transcoding to multiple formats and thumbnail extraction, uploads all results.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[processVideo] --> B[downloadFromStorage]
          B --> C[batchTriggerAndWait]

          C --> D[transcodeToHD]
          C --> E[transcodeToSD]
          C --> F[extractThumbnail]

          D --> G[uploadToStorage]
          E --> G
          F --> G
      ```
    </div>
  </Tab>

  <Tab title="Adaptive video processing">
    **Router + Coordinator pattern**. Analyzes video metadata to determine source resolution, routes to appropriate transcoding preset, batch triggers parallel post-processing for thumbnails, preview clips, and chapter detection.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[processVideoUpload] --> B[analyzeMetadata]
          B --> C{Source<br/>Resolution?}

          C -->|4K Source| D[transcode4K]
          C -->|HD Source| E[transcodeHD]
          C -->|SD Source| F[transcodeSD]

          D --> G[coordinatePostProcessing]
          E --> G
          F --> G

          G --> H[batchTriggerAndWait]
          H --> I[extractThumbnails]
          H --> J[generatePreview]
          H --> K[detectChapters]

          I --> L[uploadToStorage]
          J --> L
          K --> L

          L --> M[notifyComplete]
      ```
    </div>
  </Tab>

  <Tab title="Smart image optimization">
    **Router + Coordinator pattern**. Analyzes image content to detect type, routes to specialized processing (background removal for products, face detection for portraits, scene analysis for landscapes), upscales with AI, batch triggers parallel variant generation.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[processImageUpload] --> B[analyzeContent]
          B --> C{Content<br/>Type?}

          C -->|Product| D[removeBackground]
          C -->|Portrait| E[detectFaces]
          C -->|Landscape| F[analyzeScene]

          D --> G[upscaleWithAI]
          E --> G
          F --> G

          G --> H[batchTriggerAndWait]
          H --> I[generateWebP]
          H --> J[generateThumbnails]
          H --> K[generateSocialCrops]

          I --> L[uploadToStorage]
          J --> L
          K --> L
      ```
    </div>
  </Tab>

  <Tab title="Podcast production">
    **Coordinator pattern**. Pre-processes raw audio with noise reduction and speaker diarization, batch triggers parallel tasks for transcription (Deepgram), audio enhancement, and chapter detection, aggregates results to generate show notes and publish.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[processAudioUpload] --> B[cleanAudio]
          B --> C[coordinateProcessing]

          C --> D[batchTriggerAndWait]
          D --> E[transcribeWithDeepgram]
          D --> F[enhanceAudio]
          D --> G[detectChapters]

          E --> H[generateShowNotes]
          F --> H
          G --> H

          H --> I[publishToPlatforms]
      ```
    </div>
  </Tab>

  <Tab title="Document extraction with approval">
    **Router pattern with human-in-the-loop**. Detects file type and routes to appropriate processor, classifies document with AI to determine type (invoice/contract/receipt), extracts structured data fields, optionally pauses with wait.forToken for human approval.

    <div>
      ```mermaid theme={"theme":"css-variables"}
      graph TB
          A[processDocumentUpload] --> B[detectFileType]

          B -->|PDF| C[extractText]
          B -->|Word/Excel| D[convertToPDF]
          B -->|Image| E[runOCR]

          C --> F[classifyDocument]
          D --> F
          E --> F

          F -->|Invoice| G[extractLineItems]
          F -->|Contract| H[extractClauses]
          F -->|Receipt| I[extractExpenses]

          G --> J{Needs<br/>Review?}
          H --> J
          I --> J

          J -->|Yes| K[wait.forToken approval]
          J -->|No| L[processAndIntegrate]
          K --> L
      ```
    </div>
  </Tab>
</Tabs>

## Featured use cases

<CardGroup>
  <Card title="Data processing & ETL workflows" icon="database" href="/guides/use-cases/data-processing-etl">
    Build complex data pipelines that process large datasets without timeouts.
  </Card>

  <Card title="Media processing workflows" icon="film" href="/guides/use-cases/media-processing">
    Batch process videos, images, audio, and documents with no execution time limits.
  </Card>

  <Card title="AI media generation workflows" icon="wand-magic-sparkles" href="/guides/use-cases/media-generation">
    Generate images, videos, audio, documents and other media using AI models.
  </Card>

  <Card title="Marketing workflows" icon="bullhorn" href="/guides/use-cases/marketing">
    Build drip campaigns, create marketing content, and orchestrate multi-channel campaigns.
  </Card>
</CardGroup>


# Use cases
Source: https://trigger.dev/docs/guides/use-cases/overview

Explore common use cases for Trigger.dev including data processing, media workflows, marketing automation, and AI generation

Trigger.dev handles workflows that traditional platforms struggle with: long-running operations, unpredictable API latencies, multi-hour processing, and complex orchestration patterns. Our platform provides no timeout limits, automatic retries, and real-time progress tracking built in.

## Featured use cases

<CardGroup>
  <Card title="Data processing & ETL workflows" icon="database" href="/guides/use-cases/data-processing-etl">
    Build complex data pipelines that process large datasets without timeouts.
  </Card>

  <Card title="Media processing workflows" icon="film" href="/guides/use-cases/media-processing">
    Batch process videos, images, audio, and documents with no execution time limits.
  </Card>

  <Card title="AI media generation workflows" icon="wand-magic-sparkles" href="/guides/use-cases/media-generation">
    Generate images, videos, audio, documents and other media using AI models.
  </Card>

  <Card title="Marketing workflows" icon="bullhorn" href="/guides/use-cases/marketing">
    Build drip campaigns, create marketing content, and orchestrate multi-channel campaigns.
  </Card>
</CardGroup>


# Email us
Source: https://trigger.dev/docs/help-email



You can [email us](https://trigger.dev/contact) by filling out this form.


# Slack support
Source: https://trigger.dev/docs/help-slack

Trigger.dev Pro plan customers can request a private Slack Connect channel for support.

If you're on the Trigger.dev Pro plan, you can request a private Slack Connect channel.

To do this:

1. Login to the [Trigger.dev web app](https://cloud.trigger.dev).
2. Subscribe to a paid plan if you haven't already.
3. In the bottom-left corner click "Join our Slack".


# Hidden tasks
Source: https://trigger.dev/docs/hidden-tasks

Create tasks that are not exported from your trigger files but can still be executed.

Hidden tasks are tasks that are not exported from your trigger files but can still be executed. These tasks are only accessible to other tasks within the same file or module where they're defined.

```ts trigger/my-task.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

// This is a hidden task - not exported
const internalTask = task({
  id: "internal-processing",
  run: async (payload: any, { ctx }) => {
    // Internal processing logic
  },
});
```

Hidden tasks are useful for creating internal workflows that should only be triggered by other tasks in the same file:

```ts trigger/my-workflow.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

// Hidden task for internal use
const processData = task({
  id: "process-data",
  run: async (payload: { data: string }, { ctx }) => {
    // Process the data
    return { processed: payload.data.toUpperCase() };
  },
});

// Public task that uses the hidden task
export const mainWorkflow = task({
  id: "main-workflow",
  run: async (payload: any, { ctx }) => {
    const result = await processData.trigger({ data: payload.input });
    return result;
  },
});
```

You can also create packages of reusable tasks that can be imported and used without needing to re-export them:

```ts trigger/my-task.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { sendToSlack } from "@repo/tasks"; // Hidden task from another package

export const notificationTask = task({
  id: "send-notification",
  run: async (payload: any, { ctx }) => {
    await sendToSlack.trigger(payload);
  },
});
```


# How Trigger.dev works
Source: https://trigger.dev/docs/how-it-works

Understand how Trigger.dev works and how it can help you.

## Introduction

Trigger.dev v3 allows you to integrate long-running async tasks into your application and run them in the background. This allows you to offload tasks that take a long time to complete, such as sending multi-day email campaigns, processing videos, or running long chains of AI tasks.

For example, the below task processes a video with `ffmpeg` and sends the results to an s3 bucket, then updates a database with the results and sends an email to the user.

```ts /trigger/video.ts theme={"theme":"css-variables"}
import { logger, task } from "@trigger.dev/sdk";
import { updateVideoUrl } from "../db.js";
import ffmpeg from "fluent-ffmpeg";
import { Readable } from "node:stream";
import type { ReadableStream } from "node:stream/web";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { sendEmail } from "../email.js";
import { getVideo } from "../db.js";

// Initialize S3 client
const s3Client = new S3Client({
  region: process.env.AWS_REGION,
});

export const convertVideo = task({
  id: "convert-video",
  retry: {
    maxAttempts: 5,
    minTimeoutInMs: 1000,
    maxTimeoutInMs: 10000,
    factor: 2,
  },
  run: async ({ videoId }: { videoId: string }) => {
    const { url, userId } = await getVideo(videoId);

    const outputPath = path.join("/tmp", `output_${videoId}.mp4`);

    const response = await fetch(url);

    await new Promise((resolve, reject) => {
      ffmpeg(Readable.fromWeb(response.body as ReadableStream))
        .videoFilters("scale=iw/2:ih/2")
        .output(outputPath)
        .on("end", resolve)
        .on("error", reject)
        .run();
    });

    const processedContent = await fs.readFile(outputPath);

    // Upload to S3
    const s3Key = `processed-videos/output_${videoId}.mp4`;

    const uploadParams = {
      Bucket: process.env.S3_BUCKET,
      Key: s3Key,
      Body: processedContent,
    };

    await s3Client.send(new PutObjectCommand(uploadParams));
    const s3Url = `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${s3Key}`;

    logger.info("Video converted", { videoId, s3Url });

    // Update database
    await updateVideoUrl(videoId, s3Url);

    await sendEmail(
      userId,
      "Video Processing Complete",
      `Your video has been processed and is available at: ${s3Url}`
    );

    return { success: true, s3Url };
  },
});
```

Now in your application, you can trigger this task by calling:

```ts theme={"theme":"css-variables"}
import { NextResponse } from "next/server";
import { tasks } from "@trigger.dev/sdk";
import type { convertVideo } from "./trigger/video";
//     👆 **type-only** import

export async function POST(request: Request) {
  const body = await request.json();

  // Trigger the task, this will return before the task is completed
  const handle = await tasks.trigger<typeof convertVideo>("convert-video", body);

  return NextResponse.json(handle);
}
```

This will schedule the task to run in the background and return a handle that you can use to check the status of the task. This allows your backend application to respond quickly to the user and offload the long-running task to Trigger.dev.

## The CLI

Trigger.dev comes with a CLI that allows you to initialize Trigger.dev into your project, deploy your tasks, and run your tasks locally. You can run it via `npx` like so:

```sh theme={"theme":"css-variables"}
npx trigger.dev@latest login # Log in to your Trigger.dev account
npx trigger.dev@latest init # Initialize Trigger.dev in your project
npx trigger.dev@latest dev # Run your tasks locally
npx trigger.dev@latest deploy # Deploy your tasks to the Trigger.dev instance
```

All these commands work with the Trigger.dev cloud and/or your self-hosted instance. It supports multiple profiles so you can easily switch between different accounts or instances.

```sh theme={"theme":"css-variables"}
npx trigger.dev@latest login --profile <profile> -a https://trigger.example.com # Log in to a specific profile into a self-hosted instance
npx trigger.dev@latest dev --profile <profile> # Initialize Trigger.dev in your project
npx trigger.dev@latest deploy --profile <profile> # Deploy your tasks to the Trigger.dev instance
```

## Trigger.dev architecture

Trigger.dev implements a serverless architecture (without timeouts!) that allows you to run your tasks in a scalable and reliable way. When you run `npx trigger.dev@latest deploy`, we build and deploy your task code to your Trigger.dev instance. Then, when you trigger a task from your application, it's run in a secure, isolated environment with the resources you need to complete the task. A simplified diagram for a task execution looks like this:

```mermaid theme={"theme":"css-variables"}
sequenceDiagram
  participant App
  participant Trigger.dev
  participant Task Worker

  App->>Trigger.dev: Trigger task
  Trigger.dev-->>App: Task handle
  Trigger.dev->>Task Worker: Run task
  Task Worker-->>Trigger.dev: Task completed
```

In reality there are many more components involved, such as the task queue, the task scheduler, and the task worker pool, logging (etc.), but this diagram gives you a high-level overview of how Trigger.dev works.

## The Checkpoint-Resume System

Trigger.dev implements a powerful Checkpoint-Resume System that enables efficient execution of long-running background tasks in a serverless-like environment. This system allows tasks to pause, checkpoint their state, and resume seamlessly, optimizing resource usage and enabling complex workflows.

Here's how the Checkpoint-Resume System works:

1. **Task Execution**: When a task is triggered, it runs in an isolated environment with all necessary resources.

2. **Subtask Handling**: If a task needs to trigger a subtask, it can do so and wait for its completion using `triggerAndWait`

3. **State Checkpointing**: While waiting for a subtask or during a long programmed pause (e.g., `wait.for({ minutes: 5 })`), the system uses CRIU (Checkpoint/Restore In Userspace) to create a checkpoint of the task's entire state, including memory, CPU registers, and open file descriptors. A `wait.for()` or `wait.until()` shorter than 60 seconds doesn't checkpoint — see [Wait](/docs/wait).

4. **Resource Release**: After checkpointing, the parent task's resources are released, freeing up the execution environment.

5. **Efficient Storage**: The checkpoint is efficiently compressed and stored on disk, ready to be restored when needed.

6. **Event-Driven Resumption**: When a subtask completes or a wait period ends, Trigger.dev's event system triggers the restoration process.

7. **State Restoration**: The checkpoint is loaded back into a new execution environment, restoring the task to its exact state before suspension.

8. **Seamless Continuation**: The task resumes execution from where it left off, with any subtask results or updated state seamlessly integrated.

This approach allows Trigger.dev to manage resources efficiently, handle complex task dependencies, and provide a virtually limitless execution time for your tasks, all while maintaining the simplicity and scalability of a serverless architecture.

Example of a parent and child task using the Checkpoint-Resume System:

```ts theme={"theme":"css-variables"}
import { task, wait } from "@trigger.dev/sdk";

export const parentTask = task({
  id: "parent-task",
  run: async () => {
    console.log("Starting parent task");

    // This will cause the parent task to be checkpointed and suspended
    const result = await childTask.triggerAndWait({ data: "some data" });

    console.log("Child task result:", result);

    // This will also cause the task to be checkpointed and suspended
    await wait.for({ minutes: 5 });

    console.log("Resumed after 5 minutes");

    return "Parent task completed";
  },
});

export const childTask = task({
  id: "child-task",
  run: async (payload: { data: string }) => {
    console.log("Starting child task with data:", payload.data);

    // Simulate some work
    await sleep(5);

    return "Child task result";
  },
});
```

The diagram below illustrates the flow of the parent and child tasks using the Checkpoint-Resume System:

```mermaid theme={"theme":"css-variables"}
sequenceDiagram
    participant App
    participant Trigger.dev
    participant Parent Task
    participant Child Task
    participant CR System
    participant Storage

    App->>Trigger.dev: Trigger parent task
    Trigger.dev->>Parent Task: Start execution
    Parent Task->>Child Task: Trigger child task
    Parent Task->>CR System: Request snapshot
    CR System->>Storage: Store snapshot
    CR System-->>Parent Task: Confirm snapshot stored
    Parent Task->>Trigger.dev: Release resources

    Child Task->>Trigger.dev: Complete execution
    Trigger.dev->>CR System: Request parent task restoration
    CR System->>Storage: Retrieve snapshot
    CR System->>Parent Task: Restore state
    Parent Task->>Trigger.dev: Resume execution
    Parent Task->>Trigger.dev: Complete execution
```

<Note>
  This is why, in the Trigger.dev Cloud, we don't charge for the time waiting for subtasks or the
  time spent in a paused state.
</Note>

## Durable execution

Trigger.dev's Checkpoint-Resume System, combined with idempotency keys, enables durable execution of complex workflows. This approach allows for efficient retries and caching of results, ensuring that work is not unnecessarily repeated in case of failures.

### How it works

1. **Task breakdown**: Complex workflows are broken down into smaller, independent subtasks.
2. **Idempotency keys**: Each subtask is assigned a unique idempotency key.
3. **Result caching**: The output of each subtask is cached based on its idempotency key.
4. **Intelligent retries**: If a failure occurs, only the failed subtask and subsequent tasks are retried.

### Example: Video processing workflow

Let's rewrite the `convert-video` task above to be more durable:

<CodeGroup>
  ```ts /trigger/video.ts theme={"theme":"css-variables"}
  import { idempotencyKeys, logger, task } from "@trigger.dev/sdk";
  import { processVideo, sendUserEmail, uploadToS3 } from "./tasks.js";
  import { updateVideoUrl } from "../db.js";

  export const convertVideo = task({
    id: "convert-video",
    retry: {
      maxAttempts: 5,
      minTimeoutInMs: 1000,
      maxTimeoutInMs: 10000,
      factor: 2,
    },
    run: async ({ videoId }: { videoId: string }) => {
      // Automatically scope the idempotency key to this run, across retries
      const idempotencyKey = await idempotencyKeys.create(videoId);

      // Process video
      const { processedContent } = await processVideo
        .triggerAndWait({ videoId }, { idempotencyKey })
        .unwrap(); // Calling unwrap will return the output of the subtask, or throw an error if the subtask failed

      // Upload to S3
      const { s3Url } = await uploadToS3
        .triggerAndWait({ processedContent, videoId }, { idempotencyKey })
        .unwrap();

      // Update database
      await updateVideoUrl(videoId, s3Url);

      // Send email, we don't need to wait for this to finish
      await sendUserEmail.trigger({ videoId, s3Url }, { idempotencyKey });

      return { success: true, s3Url };
    },
  });
  ```

  ```ts /trigger/tasks.ts theme={"theme":"css-variables"}
  import { task, logger } from "@trigger.dev/sdk";
  import ffmpeg from "fluent-ffmpeg";
  import { Readable } from "node:stream";
  import type { ReadableStream } from "node:stream/web";
  import * as fs from "node:fs/promises";
  import * as path from "node:path";
  import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
  import { sendEmail } from "../email.js";
  import { getVideo } from "../db.js";

  // Initialize S3 client
  const s3Client = new S3Client({
    region: process.env.AWS_REGION,
  });

  export const processVideo = task({
    id: "process-video",
    run: async ({ videoId }: { videoId: string }) => {
      const { url } = await getVideo(videoId);

      const outputPath = path.join("/tmp", `output_${videoId}.mp4`);
      const response = await fetch(url);

      await logger.trace("ffmpeg", async (span) => {
        await new Promise((resolve, reject) => {
          ffmpeg(Readable.fromWeb(response.body as ReadableStream))
            .videoFilters("scale=iw/2:ih/2")
            .output(outputPath)
            .on("end", resolve)
            .on("error", reject)
            .run();
        });
      });

      const processedContent = await fs.readFile(outputPath);

      await fs.unlink(outputPath);

      return { processedContent: processedContent.toString("base64") };
    },
  });

  export const uploadToS3 = task({
    id: "upload-to-s3",
    run: async (payload: { processedContent: string; videoId: string }) => {
      const { processedContent, videoId } = payload;

      const s3Key = `processed-videos/output_${videoId}.mp4`;

      const uploadParams = {
        Bucket: process.env.S3_BUCKET,
        Key: s3Key,
        Body: Buffer.from(processedContent, "base64"),
      };

      await s3Client.send(new PutObjectCommand(uploadParams));
      const s3Url = `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${s3Key}`;

      return { s3Url };
    },
  });

  export const sendUserEmail = task({
    id: "send-user-email",
    run: async ({ videoId, s3Url }: { videoId: string; s3Url: string }) => {
      const { userId } = await getVideo(videoId);

      return await sendEmail(
        userId,
        "Video Processing Complete",
        `Your video has been processed and is available at: ${s3Url}`
      );
    },
  });
  ```
</CodeGroup>

### How retries work

Let's say the email sending fails in our video processing workflow. Here's how the retry process works:

1. The main task throws an error and is scheduled for retry.
2. When retried, it starts from the beginning, but leverages cached results for completed subtasks.

Here's a sequence diagram illustrating this process:

```mermaid theme={"theme":"css-variables"}
sequenceDiagram
  participant Main as Main Task
  participant Process as Process Video
  participant Upload as Upload to S3
  participant DB as Update Database
  participant Email as Send Email

  Main->>Process: triggerAndWait (1st attempt)
  Process-->>Main: Return result
  Main->>Upload: triggerAndWait (1st attempt)
  Upload-->>Main: Return result
  Main->>DB: Update
  Main->>Email: triggerAndWait (1st attempt)
  Email--xMain: Fail
  Main-->>Main: Schedule retry

  Main->>Process: triggerAndWait (2nd attempt)
  Process-->>Main: Return cached result
  Main->>Upload: triggerAndWait (2nd attempt)
  Upload-->>Main: Return cached result
  Main->>DB: Update (idempotent)
  Main->>Email: triggerAndWait (2nd attempt)
  Email-->>Main: Success
```

## The build system

When you run `npx trigger.dev@latest deploy` or `npx trigger.dev@latest dev`, we build your task code using our build system, which is powered by [esbuild](https://esbuild.github.io/). When deploying, the code is packaged up into a Docker image and deployed to your Trigger.dev instance. When running in dev mode, the code is built and run locally on your machine. Some features of our build system include:

* **Bundled by default**: Code + dependencies are bundled and tree-shaked by default.
* **Build extensions**: Use and write custom build extensions to transform your code or the resulting docker image.
* **ESM ouput**: We output to ESM, which allows tree-shaking and better performance.

You can review the build output by running deploy with the `--dry-run` flag, which will output the Containerfile and the build output.

Learn more about working with our build system in the [configuration docs](/docs/config/config-file).

## Dev mode

When you run `npx trigger.dev@latest dev`, we run your task code locally on your machine. All scheduling is still done in the Trigger.dev server instance, but the task code is run locally. This allows you to develop and test your tasks locally before deploying them to the cloud, and is especially useful for debugging and testing.

* The same build system is used in dev mode, so you can be sure that your code will run the same locally as it does in the cloud.
* Changes are automatically detected and a new version is spun up when you save your code.
* Add debuggers and breakpoints to your code and debug it locally.
* Each task is run in a separate process, so you can run multiple tasks in parallel.
* Auto-cancels tasks when you stop the dev server.

<Note>
  Trigger.dev currently does not support "offline" dev mode, where you can run tasks without an
  internet connection. [Please let us know](https://feedback.trigger.dev/) if this is a feature you
  want/need.
</Note>

## Staging and production environments

Trigger.dev supports deploying to `prod` and `staging` environments. This allows you to test your tasks in a staging environment before deploying them to production. You can deploy to a different environment by running `npx trigger.dev@latest deploy --env <env>`, where `<env>` is either `prod` or `staging`. Each environment has its own API Key, which you can use to trigger tasks in that environment.

For additional isolated environments, you can use [preview branches](/docs/deployment/preview-branches), which allow you to create separate environments for each branch of your code.

## OpenTelemetry

The Trigger.dev logging and task dashboard is powered by OpenTelemetry traces and logs, which allows you to trace your tasks and auto-instrument your code. We also auto-correlate logs from subtasks and parent tasks, making it easy view the entire trace of a task execution. A single run of the video processing task above looks like this in the dashboard:

<img alt="OpenTelemetry trace" />

Because we use standard OpenTelemetry, you can instrument your code and OpenTelemetry compatible libraries to get detailed traces and logs of your tasks. The above trace instruments both Prisma and the AWS SDK:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { PrismaInstrumentation } from "@prisma/instrumentation";
import { AwsInstrumentation } from "@opentelemetry/instrumentation-aws-sdk";

export default defineConfig({
  project: "<your-project-ref>",
  instrumentations: [new PrismaInstrumentation(), new AwsInstrumentation()],
});
```


# How to reduce your spend
Source: https://trigger.dev/docs/how-to-reduce-your-spend

Tips and best practices to reduce your costs on Trigger.dev

## Check out your usage page regularly

Monitor your usage dashboard to understand your spending patterns. You can see:

* Your most expensive tasks
* Your total duration by task
* Number of runs by task
* Spikes in your daily usage

<img alt="Usage dashboard" />

You can view your usage page by clicking the "Organization" menu in the top left of the dashboard and then clicking "Usage".

## Set billing limits and alerts

Configure billing limits and alerts in your dashboard to protect against unexpected usage and get notified when you approach spending thresholds. This helps you:

* Set a monthly compute spend limit for your organization
* Catch unexpected cost increases early
* Identify runaway tasks before they become expensive

You can open the settings from the **Organization** menu in the top left of the dashboard, then **Settings** → **Billing limits**. [Read the full billing limits and alerts docs](/docs/billing-limits) for how limits are enforced, the grace period, and resuming after hitting a limit.

## Reduce your machine sizes

The larger the machine, the more it costs per second. [View the machine pricing](https://trigger.dev/pricing#computePricing).

Start with the smallest machine that works, then scale up only if needed:

```ts theme={"theme":"css-variables"}
// Default: small-1x (0.5 vCPU, 0.5 GB RAM)
export const lightTask = task({
  id: "light-task",
  // No machine config needed - uses small-1x by default
  run: async (payload) => {
    // Simple operations
  },
});

// Only use larger machines when necessary
export const heavyTask = task({
  id: "heavy-task",
  machine: "medium-1x", // 1 vCPU, 2 GB RAM
  run: async (payload) => {
    // CPU/memory intensive operations
  },
});
```

You can also override machine size when triggering if you know certain payloads need more resources. [Read more about machine sizes](/docs/machines).

## Avoid duplicate work using idempotencyKey

Idempotency keys prevent expensive duplicate work by ensuring the same operation isn't performed multiple times. This is especially valuable during task retries or when the same trigger might fire multiple times.

When you use an idempotency key, Trigger.dev remembers the result and skips re-execution, saving you compute costs:

```ts theme={"theme":"css-variables"}
export const expensiveApiCall = task({
  id: "expensive-api-call",
  run: async (payload: { userId: string }) => {
    // This expensive operation will only run once per user
    await wait.for(
      { seconds: 30 },
      {
        idempotencyKey: `user-processing-${payload.userId}`,
        idempotencyKeyTTL: "1h",
      }
    );

    const result = await processUserData(payload.userId);
    return result;
  },
});
```

You can use idempotency keys with various wait functions:

```ts theme={"theme":"css-variables"}
// Skip waits during retries
const token = await wait.createToken({
  idempotencyKey: `daily-report-${new Date().toDateString()}`,
  idempotencyKeyTTL: "24h",
});

// Prevent duplicate child task execution
await childTask.triggerAndWait(
  { data: payload },
  {
    idempotencyKey: `process-${payload.id}`,
    idempotencyKeyTTL: "1h",
  }
);
```

The `idempotencyKeyTTL` controls how long the result is cached. Use shorter TTLs (like "1h") for time-sensitive operations, or longer ones (up to 30 days default) for expensive operations that rarely need re-execution. This prevents both unnecessary duplicate work and stale data issues.

## Do more work in parallel in a single task

Sometimes it's more efficient to do more work in a single task than split across many. This is particularly true when you're doing lots of async work such as API calls – most of the time is spent waiting, so it's an ideal candidate for doing calls in parallel inside the same task.

```ts theme={"theme":"css-variables"}
export const processItems = task({
  id: "process-items",
  run: async (payload: { items: string[] }) => {
    // Process all items in parallel
    const promises = payload.items.map((item) => processItem(item));
    // This works very well for API calls
    await Promise.all(promises);
  },
});
```

## Don't needlessly retry

When an error is thrown in a task, your run will be automatically reattempted based on your [retry settings](/docs/tasks/overview#retry-options).

Try setting lower `maxAttempts` for less critical tasks:

```ts theme={"theme":"css-variables"}
export const apiTask = task({
  id: "api-task",
  retry: {
    maxAttempts: 2, // Don't retry forever
  },
  run: async (payload) => {
    // API calls that might fail
  },
});
```

This is very useful for intermittent errors, but if there's a permanent error you don't want to retry because you will just keep failing and waste compute. Use [AbortTaskRunError](/docs/errors-retrying#using-aborttaskrunerror) to prevent a retry:

```ts theme={"theme":"css-variables"}
import { task, AbortTaskRunError } from "@trigger.dev/sdk";

export const someTask = task({
  id: "some-task",
  run: async (payload) => {
    const result = await doSomething(payload);

    if (!result.success) {
      // This is a known permanent error, so don't retry
      throw new AbortTaskRunError(result.error);
    }

    return result;
  },
});
```

## Use appropriate maxDuration settings

Set realistic maxDurations to prevent runs from executing for too long:

```ts theme={"theme":"css-variables"}
export const boundedTask = task({
  id: "bounded-task",
  maxDuration: 300, // 5 minutes max
  run: async (payload) => {
    // Task will be terminated after 5 minutes
  },
});
```

## Use waitpoints instead of polling

You don't pay for compute during any wait longer than 5 seconds. Use `wait.for()`, `wait.until()`, or `triggerAndWait()` instead of polling loops.

```ts theme={"theme":"css-variables"}
import { task, wait } from "@trigger.dev/sdk";

export const waitpointTask = task({
  id: "waitpoint-task",
  run: async (payload) => {
    // You aren't charged for compute during this wait
    await wait.for({ minutes: 5 });

    // The parent isn't charged either while it waits for a child task
    const result = await childTask.triggerAndWait({ data: payload });
    return result;
  },
});
```

Waiting saves you compute, but it doesn't always free up concurrency — see [Wait](/docs/wait).

## Use debounce to consolidate multiple triggers

When a task might be triggered multiple times in quick succession, use debounce to consolidate them into a single run. This is useful for document indexing, webhook aggregation, cache invalidation, and real-time sync scenarios.

```ts theme={"theme":"css-variables"}
// Multiple rapid triggers consolidate into 1 run
await updateIndex.trigger(
  { docId: "doc-123" },
  { debounce: { key: "doc-123", delay: "5s" } }
);

// Use trailing mode to process the most recent payload
await processUpdate.trigger(
  { version: 2 },
  { debounce: { key: "update-123", delay: "10s", mode: "trailing" } }
);
```

[Read more about debounce](/docs/triggering#debounce).


# Idempotency
Source: https://trigger.dev/docs/idempotency

An API call or operation is idempotent if it has the same result when called more than once.

We currently support idempotency at the task level, meaning that if you trigger a task with the same `idempotencyKey` twice, the second request will not create a new task run. Instead, the original run's handle is returned, allowing you to track the existing run's progress.

## Why use idempotency keys?

The most common use case is **preventing duplicate child tasks when a parent task retries**. Without idempotency keys, each retry of the parent would trigger a new child task run:

```mermaid theme={"theme":"css-variables"}
sequenceDiagram
    participant Parent as Parent Task
    participant SDK as Trigger.dev
    participant Child as Child Task

    Note over Parent: Attempt 1
    Parent->>SDK: trigger(childTask, {idempotencyKey})
    SDK->>Child: Creates child run
    Child-->>SDK: Running...
    Parent->>Parent: Fails, will retry

    Note over Parent: Attempt 2 (retry)
    Parent->>SDK: trigger(childTask, {idempotencyKey})
    Note over SDK: Same key, returns existing run
    SDK-->>Parent: Returns original child run
    
    Note over Child: Child task only runs once
```

Other common use cases include:

* **Preventing duplicate emails** - Ensure a confirmation email is only sent once, even if the parent task retries
* **Avoiding double-charging customers** - Prevent duplicate payment processing during retries
* **One-time setup tasks** - Ensure initialization or migration tasks only run once
* **Deduplicating webhook processing** - Handle the same webhook event only once, even if it's delivered multiple times

## `idempotencyKey` option

You can provide an `idempotencyKey` when triggering a task:

```ts theme={"theme":"css-variables"}
import { idempotencyKeys, task } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  retry: {
    maxAttempts: 4,
  },
  run: async (payload: any) => {
    // This idempotency key will be unique to this task run, meaning the childTask will only be triggered once across all retries
    const idempotencyKey = await idempotencyKeys.create("my-task-key");

    // childTask will only be triggered once with the same idempotency key
    await childTask.trigger({ foo: "bar" }, { idempotencyKey });

    // Do something else, that may throw an error and cause the task to be retried
    throw new Error("Something went wrong");
  },
});
```

You can use the `idempotencyKeys.create` SDK function to create an idempotency key before passing it to the `options` object.

We automatically inject the run ID when generating the idempotency key when running inside a task by default. You can turn it off by passing the `scope` option to `idempotencyKeys.create`:

```ts theme={"theme":"css-variables"}
import { idempotencyKeys, task } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  retry: {
    maxAttempts: 4,
  },
  run: async (payload: any) => {
    // This idempotency key will be globally unique, meaning only a single task run will be triggered with this key
    const idempotencyKey = await idempotencyKeys.create("my-task-key", { scope: "global" });

    // childTask will only be triggered once with the same idempotency key
    await childTask.trigger({ foo: "bar" }, { idempotencyKey });
  },
});
```

If you are triggering a task from your backend code, you can use the `idempotencyKeys.create` SDK function to create an idempotency key.

```ts theme={"theme":"css-variables"}
import { idempotencyKeys, tasks } from "@trigger.dev/sdk";

// You can also pass an array of strings to create a idempotency key
const idempotencyKey = await idempotencyKeys.create([myUser.id, "my-task"]);
await tasks.trigger("my-task", { some: "data" }, { idempotencyKey });
```

You can also pass a string to the `idempotencyKey` option, without first creating it with `idempotencyKeys.create`.

```ts theme={"theme":"css-variables"}
import { myTask } from "./trigger/myTasks";

// You can also pass an array of strings to create a idempotency key
await myTask.trigger({ some: "data" }, { idempotencyKey: myUser.id });
```

<Note>
  When you pass a raw string, it defaults to `"run"` scope (scoped to the parent run). See [Default behavior](#default-behavior) for details on how scopes work and how to use global scope instead.
</Note>

<Note>Make sure you provide sufficiently unique keys to avoid collisions.</Note>

<Note>
  Idempotency keys are limited to 2048 characters. Keys produced by `idempotencyKeys.create()` are 64-character hashes and always fit; this limit only matters if you pass a long raw string. Requests above the limit return `400`.
</Note>

You can pass the `idempotencyKey` when calling `batchTrigger` as well:

```ts theme={"theme":"css-variables"}
import { tasks } from "@trigger.dev/sdk";

await tasks.batchTrigger("my-task", [
  {
    payload: { some: "data" },
    options: { idempotencyKey: await idempotencyKeys.create(myUser.id) },
  },
]);
```

## Understanding scopes

The `scope` option determines how your idempotency key is processed. When you provide a key, it gets hashed together with additional context based on the scope. This means the same key string can produce different idempotency behaviors depending on the scope you choose.

### Available scopes

| Scope       | What gets hashed                    | Description                                               | Use case                                                |
| ----------- | ----------------------------------- | --------------------------------------------------------- | ------------------------------------------------------- |
| `"run"`     | `key + parentRunId`                 | Key is combined with the parent run ID                    | Prevent duplicates within a single parent run (default) |
| `"attempt"` | `key + parentRunId + attemptNumber` | Key is combined with the parent run ID and attempt number | Allow child tasks to re-run on each retry of the parent |
| `"global"`  | `key`                               | Key is used as-is, no context added                       | Ensure a task only runs once ever, regardless of parent |

### `run` scope (default)

The `run` scope makes the idempotency key unique to the current parent task run. This is the default behavior for both raw strings and `idempotencyKeys.create()`.

```ts theme={"theme":"css-variables"}
import { idempotencyKeys, task } from "@trigger.dev/sdk";

export const processOrder = task({
  id: "process-order",
  retry: { maxAttempts: 3 },
  run: async (payload: { orderId: string; email: string }) => {
    // This key is scoped to this specific run of processOrder
    // If processOrder retries, the same key still refers to the same child run
    const idempotencyKey = await idempotencyKeys.create(`send-confirmation-${payload.orderId}`);

    // sendEmail will only be triggered once, even if processOrder retries multiple times
    await sendEmail.trigger(
      { to: payload.email, subject: "Order confirmed" },
      { idempotencyKey }
    );

    // ... more processing that might fail and cause a retry
  },
});
```

With `run` scope, if you trigger `processOrder` twice with different run IDs, both will send emails because the idempotency keys are different (they include different parent run IDs).

### `attempt` scope

The `attempt` scope makes the idempotency key unique to each attempt of the parent task. Use this when you want child tasks to re-execute on each retry.

```ts theme={"theme":"css-variables"}
import { idempotencyKeys, task } from "@trigger.dev/sdk";

export const syncData = task({
  id: "sync-data",
  retry: { maxAttempts: 3 },
  run: async (payload: { userId: string }) => {
    // This key changes on each retry attempt
    const idempotencyKey = await idempotencyKeys.create(`fetch-${payload.userId}`, {
      scope: "attempt",
    });

    // fetchLatestData will run again on each retry, getting fresh data
    const result = await fetchLatestData.triggerAndWait(
      { userId: payload.userId },
      { idempotencyKey }
    );

    // Process the fresh data...
  },
});
```

### `global` scope

The `global` scope makes the idempotency key truly global across all runs. Use this when you want to ensure a task only runs once ever (until the TTL expires), regardless of which parent task triggered it.

```ts theme={"theme":"css-variables"}
import { idempotencyKeys, task } from "@trigger.dev/sdk";

export const onboardUser = task({
  id: "onboard-user",
  run: async (payload: { userId: string; email: string }) => {
    // This key is global - the welcome email will only be sent once per user,
    // even if onboardUser is triggered multiple times from different places
    const idempotencyKey = await idempotencyKeys.create(`welcome-email-${payload.userId}`, {
      scope: "global",
    });

    await sendWelcomeEmail.trigger(
      { to: payload.email },
      { idempotencyKey, idempotencyKeyTTL: "7d" }
    );
  },
});
```

<Note>
  Even with `global` scope, idempotency keys are still isolated to the specific task and environment. Using the same key to trigger *different* tasks will not deduplicate - both tasks will run. See [Environment and task scoping](#environment-and-task-scoping) for more details.
</Note>

## Default behavior

Understanding the default behavior is important to avoid unexpected results:

### Passing a raw string

When you pass a raw string directly to the `idempotencyKey` option, it is automatically processed with `run` scope:

```ts theme={"theme":"css-variables"}
// These two are equivalent when called inside a task:
await childTask.trigger(payload, { idempotencyKey: "my-key" });
await childTask.trigger(payload, { idempotencyKey: await idempotencyKeys.create("my-key") });
```

<Warning>
  **Breaking change in v4.3.1:** In v4.3.0 and earlier, raw strings defaulted to `global` scope. Starting in v4.3.1, raw strings now default to `run` scope. If you're upgrading and relied on the previous global behavior, you must now explicitly use `idempotencyKeys.create("key", { scope: "global" })`.
</Warning>

This means raw strings are scoped to the parent run by default. If you want global behavior, you must explicitly create the key with `scope: "global"`:

```ts theme={"theme":"css-variables"}
// For global idempotency, you must use idempotencyKeys.create with scope: "global"
const idempotencyKey = await idempotencyKeys.create("my-key", { scope: "global" });
await childTask.trigger(payload, { idempotencyKey });
```

### Triggering from backend code

When triggering tasks from your backend code (outside of a task), there is no parent run context. In this case, `run` and `attempt` scopes behave the same as `global` since there's no run ID or attempt number to inject:

```ts theme={"theme":"css-variables"}
// In your backend code (e.g., an API route)
import { idempotencyKeys, tasks } from "@trigger.dev/sdk";

// All three of these behave the same when called outside a task:
await tasks.trigger("my-task", payload, { idempotencyKey: "my-key" });
await tasks.trigger("my-task", payload, {
  idempotencyKey: await idempotencyKeys.create("my-key", { scope: "run" }),
});
await tasks.trigger("my-task", payload, {
  idempotencyKey: await idempotencyKeys.create("my-key", { scope: "global" }),
});
```

<Note>
  When triggering from backend code, the scope doesn't matter since there's no task context. All scopes effectively behave as global.
</Note>

## `idempotencyKeyTTL` option

The `idempotencyKeyTTL` option defines a time window during which a task with the same idempotency key will only run once. Here's how it works:

1. When you trigger a task with an idempotency key and set `idempotencyKeyTTL: "5m"`, it creates a 5-minute window.
2. During this window, any subsequent triggers with the same idempotency key will return the original task run instead of creating a new one.
3. Once the TTL window expires, the next trigger with that idempotency key will create a new task run and start a new time window.

<img alt="idempotency-key-ttl" />

By default idempotency keys are stored for 30 days. You can change this by passing the `idempotencyKeyTTL` option when triggering a task:

```ts theme={"theme":"css-variables"}
import { idempotencyKeys, task, wait } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  retry: {
    maxAttempts: 4,
  },
  run: async (payload: any) => {
    const idempotencyKey = await idempotencyKeys.create("my-task-key");

    // The idempotency key will expire after 60 seconds
    await childTask.trigger({ foo: "bar" }, { idempotencyKey, idempotencyKeyTTL: "60s" });

    await wait.for({ seconds: 61 });

    // The idempotency key will have expired, so the childTask will be triggered again
    await childTask.trigger({ foo: "bar" }, { idempotencyKey });

    // Do something else, that may throw an error and cause the task to be retried
    throw new Error("Something went wrong");
  },
});
```

You can use the following units for the `idempotencyKeyTTL` option:

* `s` for seconds (e.g. `60s`)
* `m` for minutes (e.g. `5m`)
* `h` for hours (e.g. `2h`)
* `d` for days (e.g. `3d`)

## Failed runs and idempotency

When a run with an idempotency key **fails**, the key is automatically cleared. This means triggering the same task with the same idempotency key will create a new run. However, **successful** and **canceled** runs keep their idempotency key. If you need to re-trigger after a successful or canceled run, you can:

1. **Reset the idempotency key** using `idempotencyKeys.reset()`:

```ts theme={"theme":"css-variables"}
import { idempotencyKeys } from "@trigger.dev/sdk";

// Clear the idempotency key to allow re-triggering
await idempotencyKeys.reset("my-task", "my-idempotency-key");

// Now you can trigger the task again
await myTask.trigger(payload, { idempotencyKey: "my-idempotency-key" });
```

2. **Use a shorter TTL** so the key expires automatically:

```ts theme={"theme":"css-variables"}
// Key expires after 5 minutes
await myTask.trigger(payload, { 
  idempotencyKey: "my-key", 
  idempotencyKeyTTL: "5m" 
});
```

## Payload-based idempotency

We don't currently support payload-based idempotency, but you can implement it yourself by hashing the payload and using the hash as the idempotency key.

```ts theme={"theme":"css-variables"}
import { idempotencyKeys, task } from "@trigger.dev/sdk";
import { createHash } from "node:crypto";

// Somewhere in your code
const idempotencyKey = await idempotencyKeys.create(hash(childPayload));
// childTask will only be triggered once with the same idempotency key
await tasks.trigger("child-task", { some: "payload" }, { idempotencyKey });

// Create a hash of the payload using Node.js crypto
// Ideally, you'd do a stable serialization of the payload before hashing, to ensure the same payload always results in the same hash
function hash(payload: any): string {
  const hash = createHash("sha256");
  hash.update(JSON.stringify(payload));
  return hash.digest("hex");
}
```

## Resetting idempotency keys

You can reset an idempotency key to clear it from all associated runs. This is useful if you need to allow a task to be triggered again with the same idempotency key.

When you reset an idempotency key, it will be cleared for all runs that match both the task identifier and the idempotency key in the current environment. This allows you to trigger the task again with the same key.

### API signature

```ts theme={"theme":"css-variables"}
idempotencyKeys.reset(
  taskIdentifier: string,
  idempotencyKey: string,
  requestOptions?: ZodFetchOptions
): Promise<{ id: string }>
```

| Parameter        | Description                                                      |
| ---------------- | ---------------------------------------------------------------- |
| `taskIdentifier` | The identifier of the task (e.g., `"my-task"`)                   |
| `idempotencyKey` | The idempotency key hash to reset (the 64-character hash string) |
| `requestOptions` | Optional fetch options for the API request                       |

### Resetting keys created with `idempotencyKeys.create()`

When you pass an `IdempotencyKey` created with `idempotencyKeys.create()`, the scope and original key are automatically extracted, making it easy to reset:

```ts theme={"theme":"css-variables"}
import { idempotencyKeys, task } from "@trigger.dev/sdk";

export const parentTask = task({
  id: "parent-task",
  run: async (payload) => {
    const key = await idempotencyKeys.create("my-key", { scope: "global" });
    await childTask.trigger(payload, { idempotencyKey: key });

    // Later in the same task, reset it - options extracted automatically
    await idempotencyKeys.reset("child-task", key);
  },
});
```

### Resetting global keys

Global keys are the simplest to reset since they don't require any run context:

```ts theme={"theme":"css-variables"}
import { idempotencyKeys } from "@trigger.dev/sdk";

// From anywhere - inside a task or from your backend code
await idempotencyKeys.reset("my-task", "my-key", { scope: "global" });
```

### Resetting run-scoped keys

Keys created with `"run"` scope (the default) include the parent run ID in the hash. When resetting from inside the same task, the run ID is automatically available:

```ts theme={"theme":"css-variables"}
import { idempotencyKeys, task } from "@trigger.dev/sdk";

export const parentTask = task({
  id: "parent-task",
  run: async (payload) => {
    // Create a run-scoped key (default)
    const key = await idempotencyKeys.create("my-key");
    await childTask.trigger(payload, { idempotencyKey: key });

    // Reset works automatically inside the task - run ID is available from context
    await idempotencyKeys.reset("child-task", key);
  },
});
```

When resetting from outside a task, you must provide the `parentRunId` if the key was created within a task context:

```ts theme={"theme":"css-variables"}
import { idempotencyKeys } from "@trigger.dev/sdk";

// If the key was created within a task, you need the parent run ID
await idempotencyKeys.reset("my-task", "my-key", {
  scope: "run",
  parentRunId: "run_abc123"
});
```

<Note>
  If you triggered the task from backend code, all scopes behave as global (see [Triggering from backend code](#triggering-from-backend-code)). Use `scope: "global"` when resetting.
</Note>

### Resetting attempt-scoped keys

Keys created with `"attempt"` scope include both the parent run ID and attempt number. When resetting from outside a task, you must provide both:

```ts theme={"theme":"css-variables"}
import { idempotencyKeys } from "@trigger.dev/sdk";

// From your backend code
await idempotencyKeys.reset("my-task", "my-key", {
  scope: "attempt",
  parentRunId: "run_abc123",
  attemptNumber: 1
});
```

<Warning>
  If you try to reset a `"run"` or `"attempt"` scoped key from outside a task without providing the required `parentRunId` (and `attemptNumber` for attempt scope), it will throw an error.
</Warning>

### Resetting from the dashboard

You can also reset idempotency keys directly from the Trigger.dev dashboard:

1. Navigate to the run that has the idempotency key you want to reset
2. In the run details panel, find the "Idempotency key" section
3. Click the "Reset" button

This is useful when you need to manually allow a task to be re-triggered without writing code.

<img alt="Idempotency section in the run details pane showing the key, scope, and expiration time" />

<Note>
  Resetting an idempotency key only affects runs in the current environment. The reset is scoped to the specific task identifier and idempotency key combination.
</Note>

## Important notes

### Environment and task scoping

Idempotency keys, even the ones scoped globally, are actually scoped to the task and the environment. This means that you cannot collide with keys from other environments (e.g. dev will never collide with prod), or to other projects and orgs.

If you use the same idempotency key for triggering different tasks, the tasks will not be idempotent, and both tasks will be triggered. There's currently no way to make multiple tasks idempotent with the same key.

### How scopes affect the key

The scope determines what gets hashed alongside your key:

* Same key + `"run"` scope in different parent runs = different hashes = both tasks run
* Same key + `"global"` scope in different parent runs = same hash = only first task runs
* Same key + different scopes = different hashes = both tasks run

This is why understanding scopes is crucial: the same string key can produce different idempotency behavior depending on the scope and context.


# Welcome to the Trigger.dev docs
Source: https://trigger.dev/docs/introduction

Find all the resources and guides you need to get started

<CardGroup>
  <Card title="Quick start" href="/quick-start">
    Get started with Trigger.dev and run your first task in 3 minutes
  </Card>

  <Card title="Guides, frameworks & examples" href="/guides/introduction#example-tasks">
    Browse our wide range of guides, frameworks and example projects
  </Card>

  <Card title="Building with AI" href="/building-with-ai">
    Learn how to build Trigger.dev projects using AI coding assistants
  </Card>

  <Card title="Video walkthrough" href="/video-walkthrough">
    Watch an end-to-end demo of Trigger.dev in 10 minutes
  </Card>
</CardGroup>

## What is Trigger.dev?

Trigger.dev is an open source background jobs framework that lets you write reliable workflows in plain async code. Run long-running AI tasks, handle complex background jobs, and build AI agents with built-in queuing, automatic retries, and real-time monitoring. No timeouts, elastic scaling, and zero infrastructure management required.

We provide everything you need to build and manage background tasks: a CLI and SDK for writing tasks in your existing codebase, support for both [regular](/docs/tasks/overview) and [scheduled](/docs/tasks/scheduled) tasks, full observability through our dashboard, and a [Realtime API](/docs/realtime) with [React hooks](/docs/realtime/react-hooks#realtime-hooks) for showing task status in your frontend. You can use [Trigger.dev Cloud](https://cloud.trigger.dev) or [self-host](/docs/self-hosting/overview) on your own infrastructure.

## Learn the concepts

<CardGroup>
  <Card title="Writing tasks" icon="wand-magic-sparkles" href="/tasks/overview">
    Tasks are the core of Trigger.dev. Learn what they are and how to write them.
  </Card>

  <Card title="Triggering tasks" icon="bullseye-pointer" href="/triggering">
    Learn how to trigger tasks from your codebase.
  </Card>

  <Card title="Runs" icon="person-running" href="/runs">
    Runs are the instances of tasks that are executed. Learn how they work.
  </Card>

  <Card title="API keys" icon="key" href="/apikeys">
    API keys are used to authenticate requests to the Trigger.dev API. Learn how to create and use
    them.
  </Card>
</CardGroup>

## Explore by feature

<CardGroup>
  <Card title="Scheduled tasks (cron)" icon="clock" href="/tasks/scheduled">
    Scheduled tasks are a type of task that is scheduled to run at a specific time.
  </Card>

  <Card title="Realtime API" icon="loader" href="/realtime">
    The Realtime API allows you to trigger tasks and get the status of runs.
  </Card>

  <Card title="React hooks" icon="react" href="/realtime/react-hooks">
    React hooks are a way to show task status in your frontend.
  </Card>

  <Card title="Waits" icon="calendar-clock" href="/wait">
    Waits are a way to wait for a task to finish before continuing.
  </Card>

  <Card title="Errors and retries" icon="message-exclamation" href="/errors-retrying">
    Learn how to handle errors and retries.
  </Card>

  <Card title="Concurrency & Queues" icon="line-height" href="/queue-concurrency">
    Configure what you want to happen when there is more than one run at a time.
  </Card>

  <Card title="Wait for token (human-in-the-loop)" icon="hand" href="/wait-for-token">
    Pause runs until a token is completed via an approval workflow.
  </Card>

  <Card title="Build extensions" icon="gear" href="/config/extensions/overview">
    Customize the build process or the resulting bundle and container image.
  </Card>
</CardGroup>

## Explore by build extension

| Extension             | What it does                                                 | Docs                                                                   |
| :-------------------- | :----------------------------------------------------------- | :--------------------------------------------------------------------- |
| prismaExtension       | Use Prisma with Trigger.dev                                  | [prismaExtension docs](/docs/config/extensions/prismaExtension)             |
| pythonExtension       | Execute Python scripts in Trigger.dev                        | [pythonExtension docs](/docs/config/extensions/pythonExtension)             |
| playwright            | Use Playwright with Trigger.dev                              | [playwright extension docs](/docs/config/extensions/playwright)             |
| puppeteer             | Use Puppeteer with Trigger.dev                               | [puppeteer extension docs](/docs/config/extensions/puppeteer)               |
| lightpanda            | Use Lightpanda with Trigger.dev                              | [lightpanda extension docs](/docs/config/extensions/lightpanda)             |
| ffmpeg                | Use FFmpeg with Trigger.dev                                  | [ffmpeg extension docs](/docs/config/extensions/ffmpeg)                     |
| aptGet                | Install system packages with aptGet                          | [aptGet extension docs](/docs/config/extensions/aptGet)                     |
| additionalFiles       | Copy additional files to the build directory                 | [additionalFiles docs](/docs/config/extensions/additionalFiles)             |
| additionalPackages    | Include additional packages in the build                     | [additionalPackages docs](/docs/config/extensions/additionalPackages)       |
| syncEnvVars           | Automatically sync environment variables to Trigger.dev      | [syncEnvVars docs](/docs/config/extensions/syncEnvVars)                     |
| esbuildPlugin         | Add existing or custom esbuild plugins to your build process | [esbuildPlugin docs](/docs/config/extensions/esbuildPlugin)                 |
| emitDecoratorMetadata | Support for the emitDecoratorMetadata TypeScript compiler    | [emitDecoratorMetadata docs](/docs/config/extensions/emitDecoratorMetadata) |
| audioWaveform         | Support for Audio Waveform in your project                   | [audioWaveform docs](/docs/config/extensions/audioWaveform)                 |

## Explore by example

<CardGroup>
  <Card title="FFmpeg" href="/guides/examples/ffmpeg-video-processing" />

  <Card title="Fal.ai" href="/guides/examples/fal-ai-image-to-cartoon" />

  <Card title="Puppeteer" href="/guides/examples/puppeteer" />

  <Card title="LibreOffice" href="/guides/examples/libreoffice-pdf-conversion" />

  <Card title="OpenAI" href="/guides/examples/open-ai-with-retrying" />

  <Card title="Browserbase" href="/guides/examples/scrape-hacker-news" />

  <Card title="Sentry" href="/guides/examples/sentry-error-tracking" />

  <Card title="Resend" href="/guides/examples/resend-email-sequence" />

  <Card title="Vercel AI SDK" href="/guides/examples/vercel-ai-sdk" />

  <Card title="Sharp" href="/guides/examples/sharp-image-processing" />

  <Card title="Deepgram" href="/guides/examples/deepgram-transcribe-audio" />

  <Card title="Supabase" href="/guides/examples/supabase-database-operations" />

  <Card title="DALL•E" href="/guides/examples/dall-e3-generate-image" />

  <Card title="Firecrawl" href="/guides/examples/firecrawl-url-crawl" />

  <Card title="Lightpanda" href="/guides/examples/lightpanda" />
</CardGroup>

## Getting help

We'd love to hear from you or give you a hand getting started. Here are some ways to get in touch with us.

<CardGroup>
  <Card title="Join our Discord server" icon="discord" href="https://discord.gg/kA47vcd8P6">
    Our Discord is the best place to get help with any questions about Trigger.dev.
  </Card>

  <Card
    title="Follow us on X (Twitter)"
    icon={
  <svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 0 512 512">
    <path d="M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z" />
  </svg>
}
    href="https://twitter.com/triggerdotdev"
  >
    Follow us to get the latest updates and news.
  </Card>

  <Card title="Schedule a call" icon="phone" href="https://cal.com/team/triggerdotdev/founders-call">
    Arrange a call with one of the founders to get help with any questions.
  </Card>

  <Card title="Give us a star on GitHub" icon="star" href="https://github.com/triggerdotdev/trigger.dev">
    Check us out our GitHub repo and give us a star if you like what we're doing.
  </Card>
</CardGroup>


# Limits
Source: https://trigger.dev/docs/limits

There are some hard and soft limits that you might hit.

You can view your current limits, quotas, and rate limit usage in real-time by visiting the **Limits** page in the dashboard (accessible from the left sidebar). This page shows current rate limit token availability, quota usage, and plan features for your organization.

<Note>
  Looking to cap your monthly spend? That's a setting you control, not a platform limit — see
  [Billing limits and alerts](/docs/billing-limits).
</Note>

## Concurrency limits

| Pricing tier | Limit                |
| :----------- | :------------------- |
| Free         | 10 concurrent runs   |
| Hobby        | 25 concurrent runs   |
| Pro          | 100+ concurrent runs |

Extra concurrency above the Pro tier limit is available via the dashboard. Click the "Concurrency" page from the left sidebar when on the Pro plan to purchase more.

## Rate limits

Generally speaking each SDK call is an API call.

| Limit | Details                   |
| :---- | :------------------------ |
| API   | 1,500 requests per minute |

You can request a higher rate limit from us if you're on a paid plan.

The most common cause of hitting the API rate limit is if you're calling `trigger()` on a task in a loop, instead of doing this use `batchTrigger()` which will trigger multiple tasks in a single API call. You can have up to 1,000 tasks in a single batch trigger call with SDK 4.3.1+ (500 in prior versions).

## Queued tasks

The maximum number of runs that can be queued **per queue** (not across all queues in the environment). Each queue can hold up to its limit independently. When a queue hits its limit, new triggers to that queue are rejected.

<Note>
  The limits below apply to [Trigger.dev Cloud](https://trigger.dev). If you self-host Trigger.dev, queue size limits are configurable via the `MAXIMUM_DEV_QUEUE_SIZE` and `MAXIMUM_DEPLOYED_QUEUE_SIZE` environment variables — see [Self-hosting environment variables](/docs/self-hosting/env/webapp#run-engine).
</Note>

| Pricing tier | Development (per queue) | Staging / Production (per queue) |
| :----------- | :---------------------- | :------------------------------- |
| Free         | 500                     | 10,000                           |
| Hobby        | 500                     | 250,000                          |
| Pro          | 5,000                   | 1,000,000                        |

## Maximum run TTL

On Trigger.dev Cloud, all runs have an enforced maximum TTL of 14 days. Runs without an explicit TTL automatically receive the 14-day TTL; runs with a TTL longer than 14 days are clamped to 14 days. This prevents queued runs from accumulating indefinitely. If you self-host, you can configure a maximum TTL via the `RUN_ENGINE_DEFAULT_MAX_TTL` environment variable — see [Self-hosting environment variables](/docs/self-hosting/env/webapp#run-engine).

## Schedules

| Pricing tier | Limit              |
| :----------- | :----------------- |
| Free         | 10 per project     |
| Hobby        | 100 per project    |
| Pro          | 1,000+ per project |

Additional bundles above the Pro tier are available for \$10/month per 1,000 schedules. Contact us via [email](https://trigger.dev/contact) or [Discord](https://trigger.dev/discord) to request more.

When attaching schedules to tasks we strongly recommend you add them [in our dashboard](/docs/tasks/scheduled#attaching-schedules-in-the-dashboard) if they're "static". That way you can control them easily per environment.

If you add them [dynamically using code](/docs/management/schedules/create) make sure you add a `deduplicationKey` so you don't add the same schedule to a task multiple times. If you don't your task will get triggered multiple times, it will cost you more, and you will hit the limit.

If you're creating schedules for your user you will definitely need to request more schedules from us.

## Projects

| Pricing tier | Limit               |
| :----------- | :------------------ |
| All tiers    | 10 per organization |

Each project receives its own concurrency allocation. If you need to support multiple tenants with the same codebase but different environment variables, see the [Multi-tenant applications](/docs/deploy-environment-variables#multi-tenant-applications) section for a recommended workaround.

## Preview branches

| Pricing tier | Limit                |
| :----------- | :------------------- |
| Free         | Not available        |
| Hobby        | 5 preview branches   |
| Pro          | 20+ preview branches |

Additional bundles above the Pro tier are available for \$10/month per preview branch. Contact us via [email](https://trigger.dev/contact) or [Discord](https://trigger.dev/discord) to request more.

## Realtime connections

| Pricing tier | Limit                       |
| :----------- | :-------------------------- |
| Free         | 10 concurrent connections   |
| Hobby        | 50 concurrent connections   |
| Pro          | 500+ concurrent connections |

Additional bundles are available for \$10/month per 100 concurrent connections. Contact us via [email](https://trigger.dev/contact) or [Discord](https://trigger.dev/discord) to request more.

## Task payloads and outputs

| Limit                  | Details                                                            |
| :--------------------- | :----------------------------------------------------------------- |
| Single trigger payload | Must not exceed 3MB                                                |
| Batch trigger payload  | Each item can be up to 3MB (SDK 4.3.1+). Prior: 1MB total combined |
| Task outputs           | Must not exceed 10MB                                               |

Large payloads and outputs are offloaded to object storage automatically. You don't need to do anything to handle this in your tasks, as we upload and download the data transparently during operation.

On the way in, the SDK uploads any trigger or batch-item payload over 128KB to object storage before sending, so large triggers and batches don't exceed the request body limit. `trigger` and `triggerAndWait` have done this since SDK 4.5.0, and `batchTrigger` and `batchTriggerAndWait` (including the by-id and by-task variants) do too from SDK 4.5.2.

Payloads and outputs over 512KB are kept in object storage rather than inline. Calling `runs.retrieve` returns that offloaded data as a presigned download URL (`payloadPresignedUrl` / `outputPresignedUrl`).

## Batch size

A single batch can have a maximum of 1,000 items with SDK 4.3.1+. Prior versions are limited to 500 items.

<SoftLimit />

## Batch trigger rate limits

Batch triggering uses a token bucket algorithm to rate limit the number of runs you can trigger per environment. Each run in a batch consumes one token.

| Pricing tier | Bucket size | Refill rate           |
| :----------- | :---------- | :-------------------- |
| Free         | 1,200 runs  | 100 runs every 10 sec |
| Hobby        | 5,000 runs  | 500 runs every 5 sec  |
| Pro          | 5,000 runs  | 500 runs every 5 sec  |

**How it works**: You can burst up to your bucket size, then tokens refill at the specified rate. For example, a Free user can trigger 1,200 runs immediately, then must wait for tokens to refill (100 runs become available every 10 seconds).

<Note>
  When you hit batch rate limits, the SDK throws a `BatchTriggerError` with `isRateLimited: true`.
  See [Handling batch trigger errors](/docs/triggering#handling-batch-trigger-errors) for how to detect
  and react to rate limits in your code.
</Note>

## Batch trigger processing concurrency

When you send a batch trigger, we convert it into individual runs. This limit controls the maximum number of batches being converted into runs simultaneously per environment. It is not a limit on how many batch runs can be executing at once.

| Pricing tier | Limit                 |
| :----------- | :-------------------- |
| Free         | 5 concurrent batches  |
| Hobby        | 10 concurrent batches |
| Pro          | 50 concurrent batches |

## Log retention

| Pricing tier | Limit   |
| :----------- | :------ |
| Free         | 1 day   |
| Hobby        | 7 days  |
| Pro          | 30 days |

## Log size

We limit the size of logs to prevent oversized data potentially causing issues.

<Expandable title="log limits">
  #### Attribute Limits

  * Span Attribute Count Limit: 256
  * Log Attribute Count Limit: 256
  * Span Attribute Value Length Limit: 131072 characters
  * Log Attribute Value Length Limit: 131072 characters

  #### Event and Link Limits

  * Span Event Count Limit: 10
  * Link Count Limit: 2
  * Attributes per Link Limit: 10
  * Attributes per Event Limit: 10

  #### I/O Packet Length Limit

  128 KB (131,072 bytes)

  #### Attribute Clipping Behavior

  * Attributes exceeding the value length limit (1028 characters) are discarded.
  * If the total number of attributes exceeds 256, additional attributes are not included.

  #### Attribute Value Size Calculation

  * Strings: Actual length of the string
  * Numbers: 8 bytes
  * Booleans: 4 bytes
  * Arrays: Sum of the sizes of all elements
  * Undefined or null: 0 bytes
</Expandable>

## Alerts

An alert destination is a single email address, Slack channel, or webhook URL that you want to send alerts to.

| Pricing tier | Limit                   |
| :----------- | :---------------------- |
| Free         | 1 alert destination     |
| Hobby        | 3 alert destinations    |
| Pro          | 100+ alert destinations |

If you're on the Pro plan and need more than the plan limit, you can request more by contacting us via [email](https://trigger.dev/contact) or [Discord](https://trigger.dev/discord).

## Query

Query execution is subject to the following limits:

| Limit              | Details               |
| :----------------- | :-------------------- |
| Max execution time | 10 seconds per query  |
| Max result rows    | 10,000 rows per query |
| Concurrent queries | 3 per project         |

### Query lookback period

The maximum time range a query can look back is based on your plan:

| Pricing tier | Limit   |
| :----------- | :------ |
| Free         | 1 day   |
| Hobby        | 7 days  |
| Pro          | 30 days |

If your query's time range exceeds your plan's lookback limit, it will be automatically clipped to the maximum allowed period.

## Metric widget concurrency

The number of metric widgets that can be queried concurrently per project.

| Limit                     | Details        |
| :------------------------ | :------------- |
| Concurrent widget queries | 30 per project |

## Machines

The default machine is `small-1x` which has 0.5 vCPU and 0.5 GB of RAM. You can optionally configure a higher spec machine which will increase the cost of running the task but can also improve the performance of the task if it is CPU or memory bound.

See the [machine configurations](/docs/machines#machine-configurations) for more details.

## Team members

| Pricing tier | Limit            |
| :----------- | :--------------- |
| Free         | 5 team members   |
| Hobby        | 5 team members   |
| Pro          | 25+ team members |

Additional seats are available for \$20/month per seat. Contact us via [email](https://trigger.dev/contact) or [Discord](https://trigger.dev/discord) to request more.


# Logging, tracing & metrics
Source: https://trigger.dev/docs/logging

How to use the built-in logging, tracing, and metrics system.

<img alt="The run log" />

The run log shows you exactly what happened in every run of your tasks. It is comprised of logs, traces and spans.

## Logs

You can use `console.log()`, `console.error()`, etc as normal and they will be shown in your run log. This is the standard function so you can use it as you would in any other JavaScript or TypeScript code. Logs from any functions/packages will also be shown.

### logger

We recommend that you use our `logger` object which creates structured logs. Structured logs will make it easier for you to search the logs to quickly find runs.

```ts /trigger/logging.ts theme={"theme":"css-variables"}
import { task, logger } from "@trigger.dev/sdk";

export const loggingExample = task({
  id: "logging-example",
  run: async (payload: { data: Record<string, string> }) => {
    //the first parameter is the message, the second parameter must be a key-value object (Record<string, unknown>)
    logger.debug("Debug message", payload.data);
    logger.log("Log message", payload.data);
    logger.info("Info message", payload.data);
    logger.warn("You've been warned", payload.data);
    logger.error("Error message", payload.data);
  },
});
```

## Tracing and spans

Tracing is a way to follow the flow of your code. It's very useful for debugging and understanding how your code is working, especially with long-running or complex tasks.

Trigger.dev uses OpenTelemetry tracing under the hood. With automatic tracing for many things like task triggering, task attempts, HTTP requests, and more.

| Name          | Description                      |
| :------------ | :------------------------------- |
| Task triggers | Task triggers                    |
| Task attempts | Task attempts                    |
| HTTP requests | HTTP requests made by your code. |

### Adding instrumentations

<img alt="The run log" />

You can [add instrumentations](/docs/config/config-file#instrumentations). The Prisma one above will automatically trace all Prisma queries.

### Add custom traces

If you want to add custom traces to your code, you can use the `logger.trace` function. It will create a new OTEL trace and you can set attributes on it.

```ts theme={"theme":"css-variables"}
import { logger, task } from "@trigger.dev/sdk";

export const customTrace = task({
  id: "custom-trace",
  run: async (payload) => {
    //you can wrap code in a trace, and set attributes
    const user = await logger.trace("fetch-user", async (span) => {
      span.setAttribute("user.id", "1");

      //...do stuff

      //you can return a value
      return {
        id: "1",
        name: "John Doe",
        fetchedAt: new Date(),
      };
    });

    const usersName = user.name;
  },
});
```

## Metrics

Trigger.dev collects system and runtime metrics automatically for deployed tasks, and provides an API for recording custom metrics using OpenTelemetry.

You can view metrics in the [Dashboards](/docs/observability/dashboards), query them with [TRQL](/docs/observability/query), and export them to external services via [telemetry exporters](/docs/config/config-file#telemetry-exporters).

### Custom metrics API

Import `otel` from `@trigger.dev/sdk` and use the standard OpenTelemetry Metrics API to create custom instruments.

Create instruments **at module level** (outside the task `run` function) so they are reused across runs:

```ts /trigger/metrics.ts theme={"theme":"css-variables"}
import { task, logger, otel } from "@trigger.dev/sdk";

// Create a meter — instruments are created once at module level
const meter = otel.metrics.getMeter("my-app");

const itemsProcessed = meter.createCounter("items.processed", {
  description: "Total number of items processed",
  unit: "items",
});

const itemDuration = meter.createHistogram("item.duration", {
  description: "Time spent processing each item",
  unit: "ms",
});

const queueDepth = meter.createUpDownCounter("queue.depth", {
  description: "Current queue depth",
  unit: "items",
});

export const processQueue = task({
  id: "process-queue",
  run: async (payload: { items: string[] }) => {
    queueDepth.add(payload.items.length);

    for (const item of payload.items) {
      const start = performance.now();

      // ... process item ...

      const elapsed = performance.now() - start;

      itemsProcessed.add(1, { "item.type": "order" });
      itemDuration.record(elapsed, { "item.type": "order" });
      queueDepth.add(-1);
    }

    logger.info("Queue processed", { count: payload.items.length });
  },
});
```

#### Available instrument types

| Instrument    | Method                        | Use case                                                         |
| :------------ | :---------------------------- | :--------------------------------------------------------------- |
| Counter       | `meter.createCounter()`       | Monotonically increasing values (items processed, requests sent) |
| Histogram     | `meter.createHistogram()`     | Distributions of values (durations, sizes)                       |
| UpDownCounter | `meter.createUpDownCounter()` | Values that go up and down (queue depth, active connections)     |

All instruments accept optional attributes when recording values. Attributes let you break down metrics by dimension (e.g., by item type, status, or region).

### Automatic system and runtime metrics

Trigger.dev automatically collects the following metrics for deployed tasks. No configuration is needed. Requires SDK version **4.4.1 or later**.

| Metric name                     | Type    | Unit    | Description                  |
| :------------------------------ | :------ | :------ | :--------------------------- |
| `process.cpu.utilization`       | gauge   | ratio   | Process CPU usage (0-1)      |
| `process.cpu.time`              | counter | seconds | CPU time consumed            |
| `process.memory.usage`          | gauge   | bytes   | Process memory usage         |
| `nodejs.event_loop.utilization` | gauge   | ratio   | Event loop utilization (0-1) |
| `nodejs.event_loop.delay.p95`   | gauge   | seconds | Event loop delay p95         |
| `nodejs.event_loop.delay.max`   | gauge   | seconds | Event loop delay max         |
| `nodejs.heap.used`              | gauge   | bytes   | V8 heap used                 |
| `nodejs.heap.total`             | gauge   | bytes   | V8 heap total                |

<Note>
  In dev mode (`trigger dev`), only `process.*` and custom metrics are available.
</Note>

### Context attributes

All metrics (both automatic and custom) are tagged with run context so you can filter and group them:

* `run_id` — the run that produced the metric
* `task_identifier` — the task slug
* `attempt_number` — the attempt number
* `machine_name` — the machine preset (e.g., `small-1x`)
* `worker_version` — the deployed worker version
* `environment_type` — `PRODUCTION`, `STAGING`, `DEVELOPMENT`, or `PREVIEW`

### Querying metrics

Use [TRQL](/docs/observability/query) to query metrics data. For example, to see average CPU utilization over time:

```sql theme={"theme":"css-variables"}
SELECT
  timeBucket(),
  avg(value) AS avg_cpu
FROM metrics
WHERE metric_name = 'process.cpu.utilization'
GROUP BY timeBucket
ORDER BY timeBucket
LIMIT 1000
```

See the [Query page](/docs/observability/query#metrics-table-columns) for the full `metrics` table schema.

### Exporting metrics

You can send metrics to external observability services (Axiom, Honeycomb, Datadog, etc.) by configuring [telemetry exporters](/docs/config/config-file#telemetry-exporters) in your `trigger.config.ts`.


# Machines
Source: https://trigger.dev/docs/machines

Configure the number of vCPUs and GBs of RAM you want the task to use.

The `machine` configuration is optional. Using higher spec machines will increase the cost of running the task but can also improve the performance of the task if it is CPU or memory bound.

```ts /trigger/heavy-task.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

export const heavyTask = task({
  id: "heavy-task",
  machine: "large-1x",
  run: async ({ payload, ctx }) => {
    //...
  },
});
```

The default machine is `small-1x` which has 0.5 vCPU and 0.5 GB of RAM. You can change the default machine in your `trigger.config.ts` file:

```ts trigger.config.ts theme={"theme":"css-variables"}
import type { TriggerConfig } from "@trigger.dev/sdk";

export const config: TriggerConfig = {
  machine: "small-2x",
  // ... other config
};
```

## Machine configurations

| Preset             | vCPU | Memory | Disk space |
| :----------------- | :--- | :----- | :--------- |
| micro              | 0.25 | 0.25   | 10GB       |
| small-1x (default) | 0.5  | 0.5    | 10GB       |
| small-2x           | 1    | 1      | 10GB       |
| medium-1x          | 1    | 2      | 10GB       |
| medium-2x          | 2    | 4      | 10GB       |
| large-1x           | 4    | 8      | 10GB       |
| large-2x           | 8    | 16     | 10GB       |

You can view the Trigger.dev cloud pricing for these machines [here](https://trigger.dev/pricing#computePricing).

## Overriding the machine when triggering

You can also override the task machine when you [trigger](/docs/triggering) it:

```ts theme={"theme":"css-variables"}
await tasks.trigger<typeof heavyTask>(
  "heavy-task",
  { message: "hello world" },
  { machine: "large-2x" }
);
```

This is useful when you know that a certain payload will require more memory than the default machine. For example, you know it's a larger file or a customer that has a lot of data.

## Out Of Memory (OOM) errors

Sometimes you might see one of your runs fail with an "Out Of Memory" error.

> TASK\_PROCESS\_OOM\_KILLED. Your run was terminated due to exceeding the machine's memory limit. Try increasing the machine preset in your task options or replay using a larger machine.

We automatically detect common Out Of Memory errors:

* When using Node.js, if the V8 heap limit is exceeded (this can happen when creating large long-lived objects)
* When the entire process exceeds the memory limit of the machine the run is executing on.
* When a child process, such as ffmpeg, causes the memory limit of the machine to be exceeded, and exits with a non-zero code.

### Memory/Resource monitoring

To better understand why an OOM error occurred, we've published a helper class that will log memory debug information at regular intervals.

First, add this `ResourceMonitor` class to your project:

<Accordion title="View ResourceMonitor class">
  ```ts /src/resourceMonitor.ts theme={"theme":"css-variables"}
  import { promisify } from "node:util";
  import { exec } from "node:child_process";
  import os from "node:os";
  import { promises as fs } from "node:fs";
  import { type Context, logger } from "@trigger.dev/sdk";
  import { getHeapStatistics } from "node:v8";
  import { PerformanceObserver, constants } from "node:perf_hooks";

  const execAsync = promisify(exec);

  export type DiskMetrics = {
    total: number;
    used: number;
    free: number;
    percentUsed: number;
    warning?: string;
  };

  export type MemoryMetrics = {
    total: number;
    free: number;
    used: number;
    percentUsed: number;
  };

  export type NodeProcessMetrics = {
    memoryUsage: number;
    memoryUsagePercent: number;
    heapUsed: number;
    heapSizeLimit: number;
    heapUsagePercent: number;
    availableHeap: number;
    isNearHeapLimit: boolean;
  };

  export type TargetProcessMetrics = {
    method: string;
    processName: string;
    count: number;
    processes: ProcessInfo[];
    averages: {
      cpu: number;
      memory: number;
      rss: number;
      vsz: number;
    } | null;
    totals: {
      cpu: number;
      memory: number;
      rss: number;
      vsz: number;
    } | null;
  };

  export type ProcessMetrics = {
    node: NodeProcessMetrics;
    targetProcess: TargetProcessMetrics | null;
  };

  type GCSummary = {
    count: number;
    totalDuration: number; // ms
    avgDuration: number; // ms
    maxDuration: number; // ms
    kinds: Record<
      string,
      {
        // breakdown by kind
        count: number;
        totalDuration: number;
        avgDuration: number;
        maxDuration: number;
      }
    >;
  };

  type ProcessInfo = {
    user: string;
    pid: number;
    cpu: number;
    mem: number;
    vsz: number;
    rss: number;
    command: string;
  };

  export type SystemMetrics = {
    disk: DiskMetrics;
    memory: MemoryMetrics;
  };

  export type ResourceMonitorConfig = {
    dirName?: string;
    processName?: string;
    ctx: Context;
    compactLogging?: boolean;
  };

  // Constants
  const DISK_LIMIT_GB = 10;
  const DISK_LIMIT_BYTES = DISK_LIMIT_GB * 1024 * 1024 * 1024; // 10Gi in bytes

  export class ResourceMonitor {
    private logInterval: NodeJS.Timeout | null = null;
    private logger: typeof logger;
    private dirName: string;
    private processName: string | undefined;
    private ctx: Context;
    private verbose: boolean;
    private compactLogging: boolean;
    private gcObserver: PerformanceObserver | null = null;
    private bufferedGcEntries: PerformanceEntry[] = [];

    constructor(config: ResourceMonitorConfig) {
      this.logger = logger;
      this.dirName = config.dirName ?? "/tmp";
      this.processName = config.processName;
      this.ctx = config.ctx;
      this.verbose = true;
      this.compactLogging = config.compactLogging ?? false;
    }

    /**
     * Start periodic resource monitoring
     * @param intervalMs Monitoring interval in milliseconds
     */
    startMonitoring(intervalMs = 10000): void {
      if (intervalMs < 1000) {
        intervalMs = 1000;
        this.logger.warn("ResourceMonitor: intervalMs is less than 1000, setting to 1000");
      }

      if (this.logInterval) {
        clearInterval(this.logInterval);
      }

      this.logInterval = setInterval(this.logResources.bind(this), intervalMs);

      this.gcObserver = new PerformanceObserver((list) => {
        this.bufferedGcEntries.push(...list.getEntries());
      });

      this.gcObserver.observe({ entryTypes: ["gc"], buffered: true });
    }

    /**
     * Stop resource monitoring
     */
    stopMonitoring(): void {
      if (this.logInterval) {
        clearInterval(this.logInterval);
        this.logInterval = null;
      }

      if (this.gcObserver) {
        this.gcObserver.disconnect();
        this.gcObserver = null;
      }
    }

    private async logResources() {
      try {
        await this.logResourceSnapshot("ResourceMonitor");
      } catch (error) {
        this.logger.error(
          `Resource monitoring error: ${error instanceof Error ? error.message : String(error)}`
        );
      }
    }

    /**
     * Get combined system metrics (disk and memory)
     */
    private async getSystemMetrics(): Promise<SystemMetrics> {
      const [disk, memory] = await Promise.all([this.getDiskMetrics(), this.getMemoryMetrics()]);
      return { disk, memory };
    }

    /**
     * Get disk space information
     */
    private async getDiskMetrics(): Promise<DiskMetrics> {
      try {
        // Even with permission errors, du will output a total
        const { stdout, stderr } = await execAsync(`du -sb ${this.dirName} || true`);

        // Get the last line of stdout which contains the total
        const lastLine = stdout.split("\n").filter(Boolean).pop() || "";
        const usedBytes = parseInt(lastLine.split("\t")[0], 10);

        const effectiveTotal = DISK_LIMIT_BYTES;
        const effectiveUsed = Math.min(usedBytes, DISK_LIMIT_BYTES);
        const effectiveFree = effectiveTotal - effectiveUsed;
        const percentUsed = (effectiveUsed / effectiveTotal) * 100;

        const metrics: DiskMetrics = {
          total: effectiveTotal,
          used: effectiveUsed,
          free: effectiveFree,
          percentUsed,
        };

        // If we had permission errors, add a warning
        if (stderr.includes("Permission denied") || stderr.includes("cannot access")) {
          metrics.warning = "Some directories were not accessible";
        } else if (stderr.includes("No such file or directory")) {
          metrics.warning = "The directory does not exist";
        }

        return metrics;
      } catch (error) {
        this.logger.error(
          `Error getting disk metrics: ${error instanceof Error ? error.message : String(error)}`
        );
        return {
          free: DISK_LIMIT_BYTES,
          total: DISK_LIMIT_BYTES,
          used: 0,
          percentUsed: 0,
          warning: "Failed to measure disk usage",
        };
      }
    }

    /**
     * Get memory metrics
     */
    private getMemoryMetrics(): MemoryMetrics {
      const total = os.totalmem();
      const free = os.freemem();
      const used = total - free;
      const percentUsed = (used / total) * 100;

      return { total, free, used, percentUsed };
    }

    /**
     * Get process-specific metrics using /proc filesystem
     */
    private async getProcMetrics(pids: number[]): Promise<ProcessInfo[]> {
      return Promise.all(
        pids.map(async (pid) => {
          try {
            // Read process status
            const status = await fs.readFile(`/proc/${pid}/status`, "utf8");
            const cmdline = await fs.readFile(`/proc/${pid}/cmdline`, "utf8");
            const stat = await fs.readFile(`/proc/${pid}/stat`, "utf8");

            // Parse VmRSS (resident set size) from status
            const rss = parseInt(status.match(/VmRSS:\s+(\d+)/)?.[1] ?? "0", 10);
            // Parse VmSize (virtual memory size) from status
            const vsz = parseInt(status.match(/VmSize:\s+(\d+)/)?.[1] ?? "0", 10);
            // Get process owner
            const user = (await fs.stat(`/proc/${pid}`)).uid.toString();

            // Parse CPU stats from /proc/[pid]/stat
            const stats = stat.split(" ");
            const utime = parseInt(stats[13], 10);
            const stime = parseInt(stats[14], 10);
            const starttime = parseInt(stats[21], 10);

            // Calculate CPU percentage
            const totalTime = utime + stime;
            const uptime = os.uptime();
            const hertz = 100; // Usually 100 on Linux
            const elapsedTime = uptime - starttime / hertz;
            const cpuUsage = 100 * (totalTime / hertz / elapsedTime);

            // Calculate memory percentage against total system memory
            const totalMem = os.totalmem();
            const memoryPercent = (rss * 1024 * 100) / totalMem;

            return {
              user,
              pid,
              cpu: cpuUsage,
              mem: memoryPercent,
              vsz,
              rss,
              command: cmdline.replace(/\0/g, " ").trim(),
            };
          } catch (error) {
            return null;
          }
        })
      ).then((results) => results.filter((r): r is ProcessInfo => r !== null));
    }

    /**
     * Find PIDs for a process name using /proc filesystem
     */
    private async findPidsByName(processName?: string): Promise<number[]> {
      if (!processName) {
        return [];
      }

      try {
        const pids: number[] = [];
        const procDirs = await fs.readdir("/proc");

        for (const dir of procDirs) {
          if (!/^\d+$/.test(dir)) continue;

          const processPid = parseInt(dir, 10);

          // Ignore processes that have a lower PID than our own PID
          if (processPid <= process.pid) {
            continue;
          }

          try {
            const cmdline = await fs.readFile(`/proc/${dir}/cmdline`, "utf8");
            if (cmdline.includes(processName)) {
              pids.push(parseInt(dir, 10));
            }
          } catch {
            // Ignore errors reading individual process info
            continue;
          }
        }

        return pids;
      } catch {
        return [];
      }
    }

    /**
     * Get process-specific metrics
     */
    private async getProcessMetrics(): Promise<ProcessMetrics> {
      // Get Node.js process metrics
      const totalMemory = os.totalmem();
      // Convert GB to bytes (machine.memory is in GB)
      const machineMemoryBytes = this.ctx.machine
        ? this.ctx.machine.memory * 1024 * 1024 * 1024
        : totalMemory;
      const nodeMemoryUsage = process.memoryUsage();

      // Node process percentage is based on machine memory if available, otherwise system memory
      const nodeMemoryPercent = (nodeMemoryUsage.rss / machineMemoryBytes) * 100;
      const heapStats = getHeapStatistics();

      const nodeMetrics: NodeProcessMetrics = {
        memoryUsage: nodeMemoryUsage.rss,
        memoryUsagePercent: nodeMemoryPercent,
        heapUsed: nodeMemoryUsage.heapUsed,
        heapSizeLimit: heapStats.heap_size_limit,
        heapUsagePercent: (heapStats.used_heap_size / heapStats.heap_size_limit) * 100,
        availableHeap: heapStats.total_available_size,
        isNearHeapLimit: heapStats.used_heap_size / heapStats.heap_size_limit > 0.8,
      };

      let method = "ps";

      try {
        let processes: ProcessInfo[] = [];

        // Try ps first, fall back to /proc if it fails
        try {
          const { stdout: psOutput } = await execAsync(
            `ps aux | grep ${this.processName} | grep -v grep`
          );

          if (psOutput.trim()) {
            processes = psOutput
              .trim()
              .split("\n")
              .filter((line) => {
                const parts = line.trim().split(/\s+/);
                const pid = parseInt(parts[1], 10);

                // Ignore processes that have a lower PID than our own PID
                return pid > process.pid;
              })
              .map((line) => {
                const parts = line.trim().split(/\s+/);
                return {
                  user: parts[0],
                  pid: parseInt(parts[1], 10),
                  cpu: parseFloat(parts[2]),
                  mem: parseFloat(parts[3]),
                  vsz: parseInt(parts[4], 10),
                  rss: parseInt(parts[5], 10),
                  command: parts.slice(10).join(" "),
                };
              });
          }
        } catch {
          // ps failed, try /proc instead
          method = "proc";
          const pids = await this.findPidsByName(this.processName);
          processes = await this.getProcMetrics(pids);
        }

        if (processes.length === 0) {
          return {
            node: nodeMetrics,
            targetProcess: this.processName
              ? {
                  method,
                  processName: this.processName,
                  count: 0,
                  processes: [],
                  averages: null,
                  totals: null,
                }
              : null,
          };
        }

        // For CPU:
        // - ps shows CPU percentage per core (e.g., 100% = 1 core)
        // - machine.cpu is in cores (e.g., 0.5 = half a core)
        // - we want to show percentage of allocated CPU (e.g., 100% = using all allocated CPU)
        const availableCpu = this.ctx.machine?.cpu ?? os.cpus().length;
        const cpuNormalizer = availableCpu * 100; // Convert to basis points for better precision with fractional CPUs

        // For Memory:
        // - ps 'mem' is already a percentage of system memory
        // - we need to convert it to a percentage of machine memory
        // - if machine memory is 0.5GB and system has 16GB, we multiply the percentage by 32
        const memoryScaleFactor = this.ctx.machine ? totalMemory / machineMemoryBytes : 1;

        const totals = processes.reduce(
          (acc, proc) => ({
            cpu: acc.cpu + proc.cpu,
            // Scale memory percentage to machine memory
            // TODO: test this
            memory: acc.memory + proc.mem * memoryScaleFactor,
            rss: acc.rss + proc.rss,
            vsz: acc.vsz + proc.vsz,
          }),
          { cpu: 0, memory: 0, rss: 0, vsz: 0 }
        );

        const count = processes.length;

        const averages = {
          cpu: totals.cpu / (count * cpuNormalizer),
          memory: totals.memory / count,
          rss: totals.rss / count,
          vsz: totals.vsz / count,
        };

        return {
          node: nodeMetrics,
          targetProcess: this.processName
            ? {
                method,
                processName: this.processName,
                count,
                processes,
                averages,
                totals: {
                  cpu: totals.cpu / cpuNormalizer,
                  memory: totals.memory,
                  rss: totals.rss,
                  vsz: totals.vsz,
                },
              }
            : null,
        };
      } catch (error) {
        return {
          node: nodeMetrics,
          targetProcess: this.processName
            ? {
                method,
                processName: this.processName,
                count: 0,
                processes: [],
                averages: null,
                totals: null,
              }
            : null,
        };
      }
    }

    /**
     * Log a snapshot of current resource usage
     */
    async logResourceSnapshot(label = "Resource Snapshot"): Promise<void> {
      try {
        const payload = await this.getResourceSnapshotPayload();
        const enhancedLabel = this.compactLogging
          ? this.createCompactLabel(payload, label)
          : this.createEnhancedLabel(payload, label);

        if (payload.process.node.isNearHeapLimit) {
          this.logger.warn(`${enhancedLabel}: Node is near heap limit`, payload);
        } else {
          this.logger.info(enhancedLabel, payload);
        }
      } catch (error) {
        this.logger.error(
          `Error logging resource snapshot: ${error instanceof Error ? error.message : String(error)}`
        );
      }
    }

    /**
     * Create an enhanced log label with key metrics for quick scanning
     */
    private createEnhancedLabel(payload: any, baseLabel: string): string {
      const parts: string[] = [baseLabel];

      // System resources with text indicators
      const diskPercent = parseFloat(payload.system.disk.percentUsed);
      const memoryPercent = parseFloat(payload.system.memory.percentUsed);
      const diskIndicator = this.getTextIndicator(diskPercent, 80, 90);
      const memIndicator = this.getTextIndicator(memoryPercent, 80, 90);
      parts.push(`Disk:${diskPercent.toFixed(1).padStart(5)}%${diskIndicator}`);
      parts.push(`Mem:${memoryPercent.toFixed(1).padStart(5)}%${memIndicator}`);

      // Node process metrics with text indicators
      const nodeMemPercent = parseFloat(payload.process.node.memoryUsagePercent);
      const heapPercent = parseFloat(payload.process.node.heapUsagePercent);
      const nodeIndicator = this.getTextIndicator(nodeMemPercent, 70, 85);
      const heapIndicator = this.getTextIndicator(heapPercent, 70, 85);
      parts.push(`Node:${nodeMemPercent.toFixed(1).padStart(4)}%${nodeIndicator}`);
      parts.push(`Heap:${heapPercent.toFixed(1).padStart(4)}%${heapIndicator}`);

      // Target process metrics (if available)
      if (payload.process.targetProcess && payload.process.targetProcess.count > 0) {
        const targetCpu = payload.process.targetProcess.totals?.cpuPercent || "0";
        const targetMem = payload.process.targetProcess.totals?.memoryPercent || "0";
        const targetCpuNum = parseFloat(targetCpu);
        const targetMemNum = parseFloat(targetMem);
        const cpuIndicator = this.getTextIndicator(targetCpuNum, 80, 90);
        const memIndicator = this.getTextIndicator(targetMemNum, 80, 90);
        parts.push(
          `${payload.process.targetProcess.processName}:${targetCpu.padStart(
            4
          )}%${cpuIndicator}/${targetMem.padStart(4)}%${memIndicator}`
        );
      }

      // GC activity with performance indicators
      if (payload.gc && payload.gc.count > 0) {
        const avgDuration = payload.gc.avgDuration;
        const gcIndicator = this.getTextIndicator(avgDuration, 5, 10, true);
        parts.push(
          `GC:${payload.gc.count.toString().padStart(2)}(${avgDuration
            .toFixed(1)
            .padStart(4)}ms)${gcIndicator}`
        );
      }

      // Machine constraints
      if (payload.constraints) {
        parts.push(`[${payload.constraints.cpu}CPU/${payload.constraints.memoryGB}GB]`);
      }

      // Warning indicators (only show critical ones in the main label)
      const criticalWarnings: string[] = [];
      if (payload.process.node.isNearHeapLimit) criticalWarnings.push("HEAP_LIMIT");
      if (diskPercent > 90) criticalWarnings.push("DISK_CRITICAL");
      if (memoryPercent > 95) criticalWarnings.push("MEM_CRITICAL");
      if (payload.system.disk.warning) criticalWarnings.push("DISK_WARN");

      if (criticalWarnings.length > 0) {
        parts.push(`[${criticalWarnings.join(",")}]`);
      }

      return parts.join(" | ");
    }

    /**
     * Get text-based indicator for percentage values
     */
    private getTextIndicator(
      value: number,
      warningThreshold: number,
      criticalThreshold: number,
      isDuration = false
    ): string {
      if (isDuration) {
        // For duration values, higher is worse
        if (value >= criticalThreshold) return " [CRIT]";
        if (value >= warningThreshold) return " [WARN]";
        return " [OK]";
      } else {
        // For percentage values, higher is worse
        if (value >= criticalThreshold) return " [CRIT]";
        if (value >= warningThreshold) return " [WARN]";
        return " [OK]";
      }
    }

    /**
     * Create a compact version of the enhanced label for high-frequency logging
     */
    private createCompactLabel(payload: any, baseLabel: string): string {
      const parts: string[] = [baseLabel];

      // Only show critical metrics in compact mode
      const diskPercent = parseFloat(payload.system.disk.percentUsed);
      const memoryPercent = parseFloat(payload.system.memory.percentUsed);
      const heapPercent = parseFloat(payload.process.node.heapUsagePercent);

      // Use single character indicators for compactness
      const diskIndicator = diskPercent > 90 ? "!" : diskPercent > 80 ? "?" : ".";
      const memIndicator = memoryPercent > 95 ? "!" : memoryPercent > 80 ? "?" : ".";
      const heapIndicator = heapPercent > 85 ? "!" : heapPercent > 70 ? "?" : ".";

      parts.push(`D:${diskPercent.toFixed(0).padStart(2)}%${diskIndicator}`);
      parts.push(`M:${memoryPercent.toFixed(0).padStart(2)}%${memIndicator}`);
      parts.push(`H:${heapPercent.toFixed(0).padStart(2)}%${heapIndicator}`);

      // GC activity (only if significant)
      if (payload.gc && payload.gc.count > 0 && payload.gc.avgDuration > 2) {
        const gcIndicator =
          payload.gc.avgDuration > 10 ? "!" : payload.gc.avgDuration > 5 ? "?" : ".";
        parts.push(`GC:${payload.gc.count}${gcIndicator}`);
      }

      return parts.join(" ");
    }

    async getResourceSnapshotPayload() {
      const [systemMetrics, processMetrics] = await Promise.all([
        this.getSystemMetrics(),
        this.getProcessMetrics(),
      ]);

      const gcSummary = summarizeGCEntries(this.bufferedGcEntries);
      this.bufferedGcEntries = [];

      const formatBytes = (bytes: number) => (bytes / (1024 * 1024)).toFixed(2);
      const formatPercent = (value: number) => value.toFixed(1);

      return {
        system: {
          disk: {
            limitGiB: DISK_LIMIT_GB,
            dirName: this.dirName,
            usedGiB: (systemMetrics.disk.used / (1024 * 1024 * 1024)).toFixed(2),
            freeGiB: (systemMetrics.disk.free / (1024 * 1024 * 1024)).toFixed(2),
            percentUsed: formatPercent(systemMetrics.disk.percentUsed),
            warning: systemMetrics.disk.warning,
          },
          memory: {
            freeGB: (systemMetrics.memory.free / (1024 * 1024 * 1024)).toFixed(2),
            percentUsed: formatPercent(systemMetrics.memory.percentUsed),
          },
        },
        gc: gcSummary,
        constraints: this.ctx.machine
          ? {
              cpu: this.ctx.machine.cpu,
              memoryGB: this.ctx.machine.memory,
              diskGB: DISK_LIMIT_BYTES / (1024 * 1024 * 1024),
            }
          : {
              cpu: os.cpus().length,
              memoryGB: Math.floor(os.totalmem() / (1024 * 1024 * 1024)),
              note: "Using system resources (no machine constraints specified)",
            },
        process: {
          node: {
            memoryUsageMB: formatBytes(processMetrics.node.memoryUsage),
            memoryUsagePercent: formatPercent(processMetrics.node.memoryUsagePercent),
            heapUsedMB: formatBytes(processMetrics.node.heapUsed),
            heapSizeLimitMB: formatBytes(processMetrics.node.heapSizeLimit),
            heapUsagePercent: formatPercent(processMetrics.node.heapUsagePercent),
            availableHeapMB: formatBytes(processMetrics.node.availableHeap),
            isNearHeapLimit: processMetrics.node.isNearHeapLimit,
            ...(this.verbose
              ? {
                  heapStats: getHeapStatistics(),
                }
              : {}),
          },
          targetProcess: processMetrics.targetProcess
            ? {
                method: processMetrics.targetProcess.method,
                processName: processMetrics.targetProcess.processName,
                count: processMetrics.targetProcess.count,
                averages: processMetrics.targetProcess.averages
                  ? {
                      cpuPercent: formatPercent(processMetrics.targetProcess.averages.cpu * 100),
                      memoryPercent: formatPercent(processMetrics.targetProcess.averages.memory),
                      rssMB: formatBytes(processMetrics.targetProcess.averages.rss * 1024),
                      vszMB: formatBytes(processMetrics.targetProcess.averages.vsz * 1024),
                    }
                  : null,
                totals: processMetrics.targetProcess.totals
                  ? {
                      cpuPercent: formatPercent(processMetrics.targetProcess.totals.cpu * 100),
                      memoryPercent: formatPercent(processMetrics.targetProcess.totals.memory),
                      rssMB: formatBytes(processMetrics.targetProcess.totals.rss * 1024),
                      vszMB: formatBytes(processMetrics.targetProcess.totals.vsz * 1024),
                    }
                  : null,
              }
            : null,
        },
        timestamp: new Date().toISOString(),
      };
    }
  }

  function summarizeGCEntries(entries: PerformanceEntry[]): GCSummary {
    if (entries.length === 0) {
      return {
        count: 0,
        totalDuration: 0,
        avgDuration: 0,
        maxDuration: 0,
        kinds: {},
      };
    }

    let totalDuration = 0;
    let maxDuration = 0;
    const kinds: Record<string, { count: number; totalDuration: number; maxDuration: number }> = {};

    for (const e of entries) {
      const duration = e.duration;
      totalDuration += duration;
      if (duration > maxDuration) maxDuration = duration;

      const kind = kindName((e as any)?.detail?.kind ?? "unknown");
      if (!kinds[kind]) {
        kinds[kind] = { count: 0, totalDuration: 0, maxDuration: 0 };
      }
      kinds[kind].count += 1;
      kinds[kind].totalDuration += duration;
      if (duration > kinds[kind].maxDuration) kinds[kind].maxDuration = duration;
    }

    // finalize averages
    const avgDuration = totalDuration / entries.length;
    const kindsWithAvg: GCSummary["kinds"] = {};
    for (const [kind, stats] of Object.entries(kinds)) {
      kindsWithAvg[kind] = {
        count: stats.count,
        totalDuration: stats.totalDuration,
        avgDuration: stats.totalDuration / stats.count,
        maxDuration: stats.maxDuration,
      };
    }

    return {
      count: entries.length,
      totalDuration,
      avgDuration,
      maxDuration,
      kinds: kindsWithAvg,
    };
  }

  const kindName = (k: number | string) => {
    if (typeof k === "number") {
      return (
        {
          [constants.NODE_PERFORMANCE_GC_MAJOR]: "major",
          [constants.NODE_PERFORMANCE_GC_MINOR]: "minor",
          [constants.NODE_PERFORMANCE_GC_INCREMENTAL]: "incremental",
          [constants.NODE_PERFORMANCE_GC_WEAKCB]: "weak-cb",
        }[k] ?? `kind:${k}`
      );
    }
    return k;
  };
  ```
</Accordion>

Then, in your task, you can create an instance of the `ResourceMonitor` class and start monitoring memory, disk, and CPU usage:

```ts /src/trigger/example.ts theme={"theme":"css-variables"}
import { task, logger, wait } from "@trigger.dev/sdk";
import { ResourceMonitor } from "../resourceMonitor.js";

// Middleware to enable the resource monitor
tasks.middleware("resource-monitor", async ({ ctx, next }) => {
  const resourceMonitor = new ResourceMonitor({
    ctx,
  });

  // Only enable the resource monitor if the environment variable is set
  if (process.env.RESOURCE_MONITOR_ENABLED === "1") {
    resourceMonitor.startMonitoring(1_000);
  }

  await next();

  resourceMonitor.stopMonitoring();
});

export const resourceMonitorTest = task({
  id: "resource-monitor-test",
  run: async (payload: any, { ctx }) => {
    const interval = createMemoryPressure();

    await setTimeout(180_000);

    clearInterval(interval);

    return {
      message: "Hello, resources!",
    };
  },
});
```

This will produce logs that look like this:

<img alt="Resource monitor logs" />

If you are spawning a child process and you want to monitor its memory usage, you can pass the `processName` option to the `ResourceMonitor` class:

```ts /src/trigger/example.ts theme={"theme":"css-variables"}
const resourceMonitor = new ResourceMonitor({
  ctx,
  processName: "ffmpeg",
});
```

This will produce logs that includes the memory and CPU usage of the `ffmpeg` process:

<img alt="Resource monitor logs" />

### Explicit OOM errors

You can explicitly throw an Out Of Memory error in your task. This can be useful if you use a native package that detects it's going to run out of memory and then stops before it runs out. If you can detect this, you can then throw this error.

```ts /trigger/heavy-task.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { OutOfMemoryError } from "@trigger.dev/sdk";

export const yourTask = task({
  id: "your-task",
  machine: "medium-1x",
  run: async (payload: any, { ctx }) => {
    //...

    throw new OutOfMemoryError();
  },
});
```

If OOM errors happen regularly you need to either optimize the memory-efficiency of your code, or increase the machine.

### Retrying with a larger machine

If you are seeing rare OOM errors, it might make sense to add a setting to your task to retry with a large machine when an OOM happens:

```ts /trigger/heavy-task.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

export const yourTask = task({
  id: "your-task",
  machine: "medium-1x",
  retry: {
    outOfMemory: {
      machine: "large-1x",
    },
  },
  run: async (payload: any, { ctx }) => {
    //...
  },
});
```

<Note>
  This will only retry the task if you get an OOM error. It won't permanently change the machine
  that a new run starts on, so if you consistently see OOM errors you should change the machine in
  the `machine` property.
</Note>


# Advanced usage
Source: https://trigger.dev/docs/management/advanced-usage

Advanced usage of the Trigger.dev management API

### Accessing raw HTTP responses

All API methods return a `Promise` subclass `ApiPromise` that includes helpers for accessing the underlying HTTP response:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

async function main() {
  const { data: run, response: raw } = await runs.retrieve("run_1234").withResponse();

  console.log(raw.status);
  console.log(raw.headers);

  const response = await runs.retrieve("run_1234").asResponse(); // Returns a Response object

  console.log(response.status);
  console.log(response.headers);
}
```


# Authentication
Source: https://trigger.dev/docs/management/authentication

Authenticating with the Trigger.dev management API

There are two methods of authenticating with the management API: using a secret key associated with a specific environment in a project (`secretKey`), or using a personal access token (`personalAccessToken`). Both methods should only be used in a backend server, as they provide full access to the project.

<Note>
  There is a separate authentication strategy when making requests from your frontend application.
  See the [Realtime guide](/docs/realtime/overview) for more information. This guide is for backend usage
  only.
</Note>

Certain API functions work with both authentication methods, but require different arguments depending on the method used. For example, the `runs.list` function can be called using either a `secretKey` or a `personalAccessToken`, but the `projectRef` argument is required when using a `personalAccessToken`:

```ts theme={"theme":"css-variables"}
import { configure, runs } from "@trigger.dev/sdk";

// Using secretKey authentication
configure({
  secretKey: process.env["TRIGGER_SECRET_KEY"], // starts with tr_dev_, tr_prod_, or tr_preview_
});

function secretKeyExample() {
  return runs.list({
    limit: 10,
    status: ["COMPLETED"],
  });
}

// Using personalAccessToken authentication
configure({
  secretKey: process.env["TRIGGER_ACCESS_TOKEN"], // starts with tr_pat_
});

function personalAccessTokenExample() {
  // Notice the projectRef argument is required when using a personalAccessToken
  return runs.list("prof_1234", {
    limit: 10,
    status: ["COMPLETED"],
    projectRef: "tr_proj_1234567890",
  });
}
```

<Accordion title="View endpoint support">
  Consult the following table to see which endpoints support each authentication method.

  | Endpoint               | Secret key | Personal Access Token |
  | ---------------------- | ---------- | --------------------- |
  | `task.trigger`         | ✅          |                       |
  | `task.batchTrigger`    | ✅          |                       |
  | `runs.list`            | ✅          | ✅                     |
  | `runs.retrieve`        | ✅          |                       |
  | `runs.cancel`          | ✅          |                       |
  | `runs.replay`          | ✅          |                       |
  | `envvars.list`         | ✅          | ✅                     |
  | `envvars.retrieve`     | ✅          | ✅                     |
  | `envvars.upload`       | ✅          | ✅                     |
  | `envvars.create`       | ✅          | ✅                     |
  | `envvars.update`       | ✅          | ✅                     |
  | `envvars.del`          | ✅          | ✅                     |
  | `schedules.list`       | ✅          |                       |
  | `schedules.create`     | ✅          |                       |
  | `schedules.retrieve`   | ✅          |                       |
  | `schedules.update`     | ✅          |                       |
  | `schedules.activate`   | ✅          |                       |
  | `schedules.deactivate` | ✅          |                       |
  | `schedules.del`        | ✅          |                       |
</Accordion>

### Secret key

Secret key authentication scopes the API access to a specific environment in a project, and works with certain endpoints. You can read our [API Keys guide](/docs/apikeys) for more information.

### Personal Access Token (PAT)

A PAT is a token associated with a specific user, and gives access to all the orgs, projects, and environments that the user has access to. You can identify a PAT by the `tr_pat_` prefix. Because a PAT does not scope access to a specific environment, you must provide the `projectRef` argument when using a PAT (and sometimes the environment as well).

For example, when uploading environment variables using a PAT, you must provide the `projectRef` and `environment` arguments:

```ts theme={"theme":"css-variables"}
import { configure, envvars } from "@trigger.dev/sdk";

configure({
  secretKey: process.env["TRIGGER_ACCESS_TOKEN"], // starts with tr_pat_
});

await envvars.upload("proj_1234", "dev", {
  variables: {
    MY_ENV_VAR: "MY_ENV_VAR_VALUE",
  },
  override: true,
});
```

### Preview branch targeting

When working with preview branches, you may need to target a specific branch when making API calls. This is particularly useful for managing environment variables or other resources that are scoped to individual preview branches.

<Tabs>
  <Tab title="SDK">
    To target a specific preview branch, include the `previewBranch` option in your SDK configuration:

    ```ts theme={"theme":"css-variables"}
    import { configure, envvars } from "@trigger.dev/sdk";

    configure({
      secretKey: process.env["TRIGGER_ACCESS_TOKEN"], // starts with tr_pat_
      previewBranch: "feature-xyz",
    });

    await envvars.update("proj_1234", "preview", "DATABASE_URL", {
      value: "your_preview_database_url",
    });
    ```
  </Tab>

  <Tab title="cURL">
    To target a specific preview or development branch, include the `x-trigger-branch` header in your API requests with the branch name as the value:

    ```bash theme={"theme":"css-variables"}
    curl --request PUT \
      --url https://api.trigger.dev/api/v1/projects/{projectRef}/envvars/preview/DATABASE_URL \
      --header 'Authorization: Bearer <token>' \
      --header 'x-trigger-branch: feature-xyz' \
      --header 'Content-Type: application/json' \
      --data '{
        "value": "your_preview_database_url"
      }'
    ```
  </Tab>
</Tabs>

This will set the `DATABASE_URL` environment variable specifically for the `feature-xyz` preview branch.

<Note>
  The `x-trigger-branch` header is only relevant when working with the `preview` or `dev` environments (`{env}
      ` parameter set to `preview` or `development`). It has no effect when working with `staging`, or `prod`
  environments.
</Note>

#### SDK usage with preview branches

When using the SDK to manage preview branch environment variables, the branch targeting is handled automatically when you're running in a preview environment with the `TRIGGER_PREVIEW_BRANCH` environment variable set. However, you can also specify the branch explicitly:

```ts theme={"theme":"css-variables"}
import { configure, envvars } from "@trigger.dev/sdk";

configure({
  secretKey: process.env["TRIGGER_ACCESS_TOKEN"], // starts with tr_pat_
  previewBranch: "feature-xyz", // Optional: specify the branch
});

await envvars.update("proj_1234", "preview", "DATABASE_URL", {
  value: "your_preview_database_url",
});
```

### Talking to multiple projects, environments, or branches

A long-running process often needs to talk to more than one Trigger.dev target. There are two patterns:

* **`new TriggerClient({...})`** — an explicit instance that owns its own auth, baseURL, and preview branch. Use this when the targets are long-lived (a dashboard that watches prod + preview, a worker that triggers across multiple projects, etc.). Each instance is fully isolated and concurrent calls don't interfere. See [Multiple SDK clients](/docs/management/multiple-clients) for details.
* **`auth.withAuth(config, fn)`** — runs a single callback under a temporary config override, then restores. Use this for short, sequential overrides (e.g. one batch under a different token) where keeping a dedicated client around is overkill.

```ts theme={"theme":"css-variables"}
import { auth, runs } from "@trigger.dev/sdk";

const projectBRuns = await auth.withAuth(
  { accessToken: process.env.TRIGGER_SECRET_KEY_PROJECT_B },
  async () => {
    return runs.list({ limit: 10 });
  },
);
```

Any SDK call inside the callback uses the overridden config. Calls outside the callback continue to use whatever was set by `configure` (or picked up from `TRIGGER_SECRET_KEY`).

The override is scoped via [AsyncLocalStorage](https://nodejs.org/api/async_context.html), so concurrent `auth.withAuth` calls (including overlapping calls inside `Promise.all` with different tokens) do not interfere. Nested calls compose — an inner `auth.withAuth({ accessToken })` inside an outer `auth.withAuth({ baseURL })` runs with both fields applied.

Unlike `TriggerClient` instances (which stay isolated unless you opt in), `auth.withAuth` keeps the surrounding task context: a call made inside a task still inherits `parentRunId`, version locking, and the test flag, the same as a direct SDK call. See the [isolation contract](/docs/management/multiple-clients#isolation-contract).

<Note>
  On runtimes without AsyncLocalStorage (browsers and some edge runtimes), the SDK falls back to
  swapping the global config in place for the duration of the callback, which is not safe under
  concurrency. If you need concurrent multi-target calls there, use
  [`new TriggerClient({...})`](/docs/management/multiple-clients) instances instead.
</Note>

## Session scopes

[Sessions](/docs/ai-chat/sessions) are addressed by a session-scoped public access token — a short-lived JWT you mint in your backend and pass to frontend or server-side clients. The token carries one or both of two scopes, each pinned to a session by its friendly ID (`session_…`) or your `externalId`:

| Scope                 | Grants                                                                                                                               |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `read:sessions:{id}`  | Retrieve the session, list its runs, and subscribe to and drain both its `.in` and `.out` [channels](/docs/management/sessions/channels). |
| `write:sessions:{id}` | Append to the session's `.in` channel, and create runs on the session (including the create call itself).                            |

Two boundaries follow from the table, and both are enforced server-side:

* **`write:sessions` does not grant `.out` append.** The `.out` channel is the task's to write. Appending to `.out` requires a **secret key**; a public token gets `403`.
* **Updating or closing a session requires a secret key.** A session public token cannot call `PATCH /api/v1/sessions/{session}` or `POST /api/v1/sessions/{session}/close` — those are admin operations.

Mint a token with `auth.createPublicToken` in your backend:

```ts Your backend theme={"theme":"css-variables"}
import { auth } from "@trigger.dev/sdk";

const publicToken = await auth.createPublicToken({
  scopes: {
    read: { sessions: "session_123" },
    write: { sessions: "session_123" },
  },
});
```

`sessions` accepts a single ID or an array. The default token TTL is 1 hour. One token authorizes **both** URL forms — pass either your `externalId` or the `session_…` ID in the path.

The `publicAccessToken` returned by [`sessions.start()`](/docs/management/sessions/create) already carries both scopes for the session it created, so you usually don't mint one by hand for the create flow.

For the full channel HTTP surface these scopes authorize, see [Session channels](/docs/management/sessions/channels). For the SDK side, see [Sessions](/docs/ai-chat/sessions). For general public-token usage (expiration formats, trigger tokens, scoping to runs and tasks), see [Realtime authentication](/docs/realtime/auth).


# Auto-pagination
Source: https://trigger.dev/docs/management/auto-pagination

Using auto-pagination with the Trigger.dev management API

All list endpoints in the management API support auto-pagination.
You can use `for await … of` syntax to iterate through items across all pages:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

async function fetchAllRuns() {
  const allRuns = [];

  for await (const run of runs.list({ limit: 10 })) {
    allRuns.push(run);
  }

  return allRuns;
}
```

You can also use helpers on the return value from any `list` method to get the next/previous page of results:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

async function main() {
  let page = await runs.list({ limit: 10 });

  for (const run of page.data) {
    console.log(run);
  }

  while (page.hasNextPage()) {
    page = await page.getNextPage();
    // ... do something with the next page
  }
}
```


# Create batch
Source: https://trigger.dev/docs/management/batches/create

openapi POST /api/v3/batches
Phase 1 of 2-phase batch API. Creates a batch record and optionally blocks the parent run for batchTriggerAndWait.
After creating a batch, stream items via POST /api/v3/batches/{batchId}/items.




# Retrieve a batch
Source: https://trigger.dev/docs/management/batches/retrieve

v3-openapi GET /api/v1/batches/{batchId}
Retrieve a batch by its ID, including its status and the IDs of all runs in the batch.



# Retrieve batch results
Source: https://trigger.dev/docs/management/batches/retrieve-results

v3-openapi GET /api/v1/batches/{batchId}/results
Returns the execution results of all completed runs in a batch. Only finished runs (successful or failed) are included in the items array — runs that are still executing are omitted. Returns 404 if the batch doesn't exist.



# Stream batch items
Source: https://trigger.dev/docs/management/batches/stream-items

openapi POST /api/v3/batches/{batchId}/items
Phase 2 of 2-phase batch API. Accepts an NDJSON stream of batch items and enqueues them.
Each line in the body should be a valid BatchItemNDJSON object.
The stream is processed with backpressure - items are enqueued as they arrive.
The batch is sealed when the stream completes successfully.




# Abort bulk action
Source: https://trigger.dev/docs/management/bulk-actions/abort

v3-openapi POST /api/v1/bulk-actions/{bulkActionId}/abort
Abort a pending bulk action so it stops processing additional runs. Runs already processed by the action are not undone.



# Create bulk action
Source: https://trigger.dev/docs/management/bulk-actions/create

v3-openapi POST /api/v1/bulk-actions
Create an asynchronous bulk action to cancel or replay runs selected by run IDs or filters.



# List bulk actions
Source: https://trigger.dev/docs/management/bulk-actions/list

v3-openapi GET /api/v1/bulk-actions
List bulk actions in the current environment. Bulk actions are returned newest first and can be paginated with cursor pagination.



# Retrieve bulk action
Source: https://trigger.dev/docs/management/bulk-actions/retrieve

v3-openapi GET /api/v1/bulk-actions/{bulkActionId}
Retrieve the status and aggregate processing counts for a bulk action.



# Get latest deployment
Source: https://trigger.dev/docs/management/deployments/get-latest

v3-openapi GET /api/v1/deployments/latest
Retrieve information about the latest unmanaged deployment for the authenticated project.

<Warning>
  This endpoint only returns **unmanaged** deployments, which are used in self-hosted setups. It
  will return `404` for standard CLI deployments made against Trigger.dev Cloud.

  If you're using the CLI to deploy, use the [list deployments](/docs/management/deployments/list) endpoint instead.
</Warning>


# List deployments
Source: https://trigger.dev/docs/management/deployments/list

v3-openapi GET /api/v1/deployments
List all deployments for the authenticated environment, ordered by most recent first.



# Promote deployment
Source: https://trigger.dev/docs/management/deployments/promote

v3-openapi POST /api/v1/deployments/{version}/promote
Promote a previously deployed version to be the current version for the environment. This makes the specified version active for new task runs.



# Get deployment
Source: https://trigger.dev/docs/management/deployments/retrieve

v3-openapi GET /api/v1/deployments/{deploymentId}
Retrieve information about a specific deployment by its ID.



# Create Env Var
Source: https://trigger.dev/docs/management/envvars/create

v3-openapi POST /api/v1/projects/{projectRef}/envvars/{env}
Create a new environment variable for a specific project and environment.



# Delete Env Var
Source: https://trigger.dev/docs/management/envvars/delete

v3-openapi DELETE /api/v1/projects/{projectRef}/envvars/{env}/{name}
Delete a specific environment variable for a specific project and environment.



# Import Env Vars
Source: https://trigger.dev/docs/management/envvars/import

v3-openapi POST /api/v1/projects/{projectRef}/envvars/{env}/import
Upload mulitple environment variables for a specific project and environment.



# List Env Vars
Source: https://trigger.dev/docs/management/envvars/list

v3-openapi GET /api/v1/projects/{projectRef}/envvars/{env}
List all environment variables for a specific project and environment.



# Retrieve Env Var
Source: https://trigger.dev/docs/management/envvars/retrieve

v3-openapi GET /api/v1/projects/{projectRef}/envvars/{env}/{name}
Retrieve a specific environment variable for a specific project and environment.



# Update Env Var
Source: https://trigger.dev/docs/management/envvars/update

v3-openapi PUT /api/v1/projects/{projectRef}/envvars/{env}/{name}
Update a specific environment variable for a specific project and environment.



# Errors and retries
Source: https://trigger.dev/docs/management/errors-and-retries

Handling errors and retries with the Trigger.dev management API

## Handling errors

When the SDK method is unable to connect to the API server, or the API server returns a non-successful response, the SDK will throw an `ApiError` that you can catch and handle:

```ts theme={"theme":"css-variables"}
import { runs, APIError } from "@trigger.dev/sdk";

async function main() {
  try {
    const run = await runs.retrieve("run_1234");
  } catch (error) {
    if (error instanceof ApiError) {
      console.error(`API error: ${error.status}, ${error.headers}, ${error.body}`);
    } else {
      console.error(`Unknown error: ${error.message}`);
    }
  }
}
```

## Retries

The SDK will automatically retry requests that fail due to network errors or server errors. By default, the SDK will retry requests up to 3 times, with an exponential backoff delay between retries.

You can customize the retry behavior by passing a `requestOptions` option to the `configure` function:

```ts theme={"theme":"css-variables"}
import { configure } from "@trigger.dev/sdk";

configure({
  requestOptions: {
    retry: {
      maxAttempts: 5,
      minTimeoutInMs: 1000,
      maxTimeoutInMs: 5000,
      factor: 1.8,
      randomize: true,
    },
  },
});
```

All SDK functions also take a `requestOptions` parameter as the last argument, which can be used to customize the request options. You can use this to disable retries for a specific request:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

async function main() {
  const run = await runs.retrieve("run_1234", {
    retry: {
      maxAttempts: 1, // Disable retries
    },
  });
}
```

<Note>
  When running inside a task, the SDK ignores customized retry options for certain functions (e.g.,
  `task.trigger`, `task.batchTrigger`), and uses retry settings optimized for task execution.
</Note>


# Ignore an error
Source: https://trigger.dev/docs/management/errors/ignore

v3-openapi POST /api/v1/errors/{errorId}/ignore
Mark an error group as ignored. Provide a `duration` to ignore it for a fixed window, and/or thresholds that re-surface the error when exceeded. Send a JSON body (use `{}` to ignore indefinitely).




# List errors
Source: https://trigger.dev/docs/management/errors/list

v3-openapi GET /api/v1/errors
List error groups in a specific environment. Runs that fail are grouped by a fingerprint derived from the error type, message, and stack trace. Filter by task identifier, version, status, search text, and time range.




# Resolve an error
Source: https://trigger.dev/docs/management/errors/resolve

v3-openapi POST /api/v1/errors/{errorId}/resolve
Mark an error group as resolved. Optionally record the worker version that resolved it. Send a JSON body (use `{}` when you have no fields to set).




# Retrieve an error
Source: https://trigger.dev/docs/management/errors/retrieve

v3-openapi GET /api/v1/errors/{errorId}
Retrieve detailed information about a single error group, including its lifecycle state and the worker versions it has affected.



# Unresolve an error
Source: https://trigger.dev/docs/management/errors/unresolve

v3-openapi POST /api/v1/errors/{errorId}/unresolve
Move a resolved or ignored error group back to unresolved.



# Multiple SDK clients
Source: https://trigger.dev/docs/management/multiple-clients

Use TriggerClient to talk to multiple Trigger.dev projects, environments, or preview branches from a single process.

The global `configure()` API binds the SDK to one set of credentials per process. When a single process needs to talk to more than one Trigger.dev project, environment, or preview branch, use `new TriggerClient({...})` for each target instead. Each instance owns its own auth, baseURL, and preview branch, and concurrent calls across instances stay isolated.

```ts theme={"theme":"css-variables"}
import { TriggerClient } from "@trigger.dev/sdk";

const prod = new TriggerClient({ accessToken: process.env.TRIGGER_PROD_KEY });
const preview = new TriggerClient({
  accessToken: process.env.TRIGGER_PREVIEW_KEY,
  previewBranch: "signup-flow",
});

const payload = { to: "user@example.com" };
await prod.tasks.trigger("send-email", payload);
await preview.runs.list({ status: ["COMPLETED"] });
```

## Configuration

`TriggerClient` accepts the same fields as `configure()`:

| Field            | Description                                                                                        | Env-var fallback                                       |
| ---------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `accessToken`    | Secret key (`tr_dev_*`, `tr_prod_*`, `tr_preview_*`) or personal access token (`tr_pat_*`).        | `TRIGGER_SECRET_KEY`, then `TRIGGER_ACCESS_TOKEN`      |
| `previewBranch`  | Preview branch name when using a `tr_preview_*` key.                                               | `TRIGGER_PREVIEW_BRANCH`, then `VERCEL_GIT_COMMIT_REF` |
| `baseURL`        | Override the Trigger.dev API URL. Defaults to `https://api.trigger.dev`.                           | `TRIGGER_API_URL`                                      |
| `requestOptions` | Request-level options (retry policy, additional headers, etc.) — see the `ApiRequestOptions` type. | —                                                      |

Fields not passed to the constructor fall back to the matching env var (and then to a sensible default for `baseURL`). Explicit constructor values always win, so you can mix env-var-backed clients and fully explicit clients in the same process.

```ts theme={"theme":"css-variables"}
// Picks up TRIGGER_SECRET_KEY / TRIGGER_PREVIEW_BRANCH from env.
const fromEnv = new TriggerClient();

// Explicit values override env entirely.
const explicit = new TriggerClient({
  accessToken: process.env.OTHER_PROJECT_KEY,
  previewBranch: "feature-x",
});
```

If no `accessToken` resolves from either the constructor or env vars, the first API call throws an `ApiClientMissingError` with a clear message.

## What's on a TriggerClient instance

Each instance exposes the management surface as namespaced properties: `tasks`, `runs`, `batch`, `schedules`, `envvars`, `queues`, `deployments`, `prompts`, and `auth`.

```ts theme={"theme":"css-variables"}
import type { emailTask } from "./trigger/email";

const client = new TriggerClient();

await client.tasks.trigger<typeof emailTask>("send-email", { to: "user@example.com" });
await client.runs.list({ status: ["COMPLETED"], limit: 10 });
await client.schedules.create({ task: "daily-report", cron: "0 9 * * *" });
await client.envvars.update("proj_1234", "preview", "DATABASE_URL", { value: "..." });
```

Methods that only make sense inside a running task are not on the instance surface: `tasks.triggerAndWait`, `tasks.batchTriggerAndWait`, `tasks.triggerAndSubscribe`, `batch.triggerAndWait`, `batch.triggerByTaskAndWait`, and the task-definition helpers (`schedules.task`, `prompts.define`).

## Isolation contract

When you make a call through a `TriggerClient` instance, the SDK does not look at the process-wide global config, env vars (other than the constructor-time fallback), or the ambient task context. Two instances pointing at different projects can run in the same process — including in parallel under `Promise.all` — without interfering with each other.

That isolation also means a call from inside a task does not automatically inherit the surrounding task's `parentRunId`, `lockToVersion`, or test flag. If you specifically want a call to inherit those (rare — usually you want a clean external trigger), opt in with `inheritContext: true`:

```ts theme={"theme":"css-variables"}
const sameProject = new TriggerClient({
  accessToken: process.env.TRIGGER_SECRET_KEY,
  inheritContext: true,
});
```

## When to use what

| Scenario                                                            | Recommended                           |
| ------------------------------------------------------------------- | ------------------------------------- |
| Single process, single project/env                                  | `configure()` (or env vars only)      |
| Single process talking to multiple projects, envs, or branches      | `new TriggerClient({...})` per target |
| Short, sequential override (e.g. one batch under a different token) | `auth.withAuth(config, fn)`           |
| Inside a task, trigger a run in a different project                 | `new TriggerClient({...})`            |

See [Authentication](/docs/management/authentication) for the underlying token types and the `auth.withAuth` helper.


# Management API overview
Source: https://trigger.dev/docs/management/overview

Using the Trigger.dev management API

## Installation

The management API is available through the same `@trigger.dev/sdk` package used in defining and triggering tasks. If you have already installed the package in your project, you can skip this step.

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npm i @trigger.dev/sdk@latest
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm add @trigger.dev/sdk@latest
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn add @trigger.dev/sdk@latest
  ```
</CodeGroup>

## Usage

All `v3` functionality is provided through the `@trigger.dev/sdk` module. You can import the entire module or individual resources as needed.

```ts theme={"theme":"css-variables"}
import { configure, runs } from "@trigger.dev/sdk";

configure({
  // this is the default and if the `TRIGGER_SECRET_KEY` environment variable is set, can omit calling configure
  secretKey: process.env["TRIGGER_SECRET_KEY"],
});

async function main() {
  const completedRuns = await runs.list({
    limit: 10,
    status: ["COMPLETED"],
  });
}

main().catch(console.error);
```

### Multiple clients in one process

If a single process needs to talk to more than one Trigger.dev project, environment, or preview branch, use `new TriggerClient({...})` for each target instead of `configure()`. Each instance owns its own auth and config, with no shared global state. See [Multiple SDK clients](/docs/management/multiple-clients) for the full pattern.

```ts theme={"theme":"css-variables"}
import { TriggerClient } from "@trigger.dev/sdk";

const prod = new TriggerClient({ accessToken: process.env.TRIGGER_PROD_KEY });
const preview = new TriggerClient({
  accessToken: process.env.TRIGGER_PREVIEW_KEY,
  previewBranch: "signup-flow",
});

const payload = { to: "user@example.com" };
await prod.tasks.trigger("send-email", payload);
await preview.runs.list({ status: ["COMPLETED"] });
```


# List dashboards
Source: https://trigger.dev/docs/management/query/dashboards

v3-openapi GET /api/v1/query/dashboards
List available built-in dashboards with their widgets. Each dashboard contains pre-built TRQL queries for common metrics like run success rates, costs, and LLM usage.



# Execute a query
Source: https://trigger.dev/docs/management/query/execute

v3-openapi POST /api/v1/query
Execute a TRQL (Trigger.dev Query Language) query against your run data. TRQL is a SQL-style query language that allows you to analyze runs, calculate metrics, and export data.

See the [Query documentation](/docs/observability/query#example-queries) for comprehensive examples including:

* Failed runs analysis
* Task success rates over time
* Cost tracking and optimization
* Performance metrics and percentiles


# Get query schema
Source: https://trigger.dev/docs/management/query/schema

v3-openapi GET /api/v1/query/schema
Get the schema for TRQL queries, including all available tables, their columns, data types, descriptions, and allowed values.



# Override Concurrency Limit
Source: https://trigger.dev/docs/management/queues/concurrency-override

v3-openapi POST /api/v1/queues/{queueParam}/concurrency/override
Override the concurrency limit of a queue. This is useful for temporarily scaling up or down based on demand.



# Reset Concurrency Limit
Source: https://trigger.dev/docs/management/queues/concurrency-reset

v3-openapi POST /api/v1/queues/{queueParam}/concurrency/reset
Reset the concurrency limit of a queue back to its base value defined in code.



# List Queues
Source: https://trigger.dev/docs/management/queues/list

v3-openapi GET /api/v1/queues
List all queues in your environment with pagination support.



# Pause or Resume Queue
Source: https://trigger.dev/docs/management/queues/pause

v3-openapi POST /api/v1/queues/{queueParam}/pause
Pause a queue to prevent new runs from starting, or resume a paused queue. Runs that are currently executing will continue to completion.



# Retrieve Queue
Source: https://trigger.dev/docs/management/queues/retrieve

v3-openapi GET /api/v1/queues/{queueParam}
Get a queue by its ID, or by type and name.



# Add tags to a run
Source: https://trigger.dev/docs/management/runs/add-tags

v3-openapi POST /api/v1/runs/{runId}/tags
Adds one or more tags to a run. Runs can have a maximum of 10 tags. Duplicate tags are ignored.



# Cancel run
Source: https://trigger.dev/docs/management/runs/cancel

v3-openapi POST /api/v2/runs/{runId}/cancel
Cancels an in-progress run. If the run is already completed, this will have no effect.



# List runs
Source: https://trigger.dev/docs/management/runs/list

v3-openapi GET /api/v1/runs
List runs in a specific environment. You can filter the runs by status, created at, task identifier, version, and more.



# Replay run
Source: https://trigger.dev/docs/management/runs/replay

v3-openapi POST /api/v1/runs/{runId}/replay
Creates a new run with the same payload and options as the original run.



# Reschedule run
Source: https://trigger.dev/docs/management/runs/reschedule

v3-openapi POST /api/v1/runs/{runId}/reschedule
Updates a delayed run with a new delay. Only valid when the run is in the DELAYED state.



# Retrieve run
Source: https://trigger.dev/docs/management/runs/retrieve

v3-openapi GET /api/v3/runs/{runId}
Retrieve information about a run, including its status, payload, output, and attempts. If you authenticate with a Public API key, we will omit the payload and output fields for security reasons.




# Retrieve run events
Source: https://trigger.dev/docs/management/runs/retrieve-events

v3-openapi GET /api/v1/runs/{runId}/events
Returns all OTel span events for a run. Useful for debugging and observability.



# Retrieve run result
Source: https://trigger.dev/docs/management/runs/retrieve-result

v3-openapi GET /api/v1/runs/{runId}/result
Returns the execution result of a completed run. Returns 404 if the run doesn't exist or hasn't finished yet.



# Retrieve run trace
Source: https://trigger.dev/docs/management/runs/retrieve-trace

v3-openapi GET /api/v1/runs/{runId}/trace
Returns the OTel trace subtree for the requested run — the run's span as `rootSpan`, its ancestor chain, and its descendant spans. For child or nested runs in a large trace, this is scoped to that run rather than the trace-wide root.

Returns the OpenTelemetry trace subtree for the run you request. The response `trace.rootSpan` is that run's span — not necessarily the trace-wide root — with its descendant spans nested under `children`.

For a child or nested run inside a large trace, this endpoint scopes the tree to that run so you still get a useful subtree even when the full trace has more spans than the platform can return in one response.


# Update metadata
Source: https://trigger.dev/docs/management/runs/update-metadata

v3-openapi PUT /api/v1/runs/{runId}/metadata
Update the metadata of a run.



# Activate Schedule
Source: https://trigger.dev/docs/management/schedules/activate

v3-openapi POST /api/v1/schedules/{schedule_id}/activate
Activate a schedule by its ID. This will only work on `IMPERATIVE` schedules that were created in the dashboard or using the imperative SDK functions like `schedules.create()`.



# Create Schedule
Source: https://trigger.dev/docs/management/schedules/create

v3-openapi POST /api/v1/schedules
Create a new `IMPERATIVE` schedule based on the specified options.



# Deactivate Schedule
Source: https://trigger.dev/docs/management/schedules/deactivate

v3-openapi POST /api/v1/schedules/{schedule_id}/deactivate
Deactivate a schedule by its ID. This will only work on `IMPERATIVE` schedules that were created in the dashboard or using the imperative SDK functions like `schedules.create()`.



# Delete Schedule
Source: https://trigger.dev/docs/management/schedules/delete

v3-openapi DELETE /api/v1/schedules/{schedule_id}
Delete a schedule by its ID. This will only work on `IMPERATIVE` schedules that were created in the dashboard or using the imperative SDK functions like `schedules.create()`.



# List Schedules
Source: https://trigger.dev/docs/management/schedules/list

v3-openapi GET /api/v1/schedules
List all schedules. You can also paginate the results.



# Retrieve Schedule
Source: https://trigger.dev/docs/management/schedules/retrieve

v3-openapi GET /api/v1/schedules/{schedule_id}
Get a schedule by its ID.



# Get timezones
Source: https://trigger.dev/docs/management/schedules/timezones

v3-openapi GET /api/v1/timezones
Get all supported timezones that schedule tasks support.



# Update Schedule
Source: https://trigger.dev/docs/management/schedules/update

v3-openapi PUT /api/v1/schedules/{schedule_id}
Update a schedule by its ID. This will only work on `IMPERATIVE` schedules that were created in the dashboard or using the imperative SDK functions like `schedules.create()`.



# Session channels
Source: https://trigger.dev/docs/management/sessions/channels

The raw HTTP endpoints behind a session's .in and .out streams: append records, read them over SSE, and drain them non-streaming.

Every session has two durable streams: `.in` carries records from your clients to the task, `.out` carries records from the task back to your clients. The [`sessions` SDK](/docs/ai-chat/sessions) wraps these as `session.in.*` and `session.out.*`. This page documents the underlying HTTP endpoints for callers that aren't using the TypeScript SDK.

All channel endpoints live under `/realtime/v1/sessions/{session}/{io}`, where:

* `{session}` is the session's friendly ID (`session_…`) or your `externalId`. One token authorizes both forms.
* `{io}` is either `in` or `out`.

Authorize requests with a secret key or a [session public token](/docs/management/authentication#session-scopes). The token's scopes decide what you can do — see [Authorization](#authorization) below.

## Append a record

Append a single record to a channel.

```bash Append to .in theme={"theme":"css-variables"}
curl -X POST "https://api.trigger.dev/realtime/v1/sessions/{session}/in/append" \
  -H "Authorization: Bearer $TRIGGER_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Part-Id: 0f8c2b1e-..." \
  --data '{"type":"user-message","text":"hello"}'
```

The body is the raw record — any text up to 1MiB (records over the per-record cap return `413`). The response is `{ "ok": true }`.

Set the `X-Part-Id` header to a unique value per record to make the append idempotent: replaying the same `X-Part-Id` does not duplicate the record. Appending to a closed or expired session returns `400`.

<Warning>
  Appending to `.out` requires a **secret key**. A session public token (even one with
  `write:sessions`) can only append to `.in` — appending to `.out` with a public token returns
  `403`. The `.out` stream is the task's to write.
</Warning>

## Read a channel over SSE

Subscribe to a channel as a Server-Sent Events stream. New records are delivered as they arrive.

```bash Read .out theme={"theme":"css-variables"}
curl -N "https://api.trigger.dev/realtime/v1/sessions/{session}/out" \
  -H "Authorization: Bearer $TRIGGER_TOKEN" \
  -H "Last-Event-ID: 42" \
  -H "Timeout-Seconds: 60"
```

| Header            | Direction | Description                                                                                                                        |
| ----------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `Last-Event-ID`   | request   | Resume after this sequence number. Set it to the last `id:` you received to pick up exactly where you left off after a disconnect. |
| `Timeout-Seconds` | request   | How long the server holds the stream open with no new records before closing, `1`–`600`.                                           |

Each SSE event carries:

* `id:` — the record's sequence number. Use the most recent one as `Last-Event-ID` to resume.
* `data:` — a JSON record `{ "data": <record>, "id": <id> }`. For `.out` on a `chat.agent` session, `data` is a UI message chunk (text, reasoning, tool call, or a custom data part).

```text theme={"theme":"css-variables"}
id: 42
data: {"data":{"type":"text","text":"echo: hello"},"id":42}
```

### Control records

Some `.out` events are **control records** rather than data. A control record has an empty body and carries a `trigger-control` header naming its subtype:

| Subtype            | Meaning                                                                                                                                           |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `turn-complete`    | The current turn finished. Carries sibling headers `public-access-token` (a refreshed session token), `session-in-event-id`, and `last-event-id`. |
| `upgrade-required` | The session needs to hand off to a run on a newer deployed version.                                                                               |

Route control records by their subtype instead of treating them as message content. The TypeScript SDK does this for you — `session.out.read` filters control records out of the chunk stream and surfaces them through `onControl`.

## Drain records non-streaming

Fetch a batch of records without holding an SSE connection open. Useful for polling or for reading a tail at startup.

```bash Drain .out theme={"theme":"css-variables"}
curl "https://api.trigger.dev/realtime/v1/sessions/{session}/out/records?afterEventId=42" \
  -H "Authorization: Bearer $TRIGGER_TOKEN"
```

Pass `afterEventId` to return only records after that sequence number; omit it to read from the start of the retained window. The response is:

```json theme={"theme":"css-variables"}
{
  "records": [
    { "data": { "type": "text", "text": "echo: hello" }, "id": 43, "seqNum": 43 }
  ]
}
```

Each record carries `data`, `id`, `seqNum`, and an optional `headers` array (present on control records). Page forward by passing the highest `seqNum` you received as the next `afterEventId`.

## Authorization

The action you can take depends on your token and the channel:

| Action           | Endpoint               | Required authorization                                |
| ---------------- | ---------------------- | ----------------------------------------------------- |
| Subscribe (SSE)  | `GET .../{io}`         | `read:sessions:{id}` — works on both `.in` and `.out` |
| Drain records    | `GET .../{io}/records` | `read:sessions:{id}` — works on both `.in` and `.out` |
| Append to `.in`  | `POST .../in/append`   | `write:sessions:{id}`                                 |
| Append to `.out` | `POST .../out/append`  | Secret key only                                       |

Reads work in both directions for a `read:sessions` token. Writes split by direction: a `write:sessions` token can append to `.in`, but `.out` is reserved for the task and requires a secret key. See [session scopes](/docs/management/authentication#session-scopes) for how to mint a token.

## Using the SDK instead

If you're writing TypeScript, the [`sessions` SDK](/docs/ai-chat/sessions) is the ergonomic path. `sessions.open(idOrExternalId)` returns a `SessionHandle` whose `session.in` and `session.out` channels call these endpoints for you, with auto-retry, `Last-Event-ID` resume, and control-record routing built in:

```ts Your backend theme={"theme":"css-variables"}
import { sessions } from "@trigger.dev/sdk";

const session = sessions.open(chatId);

// append to .in
await session.in.send({ type: "user-message", text: "hello" });

// read .out over SSE
const stream = await session.out.read({ signal: AbortSignal.timeout(30_000) });
for await (const chunk of stream) {
  console.log(chunk);
}
```

See [`session.in`](/docs/ai-chat/sessions#session-in-—-clients-→-task) and [`session.out`](/docs/ai-chat/sessions#session-out-—-task-→-clients) for the full handle API.


# Close session
Source: https://trigger.dev/docs/management/sessions/close

v3-openapi POST /api/v1/sessions/{session}/close
Close a session. Closing is terminal and idempotent — closing an already-closed session returns the existing row unchanged. A closed session cannot be reopened, and reusing its `externalId` on create returns `409`.

Requires a secret key — a session public token cannot close a session.



# Create session
Source: https://trigger.dev/docs/management/sessions/create

v3-openapi POST /api/v1/sessions
Create a Session and trigger its first run in one atomic call. A Session is the durable identity for a bi-directional stream of records (the `.in` and `.out` channels) that survives across the runs processing it.

Idempotent on `externalId` within an environment. Calling create again with an `externalId` that already maps to an open session returns the existing session with `isCached: true` and `201` becomes `200`. Reusing an `externalId` whose session is already closed or expired returns `409`.

Authorize with a secret key, or a public token carrying `write:sessions` for the session you are creating.



# List sessions
Source: https://trigger.dev/docs/management/sessions/list

v3-openapi GET /api/v1/sessions
List sessions in the current environment, newest first. Filter by type, tags, task identifier, external id, status, and creation window. Use cursor-based pagination with `page[after]` and `page[before]` to navigate pages.

List rows omit `triggerConfig`; retrieve a single session to read it.



# Retrieve session
Source: https://trigger.dev/docs/management/sessions/retrieve

v3-openapi GET /api/v1/sessions/{session}
Retrieve a single session by its friendly id (`session_…`) or your `externalId`. The response includes `triggerConfig` and the friendly `currentRunId` of the live run, if any.



# Update session
Source: https://trigger.dev/docs/management/sessions/update

v3-openapi PATCH /api/v1/sessions/{session}
Update a session's `tags` or `metadata`. Pass `metadata: null` to clear it.

Requires a secret key — a session public token cannot update a session. `externalId` is read-only after create: it cannot be changed or cleared. Sending a value different from the current one (including `null` when one is set) returns `422`; sending the same value is accepted as a no-op.



# Batch trigger
Source: https://trigger.dev/docs/management/tasks/batch-trigger

v3-openapi POST /api/v1/tasks/batch
Batch trigger tasks with up to 1,000 payloads with SDK 4.3.1+ (500 in prior versions).



# Trigger
Source: https://trigger.dev/docs/management/tasks/trigger

v3-openapi POST /api/v1/tasks/{taskIdentifier}/trigger
Trigger a task by its identifier.



# Trigger task batch
Source: https://trigger.dev/docs/management/tasks/trigger-batch

v3-openapi POST /api/v1/tasks/{taskIdentifier}/batch
Batch trigger a specific task with up to 1,000 payloads. All items in the batch run the same task.



# Complete a waitpoint token
Source: https://trigger.dev/docs/management/waitpoints/complete

v3-openapi POST /api/v1/waitpoints/tokens/{waitpointId}/complete
Completes a waitpoint token, unblocking any run that is waiting for it via `wait.forToken()`. An optional `data` payload can be passed and will be returned to the waiting run. If the token is already completed, this is a no-op and returns `success: true`.

This endpoint accepts both secret API keys and short-lived JWTs (public access tokens), making it safe to call from frontend clients.



# Complete a waitpoint token via HTTP callback
Source: https://trigger.dev/docs/management/waitpoints/complete-callback

v3-openapi POST /api/v1/waitpoints/tokens/{waitpointId}/callback/{callbackHash}
Completes a waitpoint token using the pre-signed callback URL returned in the `url` field when the token was created. No API key is required — the `callbackHash` in the URL acts as the authentication token.

This is designed to be given directly to external services (e.g. as a webhook URL) so they can unblock a waiting run without needing access to your API key. The entire request body is passed as the output data to the waiting run.

If the token is already completed, this is a no-op and returns `success: true`.



# Create a waitpoint token
Source: https://trigger.dev/docs/management/waitpoints/create

v3-openapi POST /api/v1/waitpoints/tokens
Creates a new waitpoint token that can be used to pause a run until an external event completes it. The token includes a `url` which can be called via HTTP POST to complete the waitpoint. Use the token ID with `wait.forToken()` inside a task to pause execution until the token is completed.



# List waitpoint tokens
Source: https://trigger.dev/docs/management/waitpoints/list

v3-openapi GET /api/v1/waitpoints/tokens
Returns a paginated list of waitpoint tokens for the current environment. Results are ordered by creation date, newest first. Use cursor-based pagination with `page[after]` and `page[before]` to navigate pages.



# Retrieve a waitpoint token
Source: https://trigger.dev/docs/management/waitpoints/retrieve

v3-openapi GET /api/v1/waitpoints/tokens/{waitpointId}
Retrieves a waitpoint token by its ID, including its current status and output if it has been completed.



# Manual setup
Source: https://trigger.dev/docs/manual-setup

How to manually setup Trigger.dev in your project.

This guide covers how to manually set up Trigger.dev in your project, including configuration for different package managers, monorepos, and build extensions. This guide replicates all the steps performed by the `trigger.dev init` command. Follow our [Quickstart](/docs/quick-start) for a more streamlined setup.

## Prerequisites

* Node.js 18.20+ (or Bun runtime)
* A Trigger.dev account (sign up at [trigger.dev](https://trigger.dev))
* TypeScript 5.0.4 or later (for TypeScript projects)

## CLI Authentication

Before setting up your project, you need to authenticate the CLI with Trigger.dev:

```bash theme={"theme":"css-variables"}
# Login to Trigger.dev
npx trigger.dev@latest login

# Or with a specific API URL (for self-hosted instances)
npx trigger.dev@latest login --api-url https://your-trigger-instance.com
```

This will open your browser to authenticate. Once authenticated, you'll need to select or create a project in the Trigger.dev dashboard to get your project reference (e.g., `proj_abc123`).

## Package installation

Install the required packages based on your package manager:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npm add @trigger.dev/sdk@latest
  npm add --save-dev @trigger.dev/build@latest
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm add @trigger.dev/sdk@latest
  pnpm add -D @trigger.dev/build@latest
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn add @trigger.dev/sdk@latest
  yarn add -D @trigger.dev/build@latest
  ```

  ```bash bun theme={"theme":"css-variables"}
  bun add @trigger.dev/sdk@latest
  bun add -D @trigger.dev/build@latest
  ```
</CodeGroup>

## Environment variables

For local development, you need to set up the `TRIGGER_SECRET_KEY` environment variable. This key authenticates your application with Trigger.dev.

1. Go to your project dashboard in Trigger.dev
2. Navigate to the "API Keys" page
3. Copy the **DEV** secret key
4. Add it to your local environment file:

```bash theme={"theme":"css-variables"}
TRIGGER_SECRET_KEY=tr_dev_xxxxxxxxxx
```

### Self-hosted instances

If you're using a self-hosted Trigger.dev instance, also set:

```bash theme={"theme":"css-variables"}
TRIGGER_API_URL=https://your-trigger-instance.com
```

## CLI setup

You can run the Trigger.dev CLI in two ways:

### Option 1: Using npx/pnpm dlx/yarn dlx

```bash theme={"theme":"css-variables"}
# npm
npx trigger.dev@latest dev

# pnpm
pnpm dlx trigger.dev@latest dev

# yarn
yarn dlx trigger.dev@latest dev
```

### Option 2: Add as dev dependency

Add the CLI to your `package.json`:

```json theme={"theme":"css-variables"}
{
  "devDependencies": {
    "trigger.dev": "^4.0.0"
  }
}
```

Then add scripts to your `package.json`:

```json theme={"theme":"css-variables"}
{
  "scripts": {
    "dev:trigger": "trigger dev",
    "deploy:trigger": "trigger deploy"
  }
}
```

### Version pinning

Make sure to pin the version of the CLI to the same version as the SDK that you are using:

```json theme={"theme":"css-variables"}
"devDependencies": {
  "trigger.dev": "^4.0.0",
  "@trigger.dev/build": "^4.0.0"
},
"dependencies": {
  "@trigger.dev/sdk": "^4.0.0"
}
```

While running the CLI `dev` or `deploy` commands, the CLI will automatically detect mismatched versions and warn you.

## Configuration file

Create a `trigger.config.ts` file in your project root (or `trigger.config.mjs` for JavaScript projects):

```typescript theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  // Your project ref from the Trigger.dev dashboard
  project: "<your-project-ref>", // e.g., "proj_abc123"

  // Directories containing your tasks
  dirs: ["./src/trigger"], // Customize based on your project structure

  // Retry configuration
  retries: {
    enabledInDev: false, // Enable retries in development
    default: {
      maxAttempts: 3,
      minTimeoutInMs: 1000,
      maxTimeoutInMs: 10000,
      factor: 2,
      randomize: true,
    },
  },

  // Build configuration (optional)
  build: {
    extensions: [], // Build extensions go here
  },

  // Max duration of a task in seconds
  maxDuration: 3600,
});
```

### Using the Bun runtime

By default, Trigger.dev will use the Node.js runtime. If you're using Bun, you can specify the runtime:

```typescript theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<your-project-ref>",
  runtime: "bun",
  dirs: ["./src/trigger"],
});
```

See our [Bun runtime documentation](/docs/guides/frameworks/bun) for more information.

## Add your first task

Create a `trigger` directory (matching the `dirs` in your config) and add an example task:

```typescript src/trigger/example.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

export const helloWorld = task({
  id: "hello-world",
  run: async (payload: { name: string }) => {
    console.log(`Hello ${payload.name}!`);

    return {
      message: `Hello ${payload.name}!`,
      timestamp: new Date().toISOString(),
    };
  },
});
```

See our [Tasks](/docs/tasks/overview) docs for more information on how to create tasks.

## TypeScript config

If you're using TypeScript, add `trigger.config.ts` to your `tsconfig.json` include array:

```json theme={"theme":"css-variables"}
{
  "compilerOptions": {
    // ... your existing options
  },
  "include": [
    // ... your existing includes
    "trigger.config.ts"
  ]
}
```

## Git config

Add `.trigger` to your `.gitignore` file to exclude Trigger.dev's local development files:

```bash theme={"theme":"css-variables"}
# Trigger.dev
.trigger
```

If you don't have a `.gitignore` file, create one with this content.

## React hooks setup

If you're building a React frontend application and want to display task status in real-time, install the React hooks package:

### Installation

```bash theme={"theme":"css-variables"}
# npm
npm install @trigger.dev/react-hooks@latest

# pnpm
pnpm add @trigger.dev/react-hooks@latest

# yarn
yarn add @trigger.dev/react-hooks@latest

# bun
bun add @trigger.dev/react-hooks@latest
```

### Basic usage

1. **Generate a Public Access Token** in your backend:

```typescript theme={"theme":"css-variables"}
import { auth } from "@trigger.dev/sdk";

// In your backend API
export async function getPublicAccessToken() {
  const publicAccessToken = await auth.createPublicToken({
    scopes: ["read:runs"], // Customize based on needs
  });

  return publicAccessToken;
}
```

2. **Use hooks to monitor tasks**:

```tsx theme={"theme":"css-variables"}
import { useRealtimeRun } from "@trigger.dev/react-hooks";

export function TaskStatus({
  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>
      <p>Status: {run.status}</p>
      <p>Progress: {run.completedAt ? "Complete" : "Running..."}</p>
    </div>
  );
}
```

For more information, see the [React Hooks documentation](/docs/realtime/react-hooks/overview).

## Build extensions

Build extensions allow you to customize the build process. Ensure you have the `@trigger.dev/build` package installed in your project (see [package installation](#package-installation)).

Now you can use any of the built-in extensions:

```typescript theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";
import { prismaExtension } from "@trigger.dev/build/extensions/prisma";

export default defineConfig({
  project: "<project-ref>",
  build: {
    extensions: [
      prismaExtension({
        mode: "legacy",
        schema: "prisma/schema.prisma",
        migrate: true, // Run migrations on deploy
      }),
    ],
  },
});
```

See our [Build extensions](/docs/config/extensions/overview) docs for more information on how to use build extensions and the available extensions.

## Monorepo setup

There are two main approaches for setting up Trigger.dev in a monorepo:

1. **Tasks as a package**: Create a separate package for your Trigger.dev tasks that can be shared across apps
2. **Tasks in apps**: Install Trigger.dev directly in individual apps that need background tasks

Both approaches work well depending on your needs. Use the tasks package approach if you want to share tasks across multiple applications, or the app-based approach if tasks are specific to individual apps.

### Approach 1: Tasks as a package (Turborepo)

This approach creates a dedicated tasks package that can be consumed by multiple apps in your monorepo.

#### 1. Set up workspace configuration

**Root `package.json`**:

```json theme={"theme":"css-variables"}
{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint"
  },
  "devDependencies": {
    "turbo": "^2.4.4",
    "typescript": "5.8.2"
  },
  "packageManager": "pnpm@9.0.0"
}
```

**`pnpm-workspace.yaml`**:

```yaml theme={"theme":"css-variables"}
packages:
  - "apps/*"
  - "packages/*"
```

**`turbo.json`**:

```json theme={"theme":"css-variables"}
{
  "$schema": "https://turbo.build/schema.json",
  "ui": "tui",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^lint"]
    }
  }
}
```

#### 2. Create the tasks package

**`packages/tasks/package.json`**:

```json theme={"theme":"css-variables"}
{
  "name": "@repo/tasks",
  "version": "0.0.0",
  "dependencies": {
    "@trigger.dev/sdk": "^4.0.0"
  },
  "devDependencies": {
    "@trigger.dev/build": "^4.0.0"
  },
  "exports": {
    ".": "./src/trigger/index.ts",
    "./trigger": "./src/index.ts"
  }
}
```

**`packages/tasks/trigger.config.ts`**:

```typescript theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<your-project-ref>", // Replace with your project reference
  dirs: ["./src/trigger"],
  retries: {
    enabledInDev: true,
    default: {
      maxAttempts: 3,
      minTimeoutInMs: 1000,
      maxTimeoutInMs: 10000,
      factor: 2,
      randomize: true,
    },
  },
  maxDuration: 3600,
});
```

**`packages/tasks/src/index.ts`**:

```typescript theme={"theme":"css-variables"}
export * from "@trigger.dev/sdk"; // Export values and types from the Trigger.dev sdk
```

**`packages/tasks/src/trigger/index.ts`**:

```typescript theme={"theme":"css-variables"}
// Export tasks
export * from "./example";
```

**`packages/tasks/src/trigger/example.ts`**:

```typescript theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

export const helloWorld = task({
  id: "hello-world",
  run: async (payload: { name: string }) => {
    console.log(`Hello ${payload.name}!`);

    return {
      message: `Hello ${payload.name}!`,
      timestamp: new Date().toISOString(),
    };
  },
});
```

See our [turborepo-prisma-tasks-package example](https://github.com/triggerdotdev/examples/tree/main/monorepos/turborepo-prisma-tasks-package) for a more complete example.

#### 3. Use tasks in your apps

**`apps/web/package.json`**:

```json theme={"theme":"css-variables"}
{
  "name": "web",
  "dependencies": {
    "@repo/tasks": "workspace:*",
    "next": "^15.2.1",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}
```

**`apps/web/app/api/actions.ts`**:

```typescript theme={"theme":"css-variables"}
"use server";

import { tasks } from "@repo/tasks/trigger";
import type { helloWorld } from "@repo/tasks";
//     👆 type only import

export async function triggerHelloWorld(name: string) {
  try {
    const handle = await tasks.trigger<typeof helloWorld>("hello-world", {
      name: name,
    });

    return handle.id;
  } catch (error) {
    console.error(error);
    return { error: "something went wrong" };
  }
}
```

#### 4. Development workflow

Run the development server for the tasks package:

```bash theme={"theme":"css-variables"}
# From the root of your monorepo
cd packages/tasks
npx trigger.dev@latest dev

# Or using turbo (if you add dev:trigger script to tasks package.json)
turbo run dev:trigger --filter=@repo/tasks
```

### Approach 2: Tasks in apps (Turborepo)

This approach installs Trigger.dev directly in individual apps that need background tasks.

#### 1. Install in your app

**`apps/web/package.json`**:

```json theme={"theme":"css-variables"}
{
  "name": "web",
  "dependencies": {
    "@trigger.dev/sdk": "^4.0.0",
    "next": "^15.2.1",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@trigger.dev/build": "^4.0.0"
  }
}
```

#### 2. Add configuration

**`apps/web/trigger.config.ts`**:

```typescript theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<your-project-ref>", // Replace with your project reference
  dirs: ["./src/trigger"],
  retries: {
    enabledInDev: true,
    default: {
      maxAttempts: 3,
      minTimeoutInMs: 1000,
      maxTimeoutInMs: 10000,
      factor: 2,
      randomize: true,
    },
  },
  maxDuration: 3600,
});
```

#### 3. Create tasks

**`apps/web/src/trigger/example.ts`**:

```typescript theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

export const helloWorld = task({
  id: "hello-world",
  run: async (payload: { name: string }) => {
    console.log(`Hello ${payload.name}!`);

    return {
      message: `Hello ${payload.name}!`,
      timestamp: new Date().toISOString(),
    };
  },
});
```

#### 4. Use tasks in your app

**`apps/web/app/api/actions.ts`**:

```typescript theme={"theme":"css-variables"}
"use server";

import { tasks } from "@trigger.dev/sdk";
import type { helloWorld } from "../../src/trigger/example";
//     👆 type only import

export async function triggerHelloWorld(name: string) {
  try {
    const handle = await tasks.trigger<typeof helloWorld>("hello-world", {
      name: name,
    });

    return handle.id;
  } catch (error) {
    console.error(error);
    return { error: "something went wrong" };
  }
}
```

#### 5. Development workflow

```bash theme={"theme":"css-variables"}
# From the app directory
cd apps/web
npx trigger.dev@latest dev

# Or from the root using turbo
turbo run dev:trigger --filter=web
```

## Example projects

You can find a growing list of example projects in our [examples](/docs/guides/introduction) section.

## Troubleshooting

If you run into any issues, please check our [Troubleshooting](/docs/troubleshooting) page.

## Feedback

If you have any feedback, please let us know by [opening an issue](https://github.com/triggerdotdev/trigger.dev/issues).


# Agent rules
Source: https://trigger.dev/docs/mcp-agent-rules

Trigger.dev agent rules are now agent skills, installed with the trigger.dev CLI.

<Note>
  Agent rules are now **agent skills**. The standalone rule files have been replaced by skills that
  work across every major AI assistant from a single install. See [Skills](/docs/skills).
</Note>

## What changed

Trigger.dev used to install per-tool *rule files* (`.cursor/rules/trigger.*.mdc`, regions in `CLAUDE.md`, and so on) fetched from GitHub. That has been replaced by [agent skills](/docs/skills): `SKILL.md` directories in the open [Agent Skills standard](https://agentskills.io) that install with the CLI, draw their API guidance from a version-pinned reference in `@trigger.dev/sdk`, and load on demand instead of always sitting in your context.

The install command is the same, and now installs skills:

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest skills
```

`npx trigger.dev@latest install-rules` still works as an alias for `skills`, and `trigger dev` offers to install the skills on first run.

The old task and realtime guidance now lives in the `trigger-authoring-tasks` and `trigger-realtime-and-frontend` skills, alongside two new skills for building `chat.agent` AI agents. See [Skills](/docs/skills) for the full list and supported assistants.

## Next steps

<CardGroup>
  <Card title="Skills" icon="wand-magic-sparkles" href="/skills">
    Install Trigger.dev agent skills into your AI coding assistant.
  </Card>

  <Card title="MCP Server" icon="sparkles" href="/mcp-introduction">
    Give your AI assistant direct access to Trigger.dev tools and APIs.
  </Card>

  <Card title="Building with AI" icon="layer-group" href="/building-with-ai">
    See how skills and the MCP server compare.
  </Card>

  <Card title="Writing tasks" icon="code" href="/tasks/overview">
    Learn the task patterns that skills teach your AI assistant.
  </Card>
</CardGroup>


# MCP Introduction
Source: https://trigger.dev/docs/mcp-introduction

Learn how to install and configure the Trigger.dev MCP Server

## What is the Trigger.dev MCP Server?

The Trigger.dev MCP (Model Context Protocol) Server enables AI assistants to interact directly with your Trigger.dev projects. It provides a comprehensive set of tools to:

* Search Trigger.dev documentation
* Initialize new Trigger.dev projects
* List and manage your projects and organizations
* Get task information and trigger task runs
* Deploy projects to different environments
* Monitor run details and list runs with filtering options
* Query your data with TRQL and run built-in dashboard metrics

## Installation

The quickest way to get set up is the interactive installer:

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest install-mcp
```

It will detect your installed clients and configure them automatically. You can also copy-paste the config for your client below.

## Client Configuration

Each client has a slightly different config format. Copy the snippet for your client into the appropriate file.

<Tabs>
  <Tab title="Claude Code">
    Install using the command line:

    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest install-mcp --client claude-code
    ```

    Or add this configuration to `~/.claude.json` (user) or `.mcp.json` (project):

    ```json theme={"theme":"css-variables"}
    {
      "mcpServers": {
        "trigger": {
          "command": "npx",
          "args": ["trigger.dev@latest", "mcp"]
        }
      }
    }
    ```

    [View Claude Code MCP docs ↗](https://code.claude.com/docs/en/mcp)
  </Tab>

  <Tab title="Cursor">
    Install using the command line:

    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest install-mcp --client cursor
    ```

    Or add this configuration to `~/.cursor/mcp.json` (user) or `.cursor/mcp.json` (project):

    ```json theme={"theme":"css-variables"}
    {
      "mcpServers": {
        "trigger": {
          "command": "npx",
          "args": ["trigger.dev@latest", "mcp"]
        }
      }
    }
    ```

    [View Cursor MCP docs ↗](https://cursor.com/docs/context/mcp)
  </Tab>

  <Tab title="Windsurf">
    Install using the command line:

    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest install-mcp --client windsurf
    ```

    Or add this configuration to `~/.codeium/windsurf/mcp_config.json`:

    ```json theme={"theme":"css-variables"}
    {
      "mcpServers": {
        "trigger": {
          "command": "npx",
          "args": ["trigger.dev@latest", "mcp"]
        }
      }
    }
    ```

    [View Windsurf MCP docs ↗](https://docs.windsurf.com/windsurf/cascade/mcp)
  </Tab>

  <Tab title="VS Code">
    Install using the command line:

    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest install-mcp --client vscode
    ```

    Or add this configuration to `.vscode/mcp.json` (project) or `~/Library/Application Support/Code/User/mcp.json` (user, macOS):

    ```json theme={"theme":"css-variables"}
    {
      "servers": {
        "trigger": {
          "command": "npx",
          "args": ["trigger.dev@latest", "mcp"]
        }
      }
    }
    ```

    <Note>VS Code uses `servers` instead of `mcpServers`.</Note>

    [View VS Code MCP docs ↗](https://code.visualstudio.com/docs/copilot/chat/mcp-servers)
  </Tab>

  <Tab title="Zed">
    Install using the command line:

    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest install-mcp --client zed
    ```

    Or add this configuration to `~/.config/zed/settings.json`:

    ```json theme={"theme":"css-variables"}
    {
      "context_servers": {
        "trigger": {
          "source": "custom",
          "command": "npx",
          "args": ["trigger.dev@latest", "mcp"]
        }
      }
    }
    ```

    [View Zed context servers docs ↗](https://zed.dev/docs/ai/mcp)
  </Tab>

  <Tab title="Cline">
    Install using the command line:

    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest install-mcp --client cline
    ```

    Or add this configuration to `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`:

    ```json theme={"theme":"css-variables"}
    {
      "mcpServers": {
        "trigger": {
          "command": "npx",
          "args": ["trigger.dev@latest", "mcp"]
        }
      }
    }
    ```

    [View Cline MCP docs ↗](https://docs.cline.bot/mcp/configuring-mcp-servers)
  </Tab>

  <Tab title="Gemini CLI">
    Install using the command line:

    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest install-mcp --client gemini-cli
    ```

    Or add this configuration to `~/.gemini/settings.json` (user) or `.gemini/settings.json` (project):

    ```json theme={"theme":"css-variables"}
    {
      "mcpServers": {
        "trigger": {
          "command": "npx",
          "args": ["trigger.dev@latest", "mcp"]
        }
      }
    }
    ```
  </Tab>

  <Tab title="AMP">
    Install using the command line:

    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest install-mcp --client amp
    ```

    Or add this configuration to `~/.config/amp/settings.json`:

    ```json theme={"theme":"css-variables"}
    {
      "amp.mcpServers": {
        "trigger": {
          "command": "npx",
          "args": ["trigger.dev@latest", "mcp"]
        }
      }
    }
    ```

    [View Sourcegraph AMP MCP docs ↗](https://ampcode.com/manual#mcp)
  </Tab>

  <Tab title="Codex CLI">
    Install using the command line:

    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest install-mcp --client openai-codex
    ```

    Or add this configuration to `~/.codex/config.toml`:

    ```toml theme={"theme":"css-variables"}
    [mcp_servers.trigger]
    command = "npx"
    args = ["trigger.dev@latest", "mcp"]
    startup_timeout_sec = 30
    ```

    <Note>The `startup_timeout_sec = 30` is recommended. Codex defaults to 10 seconds, which may not be enough for `npx` to download the package on first run.</Note>
  </Tab>

  <Tab title="Crush">
    Install using the command line:

    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest install-mcp --client crush
    ```

    Or add this configuration to `.crush.json` (project), `crush.json`, or `~/.config/crush/crush.json` (user). Files are loaded in priority order: `.crush.json` → `crush.json` → `$HOME/.config/crush/crush.json`.

    ```json theme={"theme":"css-variables"}
    {
      "mcp": {
        "trigger": {
          "type": "stdio",
          "command": "npx",
          "args": ["trigger.dev@latest", "mcp"]
        }
      }
    }
    ```

    [View Charm MCP docs ↗](https://github.com/charmbracelet/crush)
  </Tab>

  <Tab title="opencode">
    Install using the command line:

    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest install-mcp --client opencode
    ```

    Or add this configuration to `~/.config/opencode/opencode.json` (user) or `./opencode.json` (project):

    ```json theme={"theme":"css-variables"}
    {
      "mcp": {
        "trigger": {
          "type": "local",
          "command": ["npx", "trigger.dev@latest", "mcp"],
          "enabled": true
        }
      }
    }
    ```

    [View opencode MCP docs ↗](https://opencode.ai/docs/mcp-servers/)
  </Tab>

  <Tab title="Ruler">
    Install using the command line:

    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest install-mcp --client ruler
    ```

    Or add this configuration to `.ruler/mcp.json`:

    ```json theme={"theme":"css-variables"}
    {
      "mcpServers": {
        "trigger": {
          "type": "stdio",
          "command": "npx",
          "args": ["trigger.dev@latest", "mcp"]
        }
      }
    }
    ```
  </Tab>
</Tabs>

After adding the config, restart your client. You should see a server named **trigger** connect automatically.

## Authentication

The `search_docs` tool works without authentication. All other tools require you to be logged in via the [Trigger.dev CLI](/docs/cli-login-commands). The first time you use an authenticated tool, your MCP client will prompt you to log in.

<Accordion title="CLI Options">
  The `install-mcp` command supports these options:

  **Core Options**

  * `-p, --project-ref <project ref>` — Scope the MCP server to a specific project
  * `-t, --tag <package tag>` — CLI package version to use (default: latest)
  * `--dev-only` — Restrict to the dev environment only
  * `--readonly` — Read-only mode. Hides write tools (`deploy`, `trigger_task`, `cancel_run`) so the AI cannot make changes to your account
  * `--yolo` — Install into all supported clients automatically
  * `--scope <scope>` — `user`, `project`, or `local`
  * `--client <clients...>` — Install into specific client(s)

  **Configuration Options**

  * `--log-file <log file>` — Write logs to a file
  * `-a, --api-url <value>` — Custom Trigger.dev API URL
  * `-l, --log-level <level>` — Log level (debug, info, log, warn, error, none)

  **Examples**

  Install for all supported clients:

  ```bash theme={"theme":"css-variables"}
  npx trigger.dev@latest install-mcp --yolo
  ```

  Install for specific clients:

  ```bash theme={"theme":"css-variables"}
  npx trigger.dev@latest install-mcp --client claude-code cursor --scope user
  ```

  Restrict to dev environment for a specific project:

  ```bash theme={"theme":"css-variables"}
  npx trigger.dev@latest install-mcp --dev-only --project-ref proj_abc123
  ```

  Read-only mode (prevents AI from deploying or triggering tasks):

  ```bash theme={"theme":"css-variables"}
  npx trigger.dev@latest install-mcp --readonly
  ```

  To add these options to a manual config, append them to the `args` array:

  ```json theme={"theme":"css-variables"}
  {
    "args": ["trigger.dev@latest", "mcp", "--dev-only", "--project-ref", "proj_abc123"]
  }
  ```
</Accordion>

## Getting Started

Once installed, you can start using the MCP server by asking your AI assistant questions like:

* `"Search the trigger docs for a ffmpeg example"`
* `"Initialize trigger.dev in my project"`
* `"Get all tasks in my project"`
* `"Trigger my foobar task with a sample payload"`
* `"Get the details of the latest run for my foobar task"`
* `"List all runs for my foobar task"`
* `"Deploy my project to staging"`
* `"Deploy my project to production"`
* `"How many runs failed in the last 7 days?"`
* `"Show me the overview dashboard metrics"`
* `"What tables can I query?"`

## Next Steps

<CardGroup>
  <Card title="MCP Tools" icon="wrench" href="/mcp-tools">
    Explore all available MCP tools for managing your projects.
  </Card>

  <Card title="Skills" icon="wand-magic-sparkles" href="/skills">
    Portable instruction sets that teach AI assistants Trigger.dev patterns.
  </Card>
</CardGroup>


# MCP Tools
Source: https://trigger.dev/docs/mcp-tools

Learn about how to use the tools available in the Trigger.dev MCP Server

## Documentation and Search Tools

### search\_docs

Search the Trigger.dev documentation for guides, examples, and API references.

**Example usage:**

* `"How do I create a scheduled task?"`
* `"Show me webhook examples"`
* `"What are the deployment options?"`

## Project Management Tools

### list\_orgs

List all organizations you have access to.

**Example usage:**

* `"What organizations do I have?"`
* `"Show me my orgs"`

### list\_projects

List all projects in your Trigger.dev account.

**Example usage:**

* `"What projects do I have?"`
* `"List my Trigger.dev projects"`

### create\_project\_in\_org

Create a new project in an organization.

**Example usage:**

* `"Create a new project called 'my-app'"`
* `"Set up a new Trigger.dev project"`

### initialize\_project

Initialize Trigger.dev in your project with automatic setup and configuration.

**Example usage:**

* `"Set up Trigger.dev in this project"`
* `"Add Trigger.dev to my app"`

## Task Management Tools

### get\_current\_worker

Get the current worker for a project, including the worker version, SDK version, and registered tasks with their payload schemas.

**Example usage:**

* `"What tasks are available?"`
* `"Show me the tasks in dev"`

### trigger\_task

Trigger a task to run with a specific payload. You can add a delay, set tags, configure retries, choose a machine size, pick a region, set a TTL, or use an idempotency key.

**Example usage:**

* `"Run the email-notification task"`
* `"Trigger my-task with userId 123"`
* `"Execute the sync task in production"`

## Run Monitoring Tools

### get\_run\_details

Get detailed information about a specific task run, including logs and status. Enable debug mode to get the full trace with all logs and spans.

**Example usage:**

* `"Show me details for run run_abc123"`
* `"Why did this run fail?"`

### list\_runs

List runs for a project. Filter by status, task, tags, version, machine size, or time period.

**Example usage:**

* `"Show me recent runs"`
* `"List failed runs from the last 7 days"`
* `"What runs are currently executing?"`

### wait\_for\_run\_to\_complete

Wait for a specific run to finish and return the result.

**Example usage:**

* `"Wait for run run_abc123 to complete"`

### cancel\_run

Cancel a running or queued run.

**Example usage:**

* `"Cancel run run_abc123"`
* `"Stop that task"`

## Deployment Tools

### deploy

Deploy your project to staging or production.

**Example usage:**

* `"Deploy to production"`
* `"Deploy to staging"`

### list\_deploys

List deployments for a project. Filter by status or time period.

**Example usage:**

* `"Show me recent deployments"`
* `"What's deployed to production?"`

### list\_preview\_branches

List all preview branches in the project.

**Example usage:**

* `"What preview branches exist?"`
* `"Show me preview deployments"`

## Profile Tools

### whoami

Show the current authenticated user, active CLI profile, email, and API URL.

**Example usage:**

* `"Who am I logged in as?"`
* `"What profile am I using?"`

### list\_profiles

List all configured CLI profiles and which one is currently active.

**Example usage:**

* `"What profiles do I have?"`
* `"Show me my Trigger.dev profiles"`

### switch\_profile

Switch the active CLI profile for this MCP session. This changes which Trigger.dev account and API URL are used for all subsequent tool calls.

**Example usage:**

* `"Switch to my production profile"`
* `"Use the staging profile"`

## Query and Analytics Tools

### get\_query\_schema

Get the schema for TRQL queries, including all available tables, their columns, data types, descriptions, and allowed values. Call this before using the query tool to understand what data is available.

**Example usage:**

* `"What tables and columns can I query?"`
* `"Show me the query schema"`

### query

Execute a TRQL query against your Trigger.dev data. TRQL is a SQL-style query language for analyzing runs, metrics, and LLM usage.

**Example usage:**

* `"How many runs failed in the last 7 days?"`
* `"Show me the top 10 most expensive tasks"`
* `"Query the average execution duration by task"`

### list\_dashboards

List available built-in dashboards with their widgets. Each dashboard contains pre-built queries for common metrics.

**Example usage:**

* `"What dashboards are available?"`
* `"Show me the dashboard widgets"`

### run\_dashboard\_query

Execute a single widget query from a built-in dashboard. Use `list_dashboards` first to see available dashboards and widget IDs.

**Example usage:**

* `"Run the total runs widget from the overview dashboard"`
* `"Show me the LLM cost over time from the AI dashboard"`

## Dev Server Tools

### start\_dev\_server

Start the Trigger.dev dev server (`trigger dev`) in the background. Waits up to 30 seconds for the worker to be ready.

**Example usage:**

* `"Start the dev server"`
* `"Run trigger dev"`

### stop\_dev\_server

Stop the running Trigger.dev dev server.

**Example usage:**

* `"Stop the dev server"`

### dev\_server\_status

Check the status of the dev server and view recent output. Shows whether it is stopped, starting, ready, or has errors.

**Example usage:**

* `"Is the dev server running?"`
* `"Show me the dev server logs"`
* `"Are there any build errors?"`

<Callout type="warning">
  The deploy and list\_preview\_branches tools are not available when the MCP server is running with the `--dev-only` flag. The `--readonly` flag hides deploy, trigger\_task, and cancel\_run.
</Callout>

## Agent Chat Tools

These tools let you have conversations with [chat agents](/docs/ai-chat/overview) directly from your AI coding tool. See the [Agent MCP guide](/docs/ai-chat/mcp) for a walkthrough.

### list\_agents

List all chat agents registered in the current worker. Agents are tasks created with `chat.agent()` or `chat.customAgent()`.

**Example usage:**

* `"What agents are available?"`
* `"List my chat agents"`

### start\_agent\_chat

Start a conversation with a chat agent. Returns a chat ID for use with `send_agent_message`. Optionally preloads the agent so it initializes before the first message.

**Parameters:**

* `agentId` (required) — The agent task slug (e.g., `"support-agent"`)
* `chatId` (optional) — A custom conversation ID. Auto-generated if omitted
* `clientData` (optional) — Client data to include with every message (e.g., `{ userId: "user_123" }`). Must match the agent's `clientDataSchema` if one is defined
* `preload` (optional, default: `true`) — Whether to preload the agent before the first message

**Example usage:**

* `"Start a chat with the support agent"`
* `"Talk to the pr-review agent with userId abc"`

### send\_agent\_message

Send a message to an active agent chat and get the full response back. The agent remembers full context from previous messages in the same chat.

**Parameters:**

* `chatId` (required) — The chat ID from `start_agent_chat`
* `message` (required) — The message text to send

**Example usage:**

* `"Tell the agent to review the latest PR"`
* `"Ask it what tools it has available"`

### close\_agent\_chat

Close an agent chat conversation. The agent exits its loop gracefully. Without this, the agent will close on its own when its idle timeout expires.

**Parameters:**

* `chatId` (required) — The chat ID to close

**Example usage:**

* `"Close the chat"`
* `"End the conversation"`

<Callout type="warning">
  The `start_agent_chat`, `send_agent_message`, and `close_agent_chat` tools are write operations and are not available in readonly mode.
</Callout>


# Migrating from v3
Source: https://trigger.dev/docs/migrating-from-v3

What's new in v4, how to migrate, and breaking changes.

<Warning>
  **Trigger.dev v3 has been retired**

  Trigger.dev v3 is end of life. v4 is stable, fully supported, and recommended for all users.

  * On Trigger.dev Cloud, v3 has been shut down: v3 triggers and deploys no longer run.
  * If you self-host, 4.5.0 is the last version we officially support for running v3. Stay on 4.5.0 or upgrade to v4; 4.5.1 and later reject v3 triggers, batch triggers, and deploys with an upgrade message.

  **What you need to do:** Migrate to v4. The migration takes about 2 minutes; follow the steps below. If you have questions or need help, [contact us](https://trigger.dev/contact) or reach out in our [Discord](https://trigger.dev/discord).
</Warning>

## What's new in v4?

| Feature                                                              | Description                                                                                                                                                                                                |
| :------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Wait for token](/docs/wait-for-token)                                    | Create and wait for tokens to be completed, enabling approval workflows and waiting for arbitrary external conditions.                                                                                     |
| Wait idempotency                                                     | Skip waits if the same idempotency key is used again when using [wait for](/docs/wait-for#wait-idempotency), [wait until](/docs/wait-until#wait-idempotency), or [wait for token](/docs/wait-for-token#wait-idempotency). |
| [Priority](/docs/runs/priority)                                           | Specify a priority when triggering a task.                                                                                                                                                                 |
| [Global lifecycle hooks](/docs/tasks/overview#global-lifecycle-hooks)     | Register global lifecycle hooks that are executed for all runs, regardless of the task.                                                                                                                    |
| [onWait and onResume](/docs/tasks/overview#onwait-and-onresume-functions) | Run code when a run is paused or resumed because of a wait.                                                                                                                                                |
| [onComplete](/docs/tasks/overview#oncomplete-function)                    | Run code when a run completes, regardless of whether it succeeded or failed.                                                                                                                               |
| [onCancel](/docs/tasks/overview#oncancel-function)                        | Run code when a run is cancelled.                                                                                                                                                                          |
| [Hidden tasks](/docs/hidden-tasks)                                        | Create tasks that are not exported from your trigger files but can still be executed.                                                                                                                      |
| [Middleware & locals](#middleware-and-locals)                        | The middleware system runs at the top level, executing before and after all lifecycle hooks. The locals API allows sharing data between middleware and hooks.                                              |
| [useWaitToken](/docs/realtime/react-hooks/use-wait-token)                 | Use the useWaitToken hook to complete a wait token from a React component.                                                                                                                                 |
| [Task-backed AI tools](/docs/tasks/schemaTask#task-backed-ai-tools)       | Use `schemaTask` with AI SDK `tool()` and `ai.toolExecute()` (legacy `ai.tool` is deprecated).                                                                                                             |

## Node.js support

Trigger.dev runs your tasks on specific Node.js versions:

* Node.js `21.7.3` (default)
* Node.js `22.16.0` (`node-22`)
* Node.js `24.18.0` (`node-24`)
* Node.js `26.4.0` (`node-26`)
* Bun `1.3.3` (`bun`)

You can change the runtime by setting the `runtime` field in your `trigger.config.ts` file.

```ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  // "node", "node-22", "node-24", "node-26" or "bun"
  runtime: "node-24",
  project: "<your-project-ref>",
});
```

## How to migrate to v4

First read the deprecations and breaking changes sections below.

We recommend the following steps to migrate to v4:

1. Install the v4 package.
2. Run the `trigger dev` CLI command and test your tasks locally, fixing any breaking changes.
3. Deploy to the staging environment and test your tasks in staging, fixing any breaking changes. (this step is optional, but highly recommended)
4. Once you've verified that v4 is working as expected, you should deploy your application backend with the updated v4 package.
5. Once you've deployed your application backend, you should deploy your tasks to the production environment.

Note that between steps 4 and 5, runs triggered with the v4 package will continue using v3, and only new runs triggered after step 5 is complete will use v4.

<Warning>
  Once v4 is activated in your environment, there will be a period of time where old runs will
  continue to execute using v3, while new runs will use v4. Because these engines use completely
  different underlying queues and concurrency models, it's possible you may have up to double the
  amount of concurrently executing runs. Once the runs drain from the old run engine, the
  concurrency will return to normal.
</Warning>

<Note>
  When migrating from v3 to v4, our infrastructure IPs may change. If you use IP allowlisting (e.g. for databases or APIs), [update your allowlists](https://trigger.dev/changelog/static-ips) with the current static IPs before or immediately after switching to v4 to avoid connectivity issues or downtime.
</Note>

## Migrate using AI

Use the prompt in the accordion below to help you migrate your v3 tasks to v4. The prompt gives good results when using Claude 4 Sonnet. You’ll need a relatively large token limit.

<Accordion title="Copy paste this prompt in full" icon="sparkles">
  ```md theme={"theme":"css-variables"}

  I would like you to help me migrate my v3 task code to v4. Here are the important differences:

  We've deprecated the `@trigger.dev/sdk/v3` import path and moved to a new path:

  // This is the old path
  import { task } from "@trigger.dev/sdk/v3";

  // This is the new path, use this instead
  import { task } from "@trigger.dev/sdk";

  We've renamed the `handleError` hook to `catchError`. Use this instead of `handleError`.

  `init` was previously used to initialize data used in the run function. This is the old version:

  import { task } from "@trigger.dev/sdk";

  const myTask = task({
    init: async () => {
      return {
        myClient: new MyClient(),
      };
    },
    run: async (payload: any, { ctx, init }) => {
      const client = init.myClient;
      await client.doSomething();
    },
  });

  This is the new version using middleware and locals:

  import { task, locals, tasks } from "@trigger.dev/sdk";

  // Create a local for your client
  const MyClientLocal = locals.create<MyClient>("myClient");

  // Set up middleware to initialize the client
  tasks.middleware("my-client", async ({ next }) => {
    const client = new MyClient();
    locals.set(MyClientLocal, client);
    await next();
  });

  // Helper function to get the client
  function getMyClient() {
    return locals.getOrThrow(MyClientLocal);
  }

  const myTask = task({
    run: async (payload: any, { ctx }) => {
      const client = getMyClient();
      await client.doSomething();
    },
  });

  We’ve deprecated the `toolTask` function. Use `schemaTask` plus AI SDK `tool()` with `execute: ai.toolExecute(task)` (the `ai.tool()` wrapper is deprecated). This is the old version:

  import { toolTask, schemaTask } from "@trigger.dev/sdk";
  import { z } from "zod";
  import { generateText } from "ai";

  const myToolTask = toolTask({
    id: "my-tool-task",
    run: async (payload: any, { ctx }) => {},
  });

  export const myAiTask = schemaTask({
    id: "my-ai-task",
    schema: z.object({
      text: z.string(),
    }),
    run: async (payload, { ctx }) => {
      const { text } = await generateText({
        prompt: payload.text,
        model: openai("gpt-4o"),
        tools: {
          myToolTask,
        },
      });
    },
  });

  This is the new version:

  import { schemaTask } from "@trigger.dev/sdk";
  import { ai } from "@trigger.dev/sdk/ai";
  import { z } from "zod";
  import { generateText, tool } from "ai";
  import { openai } from "@ai-sdk/openai";

  // Convert toolTask to schemaTask with a schema
  const myToolTask = schemaTask({
    id: "my-tool-task",
    schema: z.object({
      // Add appropriate schema for your tool's payload
      input: z.string(),
    }),
    run: async (payload, { ctx }) => {},
  });

  const myTool = tool({
    description: myToolTask.description ?? "",
    inputSchema: myToolTask.schema!,
    execute: ai.toolExecute(myToolTask),
  });

  export const myAiTask = schemaTask({
    id: "my-ai-task",
    schema: z.object({
      text: z.string(),
    }),
    run: async (payload, { ctx }) => {
      const { text } = await generateText({
        prompt: payload.text,
        model: openai("gpt-4o"),
        tools: {
          myTool,
        },
      });
    },
  });

  We've made several breaking changes that require code updates:

  **Queue changes**: Queues must now be defined ahead of time using the `queue` function. You can no longer create queues "on-demand" when triggering tasks. This is the old version:


  // Old v3 way - creating queue on-demand
  await myTask.trigger({ foo: "bar" }, { queue: { name: "my-queue", concurrencyLimit: 10 } });


  This is the new version:


  // New v4 way - define queue first
  import { queue, task } from "@trigger.dev/sdk";

  const myQueue = queue({
    name: "my-queue",
    concurrencyLimit: 10,
  });

  export const myTask = task({
    id: "my-task",
    queue: myQueue, // Set queue on task
    run: async (payload: any, { ctx }) => {},
  });

  // Now trigger without queue options
  await myTask.trigger({ foo: "bar" });

  // Or specify queue by name
  await myTask.trigger({ foo: "bar" }, { queue: "my-queue" });


  **Lifecycle hooks**: Function signatures have changed to use a single object parameter instead of separate parameters. Prefer `onStartAttempt` over the deprecated `onStart` when you need code to run before each attempt. This is the old version:


  // Old v3 way
  export const myTask = task({
    id: "my-task",
    onStart: (payload, { ctx }) => {},
    onSuccess: (payload, output, { ctx }) => {},
    onFailure: (payload, error, { ctx }) => {},
    catchError: (payload, { ctx, error, retry }) => {},
    run: async (payload, { ctx }) => {},
  });


  This is the new version:


  // New v4 way - single object parameter for hooks
  export const myTask = task({
    id: "my-task",
    onStartAttempt: ({ payload, ctx }) => {},  // prefer over deprecated onStart
    onSuccess: ({ payload, ctx, task, output }) => {},
    onFailure: ({ payload, ctx, task, error }) => {},
    catchError: ({ payload, ctx, task, error, retry, retryAt, retryDelayInMs }) => {},
    run: async (payload, { ctx }) => {}, // run function unchanged
  });


  **BatchTrigger changes**: The `batchTrigger` function no longer returns runs directly. This is the old version:


  // Old v3 way
  const batchHandle = await tasks.batchTrigger([
    [myTask, { foo: "bar" }],
    [myOtherTask, { baz: "qux" }],
  ]);

  console.log(batchHandle.runs); // Direct access


  This is the new version:


  // New v4 way
  const batchHandle = await tasks.batchTrigger([
    [myTask, { foo: "bar" }],
    [myOtherTask, { baz: "qux" }],
  ]);

  const batch = await batch.retrieve(batchHandle.batchId); // Use batch.retrieve()
  console.log(batch.runs);


  **triggerAndWait / batchTriggerAndWait**: In v4 these return a Result object, not the raw output. Use `if (result.ok) { ... result.output }` or call `.unwrap()` to get the output (throws if the run failed). Do not wrap `triggerAndWait` or `batchTriggerAndWait` in `Promise.all` — this is not supported.


  **Context (ctx) changes**: `ctx.attempt.id` and `ctx.attempt.status` have been removed; use `ctx.attempt.number` where needed. `ctx.task.exportName` has been removed.


  Can you help me convert the following code from v3 to v4? Please include the full converted code in the answer, do not truncate it anywhere.

  ```
</Accordion>

## Installation

To opt-in to using v4, you will need to update your dependencies to the latest version:

<CodeGroup>
  ```bash npx theme={"theme":"css-variables"}
  npx trigger.dev@latest update
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn dlx trigger.dev@latest update
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm dlx trigger.dev@latest update
  ```
</CodeGroup>

This command should update all of your `@trigger.dev/*` packages to a `4.x` version.

## Deprecations

We've deprecated the following APIs:

### @trigger.dev/sdk/v3

We've deprecated the `@trigger.dev/sdk/v3` import path and moved to a new path:

```ts theme={"theme":"css-variables"}
// This still works, but will be removed in a future version
import { task } from "@trigger.dev/sdk/v3";

// This is the new path
import { task } from "@trigger.dev/sdk";
```

### `handleError` and `init`

We've renamed the `handleError` hook to `catchError` to better reflect that it can catch and react to errors. `handleError` will be removed in a future version.

`init` was previously used to initialize data used in the run function:

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

const myTask = task({
  init: async () => {
    return {
      myClient: new MyClient(),
    };
  },
  run: async (payload: any, { ctx, init }) => {
    const client = init.myClient;
    await client.doSomething();
  },
});
```

This has now been deprecated in favor of the `locals` API and middleware. See the [Improved middleware and locals](/docs/tasks/overview#middleware-and-locals-functions) section for more details.

### toolTask

We've deprecated the `toolTask` function, which created both a Trigger.dev task and a tool compatible with the Vercel [AI SDK](https://vercel.com/docs/ai-sdk):

```ts theme={"theme":"css-variables"}
import { toolTask, schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";
import { generateText } from "ai";

const myToolTask = toolTask({
  id: "my-tool-task",
  run: async (payload: any, { ctx }) => {},
});

export const myAiTask = schemaTask({
  id: "my-ai-task",
  schema: z.object({
    text: z.string(),
  }),
  run: async (payload, { ctx }) => {
    const { text } = await generateText({
      prompt: payload.text,
      model: openai("gpt-4o"),
      tools: {
        myToolTask,
      },
    });
  },
});
```

We've replaced the `toolTask` function with `schemaTask` plus AI SDK `tool()` and `ai.toolExecute()` (the older `ai.tool()` wrapper is deprecated). See [Task-backed AI tools](/docs/tasks/schemaTask#task-backed-ai-tools).

## Breaking changes

### Queue changes

Previously, it was possible to specify a queue name of a queue that did not exist, along with a concurrency limit. The queue would then be created "on-demand" with the specified concurrency limit. If the queue did exist, the concurrency limit of the queue would be updated to the specified value:

```ts theme={"theme":"css-variables"}
await myTask.trigger({ foo: "bar" }, { queue: { name: "my-queue", concurrencyLimit: 10 } });
```

This is no longer possible, and queues must now be defined ahead of time using the `queue` function:

```ts theme={"theme":"css-variables"}
import { queue } from "@trigger.dev/sdk";

const myQueue = queue({
  name: "my-queue",
  concurrencyLimit: 10,
});
```

Now when you trigger a task, you can only specify the queue by name:

```ts theme={"theme":"css-variables"}
await myTask.trigger({ foo: "bar" }, { queue: "my-queue" });
```

Or you can set the queue on the task:

```ts theme={"theme":"css-variables"}
import { queue, task } from "@trigger.dev/sdk";

const myQueue = queue({
  name: "my-queue",
  concurrencyLimit: 10,
});

export const myTask = task({
  id: "my-task",
  queue: myQueue,
  run: async (payload: any, { ctx }) => {},
});

// You can optionally specify the queue directly on the task
export const myTask2 = task({
  id: "my-task-2",
  queue: {
    name: "my-queue-2",
    concurrencyLimit: 50,
  },
  run: async (payload: any, { ctx }) => {},
});
```

Now you can trigger these tasks without having to specify the queue name in the trigger options:

```ts theme={"theme":"css-variables"}
await myTask.trigger({ foo: "bar" }); // Will use the queue defined on the task
await myTask2.trigger({ foo: "bar" }); // Will use the queue defined on the task
```

If you're using `concurrencyKey` you can specify the `queue` and `concurrencyKey` like this:

```ts theme={"theme":"css-variables"}
const handle = await generatePullRequest.trigger(data, {
  queue: "paid-users",
  concurrencyKey: data.userId,
});
```

For each unique value of `concurrencyKey`, a new queue will be created using the `concurrencyLimit` from the queue. This allows you to have a queue per user.

### Lifecycle hooks

We've changed the function signatures of the lifecycle hooks to be more consistent and easier to use, by unifying all the parameters into a single object that can be destructured.

Previously, hooks received a payload as the first argument and then an additional object as the second argument:

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  onStart: ({ payload, ctx }) => {},
  run: async (payload, { ctx }) => {},
});
```

Now, all the parameters are passed in a single object:

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  onStart: ({ payload, ctx }) => {},
  // The run function still uses separate parameters
  run: async (payload, { ctx }) => {},
});
```

This is true for all the lifecycle hooks:

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  onStart: ({ payload, ctx, task }) => {},
  onSuccess: ({ payload, ctx, task, output }) => {},
  onFailure: ({ payload, ctx, task, error }) => {},
  onWait: ({ payload, ctx, task, wait }) => {},
  onResume: ({ payload, ctx, task, wait }) => {},
  onComplete: ({ payload, ctx, task, result }) => {},
  catchError: ({ payload, ctx, task, error, retry, retryAt, retryDelayInMs }) => {},
  run: async (payload, { ctx }) => {},
});
```

### Context changes

We've made a few small changes to the `ctx` object:

* `ctx.attempt.id` and `ctx.attempt.status` have been removed. `ctx.attempt.number` is still available.
* `ctx.task.exportName` has been removed (since we no longer require tasks to be exported to be triggered).

### BatchTrigger changes

The `batchTrigger` function no longer returns a `runs` list directly. In v3, you could access the runs directly from the batch handle:

```ts theme={"theme":"css-variables"}
// In v3
const batchHandle = await tasks.batchTrigger([
  [myTask, { foo: "bar" }],
  [myOtherTask, { baz: "qux" }],
]);

// You could access runs directly
console.log(batchHandle.runs);
```

In v4, you now need to use the `batch.retrieve()` method to get the batch with its runs:

```ts theme={"theme":"css-variables"}
// In v4
const batchHandle = await tasks.batchTrigger([
  [myTask, { foo: "bar" }],
  [myOtherTask, { baz: "qux" }],
]);

// Now you need to retrieve the batch to get the runs
const batch = await batch.retrieve(batchHandle.batchId);
console.log(batch.runs);
```

### OpenTelemetry

We are now using newer versions of the OpenTelemetry packages. This means that if you're using custom exporters you may need to update the packages:

| Package                                   | Previous Version | New Version | Change Type  |
| ----------------------------------------- | ---------------- | ----------- | ------------ |
| `@opentelemetry/api-logs`                 | 0.52.1           | 0.203.0     | Major update |
| `@opentelemetry/exporter-logs-otlp-http`  | 0.52.1           | 0.203.0     | Major update |
| `@opentelemetry/exporter-trace-otlp-http` | 0.52.1           | 0.203.0     | Major update |
| `@opentelemetry/instrumentation`          | 0.52.1           | 0.203.0     | Major update |


# Migrating from Mergent
Source: https://trigger.dev/docs/migration-mergent

A guide for migrating from Mergent to Trigger.dev

Mergent is being absorbed into Resend, so if you’re running background jobs or scheduled tasks on Mergent, now is a good time to migrate. Trigger.dev is a modern, developer-friendly platform for background jobs, workflows, and scheduling.

### Why Trigger.dev?

* **Long-running, reliable tasks:** Write typical async code, no unfamiliar syntax to learn.
* **Automatic retries, concurrency, and scheduling:** Configure your tasks in your `trigger.config.ts` file.
* **Local dev that matches prod:** Run and debug jobs locally and view everything in the dashboard.
* **Scales with you:** Deploy your tasks to Trigger.dev Cloud with no infrastructure to manage. Or self-host.

## How to migrate to Trigger.dev

### Step 1: Set up Trigger.dev

1. **Create an account** at [Trigger.dev Cloud](https://cloud.trigger.dev).
2. **Create an organization and a project.**
3. **Install the CLI** and run the local dev server:

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest init
npx trigger.dev@latest dev
```

You’ll get a local server that behaves just like production, and you’ll see your runs in the dashboard.

### Step 2: Convert your Mergent task to a Trigger.dev task

#### Example: Basic Mergent Task

Here’s a simple Mergent task that processes an image:

```ts processVideo.ts theme={"theme":"css-variables"}
export async function processVideoTask(req: { body: { videoUrl: string } }) {
  const { videoUrl } = req.body;
  // Do some video processing
  const result = await processVideo(videoUrl);
  return { success: true, processedUrl: result.url };
}
```

This is typically called by Mergent via HTTP POST, and you’d register the endpoint in the Mergent dashboard.

#### The same task in Trigger.dev

```ts trigger/processVideo.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

export const processVideoTask = task({
  id: "process-video",
  run: async (payload: { videoUrl: string }) => {
    const result = await processVideo(payload.videoUrl);
    return { success: true, processedUrl: result.url };
  },
});
```

**Key differences:**

* In Mergent, your task is an HTTP handler; in Trigger.dev, it’s a `task()` function that gets deployed on a managed worker for you.
* Trigger.dev gives you a typed payload, not a raw HTTP request.
* No need to handle HTTP status codes or errors—Trigger.dev handles retries and failures for you.
* You can export multiple tasks from a single file.

***

#### Scheduled task example

**Mergent scheduled task:**

You’d set up a schedule in the Mergent dashboard to hit your HTTP endpoint on a cron.

```ts dailyReport.ts theme={"theme":"css-variables"}
export async function dailyReportTask(req) {
  await sendDailyReport();
}
```

**Trigger.dev scheduled task:**

```ts trigger/dailyReport.ts theme={"theme":"css-variables"}
import { schedules } from "@trigger.dev/sdk";

export const dailyReportTask = schedules.task({
  id: "daily-report",
  cron: "0 0 * * *", // every day at midnight UTC
  run: async () => {
    await sendDailyReport();
  },
});
```

* In Trigger.dev, you can define the schedule right in your code (or attach it in the dashboard).
* No need to set up HTTP endpoints for each scheduled job.

## Triggering your tasks

**Mergent:** You’d trigger a task by calling the Mergent API, specifying the URL and payload.

```ts theme={"theme":"css-variables"}
const Mergent = require("mergent");
const mergent = new Mergent("API_KEY");

mergent.tasks.create({
  request: {
    url: "https://your-app.com/api/processImage",
    body: JSON.stringify({ imageUrl: "...", filters: ["blur"] }),
    headers: { "Content-Type": "application/json" },
  },
  delay: { minutes: 5 },
});
```

**Trigger.dev:** You trigger a task directly from your codebase, no HTTP endpoint needed.

```ts theme={"theme":"css-variables"}
import { processImageTask } from "@/trigger/processImage";

await processImageTask.trigger({
  imageUrl: "...",
  filters: ["blur"],
}, {
  delay: "5m",
});
```

* You can trigger tasks immediately, or add logic inside the task to delay execution (using `wait.for` or `wait.until`).
* No need to expose HTTP endpoints for every task.

**Summary:**

* Mergent tasks are HTTP handlers; Trigger.dev tasks are functions that get deployed on a managed worker for you.
* Scheduling and retries are built-in and configured in code.
* Trigger.dev tasks are type-safe, and easy to debug.
* You don’t need to manage endpoints or handle HTTP manually.

That’s it. You’re ready to migrate. If you need more advanced features such as concurrency, retries, metadata, chaining tasks, and more, check out the [Trigger.dev docs](https://trigger.dev/docs).


# Migrating from n8n
Source: https://trigger.dev/docs/migration-n8n

A practical guide for moving your n8n workflows to Trigger.dev

If you've been building with n8n and are ready to move to code-first workflows, this guide is for you. This page maps them to their Trigger.dev equivalents and walks through common patterns side by side.

Your task code runs on Trigger.dev's managed infrastructure, so there are no servers for you to provision or maintain.

## Concept map

| n8n                                               | Trigger.dev                                                                                                        |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Workflow                                          | [task](/docs/tasks/overview) plus its config (queue, retry, onFailure)                                                  |
| Schedule Trigger                                  | [schedules.task](/docs/tasks/scheduled)                                                                                 |
| Webhook node                                      | Route handler + [task.trigger()](/docs/triggering)                                                                      |
| Node                                              | A step or library call inside `run()`                                                                              |
| Execute Sub-workflow node (wait for completion)   | [tasks.triggerAndWait()](/docs/triggering#yourtask-triggerandwait)                                                      |
| Execute Sub-workflow node (execute in background) | [tasks.trigger()](/docs/triggering)                                                                                     |
| Loop over N items → Execute Sub-workflow → Merge  | [tasks.batchTriggerAndWait()](/docs/tasks#yourtask-batchtriggerandwait)                                                 |
| Loop Over Items (Split in Batches)                | `for` loop or `.map()`                                                                                             |
| IF / Switch node                                  | `if` / `switch` statements                                                                                         |
| Wait node (time interval or specific time)        | [wait.for()](/docs/wait-for) or [wait.until()](/docs/wait-until)                                                             |
| Error Trigger node / Error Workflow               | [onFailure](/docs/tasks/overview#onfailure-function) hook (both collapse into one concept in Trigger.dev)               |
| Continue On Fail                                  | `try/catch` around an individual step                                                                              |
| Stop And Error                                    | `throw new Error(...)`                                                                                             |
| Code node                                         | A function or step within `run()`                                                                                  |
| Credentials                                       | [Environment variable secret](/docs/deploy-environment-variables)                                                       |
| Execution                                         | Run (visible in the dashboard with full logs)                                                                      |
| Retry on Fail (per-node setting)                  | [retry.maxAttempts](/docs/tasks/overview#retry) (retries the whole `run()`, not a single step)                          |
| AI Agent node                                     | Any AI SDK called inside `run()` (Vercel AI SDK, Claude SDK, OpenAI SDK, etc.)                                     |
| Respond to Webhook node                           | Route handler + [task.triggerAndWait()](/docs/triggering#yourtask-triggerandwait) returning the result as HTTP response |

***

## Setup

<Steps>
  <Step title="Create an account">
    Go to [Trigger.dev Cloud](https://cloud.trigger.dev), create an account, and create a project.
  </Step>

  <Step title="Install the CLI and initialize">
    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest init
    ```

    This adds Trigger.dev to your project and creates a `trigger/` directory for your tasks.
  </Step>

  <Step title="Run the local dev server">
    ```bash theme={"theme":"css-variables"}
    npx trigger.dev@latest dev
    ```

    You'll get a local server that behaves like production. Your runs appear in the dashboard as you test.
  </Step>
</Steps>

***

## Common patterns

### Webhook trigger

In n8n you use a **Webhook** trigger node, which registers a URL that starts the workflow.

In Trigger.dev, your existing route handler receives the webhook and triggers the task:

<CodeGroup>
  ```ts app/api/webhook/route.ts theme={"theme":"css-variables"}
  import { processWebhook } from "@/trigger/process-webhook";

  export async function POST(request: Request) {
    const body = await request.json();

    await processWebhook.trigger({
      event: body.event,
      data: body.data,
    });

    return Response.json({ received: true });
  }
  ```

  ```ts trigger/process-webhook.ts theme={"theme":"css-variables"}
  import { task } from "@trigger.dev/sdk";

  export const processWebhook = task({
    id: "process-webhook",
    run: async (payload: { event: string; data: Record<string, unknown> }) => {
      // handle the webhook payload
      await handleEvent(payload.event, payload.data);
    },
  });
  ```
</CodeGroup>

***

### Chaining steps (Sub-workflows)

In n8n you use the **Execute Sub-workflow** node to call another workflow and wait for the result.

In Trigger.dev you use `triggerAndWait()`:

<CodeGroup>
  ```ts trigger/process-order.ts theme={"theme":"css-variables"}
  import { task } from "@trigger.dev/sdk";
  import { sendConfirmationEmail } from "./send-confirmation-email";

  export const processOrder = task({
    id: "process-order",
    run: async (payload: { orderId: string; email: string }) => {
      const result = await processPayment(payload.orderId);

      // trigger a subtask and wait for it to complete
      await sendConfirmationEmail.triggerAndWait({
        email: payload.email,
        orderId: payload.orderId,
        amount: result.amount,
      });

      return { processed: true };
    },
  });
  ```

  ```ts trigger/send-confirmation-email.ts theme={"theme":"css-variables"}
  import { task } from "@trigger.dev/sdk";

  export const sendConfirmationEmail = task({
    id: "send-confirmation-email",
    run: async (payload: { email: string; orderId: string; amount: number }) => {
      await sendEmail({
        to: payload.email,
        subject: `Order ${payload.orderId} confirmed`,
        body: `Your order for $${payload.amount} has been confirmed.`,
      });
    },
  });
  ```
</CodeGroup>

To trigger multiple subtasks in parallel and wait for all of them (like the **Merge** node in n8n):

```ts trigger/process-batch.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { processItem } from "./process-item";

export const processBatch = task({
  id: "process-batch",
  run: async (payload: { items: { id: string }[] }) => {
    // fan out to subtasks, collect all results
    const results = await processItem.batchTriggerAndWait(
      payload.items.map((item) => ({ payload: { id: item.id } }))
    );

    return { processed: results.runs.length };
  },
});
```

***

### Error handling

In n8n you use **Continue On Fail** on individual nodes and a separate **Error Workflow** for workflow-level failures.

In Trigger.dev:

* Use `try/catch` for recoverable errors at a specific step
* Use the `onFailure` hook for workflow-level failure handling
* Configure `retry` for automatic retries with backoff

```ts trigger/import-data.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

export const importData = task({
  id: "import-data",
  // automatic retries with exponential backoff
  retry: {
    maxAttempts: 3,
  },
  // runs if this task fails after all retries
  onFailure: async ({ payload, error }) => {
    await sendAlertToSlack(`import-data failed: ${(error as Error).message}`);
  },
  run: async (payload: { source: string }) => {
    let records;

    // continue on fail equivalent: catch the error and handle locally
    try {
      records = await fetchFromSource(payload.source);
    } catch (error) {
      records = await fetchFromFallback(payload.source);
    }

    await saveRecords(records);
  },
});
```

***

### Waiting and delays

In n8n you use the **Wait** node to pause a workflow for a fixed time or until a webhook is called.

In Trigger.dev:

```ts trigger/send-followup.ts theme={"theme":"css-variables"}
import { task, wait } from "@trigger.dev/sdk";

export const sendFollowup = task({
  id: "send-followup",
  run: async (payload: { userId: string; email: string }) => {
    await sendWelcomeEmail(payload.email);

    // wait for a fixed duration, execution is frozen, you don't pay while waiting
    await wait.for({ days: 3 });

    const hasActivated = await checkUserActivation(payload.userId);
    if (!hasActivated) {
      await sendFollowupEmail(payload.email);
    }
  },
});
```

To wait for an external event (like n8n's "On Webhook Call" resume mode), use `wait.createToken()` to generate a URL, send that URL to the external system, then pause with `wait.forToken()` until the external system POSTs to that URL to resume the run.

```ts trigger/approval-flow.ts theme={"theme":"css-variables"}
import { task, wait } from "@trigger.dev/sdk";

export const approvalFlow = task({
  id: "approval-flow",
  run: async (payload: { requestId: string; approverEmail: string }) => {
    // create a token, this generates a URL the external system can POST to
    const token = await wait.createToken({
      timeout: "48h",
      tags: [`request-${payload.requestId}`],
    });

    // send the token URL to whoever needs to resume this run
    await sendApprovalRequest(payload.approverEmail, payload.requestId, token.url);

    // pause until the external system POSTs to token.url
    const result = await wait.forToken<{ approved: boolean }>(token).unwrap();

    if (result.approved) {
      await executeApprovedAction(payload.requestId);
    } else {
      await notifyRejection(payload.requestId);
    }
  },
});
```

***

## Full example: customer onboarding workflow

Here's how a typical back office onboarding workflow translates from n8n to Trigger.dev.

**The n8n setup:** Webhook Trigger → HTTP Request (provision account) → HTTP Request (send welcome email) → HTTP Request (notify Slack) → Wait node (3 days) → HTTP Request (check activation) → IF node → HTTP Request (send follow-up).

**In Trigger.dev**, the same workflow is plain TypeScript:

```ts trigger/onboard-customer.ts theme={"theme":"css-variables"}
import { task, wait } from "@trigger.dev/sdk";
import { provisionAccount } from "./provision-account";
import { sendWelcomeEmail } from "./send-welcome-email";

export const onboardCustomer = task({
  id: "onboard-customer",
  retry: {
    maxAttempts: 3,
  },
  run: async (payload: {
    customerId: string;
    email: string;
    plan: "starter" | "pro" | "enterprise";
  }) => {
    // provision their account, throws if the subtask fails
    await provisionAccount
      .triggerAndWait({
        customerId: payload.customerId,
        plan: payload.plan,
      })
      .unwrap();

    // send welcome email
    await sendWelcomeEmail
      .triggerAndWait({
        customerId: payload.customerId,
        email: payload.email,
      })
      .unwrap();

    // notify the team
    await notifySlack(`New customer: ${payload.email} on ${payload.plan}`);

    // wait 3 days, then check if they've activated
    await wait.for({ days: 3 });

    const activated = await checkActivation(payload.customerId);
    if (!activated) {
      await sendActivationNudge(payload.email);
    }

    return { customerId: payload.customerId, activated };
  },
});
```

Trigger the workflow from your app when a new customer signs up:

```ts theme={"theme":"css-variables"}
import { onboardCustomer } from "@/trigger/onboard-customer";

await onboardCustomer.trigger({
  customerId: customer.id,
  email: customer.email,
  plan: customer.plan,
});
```

Every run is visible in the Trigger.dev dashboard with full logs, retry history, and the ability to replay any run.


# Dashboards
Source: https://trigger.dev/docs/observability/dashboards

Create custom dashboards with real-time metrics powered by TRQL queries.

## Overview

In the Trigger.dev dashboard we have built-in dashboards and you can create your own.

Dashboards are powered by [TRQL queries](/docs/observability/query) with widgets that can be displayed as charts, tables, or single values. They automatically refresh to show the latest data.

### Available metrics data

Trigger.dev automatically collects process metrics (CPU, memory) and Node.js runtime metrics (event loop, heap) for all deployed tasks -- no configuration needed. Requires SDK version **4.4.1 or later**. You can also create custom metrics using the `otel.metrics` API from the SDK.

All of this data is available in the `metrics` table for use in dashboard widgets. See [Logging, tracing & metrics](/docs/logging#metrics) for the full list of automatic metrics and how to create custom ones, or the [Query page](/docs/observability/query#metrics-table-columns) for the `metrics` table schema.

<img alt="The built-in Metrics dashboard" />

### Visualization types

* **Line chart** - Show trends over time
* **Bar chart** - Compare values across categories
* **Area chart** - Display cumulative trends
* **Table** - Show detailed data in rows
* **Single value** - Display a single metric (count, sum, average, etc.)

You can also add Titles to your dashboard.

## Filtering and time ranges

All widgets on a dashboard use the time range filter applied to the dashboard.

You can also filter the data by:

* Scope: Environment, Project, Organization
* Tasks
* Queues

## Creating custom dashboards

1. In the sidebar click the + icon next to "Dashboards".
2. Name your custom dashboard.
3. From the top-right you can "Add chart" or "Add title".
4. For charts you write [TRQL queries](/docs/observability/query) and choose a visualization type.
5. You can resize and reposition widgets on your dashboards.

## Performance considerations

### Optimize queries for metrics

1. **Use time bucketing** - `timeBucket()` automatically groups by appropriate intervals
2. **Limit result size** - Add `LIMIT` clauses, especially for table widgets
3. **Use approximate functions** - `uniq()` instead of `uniqExact()` for faster approximate counts

## Exporting metric data

Export data from any metric widget:

1. Click the widget menu (three dots)
2. Select "Copy JSON" or "Copy CSV"

## Best practices

1. **Start simple** - Begin with basic metrics and iterate based on insights
2. **Use meaningful names** - Give widgets clear, descriptive titles
3. **Group related metrics** - Organize dashboards by theme (performance, costs, errors)
4. **Test queries first** - Use the Query page to develop and test before adding to dashboards

## Troubleshooting

### Widget shows "No data"

* Check that your query returns results in the Query page
* Verify time filters include the period with data
* Ensure task/queue filters match existing runs

### Widget is slow to load

* Add time range filters to your query
* Use `LIMIT` clauses
* Simplify aggregations
* Check query execution time in Query page

### Chart displays incorrectly

* Verify column names match visualization config
* Check data types (numbers for charts, dates for time series)
* Ensure `timeBucket()` is used for time-series charts
* Review that series columns exist in query results

## Limits

Dashboards are powered by Query so have [the same limits](/docs/observability/query#limits) as Query.

There is a separate concurrency limits for metric widgets.

| Limit                     | Details        |
| :------------------------ | :------------- |
| Concurrent widget queries | 30 per project |

See [Limits](/docs/limits) for details.


# Query
Source: https://trigger.dev/docs/observability/query

Query allows you to write custom queries against your data using TRQL (Trigger.dev Query Language), a SQL-style language based on ClickHouse SQL. You can query your data through the dashboard, SDK, or REST API.

### Available tables

* `runs`: contains all task run data including status, timing, costs, and task output. Run metadata (key-value set in your task) is not available on the Query page.
* `metrics`: contains metrics data for your runs including CPU, memory, and your custom metrics
* `llm_metrics`: contains LLM/AI metrics including token usage, costs, latency, and model performance data from GenAI spans

### `metrics` table columns

| Column             | Type     | Description                                         |
| :----------------- | :------- | :-------------------------------------------------- |
| `metric_name`      | string   | Metric identifier (e.g., `process.cpu.utilization`) |
| `metric_type`      | string   | `gauge`, `sum`, or `histogram`                      |
| `metric_value`     | number   | The observed value                                  |
| `bucket_start`     | datetime | 10-second aggregation bucket start time             |
| `run_id`           | string   | Associated run ID                                   |
| `task_identifier`  | string   | Task slug                                           |
| `attempt_number`   | number   | Attempt number                                      |
| `machine_id`       | string   | Machine that produced the metric                    |
| `machine_name`     | string   | Machine preset (e.g., `small-1x`)                   |
| `worker_version`   | string   | Worker version                                      |
| `environment_type` | string   | `PRODUCTION`, `STAGING`, `DEVELOPMENT`, `PREVIEW`   |
| `attributes`       | json     | Raw JSON attributes for custom data                 |

See [Logging, tracing & metrics](/docs/logging#automatic-system-and-runtime-metrics) for the full list of automatically collected metrics and how to create custom metrics. You can visualize this data on [Dashboards](/docs/observability/dashboards).

### `prettyFormat()`

Use `prettyFormat()` to format metric values for display:

```sql theme={"theme":"css-variables"}
SELECT
  timeBucket(),
  prettyFormat(avg(metric_value), 'bytes') AS avg_memory_usage
FROM metrics
WHERE metric_name = 'process.memory.usage'
GROUP BY timeBucket
ORDER BY timeBucket
LIMIT 1000
```

Available format types: `bytes`, `percent`, `duration`, `durationSeconds`, `quantity`, `costInDollars`.

## Using the Query dashboard

Navigate to the Query page to write and execute queries. The dashboard provides:

* **AI-powered query generation** - Describe what you want in natural language
* **Syntax highlighting** - SQL syntax highlighting for better readability
* **Query history** - Access your previous queries
* **Interactive help** - Built-in documentation for TRQL syntax and functions
* **Export options** - Download results as JSON or CSV

<img alt="The Query dashboard" />

## Querying from the SDK

Use `query.execute()` to run TRQL queries programmatically from your backend code:

```typescript theme={"theme":"css-variables"}
import { query } from "@trigger.dev/sdk";

// Basic query with defaults (environment scope, json format)
const result = await query.execute("SELECT run_id, status FROM runs LIMIT 10");
console.log(result.results); // Array<Record<string, any>>
```

### Type-safe queries

Use the `QueryTable` type for nice inferred types in your query results:

```typescript theme={"theme":"css-variables"}
import { query, type QueryTable } from "@trigger.dev/sdk";

// Type-safe query using QueryTable with specific columns
const typedResult = await query.execute<QueryTable<"runs", "run_id" | "status" | "triggered_at">>(
  "SELECT run_id, status, triggered_at FROM runs LIMIT 10"
);

typedResult.results.forEach((row) => {
  console.log(row.run_id, row.status); // Fully typed!
});
```

### Query options

```typescript theme={"theme":"css-variables"}
import { query } from "@trigger.dev/sdk";

const result = await query.execute("SELECT COUNT(*) as count FROM runs", {
  // Scope: "environment" (default), "project", or "organization"
  scope: "project",
  // Time period using shorthand (e.g., "7d", "30d", "1h")
  period: "7d",
  // Or use explicit time range
  // from: new Date("2024-01-01"),
  // to: new Date("2024-01-31"),

  // Response format: "json" (default) or "csv"
  format: "json",
});
```

### CSV export

Export query results as CSV by setting `format: "csv"`:

```typescript theme={"theme":"css-variables"}
const csvResult = await query.execute("SELECT run_id, status, triggered_at FROM runs", {
  format: "csv",
  period: "7d",
});

const lines = csvResult.results.split("\n");
console.log(lines[0]); // CSV header row
```

## Querying from the REST API

Execute queries via HTTP POST to `/api/v1/query`:

```sh theme={"theme":"css-variables"}
curl -X POST https://api.trigger.dev/api/v1/query \
  -H "Authorization: Bearer YOUR_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "SELECT run_id, status FROM runs LIMIT 10",
    "scope": "environment",
    "period": "7d",
    "format": "json"
  }'
```

See the [API reference](/docs/management/query/execute) for full details.

## TRQL syntax guide

### Basic queries

Select columns from a table:

```sql theme={"theme":"css-variables"}
SELECT run_id, task_identifier, status
FROM runs
LIMIT 10
```

Alias columns with `AS`:

```sql theme={"theme":"css-variables"}
SELECT task_identifier AS task, count() AS total
FROM runs
GROUP BY task
```

### Using \*

Note that when you use `SELECT *` we don't return all the columns, we only return the core columns. This is for performance reasons (the underlying ClickHouse database is columnar and selecting lots of columns isn't efficient).

You should specify the columns you want to return.

### Filtering with WHERE

Use comparison operators:

```sql theme={"theme":"css-variables"}
SELECT run_id, task_identifier FROM runs
WHERE status = 'Failed'
```

Available operators:

```sql theme={"theme":"css-variables"}
-- Comparison operators
WHERE status = 'Failed'           -- Equal
WHERE status != 'Completed'       -- Not equal
WHERE attempt_count > 3           -- Greater than
WHERE attempt_count >= 3          -- Greater than or equal
WHERE attempt_count < 5           -- Less than
WHERE attempt_count <= 5          -- Less than or equal

-- IN for multiple values
WHERE status IN ('Failed', 'Crashed')

-- LIKE for pattern matching (% = wildcard)
WHERE task_identifier LIKE 'email%'

-- ILIKE for case-insensitive matching
WHERE task_identifier ILIKE '%send%'

-- BETWEEN for ranges
WHERE triggered_at BETWEEN '2024-01-01' AND '2024-01-31'

-- NULL checks
WHERE completed_at IS NOT NULL
WHERE completed_at IS NULL

-- Array column checks
WHERE has(tags, 'user_12345')
WHERE notEmpty(tags)
WHERE hasAny(tags, array('user_12345', 'user_67890'))
WHERE hasAll(tags, array('user_12345', 'user_67890'))
WHERE indexOf(tags, 'user_12345') > 0
WHERE arrayElement(tags, 1) = 'user_12345'
```

### Sorting and limiting

Sort results with `ORDER BY`:

```sql theme={"theme":"css-variables"}
SELECT run_id, compute_cost, triggered_at
FROM runs
ORDER BY compute_cost DESC, triggered_at ASC
LIMIT 50
```

### Grouping and aggregation

Use `GROUP BY` with aggregate functions:

```sql theme={"theme":"css-variables"}
SELECT
  task_identifier,
  avg(metric_value) AS avg_memory
FROM metrics
WHERE metric_name = 'process.memory.usage'
GROUP BY task_identifier
ORDER BY avg_memory DESC
LIMIT 20
```

## Available functions

TRQL provides a rich set of functions for data analysis.

### Aggregate functions

* `count()` - Count rows
* `countIf(col, cond)` - Count rows matching condition
* `countDistinct(col)` - Count unique values
* `sum(col)` - Sum of values
* `sumIf(col, cond)` - Sum values matching condition
* `avg(col)` - Average of values
* `min(col)` - Minimum value
* `max(col)` - Maximum value
* `median(col)` - Median value (50th percentile)
* `quantile(p)(col)` - Value at percentile p (0-1)
* `stddevPop(col)` - Population standard deviation
* `stddevSamp(col)` - Sample standard deviation

Example:

```sql theme={"theme":"css-variables"}
SELECT
  task_identifier,
  count() AS total_runs,
  avg(usage_duration) AS avg_duration_ms,
  median(usage_duration) AS median_duration_ms,
  quantile(0.95)(usage_duration) AS p95_duration_ms
FROM runs
GROUP BY task_identifier
```

### Date/time functions

**Time bucketing:**

```sql theme={"theme":"css-variables"}
-- Auto-bucket by time period based on query's time range
SELECT timeBucket(), count() AS runs
FROM runs
GROUP BY timeBucket()
```

**Date extraction:**

```sql theme={"theme":"css-variables"}
SELECT
  toYear(triggered_at) AS year,
  toMonth(triggered_at) AS month,
  toDayOfWeek(triggered_at) AS day_of_week,
  toHour(triggered_at) AS hour
FROM runs
```

**Date truncation:**

```sql theme={"theme":"css-variables"}
SELECT
  toStartOfDay(triggered_at) AS day,
  count() AS runs_per_day
FROM runs
GROUP BY day
ORDER BY day DESC
```

**Date arithmetic:**

```sql theme={"theme":"css-variables"}
-- Add/subtract time
SELECT dateAdd('day', 7, triggered_at) AS week_later
FROM runs

-- Calculate differences
SELECT dateDiff('minute', executed_at, completed_at) AS duration_minutes
FROM runs
WHERE completed_at IS NOT NULL
```

Common date functions:

* `now()` - Current date and time
* `today()` - Current date
* `toDate(dt)` - Convert to date
* `toStartOfDay(dt)`, `toStartOfHour(dt)`, `toStartOfMonth(dt)` - Truncate to start of period
* `formatDateTime(dt, format)` - Format datetime as string

### String functions

```sql theme={"theme":"css-variables"}
SELECT
  lower(status) AS status_lower,
  upper(status) AS status_upper,
  concat(task_identifier, '-', status) AS combined,
  substring(run_id, 1, 8) AS short_id,
  length(task_identifier) AS name_length
FROM runs
```

Common string functions:

* `length(s)` - String length
* `lower(s)`, `upper(s)` - Case conversion
* `concat(s1, s2, ...)` - Concatenate strings
* `substring(s, offset, len)` - Extract substring
* `trim(s)` - Remove whitespace
* `replace(s, from, to)` - Replace occurrences
* `startsWith(s, prefix)`, `endsWith(s, suffix)` - Check prefixes/suffixes

### Conditional functions

```sql theme={"theme":"css-variables"}
SELECT
  run_id,
  if(status = 'Failed', 1, 0) AS is_failed,
  multiIf(
    status = 'Completed', 'ok',
    status = 'Failed', 'bad',
    'other'
  ) AS status_category,
  coalesce(completed_at, triggered_at) AS end_time
FROM runs
```

* `if(cond, then, else)` - Conditional expression
* `multiIf(c1, t1, c2, t2, ..., else)` - Multiple conditions (like CASE)
* `coalesce(a, b, ...)` - First non-null value

### Math functions

```sql theme={"theme":"css-variables"}
SELECT
  round(compute_cost, 4) AS cost_rounded,
  ceil(usage_duration / 1000) AS duration_seconds_up,
  floor(usage_duration / 1000) AS duration_seconds_down,
  abs(compute_cost) AS cost_abs
FROM runs
```

### Array functions

Useful for working with tags and other array columns:

```sql theme={"theme":"css-variables"}
SELECT
  run_id,
  tags,
  length(tags) AS tag_count,
  has(tags, 'user_12345') AS is_production,
  arrayJoin(tags) AS individual_tag  -- Expand array to rows
FROM runs
WHERE notEmpty(tags)
```

### JSON functions

The `output`, `error`, and `metrics.attributes` columns are already JSON, so use dot notation to read or filter on them. You don't need `JSONExtract*` for these (those are for string columns).

```sql theme={"theme":"css-variables"}
SELECT
  run_id,
  output.message AS output_message,
  output.count AS count,
  output.externalId AS external_id
FROM runs
WHERE task_identifier = 'my-task'
  AND output.externalId = 'something'
ORDER BY triggered_at DESC
LIMIT 100
```

## Query scopes

Control what data your query can access:

* **`environment`** (default) - Query runs in the current environment only
* **`project`** - Query runs across all environments in the project
* **`organization`** - Query runs across all projects in the organization

```typescript theme={"theme":"css-variables"}
// Query across all environments in a project
const result = await query.execute("SELECT environment, count() FROM runs GROUP BY environment", {
  scope: "project",
});
```

## Time ranges

We recommend avoiding adding `triggered_at` in the actual TRQL query. The dashboard, API, and SDK have a time filter that is applied automatically and is easier to work with. It means the queries can be executed with multiple periods easily.

### Using period shorthand

```typescript theme={"theme":"css-variables"}
await query.execute("SELECT count() FROM runs", {
  period: "4d", // Last 4 days
});

// Supported periods: "1h", "6h", "12h", "1d", "7d", "30d", "90d", etc.
```

### Using explicit dates

```typescript theme={"theme":"css-variables"}
await query.execute("SELECT count() FROM runs", {
  from: new Date("2024-01-01"),
  to: new Date("2024-01-31"),
});

// Or use Unix timestamps
await query.execute("SELECT count() FROM runs", {
  from: Date.now() - 7 * 24 * 60 * 60 * 1000, // 7 days ago
  to: Date.now(),
});
```

## Example queries

### Failed runs (in the last 24 hours)

```sql theme={"theme":"css-variables"}
SELECT
  task_identifier,
  run_id,
  error,
  triggered_at
FROM runs
WHERE status = 'Failed'
ORDER BY triggered_at DESC
```

With the time filter set to 24h.

### Task success rate by day

```sql theme={"theme":"css-variables"}
SELECT
  toDate(triggered_at) AS day,
  task_identifier,
  countIf(status = 'Completed') AS completed,
  countIf(status = 'Failed') AS failed,
  round(completed / (completed + failed) * 100, 2) AS success_rate_pct
FROM runs
WHERE status IN ('Completed', 'Failed')
GROUP BY day, task_identifier
ORDER BY day DESC, task_identifier
```

### Top 10 most expensive runs

```sql theme={"theme":"css-variables"}
SELECT
  run_id,
  task_identifier,
  compute_cost,
  usage_duration,
  triggered_at
FROM runs
WHERE compute_cost > 0
ORDER BY compute_cost DESC
LIMIT 10
```

### Average compute duration over time

```sql theme={"theme":"css-variables"}
SELECT
  timeBucket() AS time,
  task_identifier,
  avg(usage_duration) AS avg_duration_ms,
  count() AS run_count
FROM runs
WHERE usage_duration IS NOT NULL
GROUP BY time, task_identifier
ORDER BY time ASC
```

### Runs by queue and machine

```sql theme={"theme":"css-variables"}
SELECT
  queue,
  machine,
  count() AS run_count,
  countIf(status = 'Completed') AS completed,
  countIf(status = 'Failed') AS failed
FROM runs
GROUP BY queue, machine
ORDER BY queue, machine
```

### CPU utilization over time

Track process CPU utilization bucketed over time.

```sql theme={"theme":"css-variables"}
SELECT
  timeBucket(),
  avg(metric_value) AS avg_cpu
FROM metrics
WHERE metric_name = 'process.cpu.utilization'
GROUP BY timeBucket
ORDER BY timeBucket
LIMIT 1000
```

### Memory usage by task (past 7d)

Average process memory usage per task identifier over the last 7 days.

```sql theme={"theme":"css-variables"}
SELECT
  task_identifier,
  avg(metric_value) AS avg_memory
FROM metrics
WHERE metric_name = 'process.memory.usage'
GROUP BY task_identifier
ORDER BY avg_memory DESC
LIMIT 20
```

### Available metric names

List all distinct metric names collected in your environment.

```sql theme={"theme":"css-variables"}
SELECT
  metric_name,
  count() AS sample_count
FROM metrics
GROUP BY metric_name
ORDER BY sample_count DESC
LIMIT 100
```

## Best practices

1. **Use the built-in time filtering** - The dashboard, API, and SDK have a time filter that is applied automatically and is easier to work with. It means the queries can be executed with multiple periods easily.
2. **Use LIMIT** - Add a `LIMIT` clause to reduce the rows returned if you don't need everything.
3. **Use appropriate aggregations** - For large datasets, use `uniq()` instead of `uniqExact()` for approximate but faster counts

## Limits

We have several limits to prevent abuse and ensure performance:

* **Concurrency limit**: We limit the number of concurrent queries per organization.
* **Row limit**: We limit the number of rows returned to 10k.
* **Time restrictions**: We limit the time period you can query.
* **Time/Memory limit**: We limit the memory a query can use and the time it can run for. As well as other limits like AST complexity.

See [Limits](/docs/limits) for current quota details.


# Contributing
Source: https://trigger.dev/docs/open-source-contributing

You can contribute to Trigger.dev in many ways.

Go to our [GitHub repository](https://github.com/triggerdotdev/trigger.dev) and open an issue or a pull request. We are always looking for contributors to help us improve Trigger.dev. You can contribute in many ways, including:

* Reporting bugs
* Suggesting new features
* Writing documentation
* Writing code
* Reviewing code
* Translating the app
* Sharing the app with others
* Giving feedback
* And more!


# Setting up PrivateLink in the AWS Console
Source: https://trigger.dev/docs/private-networking/aws-console-setup

Step-by-step guide for exposing a resource from your AWS account to Trigger.dev via PrivateLink.

This guide walks through setting up the AWS side of a private connection: a Network Load Balancer (NLB), a target group pointing at the resource you want to expose, and a VPC Endpoint Service that authorizes Trigger.dev to consume it.

<Info>
  Prefer Terraform? Open the "Add connection" page in the Trigger.dev dashboard and use the
  Terraform wizard to generate a ready-to-apply script. The wizard creates everything described
  below and pre-fills our AWS account ID for you.
</Info>

## Prerequisites

Before you start you'll need:

* An **AWS account** with permission to create VPC, EC2, and ELB resources
* A **resource** in a VPC subnet that you want to expose (RDS instance, ElastiCache cluster, internal API, etc.)
* The **Trigger.dev AWS account ID** — find this on the "Add connection" page in your Trigger.dev dashboard, in the "I have my details" or "Step-by-step guide" cards
* A **VPC** that contains the resource, with at least one private subnet per Availability Zone you want to serve from

<Note>
  PrivateLink connections are zonal. If your resource lives in a single AZ, your connection will
  only be available from that AZ. For higher availability, ensure target groups can route to
  multiple AZs.
</Note>

## Step 1: Create a target group pointing at your resource

The target group is how the NLB will know where to forward traffic. AWS requires a target group when creating a load balancer, so we'll set this up first.

<Steps>
  <Step title="Open the target groups page">
    Go to **EC2 → Target Groups → Create target group**.
  </Step>

  <Step title="Choose a target type">
    * **IP addresses** for RDS, ElastiCache, or any resource you can reach by IP
    * **Instances** for EC2 instances you own
    * **Application Load Balancer** if your resource sits behind an ALB

    For most database use cases, **IP addresses** is correct. NLBs don't support Lambda targets
    directly — if you need to expose a Lambda, put it behind an ALB and use the ALB target type.
  </Step>

  <Step title="Configure the target group (first step of the AWS form)">
    On the **Specify group details** page (the first of two steps in AWS's target-group form), set:

    * **Name**: e.g. `trigger-postgres-tg`
    * **Protocol**: TCP
    * **Port**: the port your resource listens on (5432 for Postgres, 6379 for Redis, 3306 for MySQL, etc.)
    * **VPC**: the VPC where your resource lives (this must match the VPC you'll use for the NLB)
    * **Health check protocol**: TCP

    Click **Next** to move to the second step (registering targets).

    <img alt="Target group first step — basic configuration" />
  </Step>

  <Step title="Register your targets (second step of the AWS form)">
    On the **Register targets** page — the second step of the IP target-group flow — paste the
    private IPs of your resource and set the port to the same value you picked above. Click
    **Include as pending below**, then **Create target group**.

    <img alt="Register targets in the target group" />

    <Expandable title="How to find the IP for an ElastiCache or RDS instance (no bastion needed)">
      Both ElastiCache and RDS expose a DNS endpoint, not an IP, on their console pages. Find the
      private IP behind the endpoint via the EC2 console:

      1. Open **EC2 → Network & Security → Network Interfaces**.
      2. In the search bar, filter by **Description** with `ElastiCache` (or `RDSNetworkInterface`
         for RDS). Optionally narrow further by **VPC ID** if you have several clusters.
      3. Read the **Primary private IPv4 address** column — that's the IP to register here. For
         multi-node clusters or read replicas, each node has its own ENI and IP.

      You can also reach the same list from **VPC → Subnets → \<your-subnet> → Network
      Interfaces tab**, which scopes the list to a single subnet.
    </Expandable>

    <Warning>
      RDS and ElastiCache endpoints' IP addresses can change after failover or maintenance. For long-lived
      connections, consider running a small Lambda or sidecar that periodically resolves the DNS name and
      updates the target group, or use a [DNS-resolved](https://aws.amazon.com/blogs/networking-and-content-delivery/hostname-as-target-for-network-load-balancers/)
      target if your setup supports it.
    </Warning>
  </Step>
</Steps>

## Step 2: Create an internal Network Load Balancer

The NLB is what PrivateLink exposes to Trigger.dev. It must be **internal** (not internet-facing).

<Steps>
  <Step title="Open the EC2 console">
    Go to **EC2 → Load Balancers → Create load balancer** and choose **Network Load Balancer**.
  </Step>

  <Step title="Configure the basics">
    * **Name**: something descriptive, e.g. `trigger-postgres-nlb`
    * **Scheme**: **Internal**
    * **IP address type**: IPv4

      <img alt="Network Load Balancer basic configuration" />
  </Step>

  <Step title="Choose VPC and subnets">
    Pick the same VPC as your target group. Select one private subnet per AZ that should serve traffic.
    Each subnet you select adds an availability zone to the endpoint.

    <img alt="Network Load Balancer VPC and Availability Zones" />
  </Step>

  <Step title="Add a TCP listener forwarding to your target group">
    Under **Listeners and routing**, configure:

    * **Protocol**: TCP
    * **Port**: same as your target group port (5432 for Postgres, 6379 for Redis, etc.)
    * **Default action**: forward to the target group you created in Step 1

      <img alt="Add the target group to the NLB listener" />
  </Step>

  <Step title="Create the load balancer and wait until it's Active">
    Click **Create load balancer**. Provisioning takes 1–2 minutes — wait until the NLB's **State**
    column shows **Active** before moving on. The endpoint service in the next step won't list the
    NLB until it's fully active.
  </Step>

  <Step title="Disable PrivateLink inbound rules enforcement on the NLB">
    Once the NLB is **Active**, open it and go to its **Security** tab, then click **Edit**. If a
    security group is attached, AWS enables **Enforce inbound rules on PrivateLink traffic** by
    default — leaving it on can cause traffic from the Trigger.dev VPC Endpoint to be silently
    dropped before reaching your listener. Uncheck **Enforce inbound rules on PrivateLink traffic**
    and save.

    <img alt="Uncheck Enforce inbound rules on PrivateLink traffic on the NLB" />
  </Step>
</Steps>

<Tip>
  Test connectivity from a bastion host or another instance in the same VPC before continuing —
  e.g. `psql -h <nlb-dns-name> -p 5432 -U user -d db`. If the NLB can't reach your resource, the
  PrivateLink connection won't either.
</Tip>

## Step 3: Create a VPC Endpoint Service

This is the resource that PrivateLink consumers connect to.

<Note>
  Confirm the NLB from Step 2 is in the **Active** state before starting this step. It won't appear
  in the **Available load balancers** dropdown until it has finished provisioning.
</Note>

<Steps>
  <Step title="Open the VPC console">
    Go to **VPC → Endpoint services → Create endpoint service**.
  </Step>

  <Step title="Configure the endpoint service">
    * **Name**: optional, but useful for identification, e.g. `trigger-postgres-endpoint`
    * **Load balancer type**: Network
    * **Available load balancers**: select the NLB you created
    * **Require acceptance for endpoint**: **No** (recommended)

      <img alt="Create VPC Endpoint Service form" />

    <Note>
      If you set "Require acceptance" to **Yes**, every connection request from Trigger.dev will
      sit in a pending state until you manually approve it. Setting it to **No** lets connections
      come up automatically once the principal is allow-listed.
    </Note>
  </Step>

  <Step title="Skip private DNS">
    Leave the "Private DNS name" option disabled. Trigger.dev tasks dial the endpoint by its
    private IP, so private DNS isn't needed.
  </Step>

  <Step title="Configure cross-region access (optional)">
    If your Trigger.dev tasks run in a **different AWS region** from your endpoint service, expand
    the **Supported Regions** section and add the region(s) where Trigger.dev should be allowed to
    create the VPC Endpoint from (for example, add `eu-central-1` if your service is in
    `us-east-1` but tasks run in `eu-central-1`).

    If your tasks and resource are in the same region, you can skip this — same-region access is
    enabled by default.

    <Note>
      Cross-region PrivateLink adds AWS data-transfer cost and \~10–30ms of latency depending on the
      region pair. Prefer same-region when possible.
    </Note>
  </Step>

  <Step title="Create the endpoint service">
    Click **Create**. The service is created immediately — you'll come back to copy its **Service
    name** once you've authorized Trigger.dev in the next step.
  </Step>
</Steps>

## Step 4: Authorize the Trigger.dev AWS account

By default, no one can connect to your endpoint service. You need to explicitly allow Trigger.dev's AWS account.

<Steps>
  <Step title="Open your endpoint service">
    Go to **VPC → Endpoint services**, select the service you just created.
  </Step>

  <Step title="Open the Allow principals tab">
    Click the **Allow principals** tab, then **Allow principals**.
  </Step>

  <Step title="Add Trigger.dev's account">
    Paste the principal ARN in this format, replacing `<account-id>` with the Trigger.dev AWS
    account ID shown in your dashboard:

    ```text theme={"theme":"css-variables"}
    arn:aws:iam::<account-id>:root
    ```

    <img alt="Allow principal dialog" />

    <Warning>
      You will find the correct AWS account ID in the **Add connection** page of the Trigger.dev
      dashboard. Do not assume an account ID — it differs between Trigger.dev environments.
    </Warning>
  </Step>

  <Step title="Click Allow principals">
    The principal is now authorized to create a VPC Endpoint targeting your service.
  </Step>

  <Step title="Copy the endpoint service name">
    On the endpoint service detail page, copy the **Service name** value — it looks like
    `com.amazonaws.vpce.us-east-1.vpce-svc-0123abcd...`. You'll paste this into the Trigger.dev
    dashboard in the next step.

    <img alt="Copy the endpoint service name" />
  </Step>
</Steps>

## Step 5: Add the connection in Trigger.dev

<Steps>
  <Step title="Open the dashboard">
    In Trigger.dev, go to **Organization Settings → Private Connections** and click **Add
    connection**.
  </Step>

  <Step title="Pick the I have my details card">
    Then fill in:

    * **Friendly name**: a short, human-readable label for this connection.
    * **VPC Endpoint Service name**: paste the `com.amazonaws.vpce.<region>.vpce-svc-...` value from Step 4.
    * **Target region**: the AWS region your endpoint service lives in.
  </Step>

  <Step title="Submit">
    Submit the form. The connection's status moves through **Pending → Provisioning → Active**.
    Provisioning typically takes 30–90 seconds.
  </Step>

  <Step title="Verify">
    Once **Active**, the dashboard shows the assigned private IP. Plug it into the
    connection-string environment variable your task already uses (for example, `DATABASE_URL` set
    on the **Environment Variables** page) and your tasks will reach the resource over
    PrivateLink.
  </Step>
</Steps>

## Troubleshooting

See the dedicated [Troubleshooting](/docs/private-networking/troubleshooting) page for common problems
such as the "Private link not found" wizard error. A few quick checks specific to this setup flow:

<Expandable title="Status stays at Pending or Provisioning for several minutes">
  * Confirm Trigger.dev's AWS account ID is in your endpoint service's **Allow principals** list.
  * Confirm the endpoint service is **Available** in the AWS console.
  * Confirm "Require acceptance" is set to **No** on the endpoint service. If it's set to **Yes**,
    the request is sitting in your pending queue and you must approve it manually.
</Expandable>

<Expandable title="Status is Active but my task can't connect">
  * Confirm the NLB has a target registered and the target's health check is passing.
  * Confirm the listener port matches the port your task code is dialing.
  * Confirm the security group on your resource allows inbound traffic from the NLB or the VPC's
    private IP range.
  * If the NLB itself has a security group attached, turn off **Enforce inbound rules on
    PrivateLink traffic** on the load balancer. See [the troubleshooting
    page](/docs/private-networking/troubleshooting#connection-is-active-but-the-assigned-ip-is-not-reachable-from-tasks)
    for details.
  * Try connecting from inside the VPC first (e.g., a bastion host) to rule out resource-side
    issues.
</Expandable>

<Expandable title="Connection works but is slow">
  * Cross-region connections add \~10–30ms RTT depending on the regions involved. If your tasks run
    in a different region than your resource, expect higher latency.
  * The NLB and target group's health checks influence connection setup time. Tighter health check
    intervals reduce failover time after a backend goes unhealthy.
</Expandable>

<Expandable title="I want to remove a connection">
  Delete the connection from **Organization Settings → Private Connections** in the Trigger.dev
  dashboard. We'll tear down our VPC Endpoint and remove the network policy automatically. You can
  then delete your VPC Endpoint Service, NLB, and target group on the AWS side.
</Expandable>


# Private networking
Source: https://trigger.dev/docs/private-networking/overview

Connect your tasks to private resources in your AWS account using AWS PrivateLink.

Private networking lets your Trigger.dev tasks reach databases, caches, and internal APIs that live inside your own AWS VPC, without exposing them to the public internet. Connectivity is established over [AWS PrivateLink](https://docs.aws.amazon.com/vpc/latest/privatelink/what-is-privatelink.html), so traffic stays on the AWS backbone.

<Info>
  Private networking is a Pro and Enterprise feature. If you'd like access, [get in touch](/docs/community).
</Info>

## What is AWS PrivateLink

AWS PrivateLink is a managed service that creates a private, one-way connection between two AWS accounts without using the public internet, NAT gateways, internet gateways, or VPN tunnels.

It works by pairing two resources:

* A **VPC Endpoint Service** in the account that owns the resource (yours). This is fronted by a Network Load Balancer (NLB) and exposes whatever ports you choose.
* A **VPC Endpoint** in the account that wants to consume the resource (Trigger.dev's). The endpoint is an Elastic Network Interface (ENI) inside our VPC with a private IP that your task pods can dial directly.

The connection is unidirectional: only the endpoint side can initiate connections. Your VPC cannot reach into ours.

## What you can use it for

Any TCP service running inside your VPC. Common use cases include:

* **Databases**: PostgreSQL (RDS, Aurora), MySQL, MongoDB, ClickHouse, Redshift
* **Caches**: ElastiCache Redis, Memcached
* **Internal APIs**: services on EKS, ECS, EC2, or Lambda exposed through an internal NLB
* **Message brokers**: self-hosted Kafka, RabbitMQ, NATS
* **Vector databases and ML services** running in private subnets

If your resource is reachable from a Network Load Balancer in the same VPC, it can be exposed to Trigger.dev via PrivateLink.

## How it works with Trigger.dev

When you add a private connection in the dashboard, the following happens:

<Steps>
  <Step title="You expose your resource">
    You create an internal NLB in front of your resource and a VPC Endpoint Service that points to it. You add Trigger.dev's AWS account as an allowed principal so we're permitted to connect.
  </Step>

  <Step title="We provision a VPC Endpoint">
    Once you submit the endpoint service name in the Trigger.dev dashboard, we provision a VPC Endpoint in our AWS account in the region you chose. The endpoint creates an ENI with a private IP that we wire up to reach your service.
  </Step>

  <Step title="Your tasks can reach the endpoint">
    Once the connection is **Active**, the dashboard shows the assigned IP. Pods running your tasks are network-authorized to connect to it.
  </Step>
</Steps>

### Connecting from your task code

When the connection becomes **Active**, the dashboard shows the assigned endpoint IP. Plug it into the connection-string environment variable your task already reads (for example, `DATABASE_URL` set on the **Environment Variables** page):

<Note>
  A private connection is scoped to your **organization**, not to a single environment. The same
  assigned IP works from any deployed environment your tasks run in — Preview branches, Staging,
  and Production — so you can set the connection-string env var per environment and point them
  all at the same private resource. (Local **Development** runs on your own machine, so it can't
  reach the endpoint IP — use a regular public connection there.)
</Note>

```typescript theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { Client } from "pg";

export const queryDatabase = task({
  id: "query-database",
  run: async () => {
    // DATABASE_URL is set in the Trigger.dev dashboard to the connection's
    // assigned IP shown in Private Connections.
    const client = new Client({
      connectionString: process.env.DATABASE_URL,
    });

    await client.connect();
    const result = await client.query("SELECT NOW()");
    await client.end();

    return result.rows;
  },
});
```

## Isolation between organizations

Private networking is set up so that each organization's connections are completely isolated from every other organization. Three layers enforce that:

### 1. Dedicated AWS account

Customer VPC Endpoints are provisioned in a **dedicated AWS account** that is separate from the account that runs Trigger.dev's task workers. The dedicated account does nothing else — it only hosts customer endpoints. This limits the blast radius of any misconfiguration: even a misbehaving endpoint cannot reach worker infrastructure beyond the routes we explicitly define.

### 2. Per-organization network policy

Inside the Kubernetes cluster that runs your tasks, the default network policy denies all traffic to private IP ranges. When your organization creates a connection, we generate a [CiliumNetworkPolicy](https://docs.cilium.io/en/stable/network/kubernetes/policy/) that:

* Targets **only pods labeled with your organization's ID**
* Allows egress **only to the specific endpoint IPs we provisioned for you**

A pod from another organization has neither the matching label nor a matching policy — its connection attempts to your endpoint IPs are dropped at the network layer before they ever reach an ENI.

### 3. AWS-level authorization

PrivateLink itself enforces a second layer of authorization. Your VPC Endpoint Service has an explicit list of `allowed_principals` — only AWS accounts you list can even establish a connection. Trigger.dev provides each org with the same Trigger.dev AWS account ID, but the AWS account ID alone is useless without the matching CiliumNetworkPolicy on our side. To reach your endpoint, traffic must:

1. Originate from a pod labeled with your org ID (enforced by us)
2. Match an egress rule with your endpoint's IPs (enforced by us)
3. Hit a VPC Endpoint Service that has authorized our account (enforced by you)

All three conditions must be true. No organization can route traffic to another organization's resources.

<Warning>
  AWS account IDs are not secrets, but the VPC Endpoint Service name is also not enough on its own —
  you must explicitly add Trigger.dev's account to your endpoint service's allowed principals before
  any connection works. We'll never see your service unless you authorize us.
</Warning>

## Limits

* **Two connections per organization.** This is a soft limit — get in touch if you need more.
* **All ports accepted.** Our security group allows all TCP ports from peered CIDRs. You control which ports are reachable through your NLB and target groups.
* **Same-region or cross-region.** Connections work within the same region as your tasks or across regions (cross-region adds AWS-level cost and \~10-30ms latency).

## Next steps

<CardGroup>
  <Card title="Create a Private Link in the AWS Console" icon="aws" href="/private-networking/aws-console-setup">
    Step-by-step instructions for creating the NLB, target group, and VPC Endpoint Service in your
    AWS account.
  </Card>

  <Card title="Troubleshooting" icon="circle-question" href="/private-networking/troubleshooting">
    Common problems when setting up a private connection and how to resolve them.
  </Card>
</CardGroup>


# Troubleshooting private networking
Source: https://trigger.dev/docs/private-networking/troubleshooting

Common problems when setting up an AWS PrivateLink connection to Trigger.dev, and how to resolve them.

This page collects common issues when adding a private connection. If your problem isn't listed here, [get in touch](/docs/community).

## "Private link not found" in the setup wizard

If the setup wizard errors out with **Private link not found** when you submit the VPC Endpoint Service name, it almost always means your endpoint service has not been shared with Trigger.dev's AWS account.

Trigger.dev cannot provision a VPC Endpoint until your endpoint service explicitly authorizes our AWS account as a consumer. Until that happens, the service name is invisible to us — even though the name itself is correct.

### How to fix it

<Steps>
  <Step title="Open your endpoint service in the AWS console">
    Go to **VPC → Endpoint services** in the AWS region where you created the service, and select
    your service.
  </Step>

  <Step title="Open the Allow principals tab">
    Click the **Allow principals** tab and check whether Trigger.dev's AWS account is listed.
  </Step>

  <Step title="Add Trigger.dev's account if it's missing">
    Click **Allow principals** and add an entry in this format, replacing `<account-id>` with the
    Trigger.dev AWS account ID shown on the **Add connection** page in your dashboard:

    ```text theme={"theme":"css-variables"}
    arn:aws:iam::<account-id>:root
    ```

    <Warning>
      Always copy the account ID from your Trigger.dev dashboard. The correct value differs between
      environments — don't reuse an ID from another source.
    </Warning>
  </Step>

  <Step title="Retry in the Trigger.dev dashboard">
    Once the principal is allow-listed, return to the **Add connection** page in Trigger.dev and
    submit the form again. The wizard should now find your endpoint service and start provisioning.
  </Step>
</Steps>

For full setup instructions including this step, see [Setting up PrivateLink in the AWS Console](/docs/private-networking/aws-console-setup).

## Connection is Active but the assigned IP is not reachable from tasks

If your private connection shows **Active** in the Trigger.dev dashboard and the NLB target group reports healthy targets, but tasks still cannot reach the assigned IP, the most common cause is that your Network Load Balancer is enforcing security group rules on PrivateLink traffic.

When a security group is attached to an NLB, AWS exposes a separate setting called **Enforce inbound rules on PrivateLink traffic**. When this is **on**, the NLB applies its security group's inbound rules to traffic arriving from VPC endpoints — and the source IP it evaluates is the **private IP of the consumer's VPC endpoint network interface**, not an IP in your own VPC. Because that IP belongs to Trigger.dev's VPC and isn't known ahead of time, the SG rule almost never matches, and traffic is silently dropped at the NLB.

### How to fix it

<Steps>
  <Step title="Open your Network Load Balancer in the AWS console">
    Go to **EC2 → Load balancers** in the region where your NLB lives and select the load balancer
    backing your endpoint service.
  </Step>

  <Step title="Edit the security group settings">
    On the **Security** tab, click **Edit**.
  </Step>

  <Step title="Turn off PrivateLink enforcement">
    Uncheck **Enforce inbound rules on PrivateLink traffic** and save.

    <Note>
      This only changes how the NLB itself filters traffic. Authorization is still enforced by the
      endpoint service's **Allow principals** list, so only AWS accounts you've explicitly
      allow-listed can connect.
    </Note>
  </Step>

  <Step title="Retry from your task">
    Re-run a task that dials the assigned private IP. The connection should now succeed.
  </Step>
</Steps>

If you need to keep the enforcement on for compliance reasons, the alternative is to widen your NLB's security group inbound rule to `0.0.0.0/0` on the listener port. Allow-listing the consumer endpoint's CIDR is not practical because it lives in Trigger.dev's VPC and may change.


# Concurrency & Queues
Source: https://trigger.dev/docs/queue-concurrency

Configure what you want to happen when there is more than one run at a time.

When you trigger a task, it isn't executed immediately. Instead, the task [run](/docs/runs) is placed into a queue for execution.

By default, each task gets its own queue and the concurrency is only limited by your environment concurrency limit. If you need more control (for example, to limit concurrency or share limits across multiple tasks), you can define a custom queue as described later.

Controlling concurrency is useful when you have a task that can't be run concurrently, or when you want to limit the number of runs to avoid overloading a resource.

It's important to note that only actively executing runs count towards concurrency limits. Runs that are delayed or waiting in a queue do not consume concurrency slots until they begin execution.

## Default concurrency

By default, all tasks have an unbounded concurrency limit, limited only by the overall concurrency limits of your environment.

<Note>
  Your environment has a base concurrency limit and a burstable limit (default burst factor of 2.0x
  the base limit). Individual queues are limited by the base concurrency limit, not the burstable
  limit. For example, if your base limit is 10, your environment can burst up to 20 concurrent runs,
  but any single queue can have at most 10 concurrent runs. If you're a paying customer you can
  request higher burst limits by [contacting us](https://www.trigger.dev/contact).
</Note>

## Setting task concurrency

You can set the concurrency limit for a task by setting the `concurrencyLimit` property on the task's queue. This limits the number of runs that can be executing at any one time:

```ts /trigger/one-at-a-time.ts theme={"theme":"css-variables"}
// This task will only run one at a time
export const oneAtATime = task({
  id: "one-at-a-time",
  queue: {
    concurrencyLimit: 1,
  },
  run: async (payload) => {
    //...
  },
});
```

This is useful if you need to control access to a shared resource, like a database or an API that has rate limits.

## Sharing concurrency between tasks

As well as putting queue settings directly on a task, you can define a queue and reuse it across multiple tasks. This allows you to share the same concurrency limit:

```ts /trigger/queue.ts theme={"theme":"css-variables"}
export const myQueue = queue({
  name: "my-queue",
  concurrencyLimit: 1,
});

export const task1 = task({
  id: "task-1",
  queue: myQueue,
  run: async (payload: { message: string }) => {
    // ...
  },
});

export const task2 = task({
  id: "task-2",
  queue: myQueue,
  run: async (payload: { message: string }) => {
    // ...
  },
});
```

In this example, `task1` and `task2` share the same queue, so only one of them can run at a time.

## Setting the queue when you trigger a run

When you trigger a task you can override the default queue. This is really useful if you sometimes have high priority runs.

The task and queue definition:

```ts /trigger/override-concurrency.ts theme={"theme":"css-variables"}
const paidQueue = queue({
  name: "paid-users",
  concurrencyLimit: 10,
});

export const generatePullRequest = task({
  id: "generate-pull-request",
  queue: {
    //normally when triggering this task it will be limited to 1 run at a time
    concurrencyLimit: 1,
  },
  run: async (payload) => {
    //todo generate a PR using OpenAI
  },
});
```

Triggering from your backend and overriding the queue:

```ts app/api/push/route.ts theme={"theme":"css-variables"}
import { generatePullRequest } from "~/trigger/override-concurrency";

export async function POST(request: Request) {
  const data = await request.json();

  if (data.branch === "main") {
    //trigger the task, with the paid users queue
    const handle = await generatePullRequest.trigger(data, {
      // Set the paid users queue
      queue: "paid-users",
    });

    return Response.json(handle);
  } else {
    //triggered with the default queue (concurrency of 1)
    const handle = await generatePullRequest.trigger(data);
    return Response.json(handle);
  }
}
```

## Concurrency keys and per-tenant queuing

If you're building an application where you want to run tasks for your users, you might want a separate queue for each of your users (or orgs, projects, etc.).

You can do this by using `concurrencyKey`. It creates a copy of the queue for each unique value of the key.

Your backend code:

```ts app/api/pr/route.ts theme={"theme":"css-variables"}
import { generatePullRequest } from "~/trigger/override-concurrency";

export async function POST(request: Request) {
  const data = await request.json();

  if (data.isFreeUser) {
    //the "free-users" queue has a concurrency limit of 1
    const handle = await generatePullRequest.trigger(data, {
      queue: "free-users",
      //this creates a free-users queue for each user
      concurrencyKey: data.userId,
    });

    //return a success response with the handle
    return Response.json(handle);
  } else {
    //the "paid-users" queue has a concurrency limit of 10
    const handle = await generatePullRequest.trigger(data, {
      queue: "paid-users",
      //this creates a paid-users queue for each user
      concurrencyKey: data.userId,
    });

    //return a success response with the handle
    return Response.json(handle);
  }
}
```

## Concurrency and subtasks

When you trigger a task that has subtasks, the subtasks will not inherit the queue from the parent task. Unless otherwise specified, subtasks will run on their own queue

```ts /trigger/subtasks.ts theme={"theme":"css-variables"}
export const parentTask = task({
  id: "parent-task",
  run: async (payload) => {
    //trigger a subtask
    await subtask.triggerAndWait(payload);
  },
});

// This subtask will run on its own queue
export const subtask = task({
  id: "subtask",
  run: async (payload) => {
    //...
  },
});
```

## Waits and concurrency

With our [task checkpoint system](/docs/how-it-works#the-checkpoint-resume-system), tasks can wait at various waitpoints (like waiting for subtasks to complete, delays, or external events). The way this system interacts with the concurrency system is important to understand.

Concurrency is only released when a run reaches a waitpoint and is checkpointed. When a run is checkpointed, it transitions to the `WAITING` state and releases its concurrency slot back to both the queue and the environment, allowing other runs to execute or resume.

This means that:

* Only actively executing runs count towards concurrency limits
* Runs in the `WAITING` state (checkpointed at waitpoints) do not consume concurrency slots
* You can have more runs in the `WAITING` state than your queue's concurrency limit
* When a waiting run resumes (e.g., when a subtask completes), it must re-acquire a concurrency slot

For example, if you have a queue with a `concurrencyLimit` of 1:

* You can only have exactly 1 run executing at a time
* You may have multiple runs in the `WAITING` state that belong to that queue
* When the executing run reaches a waitpoint and checkpoints, it releases its slot
* The next queued run can then begin execution

### Short time-based waits keep their slot

Checkpointing takes time, so a run doesn't checkpoint the moment it reaches a waitpoint. For [`wait.for()`](/docs/wait-for) and [`wait.until()`](/docs/wait-until) it happens 60 seconds into the wait, so anything shorter stays `EXECUTING` and holds its slot for the whole wait. If you're polling in a loop, use an interval comfortably above 60 seconds so the slot is actually released between polls.

### Waiting for a subtask on a different queue

When a parent task triggers and waits for a subtask on a different queue, the parent task will checkpoint and release its concurrency slot once it reaches the wait point. This prevents environment deadlocks where all concurrency slots would be occupied by waiting tasks.

```ts /trigger/waiting.ts theme={"theme":"css-variables"}
export const parentTask = task({
  id: "parent-task",
  queue: {
    concurrencyLimit: 1,
  },
  run: async (payload) => {
    //trigger a subtask and wait for it to complete
    await subtask.triggerAndWait(payload);
    // The parent task checkpoints here and releases its concurrency slot
    // allowing other tasks to execute while waiting
  },
});

export const subtask = task({
  id: "subtask",
  run: async (payload) => {
    //...
  },
});
```

When the parent task reaches the `triggerAndWait` call, it checkpoints and transitions to the `WAITING` state, releasing its concurrency slot back to both its queue and the environment. Once the subtask completes, the parent task will resume and re-acquire a concurrency slot.

## Managing queues with the SDK

The SDK provides a `queues` namespace that allows you to manage queues programmatically. You can list, retrieve, pause, resume, and modify concurrency limits for queues.

<Note>
  Import from `@trigger.dev/sdk`:

  ```ts theme={"theme":"css-variables"}
  import { queues } from "@trigger.dev/sdk";
  ```
</Note>

### Listing queues

You can list all queues in your environment with pagination support:

```ts theme={"theme":"css-variables"}
import { queues } from "@trigger.dev/sdk";

// List all queues (returns paginated results)
const allQueues = await queues.list();

// With pagination options
const pagedQueues = await queues.list({
  page: 1,
  perPage: 20,
});
```

### Retrieving a queue

You can retrieve a specific queue by its ID, or by its type and name:

```ts theme={"theme":"css-variables"}
import { queues } from "@trigger.dev/sdk";

// Using queue ID (starts with "queue_")
const queueById = await queues.retrieve("queue_1234");

// Using type and name for a task's default queue
const taskQueue = await queues.retrieve({
  type: "task",
  name: "my-task-id",
});

// Using type and name for a custom queue
const customQueue = await queues.retrieve({
  type: "custom",
  name: "my-custom-queue",
});
```

The queue object contains useful information about the queue state:

```ts theme={"theme":"css-variables"}
{
  id: "queue_1234",       // Queue ID
  name: "my-task-id",     // Queue name
  type: "task",           // "task" or "custom"
  running: 5,             // Currently executing runs
  queued: 10,             // Runs waiting to execute
  paused: false,          // Whether the queue is paused
  concurrencyLimit: 10,   // Current concurrency limit
  concurrency: {
    current: 10,          // Effective limit
    base: 10,             // Default limit from code
    override: null,       // Override value (if set)
    overriddenAt: null,   // When override was applied
    overriddenBy: null,   // Who applied the override
  }
}
```

### Pausing and resuming queues

You can pause a queue to prevent new runs from starting. Runs that are currently executing will continue to completion.

```ts theme={"theme":"css-variables"}
import { queues } from "@trigger.dev/sdk";

// Pause a queue using its ID
await queues.pause("queue_1234");

// Or using type and name
await queues.pause({ type: "task", name: "my-task-id" });
await queues.pause({ type: "custom", name: "my-custom-queue" });
```

To resume a paused queue and allow new runs to start:

```ts theme={"theme":"css-variables"}
import { queues } from "@trigger.dev/sdk";

// Resume a queue using its ID
await queues.resume("queue_1234");

// Or using type and name
await queues.resume({ type: "task", name: "my-task-id" });
await queues.resume({ type: "custom", name: "my-custom-queue" });
```

### Overriding concurrency limits

You can temporarily override a queue's concurrency limit. This is useful for scaling up or down based on demand:

```ts theme={"theme":"css-variables"}
import { queues } from "@trigger.dev/sdk";

// Set concurrency limit to 5
await queues.overrideConcurrencyLimit("queue_1234", 5);

// Or using type and name
await queues.overrideConcurrencyLimit({ type: "task", name: "my-task-id" }, 20);
```

To reset the concurrency limit back to the base value defined in your code:

```ts theme={"theme":"css-variables"}
import { queues } from "@trigger.dev/sdk";

// Reset concurrency limit to the base value
await queues.resetConcurrencyLimit("queue_1234");

// Or using type and name
await queues.resetConcurrencyLimit({ type: "task", name: "my-task-id" });
```


# Quick start: add Trigger.dev to your project
Source: https://trigger.dev/docs/quick-start

Set up Trigger.dev in your existing project in under 3 minutes. Install the SDK, create your first background task, and trigger it from your code.

## Set up with AI

Using an AI coding assistant? Copy this prompt and paste it into Claude Code, Cursor, Copilot, Windsurf, or any AI tool. It'll handle the setup for you.

<Accordion title="Copy the setup prompt">
  ```text theme={"theme":"css-variables"}
  Help me add Trigger.dev to this project.

  ## What to do

  1. I need a Trigger.dev account. If I don't have one, point me to https://cloud.trigger.dev to sign up. Wait for me to confirm.
  2. Run `npx trigger.dev@latest init` in the project root.
     - When it asks about the MCP server, recommend I install it (best DX: gives you direct access to Trigger.dev docs, deploys, and run monitoring).
     - Install the "Hello World" example task when prompted.
  3. Run `npx trigger.dev@latest dev` to start the dev server.
  4. Once the dev server is running, test the example task from the Trigger.dev dashboard.
  5. Set TRIGGER_SECRET_KEY in my .env file (or .env.local for Next.js). I can find it on the API Keys page in the dashboard.
  6. Ask me what framework I'm using and show me how to trigger the task from my backend code.

  If I've already run init and want the MCP server, run: npx trigger.dev@latest install-mcp

  ## Critical rules

  - ALWAYS import from `@trigger.dev/sdk`. NEVER import from `@trigger.dev/sdk/v3`.
  - NEVER use `client.defineJob()` — that's the deprecated v2 API.
  - Use type-only imports when triggering from backend code to avoid bundling task code:

    import type { myTask } from "./trigger/example";
    import { tasks } from "@trigger.dev/sdk";

    const handle = await tasks.trigger<typeof myTask>("hello-world", { message: "Hello from my app!" });

  ## When done, point me to

  - Writing tasks: https://trigger.dev/docs/tasks/overview
  - Real-time updates: https://trigger.dev/docs/realtime/overview
  - AI tooling: https://trigger.dev/docs/building-with-ai
  ```
</Accordion>

## Manual setup

<Steps>
  <Step title="Create a Trigger.dev account">
    Sign up at [Trigger.dev Cloud](https://cloud.trigger.dev) (or [self-host](/docs/self-hosting/overview)). The onboarding flow will guide you through creating your first organization and project.
  </Step>

  <Step title="Run the CLI `init` command">
    The easiest way to get started is to use the CLI. It will add Trigger.dev to your existing project, create a `/trigger` folder and give you an example task.

    Run this command in the root of your project to get started:

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest init
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest init
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest init
      ```
    </CodeGroup>

    It will do a few things:

    <Tip title="MCP Server">
      Our [Trigger.dev MCP server](/docs/mcp-introduction) gives your AI assistant direct access to Trigger.dev tools; search docs, trigger tasks, deploy projects, and monitor runs. We recommend installing it for the best developer experience.
    </Tip>

    1. Ask if you want to install the [Trigger.dev MCP server](/docs/mcp-introduction) for your AI assistant.
    2. Log you into the CLI if you're not already logged in.
    3. Ask you to select your project.
    4. Install the required SDK packages.
    5. Ask where you'd like to create the `/trigger` directory and create it with an example task.
    6. Create a `trigger.config.ts` file in the root of your project.

    Install the "Hello World" example task when prompted. We'll use this task to test the setup.
  </Step>

  <Step title="Run the CLI `dev` command">
    The CLI `dev` command runs a server for your tasks. It watches for changes in your `/trigger` directory and communicates with the Trigger.dev platform to register your tasks, perform runs, and send data back and forth.

    It can also update your `@trigger.dev/*` packages to prevent version mismatches and failed deploys. You will always be prompted first.

    <CodeGroup>
      ```bash npm theme={"theme":"css-variables"}
      npx trigger.dev@latest dev
      ```

      ```bash pnpm theme={"theme":"css-variables"}
      pnpm dlx trigger.dev@latest dev
      ```

      ```bash yarn theme={"theme":"css-variables"}
      yarn dlx trigger.dev@latest dev
      ```
    </CodeGroup>
  </Step>

  <Step title="Perform a test run using the dashboard">
    The CLI `dev` command spits out various useful URLs, including a link to the dashboard. Open it, find your Example task on the Tasks page, and press the "Test" button to open its test page.

    Most tasks have a "payload" which you enter in the JSON editor, but our example task doesn't need any input. You can also configure run options, pre-populate the form from recent runs, and save run templates.

    Press the "Run test" button.

    <img alt="Test page" />
  </Step>

  <Step title="View your run">
    Congratulations, you should see the run page which will live reload showing you the current state of the run.

    <img alt="Run page" />

    If you go back to your terminal you'll see that the dev command also shows the task status and links to the run log.

    <img alt="Terminal showing completed run" />
  </Step>
</Steps>

## Triggering tasks from your app

The test page in the dashboard is great for verifying your task works. To trigger tasks from your own code, you'll need to set the `TRIGGER_SECRET_KEY` environment variable. Grab it from the API Keys page in the dashboard and add it to your `.env` file.

```bash .env theme={"theme":"css-variables"}
TRIGGER_SECRET_KEY=tr_dev_...
```

See [Triggering](/docs/triggering) for the full guide, or jump straight to framework-specific setup for [Next.js](/docs/guides/frameworks/nextjs), [Remix](/docs/guides/frameworks/remix), or [Node.js](/docs/guides/frameworks/nodejs).

## Next steps

<CardGroup>
  <Card title="Building with AI" icon="brain" href="/building-with-ai">
    Build Trigger.dev projects using AI coding assistants
  </Card>

  <Card title="How to trigger your tasks" icon="bolt" href="/triggering">
    Trigger tasks from your backend code
  </Card>

  <Card title="Writing tasks" icon="wand-magic-sparkles" href="/tasks/overview">
    Task options, lifecycle hooks, retries, and queues
  </Card>

  <Card title="Guides and example projects" icon="books" href="/guides/introduction">
    Framework guides and working example repos
  </Card>
</CardGroup>


# Realtime authentication
Source: https://trigger.dev/docs/realtime/auth

Authenticating real-time API requests with Public Access Tokens or Trigger Tokens

To use the Realtime API, you need to authenticate your requests with Public Access Tokens or Trigger Tokens. These tokens provide secure, scoped access to your runs and can be used in both frontend and backend applications.

## Token Types

There are two types of tokens you can use with the Realtime API:

* **[Public Access Tokens](#public-access-tokens-for-subscribing-to-runs)** - Used to read and subscribe to run data. Can be used in both the frontend and backend.
* **[Trigger Tokens](#trigger-tokens-for-frontend-triggering-only)** - Used to trigger tasks from your frontend. These are more secure, single-use tokens that can only be used in the frontend.

## Public Access Tokens (for subscribing to runs)

Use Public Access Tokens to subscribe to runs and receive real-time updates in your frontend or backend.

### Creating Public Access Tokens

You can create a Public Access Token using the `auth.createPublicToken` function in your **backend** code:

```tsx theme={"theme":"css-variables"}
// Somewhere in your backend code
import { auth } from "@trigger.dev/sdk";

const publicToken = await auth.createPublicToken(); // 👈 this public access token has no permissions, so is pretty useless!
```

### Scopes

By default a Public Access Token has no permissions. You must specify the scopes you need when creating a Public Access Token:

```ts theme={"theme":"css-variables"}
import { auth } from "@trigger.dev/sdk";

const publicToken = await auth.createPublicToken({
  scopes: {
    read: {
      runs: true, // ❌ this token can read all runs, possibly useful for debugging/testing
    },
  },
});
```

This will allow the token to read all runs, which is probably not what you want. You can specify only certain runs by passing an array of run IDs:

```ts theme={"theme":"css-variables"}
import { auth } from "@trigger.dev/sdk";

const publicToken = await auth.createPublicToken({
  scopes: {
    read: {
      runs: ["run_1234", "run_5678"], // ✅ this token can read only these runs
    },
  },
});
```

You can scope the token to only read certain tasks:

```ts theme={"theme":"css-variables"}
import { auth } from "@trigger.dev/sdk";

const publicToken = await auth.createPublicToken({
  scopes: {
    read: {
      tasks: ["my-task-1", "my-task-2"], // 👈 this token can read all runs of these tasks
    },
  },
});
```

Or tags:

```ts theme={"theme":"css-variables"}
import { auth } from "@trigger.dev/sdk";

const publicToken = await auth.createPublicToken({
  scopes: {
    read: {
      tags: ["my-tag-1", "my-tag-2"], // 👈 this token can read all runs with these tags
    },
  },
});
```

Or a specific batch of runs:

```ts theme={"theme":"css-variables"}
import { auth } from "@trigger.dev/sdk";

const publicToken = await auth.createPublicToken({
  scopes: {
    read: {
      batch: "batch_1234", // 👈 this token can read all runs in this batch
    },
  },
});
```

You can also combine scopes. For example, to read runs with specific tags and for specific tasks:

```ts theme={"theme":"css-variables"}
import { auth } from "@trigger.dev/sdk";

const publicToken = await auth.createPublicToken({
  scopes: {
    read: {
      tasks: ["my-task-1", "my-task-2"],
      tags: ["my-tag-1", "my-tag-2"],
    },
  },
});
```

### Expiration

By default, Public Access Token's expire after 15 minutes. You can specify a different expiration time when creating a Public Access Token:

```ts theme={"theme":"css-variables"}
import { auth } from "@trigger.dev/sdk";

const publicToken = await auth.createPublicToken({
  expirationTime: "1hr",
});
```

* If `expirationTime` is a string, it will be treated as a time span
* If `expirationTime` is a number, it will be treated as a Unix timestamp
* If `expirationTime` is a `Date`, it will be treated as a date

The format used for a time span is the same as the [jose package](https://github.com/panva/jose), which is a number followed by a unit. Valid units are: "sec", "secs", "second", "seconds", "s", "minute", "minutes", "min", "mins", "m", "hour", "hours", "hr", "hrs", "h", "day", "days", "d", "week", "weeks", "w", "year", "years", "yr", "yrs", and "y". It is not possible to specify months. 365.25 days is used as an alias for a year. If the string is suffixed with "ago", or prefixed with a "-", the resulting time span gets subtracted from the current unix timestamp. A "from now" suffix can also be used for readability when adding to the current unix timestamp.

### Auto-generated tokens

When you [trigger tasks](/docs/triggering) from your backend, the `handle` received includes a `publicAccessToken` field. This token can be used to authenticate real-time requests in your frontend application.

By default, auto-generated tokens expire after 15 minutes and have a read scope for the specific run(s) that were triggered. You can customize the expiration by passing a `publicTokenOptions` object to the trigger function.

See our [triggering documentation](/docs/triggering) for detailed examples of how to trigger tasks and get auto-generated tokens.

<Note>
  **Where should I create tokens?** The standard pattern is to create tokens in your backend code (API route, server action) after triggering a task, then pass the token to your frontend. The `handle.publicAccessToken` returned by `tasks.trigger()` already does this for you. You rarely need to create tokens inside a task itself.
</Note>

### Subscribing to runs with Public Access Tokens

Once you have a Public Access Token, you can use it to authenticate requests to the Realtime API in both backend and frontend applications.

**Backend usage:** See our [backend documentation](/docs/realtime/backend) for examples of what you can do with Realtime in your backend once you have authenticated with a token.

**Frontend usage:** See our [React hooks documentation](/docs/realtime/react-hooks) for examples of using tokens with frontend components.

## Trigger Tokens (for frontend triggering only)

For triggering tasks from your frontend, you need special "trigger" tokens. These tokens can only be used once to trigger a task and are more secure than regular Public Access Tokens.

### Creating Trigger Tokens

```ts theme={"theme":"css-variables"}
import { auth } from "@trigger.dev/sdk";

// Somewhere in your backend code
const triggerToken = await auth.createTriggerPublicToken("my-task");
```

### Multiple tasks

You can pass multiple tasks to create a token that can trigger multiple tasks:

```ts theme={"theme":"css-variables"}
import { auth } from "@trigger.dev/sdk";

// Somewhere in your backend code
const triggerToken = await auth.createTriggerPublicToken(["my-task-1", "my-task-2"]);
```

### Multiple use

You can also create tokens that can be used multiple times:

```ts theme={"theme":"css-variables"}
import { auth } from "@trigger.dev/sdk";

// Somewhere in your backend code
const triggerToken = await auth.createTriggerPublicToken("my-task", {
  multipleUse: true, // ❌ Use this with caution!
});
```

### Expiration

These tokens also expire, with the default expiration time being 15 minutes. You can specify a custom expiration time:

```ts theme={"theme":"css-variables"}
import { auth } from "@trigger.dev/sdk";

// Somewhere in your backend code
const triggerToken = await auth.createTriggerPublicToken("my-task", {
  expirationTime: "24hr",
});
```

### Triggering tasks from the frontend with Trigger Tokens

Check out our [React hooks documentation](/docs/realtime/react-hooks) for examples of how to use Trigger Tokens in your frontend applications.


# Subscribe to tasks from your backend
Source: https://trigger.dev/docs/realtime/backend/overview

Subscribe to run progress, stream AI output, and react to task status changes from your backend code or other tasks.

**Subscribe to runs from your server-side code or other tasks using async iterators.** Get status updates, metadata changes, and streamed data without polling.

## What's available

| Category        | What it does                                                   | Guide                                      |
| --------------- | -------------------------------------------------------------- | ------------------------------------------ |
| **Run updates** | Subscribe to run status, metadata, and tag changes             | [Run updates](/docs/realtime/backend/subscribe) |
| **Streaming**   | Read AI output, file chunks, or any continuous data from tasks | [Streaming](/docs/realtime/backend/streams)     |

<Note>
  To learn how to emit streams from your tasks, see [Streaming data from tasks](/docs/tasks/streams).
</Note>

## Authentication

All backend functions support both server-side and client-side authentication:

* **Server-side**: Use your API key (automatically handled in tasks)
* **Client-side**: Generate a Public Access Token with appropriate scopes

See our [authentication guide](/docs/realtime/auth) for detailed information on creating and using tokens.

## Quick example

Subscribe to a run:

```ts theme={"theme":"css-variables"}
import { runs, tasks } from "@trigger.dev/sdk";

// Trigger a task
const handle = await tasks.trigger("my-task", { some: "data" });

// Subscribe to real-time updates
for await (const run of runs.subscribeToRun(handle.id)) {
  console.log(`Run ${run.id} status: ${run.status}`);
}
```


# Stream data to your backend (AI, files)
Source: https://trigger.dev/docs/realtime/backend/streams

Read AI/LLM output, file chunks, and other streaming data from your Trigger.dev tasks in backend code.

**Read streaming data from your tasks in backend code.** Consume AI completions as they generate, process file chunks, or handle any continuous data your tasks produce.

<Note>
  To emit streams from your tasks, see [Streaming data from tasks](/docs/tasks/streams). For React components, see [Streaming in React](/docs/realtime/react-hooks/streams).
</Note>

<Tip>
  Run-scoped streams are the right primitive for ephemeral I/O that lives inside a single run's lifetime. For durable, long-lived channels that outlive a run, see [`chat.agent`](/docs/ai-chat/overview): it's built on a Session row that owns the chat's runs and exposes bidirectional `.in` / `.out` channels addressed by a durable id.
</Tip>

## Reading streams

### Using defined streams (Recommended)

The recommended approach is to use [defined streams](/docs/tasks/streams#defining-typed-streams-recommended) for full type safety:

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";
import { aiStream } from "./trigger/streams";

async function consumeStream(runId: string) {
  // Read from the defined stream
  const stream = await aiStream.read(runId);

  let fullText = "";

  for await (const chunk of stream) {
    console.log("Received chunk:", chunk); // chunk is typed!
    fullText += chunk;
  }

  console.log("Final text:", fullText);
}
```

### Direct stream reading

If you prefer not to use defined streams, you can read directly by specifying the stream key:

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";

async function consumeStream(runId: string) {
  // Read from a stream by key
  const stream = await streams.read<string>(runId, "ai-output");

  for await (const chunk of stream) {
    console.log("Received chunk:", chunk);
  }
}
```

### Reading from the default stream

Every run has a default stream, so you can omit the stream key:

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";

async function consumeDefaultStream(runId: string) {
  // Read from the default stream
  const stream = await streams.read<string>(runId);

  for await (const chunk of stream) {
    console.log("Received chunk:", chunk);
  }
}
```

## Stream options

The `read()` method accepts several options for controlling stream behavior:

### Timeout

Set a timeout to stop reading if no data is received within a specified time:

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";
import { aiStream } from "./trigger/streams";

async function consumeWithTimeout(runId: string) {
  const stream = await aiStream.read(runId, {
    timeoutInSeconds: 120, // Wait up to 2 minutes for data
  });

  try {
    for await (const chunk of stream) {
      console.log("Received chunk:", chunk);
    }
  } catch (error) {
    if (error.name === "TimeoutError") {
      console.log("Stream timed out");
    }
  }
}
```

### Start index

Resume reading from a specific chunk index (useful for reconnection scenarios):

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";
import { aiStream } from "./trigger/streams";

async function resumeStream(runId: string, lastChunkIndex: number) {
  // Start reading from the chunk after the last one we received
  const stream = await aiStream.read(runId, {
    startIndex: lastChunkIndex + 1,
  });

  for await (const chunk of stream) {
    console.log("Received chunk:", chunk);
  }
}
```

### Abort signal

Use an `AbortSignal` to cancel stream reading:

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";
import { aiStream } from "./trigger/streams";

async function consumeWithCancellation(runId: string) {
  const controller = new AbortController();

  // Cancel after 30 seconds
  setTimeout(() => controller.abort(), 30000);

  const stream = await aiStream.read(runId, {
    signal: controller.signal,
  });

  try {
    for await (const chunk of stream) {
      console.log("Received chunk:", chunk);

      // Optionally abort based on content
      if (chunk.includes("STOP")) {
        controller.abort();
      }
    }
  } catch (error) {
    if (error.name === "AbortError") {
      console.log("Stream was cancelled");
    }
  }
}
```

### Combining options

You can combine multiple options:

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";
import { aiStream } from "./trigger/streams";

async function advancedStreamConsumption(runId: string) {
  const controller = new AbortController();

  const stream = await aiStream.read(runId, {
    timeoutInSeconds: 300, // 5 minute timeout
    startIndex: 0, // Start from the beginning
    signal: controller.signal, // Allow cancellation
  });

  try {
    for await (const chunk of stream) {
      console.log("Received chunk:", chunk);
    }
  } catch (error) {
    if (error.name === "AbortError") {
      console.log("Stream was cancelled");
    } else if (error.name === "TimeoutError") {
      console.log("Stream timed out");
    } else {
      console.error("Stream error:", error);
    }
  }
}
```

## Practical examples

### Reading AI streaming responses

Here's a complete example of consuming an AI stream from your backend:

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";
import { aiStream } from "./trigger/streams";

async function consumeAIStream(runId: string) {
  const stream = await aiStream.read(runId, {
    timeoutInSeconds: 300, // AI responses can take time
  });

  let fullResponse = "";
  const chunks: string[] = [];

  for await (const chunk of stream) {
    chunks.push(chunk);
    fullResponse += chunk;

    // Process each chunk as it arrives
    console.log("Chunk received:", chunk);

    // Could send to websocket, SSE, etc.
    // await sendToClient(chunk);
  }

  console.log("Stream complete!");
  console.log("Total chunks:", chunks.length);
  console.log("Full response:", fullResponse);

  return { fullResponse, chunks };
}
```

### Reading multiple streams

If a task emits multiple streams, you can read them concurrently or sequentially:

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";
import { aiStream, progressStream } from "./trigger/streams";

async function consumeMultipleStreams(runId: string) {
  // Read streams concurrently
  const [aiData, progressData] = await Promise.all([
    consumeStream(aiStream, runId),
    consumeStream(progressStream, runId),
  ]);

  return { aiData, progressData };
}

async function consumeStream<T>(
  streamDef: { read: (runId: string) => Promise<AsyncIterableStream<T>> },
  runId: string
): Promise<T[]> {
  const stream = await streamDef.read(runId);
  const chunks: T[] = [];

  for await (const chunk of stream) {
    chunks.push(chunk);
  }

  return chunks;
}
```

### Piping streams to HTTP responses

You can pipe streams directly to HTTP responses for server-sent events (SSE):

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";
import { aiStream } from "./trigger/streams";
import type { NextRequest } from "next/server";

export async function GET(request: NextRequest) {
  const runId = request.nextUrl.searchParams.get("runId");

  if (!runId) {
    return new Response("Missing runId", { status: 400 });
  }

  const stream = await aiStream.read(runId, {
    timeoutInSeconds: 300,
  });

  // Create a readable stream for SSE
  const encoder = new TextEncoder();
  const readableStream = new ReadableStream({
    async start(controller) {
      try {
        for await (const chunk of stream) {
          // Format as SSE
          const data = `data: ${JSON.stringify({ chunk })}\n\n`;
          controller.enqueue(encoder.encode(data));
        }
        controller.close();
      } catch (error) {
        controller.error(error);
      }
    },
  });

  return new Response(readableStream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "Connection": "keep-alive",
    },
  });
}
```

### Implementing retry logic

Handle transient errors with retry logic:

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";
import { aiStream } from "./trigger/streams";

async function consumeStreamWithRetry(
  runId: string,
  maxRetries = 3
): Promise<string[]> {
  let lastChunkIndex = 0;
  const allChunks: string[] = [];
  let attempt = 0;

  while (attempt < maxRetries) {
    try {
      const stream = await aiStream.read(runId, {
        startIndex: lastChunkIndex,
        timeoutInSeconds: 120,
      });

      for await (const chunk of stream) {
        allChunks.push(chunk);
        lastChunkIndex++;
      }

      // Success! Break out of retry loop
      break;
    } catch (error) {
      attempt++;

      if (attempt >= maxRetries) {
        throw new Error(`Failed after ${maxRetries} attempts: ${error.message}`);
      }

      console.log(`Retry attempt ${attempt} after error:`, error.message);

      // Wait before retrying (exponential backoff)
      await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
    }
  }

  return allChunks;
}
```

### Processing streams in chunks

Process streams in batches for efficiency:

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";
import { aiStream } from "./trigger/streams";

async function processStreamInBatches(runId: string, batchSize = 10) {
  const stream = await aiStream.read(runId);

  let batch: string[] = [];

  for await (const chunk of stream) {
    batch.push(chunk);

    if (batch.length >= batchSize) {
      // Process the batch
      await processBatch(batch);
      batch = [];
    }
  }

  // Process remaining chunks
  if (batch.length > 0) {
    await processBatch(batch);
  }
}

async function processBatch(chunks: string[]) {
  console.log(`Processing batch of ${chunks.length} chunks`);
  // Do something with the batch
  // e.g., save to database, send to queue, etc.
}
```

## Using with `runs.subscribeToRun()`

For more advanced use cases where you need both the run status and streams, you can use the `runs.subscribeToRun()` method with `.withStreams()`:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";
import type { myTask } from "./trigger/myTask";

async function subscribeToRunAndStreams(runId: string) {
  for await (const update of runs.subscribeToRun<typeof myTask>(runId).withStreams()) {
    switch (update.type) {
      case "run":
        console.log("Run update:", update.run.status);
        break;
      case "default":
        console.log("Stream chunk:", update.chunk);
        break;
    }
  }
}
```

<Note>
  For most use cases, we recommend using `streams.read()` with defined streams for better type safety and clearer code. Use `runs.subscribeToRun().withStreams()` only when you need to track both run status and stream data simultaneously.
</Note>


# Run updates (backend)
Source: https://trigger.dev/docs/realtime/backend/subscribe

Subscribe to run status changes, metadata updates, and tag changes from your backend code using async iterators.

**Subscribe to runs from your backend and get updates whenever status, metadata, or tags change.** Each function returns an async iterator that yields the run object on every change.

## runs.subscribeToRun

Subscribes to all changes to a specific run.

```ts Example theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

for await (const run of runs.subscribeToRun("run_1234")) {
  console.log(run);
}
```

This function subscribes to all changes to a run. It returns an async iterator that yields the run object whenever the run is updated. The iterator will complete when the run is finished.

**Authentication**: This function supports both server-side and client-side authentication. For server-side authentication, use your API key. For client-side authentication, you must generate a public access token with read access to the specific run. See our [authentication guide](/docs/realtime/auth) for details.

**Response**: The AsyncIterator yields the [run object](/docs/realtime/run-object).

## runs.subscribeToRunsWithTag

Subscribes to all changes to runs with a specific tag.

```ts Example theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

for await (const run of runs.subscribeToRunsWithTag("user:1234")) {
  console.log(run);
}
```

This function subscribes to all changes to runs with a specific tag. It returns an async iterator that yields the run object whenever a run with the specified tag is updated. This iterator will never complete, so you must manually break out of the loop when you no longer want to receive updates.

**Authentication**: This function supports both server-side and client-side authentication. For server-side authentication, use your API key. For client-side authentication, you must generate a public access token with read access to the specific tag. See our [authentication guide](/docs/realtime/auth) for details.

**Response**: The AsyncIterator yields the [run object](/docs/realtime/run-object).

## runs.subscribeToBatch

Subscribes to all changes for runs in a batch.

```ts Example theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

for await (const run of runs.subscribeToBatch("batch_1234")) {
  console.log(run);
}
```

This function subscribes to all changes for runs in a batch. It returns an async iterator that yields a run object whenever a run in the batch is updated. The iterator does not complete on its own, you must manually `break` the loop when you want to stop listening for updates.

**Authentication**: This function supports both server-side and client-side authentication. For server-side authentication, use your API key. For client-side authentication, you must generate a public access token with read access to the specific batch. See our [authentication guide](/docs/realtime/auth) for details.

**Response**: The AsyncIterator yields the [run object](/docs/realtime/run-object).

## Type safety

You can infer the types of the run's payload and output by passing the type of the task to the subscribe functions:

```ts theme={"theme":"css-variables"}
import { runs, tasks } from "@trigger.dev/sdk";
import type { myTask } from "./trigger/my-task";

async function myBackend() {
  const handle = await tasks.trigger("my-task", { some: "data" });

  for await (const run of runs.subscribeToRun<typeof myTask>(handle.id)) {
    // run.payload and run.output are now typed
    console.log(run.payload.some);

    if (run.output) {
      console.log(run.output.some);
    }
  }
}
```

When using `subscribeToRunsWithTag`, you can pass a union of task types:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";
import type { myTask, myOtherTask } from "./trigger/my-task";

for await (const run of runs.subscribeToRunsWithTag<typeof myTask | typeof myOtherTask>("my-tag")) {
  // Narrow down the type based on the taskIdentifier
  switch (run.taskIdentifier) {
    case "my-task": {
      console.log("Run output:", run.output.foo); // Type-safe
      break;
    }
    case "my-other-task": {
      console.log("Run output:", run.output.bar); // Type-safe
      break;
    }
  }
}
```

## Subscribe to metadata updates from your tasks

The metadata API allows you to update custom metadata on runs and receive real-time updates when metadata changes. This is useful for tracking progress, storing intermediate results, or adding custom status information that can be monitored in real-time.

<Note>
  For frontend applications using React, see our [React hooks metadata
  documentation](/docs/realtime/react-hooks/subscribe#using-metadata-to-show-progress-in-your-ui) for
  consuming metadata updates in your UI.
</Note>

When you update metadata from within a task using `metadata.set()`, `metadata.append()`, or other metadata methods, all subscribers to that run will automatically receive the updated run object containing the new metadata.

This makes metadata perfect for:

* Progress tracking
* Status updates
* Intermediate results
* Custom notifications

Use the metadata API within your task to update metadata in real-time. In this basic example task, we're updating the progress of a task as it processes items.

### How to subscribe to metadata updates

This example task updates the progress of a task as it processes items.

```ts theme={"theme":"css-variables"}
// Your task code
import { task, metadata } from "@trigger.dev/sdk";

export const progressTask = task({
  id: "progress-task",
  run: async (payload: { items: string[] }) => {
    const total = payload.items.length;

    for (let i = 0; i < payload.items.length; i++) {
      // Update progress metadata
      metadata.set("progress", {
        current: i + 1,
        total: total,
        percentage: Math.round(((i + 1) / total) * 100),
        currentItem: payload.items[i],
      });

      // Process the item
      await processItem(payload.items[i]);
    }

    metadata.set("status", "completed");
    return { processed: total };
  },
});

async function processItem(item: string) {
  // Simulate work
  await new Promise((resolve) => setTimeout(resolve, 1000));
}
```

We can now subscribe to the runs and receive real-time metadata updates.

```ts theme={"theme":"css-variables"}
// Somewhere in your backend code
import { runs } from "@trigger.dev/sdk";
import type { progressTask } from "./trigger/progress-task";

async function monitorProgress(runId: string) {
  for await (const run of runs.subscribeToRun<typeof progressTask>(runId)) {
    console.log(`Run ${run.id} status: ${run.status}`);

    if (run.metadata?.progress) {
      const progress = run.metadata.progress as {
        current: number;
        total: number;
        percentage: number;
        currentItem: string;
      };

      console.log(`Progress: ${progress.current}/${progress.total} (${progress.percentage}%)`);
      console.log(`Processing: ${progress.currentItem}`);
    }

    if (run.metadata?.status === "completed") {
      console.log("Task completed!");
      break;
    }
  }
}
```

For more information on how to write tasks that use the metadata API, as well as more examples, see our [run metadata docs](/docs/runs/metadata#more-metadata-task-examples).

### Type safety

You can get type safety for your metadata by defining types:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";
import type { progressTask } from "./trigger/progress-task";

interface ProgressMetadata {
  progress?: {
    current: number;
    total: number;
    percentage: number;
    currentItem: string;
  };
  status?: "running" | "completed" | "failed";
}

async function monitorTypedProgress(runId: string) {
  for await (const run of runs.subscribeToRun<typeof progressTask>(runId)) {
    const metadata = run.metadata as ProgressMetadata;

    if (metadata?.progress) {
      // Now you have full type safety
      console.log(`Progress: ${metadata.progress.percentage}%`);
    }
  }
}
```


# How Realtime works
Source: https://trigger.dev/docs/realtime/how-it-works

Technical architecture behind Trigger.dev's real-time run updates, built on Electric SQL and PostgreSQL syncing.

This page covers the infrastructure behind **run updates** (status, metadata, tags). [Streaming](/docs/tasks/streams) uses a separate transport.

## Architecture

The run updates system is built on top of [Electric SQL](https://electric-sql.com/), an open-source PostgreSQL syncing engine. The Trigger.dev API wraps Electric SQL and provides a simple API to subscribe to [runs](/docs/runs) and get updates as they happen.

## Run changes

You will receive updates whenever a run changes for the following reasons:

* The run moves to a new state. See our [run lifecycle docs](/docs/runs#the-run-lifecycle) for more information.
* [Run tags](/docs/tags) are added or removed.
* [Run metadata](/docs/runs/metadata) is updated.

## Run object

The run object returned by Realtime subscriptions is optimized for streaming updates and differs from the management API's run object. See [the run object](/docs/realtime/run-object) page for the complete schema and field descriptions.

## Basic usage

After you trigger a task, you can subscribe to the run using the `runs.subscribeToRun` function. This function returns an async iterator that you can use to get updates on the run status.

```ts theme={"theme":"css-variables"}
import { runs, tasks } from "@trigger.dev/sdk";

// Somewhere in your backend code
async function myBackend() {
  const handle = await tasks.trigger("my-task", { some: "data" });

  for await (const run of runs.subscribeToRun(handle.id)) {
    // This will log the run every time it changes
    console.log(run);
  }
}
```

Every time the run changes, the async iterator will yield the updated run. You can use this to update your UI, log the run status, or take any other action.

Alternatively, you can subscribe to changes to any run that includes a specific tag (or tags) using the `runs.subscribeToRunsWithTag` function.

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

// Somewhere in your backend code
for await (const run of runs.subscribeToRunsWithTag("user:1234")) {
  // This will log the run every time it changes, for all runs with the tag "user:1234"
  console.log(run);
}
```

If you've used `batchTrigger` to trigger multiple runs, you can also subscribe to changes to all the runs triggered in the batch using the `runs.subscribeToBatch` function.

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

// Somewhere in your backend code
for await (const run of runs.subscribeToBatch("batch-id")) {
  // This will log the run every time it changes, for all runs in the batch with the ID "batch-id"
  console.log(run);
}
```

## Run metadata

The run metadata API gives you the ability to add or update custom metadata on a run, which will cause the run to be updated. This allows you to extend the Realtime API with custom data attached to a run that can be used for various purposes. Some common use cases include:

* Adding a link to a related resource
* Adding a reference to a user or organization
* Adding a custom status with progress information

See our [run metadata docs](/docs/runs/metadata) for more on how to write tasks that use the metadata API.

### Using metadata with Realtime & React hooks

You can combine run metadata with the Realtime API to bridge the gap between your trigger.dev tasks and your applications in two ways:

1. Using our [React hooks](/docs/realtime/react-hooks/subscribe#using-metadata) to subscribe to metadata updates and update your UI in real-time.
2. Using our [backend functions](/docs/realtime/backend) to subscribe to metadata updates in your backend.

## Limits

The Realtime API in the Trigger.dev Cloud limits the number of concurrent subscriptions, depending on your plan. If you exceed the limit, you will receive an error when trying to subscribe to a run. For more information, see our [pricing page](https://trigger.dev/pricing).

## Learn more

* Read our Realtime blog post ["How we built a real-time service that handles 20,000 updates per second](https://trigger.dev/blog/how-we-built-realtime)
* Using Realtime: [React Hooks (frontend)](/docs/realtime/react-hooks)
* Using [Backend (server-side)](/docs/realtime/backend)


# Realtime overview
Source: https://trigger.dev/docs/realtime/overview

Get live run updates and stream data from background tasks to your frontend or backend. No polling.

**Realtime is the umbrella for everything live in Trigger.dev.** It covers two things: getting notified when a run's state changes, and streaming continuous data (like AI tokens) from a running task to your app.

Both use the same `@trigger.dev/react-hooks` package and the same authentication system. The difference is what they give you.

## Run updates vs Streaming

|                         | Run updates                                              | Streaming                                                     |
| ----------------------- | -------------------------------------------------------- | ------------------------------------------------------------- |
| **What you get**        | Run state: status, metadata, tags                        | Continuous data you define (AI tokens, file chunks, progress) |
| **When it fires**       | On state changes                                         | While the task runs, as data is produced                      |
| **Use case**            | Progress bars, status badges, dashboards                 | AI chat output, live logs, file processing                    |
| **React hook**          | [`useRealtimeRun`](/docs/realtime/react-hooks/subscribe)      | [`useRealtimeStream`](/docs/realtime/react-hooks/streams)          |
| **Setup in task code?** | No, automatic                                            | Yes, using `streams.define()`                                 |
| **Infrastructure**      | [Electric SQL](/docs/realtime/how-it-works) (PostgreSQL sync) | Streams transport                                             |

You can use both at the same time. Subscribe to a run's status (to show a progress bar) while also streaming AI output (to display tokens as they arrive).

## Run updates

Subscribe to a run and your code gets called whenever its status, [metadata](/docs/runs/metadata), or [tags](/docs/tags) change. No setup needed in your task code.

You can subscribe to:

* **Specific runs** by run ID
* **Runs with specific tags** (e.g., all runs tagged with `user:123`)
* **Batch runs** within a specific batch
* **Trigger + subscribe combos** that trigger a task and immediately subscribe (frontend only)

→ [React hooks](/docs/realtime/react-hooks/subscribe) | [Backend](/docs/realtime/backend/subscribe)

## Streaming

Define typed streams in your task, pipe data to them, and read that data from your frontend or backend as it's produced. You need to set up streams in your task code using `streams.define()`.

→ [How to emit streams from tasks](/docs/tasks/streams) | [React hooks](/docs/realtime/react-hooks/streams) | [Backend](/docs/realtime/backend/streams)

## Authentication

All Realtime hooks and functions require authentication. See the [authentication guide](/docs/realtime/auth) for setup.

## Frequently asked questions

### How do I show a progress bar for a background task?

Use [run metadata](/docs/runs/metadata) to store progress data (like a percentage), then subscribe to the run with [`useRealtimeRun`](/docs/realtime/react-hooks/subscribe). Your component re-renders on every metadata update.

### How do I stream AI/LLM responses from a background task?

Define a stream in your task with `streams.define()`, pipe your AI SDK response to it, then consume it in React with [`useRealtimeStream`](/docs/realtime/react-hooks/streams). See [Streaming data from tasks](/docs/tasks/streams) for the full guide.

### Do I need WebSockets or polling?

No. Run updates are powered by [Electric SQL](/docs/realtime/how-it-works) (HTTP-based PostgreSQL syncing). Streams use their own transport. The hooks handle connections automatically.

### Can I use both run updates and streaming together?

Yes. A common pattern: subscribe to run status with `useRealtimeRun` (progress indicator) while streaming AI output with `useRealtimeStream` (token-by-token display).


# React hooks for real-time task updates
Source: https://trigger.dev/docs/realtime/react-hooks/overview

Subscribe to background task progress, stream AI responses, and trigger tasks from React components using @trigger.dev/react-hooks.

**`@trigger.dev/react-hooks` gives your React components live access to background tasks.** Subscribe to run progress, stream AI output as it generates, or trigger tasks directly from the browser.

## Installation

Install the `@trigger.dev/react-hooks` package in your project:

<CodeGroup>
  ```bash npm theme={"theme":"css-variables"}
  npm add @trigger.dev/react-hooks
  ```

  ```bash pnpm theme={"theme":"css-variables"}
  pnpm add @trigger.dev/react-hooks
  ```

  ```bash yarn theme={"theme":"css-variables"}
  yarn install @trigger.dev/react-hooks
  ```
</CodeGroup>

## Authentication

All hooks require authentication with a Public Access Token. Pass the token via the `accessToken` option:

```tsx theme={"theme":"css-variables"}
import { useRealtimeRun } from "@trigger.dev/react-hooks";

export function MyComponent({
  runId,
  publicAccessToken,
}: {
  runId: string;
  publicAccessToken: string;
}) {
  const { run, error } = useRealtimeRun(runId, {
    accessToken: publicAccessToken,
    baseURL: "https://your-trigger-dev-instance.com", // optional, only needed if you are self-hosting Trigger.dev
  });

  // ...
}
```

Learn more about [generating and managing tokens in our authentication guide](/docs/realtime/auth).

## Available hooks

| Hook category     | What it does                                                 | Guide                                          |
| ----------------- | ------------------------------------------------------------ | ---------------------------------------------- |
| **Trigger hooks** | Trigger tasks from the browser                               | [Triggering](/docs/realtime/react-hooks/triggering) |
| **Run updates**   | Subscribe to run status, metadata, and tags                  | [Run updates](/docs/realtime/react-hooks/subscribe) |
| **Streaming**     | Consume AI output, file chunks, or any continuous data       | [Streaming](/docs/realtime/react-hooks/streams)     |
| **SWR hooks**     | One-time fetch with caching (not recommended for most cases) | [SWR](/docs/realtime/react-hooks/swr)               |

## SWR vs Realtime hooks

We offer two "styles" of hooks: SWR and Realtime. SWR hooks use the [swr](https://swr.vercel.app/) library to fetch data once and cache it. Realtime hooks use [Trigger.dev Realtime](/docs/realtime) to subscribe to updates as they happen.

<Note>
  It can be a little confusing which one to use because [swr](https://swr.vercel.app/) can also be
  configured to poll for updates. But because of rate-limits and the way the Trigger.dev API works,
  we recommend using the Realtime hooks for most use cases.
</Note>


# Stream data to React (AI, files, progress)
Source: https://trigger.dev/docs/realtime/react-hooks/streams

Display AI/LLM output token by token, stream file chunks, or pipe any continuous data from a background task into your React components.

**Display AI responses as they generate, stream file processing results, or pipe any continuous data from a running task into your React components.** Unlike [progress and status hooks](/docs/realtime/react-hooks/subscribe) (which track run state), streaming hooks give you the raw data your task produces while it runs.

<Note>
  To learn how to emit streams from your tasks, see [Streaming data from tasks](/docs/tasks/streams).
</Note>

## useRealtimeStream (Recommended)

<Note>
  Available in SDK version **4.1.0 or later**. This is the recommended way to consume streams in
  your React components.
</Note>

The `useRealtimeStream` hook allows you to subscribe to a specific stream by its run ID and stream key. This hook is designed to work seamlessly with [defined streams](/docs/tasks/streams#defining-typed-streams-recommended) for full type safety.

### Basic Usage

```tsx theme={"theme":"css-variables"}
"use client";

import { useRealtimeStream } from "@trigger.dev/react-hooks";

export function StreamViewer({
  runId,
  publicAccessToken,
}: {
  runId: string;
  publicAccessToken: string;
}) {
  const { parts, error } = useRealtimeStream<string>(runId, "ai-output", {
    accessToken: publicAccessToken,
  });

  if (error) return <div>Error: {error.message}</div>;
  if (!parts) return <div>Loading...</div>;

  return (
    <div>
      {parts.map((part, i) => (
        <span key={i}>{part}</span>
      ))}
    </div>
  );
}
```

### With Defined Streams

The recommended approach is to use defined streams for full type safety:

```tsx theme={"theme":"css-variables"}
"use client";

import { useRealtimeStream } from "@trigger.dev/react-hooks";
import { aiStream } from "@/app/streams";

export function StreamViewer({
  runId,
  publicAccessToken,
}: {
  runId: string;
  publicAccessToken: string;
}) {
  // Pass the defined stream directly - full type safety!
  const { parts, error } = useRealtimeStream(aiStream, runId, {
    accessToken: publicAccessToken,
    timeoutInSeconds: 600,
    onData: (chunk) => {
      console.log("New chunk:", chunk); // chunk is typed!
    },
  });

  if (error) return <div>Error: {error.message}</div>;
  if (!parts) return <div>Loading...</div>;

  return (
    <div>
      {parts.map((part, i) => (
        <span key={i}>{part}</span>
      ))}
    </div>
  );
}
```

### Streaming AI Responses

Here's a complete example showing how to display streaming AI responses:

```tsx theme={"theme":"css-variables"}
"use client";

import { useRealtimeStream } from "@trigger.dev/react-hooks";
import { aiStream } from "@/trigger/streams";
import { Streamdown } from "streamdown";

export function AIStreamViewer({
  runId,
  publicAccessToken,
}: {
  runId: string;
  publicAccessToken: string;
}) {
  const { parts, error } = useRealtimeStream(aiStream, runId, {
    accessToken: publicAccessToken,
    timeoutInSeconds: 300,
  });

  if (error) return <div>Error: {error.message}</div>;
  if (!parts) return <div>Loading stream...</div>;

  const text = parts.join("");

  return (
    <div className="prose">
      <Streamdown isAnimating={true}>{text}</Streamdown>
    </div>
  );
}
```

### Options

The `useRealtimeStream` hook accepts the following options:

```tsx theme={"theme":"css-variables"}
const { parts, error } = useRealtimeStream(streamOrRunId, streamKeyOrOptions, {
  accessToken: "pk_...", // Required: Public access token
  baseURL: "https://api.trigger.dev", // Optional: Custom API URL
  timeoutInSeconds: 60, // Optional: Timeout (default: 60)
  startIndex: 0, // Optional: Start from specific chunk
  throttleInMs: 16, // Optional: Throttle updates (default: 16ms)
  onData: (chunk) => {}, // Optional: Callback for each chunk
});
```

### Using Default Stream

You can omit the stream key to use the default stream:

```tsx theme={"theme":"css-variables"}
const { parts, error } = useRealtimeStream<string>(runId, {
  accessToken: publicAccessToken,
});
```

For more information on defining and using streams, see the [Streaming data from tasks](/docs/tasks/streams) documentation.

## useInputStreamSend

The `useInputStreamSend` hook lets you send data from your frontend into a running task's [input stream](/docs/tasks/streams#input-streams). Use it for cancel buttons, approval forms, or any UI that needs to push typed data into a running task.

### Basic usage

Pass the input stream's `id` (string), the run ID, and options such as `accessToken`. You typically get `runId` and `accessToken` from the object returned when you trigger the task (e.g. `handle.id`, `handle.publicAccessToken`). The hook returns `send`, `isLoading`, `error`, and `isReady`:

```tsx theme={"theme":"css-variables"}
"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, reviewer: "alice" })}
    >
      Approve
    </button>
  );
}
```

With a generic for type-safe payloads when not using a defined stream:

```tsx theme={"theme":"css-variables"}
type ApprovalPayload = { approved: boolean; reviewer: string };
const { send } = useInputStreamSend<ApprovalPayload>("approval", runId, {
  accessToken,
});
send({ approved: true, reviewer: "alice" });
```

### Options and return value

* **`streamId`**: The input stream identifier (string). Use the `id` from your defined stream (e.g. `approval.id`) or the same string you used in `streams.input<T>({ id: "approval" })`.
* **`runId`**: The run to send input to. When `runId` is undefined, `isReady` is false and `send` will not trigger.
* **`options`**: `accessToken` (required for client usage), `baseURL` (optional). See [Realtime auth](/docs/realtime/auth) for generating a public access token with the right scopes (e.g. input streams write for that run).

Return value:

* **`send(data)`**: Sends typed data to the input stream. Uses SWR mutation under the hood.
* **`isLoading`**: True while a send is in progress.
* **`error`**: Set if the last send failed.
* **`isReady`**: True when both `runId` and access token are available.

For receiving input stream data inside a task (`.wait()`, `.once()`, `.on()`), see [Input Streams](/docs/tasks/streams#input-streams) in the Streams doc.

## useRealtimeRunWithStreams

<Note>
  For new projects, we recommend using `useRealtimeStream` instead (available in SDK 4.1.0+). This
  hook is still supported for backward compatibility and use cases where you need to subscribe to
  both the run and all its streams at once.
</Note>

The `useRealtimeRunWithStreams` hook allows you to subscribe to a run by its ID and also receive any streams that are emitted by the task. This is useful when you need to access both the run metadata and multiple streams simultaneously.

```tsx theme={"theme":"css-variables"}
"use client"; // This is needed for Next.js App Router or other RSC frameworks

import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks";

export function MyComponent({
  runId,
  publicAccessToken,
}: {
  runId: string;
  publicAccessToken: string;
}) {
  const { run, streams, error } = useRealtimeRunWithStreams(runId, {
    accessToken: publicAccessToken,
  });

  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <div>Run: {run.id}</div>
      <div>
        {Object.keys(streams).map((stream) => (
          <div key={stream}>Stream: {stream}</div>
        ))}
      </div>
    </div>
  );
}
```

You can also provide the type of the streams to the `useRealtimeRunWithStreams` hook to get type-safety:

```tsx theme={"theme":"css-variables"}
import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";

type STREAMS = {
  openai: string; // this is the type of each "part" of the stream
};

export function MyComponent({
  runId,
  publicAccessToken,
}: {
  runId: string;
  publicAccessToken: string;
}) {
  const { run, streams, error } = useRealtimeRunWithStreams<typeof myTask, STREAMS>(runId, {
    accessToken: publicAccessToken,
  });

  if (error) return <div>Error: {error.message}</div>;

  const text = streams.openai?.map((part) => part).join("");

  return (
    <div>
      <div>Run: {run.id}</div>
      <div>{text}</div>
    </div>
  );
}
```

As you can see above, each stream is an array of the type you provided, keyed by the stream name. If instead of a pure text stream you have a stream of objects, you can provide the type of the object:

```tsx theme={"theme":"css-variables"}
import type { TextStreamPart } from "ai";
import type { myTask } from "@/trigger/myTask";

type STREAMS = { openai: TextStreamPart<{}> };

export function MyComponent({
  runId,
  publicAccessToken,
}: {
  runId: string;
  publicAccessToken: string;
}) {
  const { run, streams, error } = useRealtimeRunWithStreams<typeof myTask, STREAMS>(runId, {
    accessToken: publicAccessToken,
  });

  if (error) return <div>Error: {error.message}</div>;

  const text = streams.openai
    ?.filter((stream) => stream.type === "text-delta")
    ?.map((part) => part.text)
    .join("");

  return (
    <div>
      <div>Run: {run.id}</div>
      <div>{text}</div>
    </div>
  );
}
```

### Streaming AI responses with useRealtimeRunWithStreams

Here's an example showing how to display streaming OpenAI responses using `useRealtimeRunWithStreams`:

```tsx theme={"theme":"css-variables"}
import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks";
import type { aiStreaming, STREAMS } from "./trigger/ai-streaming";

function MyComponent({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) {
  const { streams } = useRealtimeRunWithStreams<typeof aiStreaming, STREAMS>(runId, {
    accessToken: publicAccessToken,
  });

  if (!streams.openai) {
    return <div>Loading...</div>;
  }

  const text = streams.openai.join(""); // `streams.openai` is an array of strings

  return (
    <div>
      <h2>OpenAI response:</h2>
      <p>{text}</p>
    </div>
  );
}
```

### AI SDK with tools

When using the AI SDK with tools with `useRealtimeRunWithStreams`, you can access tool calls and results:

```tsx theme={"theme":"css-variables"}
import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks";
import type { aiStreamingWithTools, STREAMS } from "./trigger/ai-streaming";

function MyComponent({ runId, publicAccessToken }: { runId: string; publicAccessToken: string }) {
  const { streams } = useRealtimeRunWithStreams<typeof aiStreamingWithTools, STREAMS>(runId, {
    accessToken: publicAccessToken,
  });

  if (!streams.openai) {
    return <div>Loading...</div>;
  }

  // streams.openai is an array of TextStreamPart
  const toolCall = streams.openai.find(
    (stream) => stream.type === "tool-call" && stream.toolName === "getWeather"
  );
  const toolResult = streams.openai.find((stream) => stream.type === "tool-result");
  const textDeltas = streams.openai.filter((stream) => stream.type === "text-delta");

  const text = textDeltas.map((delta) => delta.textDelta).join("");
  const weatherLocation = toolCall ? toolCall.args.location : undefined;
  const weather = toolResult ? toolResult.result.temperature : undefined;

  return (
    <div>
      <h2>OpenAI response:</h2>
      <p>{text}</p>
      <h2>Weather:</h2>
      <p>
        {weatherLocation
          ? `The weather in ${weatherLocation} is ${weather} degrees.`
          : "No weather data"}
      </p>
    </div>
  );
}
```

### Throttling updates

The `useRealtimeRunWithStreams` hook accepts an `experimental_throttleInMs` option to throttle the updates from the server. This can be useful if you are getting too many updates and want to reduce the number of updates.

```tsx theme={"theme":"css-variables"}
import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks";

export function MyComponent({
  runId,
  publicAccessToken,
}: {
  runId: string;
  publicAccessToken: string;
}) {
  const { run, streams, error } = useRealtimeRunWithStreams(runId, {
    accessToken: publicAccessToken,
    experimental_throttleInMs: 1000, // Throttle updates to once per second
  });

  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <div>Run: {run.id}</div>
      {/* Display streams */}
    </div>
  );
}
```

All other options (accessToken, baseURL, enabled, id) work the same as the other realtime hooks.

For the newer `useRealtimeStream` hook, use the `throttleInMs` option instead (see [options above](#options)).

## Frequently asked questions

### How do I stream AI/LLM responses from a background task to React?

Define a typed stream in your task with `streams.define<string>()`, pipe your AI SDK response to it with `.pipe()`, then consume it in your component with `useRealtimeStream`. See [Streaming data from tasks](/docs/tasks/streams) for the task-side setup.

### What's the difference between streaming and run updates?

[Run updates](/docs/realtime/react-hooks/subscribe) track **run state** (status, metadata, tags). Streaming (this page) pipes **continuous data** your task produces. Use run updates for progress bars and status badges. Use streaming for AI chat output, live logs, or file processing results. You can use both at the same time.

### Can I send data back into a running task from React?

Yes. Use the `useInputStreamSend` hook to send data into a running task's input stream. This is useful for cancel buttons, user approvals, or any interactive flow. See [Input Streams](/docs/tasks/streams#input-streams) for the full guide.

### Do streams work with the Vercel AI SDK?

Yes. You can pipe a Vercel AI SDK `streamText` response directly into a Trigger.dev stream using `.pipe()`. The [Streaming data from tasks](/docs/tasks/streams) page has a complete AI streaming example.


# Run updates in React
Source: https://trigger.dev/docs/realtime/react-hooks/subscribe

Build progress bars, status indicators, and live dashboards by subscribing to background task updates from React components.

**Subscribe to a run and your component re-renders whenever its status, metadata, or tags change.** Build progress bars, deployment monitors, or any UI that needs to reflect what a background task is doing right now.

For streaming continuous data (AI tokens, file chunks), see [Streaming](/docs/realtime/react-hooks/streams) instead.

## Trigger + subscribe combo hooks

Trigger a task and immediately subscribe to its run. Details in the [triggering](/docs/realtime/react-hooks/triggering) section.

* **[`useRealtimeTaskTrigger`](/docs/realtime/react-hooks/triggering#userealtimetasktrigger)** - Trigger a task and subscribe to the run
* **[`useRealtimeTaskTriggerWithStreams`](/docs/realtime/react-hooks/triggering#userealtimetasktriggerwithstreams)** - Trigger a task and subscribe to both run updates and streams

## Subscribe hooks

### useRealtimeRun

The `useRealtimeRun` hook allows you to subscribe to a run by its ID.

```tsx theme={"theme":"css-variables"}
"use client"; // This is needed for Next.js App Router or other RSC frameworks

import { useRealtimeRun } from "@trigger.dev/react-hooks";

export function MyComponent({
  runId,
  publicAccessToken,
}: {
  runId: string;
  publicAccessToken: string;
}) {
  const { run, error } = useRealtimeRun(runId, {
    accessToken: publicAccessToken,
  });

  if (error) return <div>Error: {error.message}</div>;

  return <div>Run: {run.id}</div>;
}
```

To correctly type the run's payload and output, you can provide the type of your task to the `useRealtimeRun` hook:

```tsx theme={"theme":"css-variables"}
import { useRealtimeRun } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";

export function MyComponent({
  runId,
  publicAccessToken,
}: {
  runId: string;
  publicAccessToken: string;
}) {
  const { run, error } = useRealtimeRun<typeof myTask>(runId, {
    accessToken: publicAccessToken,
  });

  if (error) return <div>Error: {error.message}</div>;

  // Now run.payload and run.output are correctly typed

  return <div>Run: {run.id}</div>;
}
```

You can supply an `onComplete` callback to the `useRealtimeRun` hook to be called when the run is completed or errored. This is useful if you want to perform some action when the run is completed, like navigating to a different page or showing a notification.

```tsx theme={"theme":"css-variables"}
import { useRealtimeRun } from "@trigger.dev/react-hooks";

export function MyComponent({
  runId,
  publicAccessToken,
}: {
  runId: string;
  publicAccessToken: string;
}) {
  const { run, error } = useRealtimeRun(runId, {
    accessToken: publicAccessToken,
    onComplete: (run, error) => {
      console.log("Run completed", run);
    },
  });

  if (error) return <div>Error: {error.message}</div>;

  return <div>Run: {run.id}</div>;
}
```

When you only need run status (for example, a progress bar or completion badge), you can omit large fields like `payload` and `output` by passing `skipColumns`. This reduces the data sent over the wire and avoids issues such as "Large HTTP Payload" warnings in tools like Sentry.

```tsx theme={"theme":"css-variables"}
import { useRealtimeRun } from "@trigger.dev/react-hooks";

export function RunStatusBadge({
  runId,
  publicAccessToken,
}: {
  runId: string;
  publicAccessToken: string;
}) {
  const { run, error } = useRealtimeRun(runId, {
    accessToken: publicAccessToken,
    skipColumns: ["payload", "output"],
  });

  if (error) return <span>Error</span>;
  if (!run) return <span>Loading…</span>;

  return <span>Status: {run.status}</span>;
}
```

You can skip any of: `payload`, `output`, `metadata`, `startedAt`, `delayUntil`, `queuedAt`, `expiredAt`, `completedAt`, `number`, `isTest`, `usageDurationMs`, `costInCents`, `baseCostInCents`, `ttl`, `payloadType`, `outputType`, `runTags`, `error`. The `useRealtimeRunsWithTag` hook also accepts a `skipColumns` option in the same way.

See our [run object reference](/docs/realtime/run-object) for the complete schema and [How it Works documentation](/docs/realtime/how-it-works) for more technical details.

### useRealtimeRunsWithTag

The `useRealtimeRunsWithTag` hook allows you to subscribe to multiple runs with a specific tag.

```tsx theme={"theme":"css-variables"}
"use client"; // This is needed for Next.js App Router or other RSC frameworks

import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";

export function MyComponent({ tag }: { tag: string }) {
  const { runs, error } = useRealtimeRunsWithTag(tag);

  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      {runs.map((run) => (
        <div key={run.id}>Run: {run.id}</div>
      ))}
    </div>
  );
}
```

To correctly type the runs payload and output, you can provide the type of your task to the `useRealtimeRunsWithTag` hook:

```tsx theme={"theme":"css-variables"}
import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";

export function MyComponent({ tag }: { tag: string }) {
  const { runs, error } = useRealtimeRunsWithTag<typeof myTask>(tag);

  if (error) return <div>Error: {error.message}</div>;

  // Now runs[i].payload and runs[i].output are correctly typed

  return (
    <div>
      {runs.map((run) => (
        <div key={run.id}>Run: {run.id}</div>
      ))}
    </div>
  );
}
```

If `useRealtimeRunsWithTag` could return multiple different types of tasks, you can pass a union of all the task types to the hook:

```tsx theme={"theme":"css-variables"}
import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";
import type { myTask1, myTask2 } from "@/trigger/myTasks";

export function MyComponent({ tag }: { tag: string }) {
  const { runs, error } = useRealtimeRunsWithTag<typeof myTask1 | typeof myTask2>(tag);

  if (error) return <div>Error: {error.message}</div>;

  // You can narrow down the type of the run based on the taskIdentifier
  for (const run of runs) {
    if (run.taskIdentifier === "my-task-1") {
      // run is correctly typed as myTask1
    } else if (run.taskIdentifier === "my-task-2") {
      // run is correctly typed as myTask2
    }
  }

  return (
    <div>
      {runs.map((run) => (
        <div key={run.id}>Run: {run.id}</div>
      ))}
    </div>
  );
}
```

### useRealtimeBatch

The `useRealtimeBatch` hook allows you to subscribe to a batch of runs by its the batch ID.

```tsx theme={"theme":"css-variables"}
"use client"; // This is needed for Next.js App Router or other RSC frameworks

import { useRealtimeBatch } from "@trigger.dev/react-hooks";

export function MyComponent({ batchId }: { batchId: string }) {
  const { runs, error } = useRealtimeBatch(batchId);

  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      {runs.map((run) => (
        <div key={run.id}>Run: {run.id}</div>
      ))}
    </div>
  );
}
```

See our [Realtime documentation](/docs/realtime) for more information.

## Using metadata to show progress in your UI

All realtime hooks automatically include metadata updates. Whenever your task updates metadata using `metadata.set()`, `metadata.append()`, or other metadata methods, your component will re-render with the updated data.

<Note>To learn how to write tasks using metadata, see our [metadata](/docs/runs/metadata) guide.</Note>

### Progress monitoring

This example demonstrates how to create a progress monitor component that can be used to display the progress of a run:

```tsx theme={"theme":"css-variables"}
"use client"; // This is needed for Next.js App Router or other RSC frameworks

import { useRealtimeRun } from "@trigger.dev/react-hooks";

export function ProgressMonitor({
  runId,
  publicAccessToken,
}: {
  runId: string;
  publicAccessToken: string;
}) {
  const { run, error, isLoading } = useRealtimeRun(runId, {
    accessToken: publicAccessToken,
  });

  if (isLoading) return <div>Loading run...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!run) return <div>Run not found</div>;

  const progress = run.metadata?.progress as
    | {
        current: number;
        total: number;
        percentage: number;
        currentItem: string;
      }
    | undefined;

  return (
    <div className="space-y-4">
      <div>
        <h3>Run Status: {run.status}</h3>
        <p>Run ID: {run.id}</p>
      </div>

      {progress && (
        <div className="space-y-2">
          <div className="flex justify-between text-sm">
            <span>Progress</span>
            <span>{progress.percentage}%</span>
          </div>
          <div className="w-full bg-gray-200 rounded-full h-2">
            <div
              className="bg-blue-600 h-2 rounded-full transition-all duration-300"
              style={{ width: `${progress.percentage}%` }}
            />
          </div>
          <p className="text-sm text-gray-600">
            Processing: {progress.currentItem} ({progress.current}/{progress.total})
          </p>
        </div>
      )}
    </div>
  );
}
```

### Reusable progress bar

This example demonstrates how to create a reusable progress bar component that can be used to display the percentage progress of a run:

```tsx theme={"theme":"css-variables"}
"use client";

import { useRealtimeRun } from "@trigger.dev/react-hooks";

interface ProgressBarProps {
  runId: string;
  publicAccessToken: string;
  title?: string;
}

export function ProgressBar({ runId, publicAccessToken, title }: ProgressBarProps) {
  const { run } = useRealtimeRun(runId, {
    accessToken: publicAccessToken,
  });

  const progress = run?.metadata?.progress as
    | {
        current?: number;
        total?: number;
        percentage?: number;
        currentItem?: string;
      }
    | undefined;

  const percentage = progress?.percentage ?? 0;
  const isComplete = run?.status === "COMPLETED";
  const isFailed = run?.status === "FAILED";

  return (
    <div className="w-full space-y-2">
      {title && <h4 className="font-medium">{title}</h4>}

      <div className="w-full bg-gray-200 rounded-full h-3">
        <div
          className={`h-3 rounded-full transition-all duration-500 ${
            isFailed ? "bg-red-500" : isComplete ? "bg-green-500" : "bg-blue-500"
          }`}
          style={{ width: `${percentage}%` }}
        />
      </div>

      <div className="flex justify-between text-sm text-gray-600">
        <span>
          {progress?.current && progress?.total
            ? `${progress.current}/${progress.total} items`
            : "Processing..."}
        </span>
        <span>{percentage}%</span>
      </div>

      {progress?.currentItem && (
        <p className="text-sm text-gray-500 truncate">Current: {progress.currentItem}</p>
      )}
    </div>
  );
}
```

### Status indicator with logs

This example demonstrates how to create a status indicator component that can be used to display the status of a run, and also logs that are emitted by the task:

```tsx theme={"theme":"css-variables"}
"use client";

import { useRealtimeRun } from "@trigger.dev/react-hooks";

interface StatusIndicatorProps {
  runId: string;
  publicAccessToken: string;
}

export function StatusIndicator({ runId, publicAccessToken }: StatusIndicatorProps) {
  const { run } = useRealtimeRun(runId, {
    accessToken: publicAccessToken,
  });

  const status = run?.metadata?.status as string | undefined;
  const logs = run?.metadata?.logs as string[] | undefined;

  const getStatusColor = (status: string | undefined) => {
    switch (status) {
      case "completed":
        return "text-green-600 bg-green-100";
      case "failed":
        return "text-red-600 bg-red-100";
      case "running":
        return "text-blue-600 bg-blue-100";
      default:
        return "text-gray-600 bg-gray-100";
    }
  };

  return (
    <div className="space-y-4">
      <div className="flex items-center space-x-2">
        <span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(status)}`}>
          {status || run?.status || "Unknown"}
        </span>
        <span className="text-sm text-gray-500">Run {run?.id}</span>
      </div>

      {logs && logs.length > 0 && (
        <div className="bg-gray-50 rounded-lg p-4">
          <h4 className="font-medium mb-2">Logs</h4>
          <div className="space-y-1 max-h-48 overflow-y-auto">
            {logs.map((log, index) => (
              <div key={index} className="text-sm text-gray-700 font-mono">
                {log}
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}
```

### Multi-stage deployment monitor

This example demonstrates how to create a multi-stage deployment monitor component that can be used to display the progress of a deployment:

```tsx theme={"theme":"css-variables"}
"use client";

import { useRealtimeRun } from "@trigger.dev/react-hooks";

interface DeploymentMonitorProps {
  runId: string;
  publicAccessToken: string;
}

const DEPLOYMENT_STAGES = [
  "initializing",
  "building",
  "testing",
  "deploying",
  "verifying",
  "completed",
] as const;

export function DeploymentMonitor({ runId, publicAccessToken }: DeploymentMonitorProps) {
  const { run } = useRealtimeRun(runId, {
    accessToken: publicAccessToken,
  });

  const status = run?.metadata?.status as string | undefined;
  const logs = run?.metadata?.logs as string[] | undefined;
  const currentStageIndex = DEPLOYMENT_STAGES.indexOf(status as any);

  return (
    <div className="space-y-6">
      <h3 className="text-lg font-semibold">Deployment Progress</h3>

      {/* Stage indicators */}
      <div className="space-y-4">
        {DEPLOYMENT_STAGES.map((stage, index) => {
          const isActive = currentStageIndex === index;
          const isCompleted = currentStageIndex > index;
          const isFailed = run?.status === "FAILED" && currentStageIndex === index;

          return (
            <div key={stage} className="flex items-center space-x-3">
              <div
                className={`w-6 h-6 rounded-full flex items-center justify-center text-sm font-medium ${
                  isFailed
                    ? "bg-red-500 text-white"
                    : isCompleted
                    ? "bg-green-500 text-white"
                    : isActive
                    ? "bg-blue-500 text-white"
                    : "bg-gray-200 text-gray-600"
                }`}
              >
                {isCompleted ? "✓" : index + 1}
              </div>
              <span
                className={`capitalize ${
                  isActive
                    ? "font-medium text-blue-600"
                    : isCompleted
                    ? "text-green-600"
                    : isFailed
                    ? "text-red-600"
                    : "text-gray-500"
                }`}
              >
                {stage}
              </span>
              {isActive && (
                <div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full" />
              )}
            </div>
          );
        })}
      </div>

      {/* Recent logs */}
      {logs && logs.length > 0 && (
        <div className="bg-black text-green-400 rounded-lg p-4 font-mono text-sm">
          <div className="space-y-1 max-h-32 overflow-y-auto">
            {logs.slice(-5).map((log, index) => (
              <div key={index}>
                <span className="text-gray-500">$ </span>
                {log}
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}
```

### Type safety

Define TypeScript interfaces for your metadata to get full type safety:

```tsx theme={"theme":"css-variables"}
"use client";

import { useRealtimeRun } from "@trigger.dev/react-hooks";

interface TaskMetadata {
  progress?: {
    current: number;
    total: number;
    percentage: number;
    currentItem: string;
  };
  status?: "initializing" | "processing" | "completed" | "failed";
  user?: {
    id: string;
    name: string;
  };
  logs?: string[];
}

export function TypedMetadataComponent({
  runId,
  publicAccessToken,
}: {
  runId: string;
  publicAccessToken: string;
}) {
  const { run } = useRealtimeRun(runId, {
    accessToken: publicAccessToken,
  });

  // Type-safe metadata access
  const metadata = run?.metadata as TaskMetadata | undefined;

  return (
    <div>
      {metadata?.progress && <p>Progress: {metadata.progress.percentage}%</p>}

      {metadata?.user && (
        <p>
          User: {metadata.user.name} ({metadata.user.id})
        </p>
      )}

      {metadata?.status && <p>Status: {metadata.status}</p>}
    </div>
  );
}
```

## Common options

### accessToken & baseURL

You can pass the `accessToken` option to the Realtime hooks to authenticate the subscription.

```tsx theme={"theme":"css-variables"}
import { useRealtimeRun } from "@trigger.dev/react-hooks";

export function MyComponent({
  runId,
  publicAccessToken,
}: {
  runId: string;
  publicAccessToken: string;
}) {
  const { run, error } = useRealtimeRun(runId, {
    accessToken: publicAccessToken,
    baseURL: "https://my-self-hosted-trigger.com", // Optional if you are using a self-hosted Trigger.dev instance
  });

  if (error) return <div>Error: {error.message}</div>;

  return <div>Run: {run.id}</div>;
}
```

### enabled

You can pass the `enabled` option to the Realtime hooks to enable or disable the subscription.

```tsx theme={"theme":"css-variables"}
import { useRealtimeRun } from "@trigger.dev/react-hooks";

export function MyComponent({
  runId,
  publicAccessToken,
  enabled,
}: {
  runId: string;
  publicAccessToken: string;
  enabled: boolean;
}) {
  const { run, error } = useRealtimeRun(runId, {
    accessToken: publicAccessToken,
    enabled,
  });

  if (error) return <div>Error: {error.message}</div>;

  return <div>Run: {run.id}</div>;
}
```

This allows you to conditionally disable using the hook based on some state.

### id

You can pass the `id` option to the Realtime hooks to change the ID of the subscription.

```tsx theme={"theme":"css-variables"}
import { useRealtimeRun } from "@trigger.dev/react-hooks";

export function MyComponent({
  id,
  runId,
  publicAccessToken,
  enabled,
}: {
  id: string;
  runId: string;
  publicAccessToken: string;
  enabled: boolean;
}) {
  const { run, error } = useRealtimeRun(runId, {
    accessToken: publicAccessToken,
    enabled,
    id,
  });

  if (error) return <div>Error: {error.message}</div>;

  return <div>Run: {run.id}</div>;
}
```

This allows you to change the ID of the subscription based on some state. Passing in a different ID will unsubscribe from the current subscription and subscribe to the new one (and remove any cached data).

## Frequently asked questions

### How do I show a progress bar for a background task in React?

Use `metadata.set()` inside your task to update a progress value, then read it with `useRealtimeRun` in your component. The hook re-renders your component on every metadata change. See [Using metadata to show progress](#using-metadata-to-show-progress-in-your-ui) above for a complete example.

### What's the difference between run updates and streaming?

Run updates (this page) give you **run state**: status, metadata, and tags. They're for progress bars, status badges, and dashboards. [Streaming](/docs/realtime/react-hooks/streams) gives you **continuous data** like AI tokens or file chunks. Use run updates for "how far along is my task?" and streaming for "show me the output as it generates."

### Can I subscribe to multiple runs at once?

Yes. Use `useRealtimeRunsWithTag` to subscribe to all runs with a specific tag (e.g., `user:123`), or `useRealtimeBatch` for all runs in a batch. Each yields an array of run objects that update in real time.

### Do I need to set up polling or WebSockets?

No. The hooks handle the connection automatically using Trigger.dev's Realtime infrastructure (built on [Electric SQL](/docs/realtime/how-it-works)). Just pass a run ID and an access token.


# SWR hooks
Source: https://trigger.dev/docs/realtime/react-hooks/swr

Fetch and cache data using SWR-based hooks

SWR hooks use the [swr](https://swr.vercel.app/) library to fetch data once and cache it. These hooks are useful when you need to fetch data without real-time updates.

<Note>
  While SWR can be configured to poll for updates, we recommend using our other [Realtime
  hooks](/docs/realtime/react-hooks/) for most use-cases due to rate-limits and the way the Trigger.dev
  API works.
</Note>

## useRun

The `useRun` hook allows you to fetch a run by its ID.

```tsx theme={"theme":"css-variables"}
"use client"; // This is needed for Next.js App Router or other RSC frameworks

import { useRun } from "@trigger.dev/react-hooks";

export function MyComponent({ runId }: { runId: string }) {
  const { run, error, isLoading } = useRun(runId);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return <div>Run: {run.id}</div>;
}
```

The `run` object returned is the same as the [run object](/docs/management/runs/retrieve) returned by the Trigger.dev API. To correctly type the run's payload and output, you can provide the type of your task to the `useRun` hook:

```tsx theme={"theme":"css-variables"}
import { useRun } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";

export function MyComponent({ runId }: { runId: string }) {
  const { run, error, isLoading } = useRun<typeof myTask>(runId, {
    refreshInterval: 0, // Disable polling
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  // Now run.payload and run.output are correctly typed

  return <div>Run: {run.id}</div>;
}
```

## Common SWR options

You can pass the following options to the all SWR hooks:

<ParamField type="boolean">
  Revalidate the data when the window regains focus.
</ParamField>

<ParamField type="boolean">
  Revalidate the data when the browser regains a network connection.
</ParamField>

<ParamField type="number">
  Poll for updates at the specified interval (in milliseconds). Polling is not recommended for most
  use-cases. Use the Realtime hooks instead.
</ParamField>

## Common SWR return values

<ResponseField name="error" type="Error">
  An error object if an error occurred while fetching the data.
</ResponseField>

<ResponseField name="isLoading" type="boolean">
  A boolean indicating if the data is currently being fetched.
</ResponseField>

<ResponseField name="isValidating" type="boolean">
  A boolean indicating if the data is currently being revalidated.
</ResponseField>

<ResponseField name="isError" type="boolean">
  A boolean indicating if an error occurred while fetching the data.
</ResponseField>


# Trigger tasks from React
Source: https://trigger.dev/docs/realtime/react-hooks/triggering

Trigger background tasks from React components and optionally subscribe to their progress or stream their output.

Trigger tasks directly from your React components. You can fire-and-forget, or trigger and immediately subscribe to the run's progress or streamed output.

<Note>
  For triggering tasks from your frontend, you need to use “trigger” tokens. These can only be used
  once to trigger a task and are more secure than regular Public Access Tokens. To learn more about
  how to create and use these tokens, see our [Trigger
  Tokens](/docs/realtime/auth#trigger-tokens-for-frontend-triggering-only) documentation.
</Note>

## Hooks

We provide three hooks for triggering tasks from your frontend application:

* `useTaskTrigger` - Trigger a task from your frontend application.
* `useRealtimeTaskTrigger` - Trigger a task from your frontend application and subscribe to the run.
* `useRealtimeTaskTriggerWithStreams` - Trigger a task from your frontend application and subscribe to the run, and also receive any streams that are emitted by the task.

### useTaskTrigger

The `useTaskTrigger` hook allows you to trigger a task from your frontend application.

```tsx theme={"theme":"css-variables"}
"use client"; // This is needed for Next.js App Router or other RSC frameworks

import { useTaskTrigger } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";
//     👆 This is the type of your task, include this to get type-safety

export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
  //                         pass the type of your task here 👇
  const { submit, handle, error, isLoading } = useTaskTrigger<typeof myTask>("my-task", {
    accessToken: publicAccessToken, // 👈 this is the "trigger" token
  });

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  if (handle) {
    return <div>Run ID: {handle.id}</div>;
  }

  return (
    <button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
      {isLoading ? "Loading..." : "Trigger Task"}
    </button>
  );
}
```

`useTaskTrigger` returns an object with the following properties:

* `submit`: A function that triggers the task. It takes the payload of the task as an argument.
* `handle`: The run handle object. This object contains the ID of the run that was triggered, along with a Public Access Token that can be used to access the run.
* `isLoading`: A boolean that indicates whether the task is currently being triggered.
* `error`: An error object that contains any errors that occurred while triggering the task.

The `submit` function triggers the task with the specified payload. You can additionally pass an optional [options](/docs/triggering#options) argument to the `submit` function:

```tsx theme={"theme":"css-variables"}
submit({ foo: "bar" }, { tags: ["tag1", "tag2"] });
```

#### Using the handle object

You can use the `handle` object to initiate a subsequent [Realtime hook](/docs/realtime/react-hooks/subscribe#userealtimerun) to subscribe to the run.

```tsx theme={"theme":"css-variables"}
"use client"; // This is needed for Next.js App Router or other RSC frameworks

import { useTaskTrigger, useRealtimeRun } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";
//     👆 This is the type of your task

export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
  //                         pass the type of your task here 👇
  const { submit, handle, error, isLoading } = useTaskTrigger<typeof myTask>("my-task", {
    accessToken: publicAccessToken, // 👈 this is the "trigger" token
  });

  //     use the handle object to preserve type-safety 👇
  const { run, error: realtimeError } = useRealtimeRun(handle, {
    accessToken: handle?.publicAccessToken,
    enabled: !!handle, // Only subscribe to the run if the handle is available
  });

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  if (handle) {
    return <div>Run ID: {handle.id}</div>;
  }

  if (realtimeError) {
    return <div>Error: {realtimeError.message}</div>;
  }

  if (run) {
    return <div>Run ID: {run.id}</div>;
  }

  return (
    <button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
      {isLoading ? "Loading..." : "Trigger Task"}
    </button>
  );
}
```

We've also created some additional hooks that allow you to trigger tasks and subscribe to the run in one step:

### useRealtimeTaskTrigger

The `useRealtimeTaskTrigger` hook allows you to trigger a task from your frontend application and then subscribe to the run in using Realtime:

```tsx theme={"theme":"css-variables"}
"use client"; // This is needed for Next.js App Router or other RSC frameworks

import { useRealtimeTaskTrigger } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";

export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
  const { submit, run, error, isLoading } = useRealtimeTaskTrigger<typeof myTask>("my-task", {
    accessToken: publicAccessToken,
  });

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  // This is the Realtime run object, which will automatically update when the run changes
  if (run) {
    return <div>Run ID: {run.id}</div>;
  }

  return (
    <button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
      {isLoading ? "Loading..." : "Trigger Task"}
    </button>
  );
}
```

### useRealtimeTaskTriggerWithStreams

The `useRealtimeTaskTriggerWithStreams` hook allows you to trigger a task from your frontend application and then subscribe to the run in using Realtime, and also receive any streams that are emitted by the task.

```tsx theme={"theme":"css-variables"}
"use client"; // This is needed for Next.js App Router or other RSC frameworks

import { useRealtimeTaskTriggerWithStreams } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";

type STREAMS = {
  openai: string; // this is the type of each "part" of the stream
};

export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
  const { submit, run, streams, error, isLoading } = useRealtimeTaskTriggerWithStreams<
    typeof myTask,
    STREAMS
  >("my-task", {
    accessToken: publicAccessToken,
  });

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  if (streams && run) {
    const text = streams.openai?.map((part) => part).join("");

    return (
      <div>
        <div>Run ID: {run.id}</div>
        <div>{text}</div>
      </div>
    );
  }

  return (
    <button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
      {isLoading ? "Loading..." : "Trigger Task"}
    </button>
  );
}
```


# useWaitToken
Source: https://trigger.dev/docs/realtime/react-hooks/use-wait-token

Use the useWaitToken hook to complete a wait token from a React component

We've added a new `useWaitToken` react hook that allows you to complete a wait token from a React component, using a Public Access Token.

```ts backend.ts theme={"theme":"css-variables"}
import { wait } from "@trigger.dev/sdk";

// Somewhere in your code, you'll need to create the token and then pass the token ID and the public token to the frontend
const token = await wait.createToken({
  timeout: "10m",
});

return {
  tokenId: token.id,
  publicToken: token.publicAccessToken, // An automatically generated public access token that expires in 1 hour
};
```

Now you can use the `useWaitToken` hook in your frontend code:

```tsx frontend.tsx theme={"theme":"css-variables"}
import { useWaitToken } from "@trigger.dev/react-hooks";

export function MyComponent({ publicToken, tokenId }: { publicToken: string; tokenId: string }) {
  const { complete } = useWaitToken(tokenId, {
    accessToken: publicToken,
  });

  return <button onClick={() => complete({ foo: "bar" })}>Complete</button>;
}
```


# The run object
Source: https://trigger.dev/docs/realtime/run-object

The run object schema for Realtime subscriptions

The [run object](/docs/realtime/run-object#the-run-object) is the main object returned by Realtime subscriptions (e.g., `runs.subscribeToRun()`). It contains all the information about the run, including the run ID, task identifier, payload, output, and more.

Type-safety is supported for the run object, so you can infer the types of the run's payload and output. See [type-safety](#type-safety) for more information.

## The run object

### Properties

<ParamField type="string">
  The run ID.
</ParamField>

<ParamField type="string">
  The task identifier.
</ParamField>

<ParamField type="object">
  The input payload for the run.
</ParamField>

<ParamField type="object">
  The output result of the run.
</ParamField>

<ParamField type="Date">
  Timestamp when the run was created.
</ParamField>

<ParamField type="Date">
  Timestamp when the run was last updated.
</ParamField>

<ParamField type="number">
  Sequential number assigned to the run.
</ParamField>

<ParamField type="RunStatus">
  Current status of the run.

  <Accordion title="RunStatus enum">
    | Status               | Description                                                                                               |
    | -------------------- | --------------------------------------------------------------------------------------------------------- |
    | `WAITING_FOR_DEPLOY` | Task hasn't been deployed yet but is waiting to be executed                                               |
    | `QUEUED`             | Run is waiting to be executed by a worker                                                                 |
    | `EXECUTING`          | Run is currently being executed by a worker                                                               |
    | `REATTEMPTING`       | Run has failed and is waiting to be retried                                                               |
    | `FROZEN`             | Run has been paused by the system, and will be resumed by the system                                      |
    | `COMPLETED`          | Run has been completed successfully                                                                       |
    | `CANCELED`           | Run has been canceled by the user                                                                         |
    | `FAILED`             | Run has been completed with errors                                                                        |
    | `CRASHED`            | Run has crashed and won't be retried, most likely the worker ran out of resources, e.g. memory or storage |
    | `INTERRUPTED`        | Run was interrupted during execution, mostly this happens in development environments                     |
    | `SYSTEM_FAILURE`     | Run has failed to complete, due to an error in the system                                                 |
    | `DELAYED`            | Run has been scheduled to run at a specific time                                                          |
    | `EXPIRED`            | Run has expired and won't be executed                                                                     |
    | `TIMED_OUT`          | Run has reached it's maxDuration and has been stopped                                                     |
  </Accordion>
</ParamField>

<ParamField type="number">
  Duration of the run in milliseconds.
</ParamField>

<ParamField type="number">
  Total cost of the run in cents.
</ParamField>

<ParamField type="number">
  Base cost of the run in cents before any additional charges.
</ParamField>

<ParamField type="string[]">
  Array of tags associated with the run.
</ParamField>

<ParamField type="string">
  Key used to ensure idempotent execution.
</ParamField>

<ParamField type="Date">
  Timestamp when the run expired.
</ParamField>

<ParamField type="string">
  Time-to-live duration for the run.
</ParamField>

<ParamField type="Date">
  Timestamp when the run finished.
</ParamField>

<ParamField type="Date">
  Timestamp when the run started.
</ParamField>

<ParamField type="Date">
  Timestamp until which the run is delayed.
</ParamField>

<ParamField type="Date">
  Timestamp when the run was queued.
</ParamField>

<ParamField type="Record<string, DeserializedJson>">
  Additional metadata associated with the run.
</ParamField>

<ParamField type="SerializedError">
  Error information if the run failed.
</ParamField>

<ParamField type="boolean">
  Indicates whether this is a test run.
</ParamField>

## Type-safety

You can infer the types of the run's payload and output by passing the type of the task to the `subscribeToRun` function. This will give you type-safe access to the run's payload and output.

```ts theme={"theme":"css-variables"}
import { runs, tasks } from "@trigger.dev/sdk";
import type { myTask } from "./trigger/my-task";

// Somewhere in your backend code
async function myBackend() {
  const handle = await tasks.trigger("my-task", { some: "data" });

  for await (const run of runs.subscribeToRun<typeof myTask>(handle.id)) {
    // This will log the run every time it changes
    console.log(run.payload.some);

    if (run.output) {
      // This will log the output if it exists
      console.log(run.output.some);
    }
  }
}
```

When using `subscribeToRunsWithTag`, you can pass a union of task types for all the possible tasks that can have the tag.

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";
import type { myTask, myOtherTask } from "./trigger/my-task";

// Somewhere in your backend code
for await (const run of runs.subscribeToRunsWithTag<typeof myTask | typeof myOtherTask>("my-tag")) {
  // You can narrow down the type based on the taskIdentifier
  switch (run.taskIdentifier) {
    case "my-task": {
      console.log("Run output:", run.output.foo); // This will be type-safe
      break;
    }
    case "my-other-task": {
      console.log("Run output:", run.output.bar); // This will be type-safe
      break;
    }
  }
}
```

This works with all realtime subscription functions:

* `runs.subscribeToRun<TaskType>()`
* `runs.subscribeToRunsWithTag<TaskType>()`
* `runs.subscribeToBatch<TaskType>()`


# Replaying
Source: https://trigger.dev/docs/replaying

A replay is a copy of a run with the same payload but against the latest version in that environment. This is useful if something went wrong and you want to try again with the latest version of your code.

### Replaying from the UI

<Tabs>
  <Tab title="From a run">
    <Steps>
      <Step title="Click the Replay button in the top right">
        <img
          alt="Select a task, then in the bottom right
click &#x22;Replay&#x22;"
        />
      </Step>

      <Step title="Confirm replay settings">
        You can edit the payload <Icon icon="circle-1" /> (if available) and choose the environment <Icon icon="circle-2" /> to replay the run in.

        <img
          alt="Select a task, then in the bottom right
click &#x22;Replay&#x22;"
        />
      </Step>
    </Steps>
  </Tab>

  <Tab title="Runs list">
    <Steps>
      <Step title="Click the action button on a run">
        <img alt="On the runs page, press the triple dot button" />
      </Step>

      <Step title="Click replay">
        <img alt="Click replay" />
      </Step>
    </Steps>
  </Tab>
</Tabs>

### Detecting replays in your task

You can check if a run is a replay using the [context](/docs/context) object:

```ts theme={"theme":"css-variables"}
export const myTask = task({
  id: "my-task",
  run: async (payload, { ctx }) => {
    if (ctx.run.isReplay) {
      // This run is a replay of a previous run
    }
  },
});
```

### Replaying using the SDK

You can replay a run using the SDK:

```ts theme={"theme":"css-variables"}
const replayedRun = await runs.replay(run.id);
```

When you call `trigger()` or `batchTrigger()` on a task you receive back a run handle which has an `id` property. You can use that `id` to replay the run.

You can also access the run id from inside a run. You could write this to your database and then replay it later.

```ts theme={"theme":"css-variables"}
export const simpleChildTask = task({
  id: "simple-child-task",
  run: async (payload, { ctx }) => {
    // the run ID (and other useful info) is in ctx
    const runId = ctx.run.id;
  },
});
```

### Bulk replaying

See [Bulk actions](/docs/bulk-actions) for more information.


# Request a feature
Source: https://trigger.dev/docs/request-feature



If you have a feature request or idea for Trigger, we'd love to hear it! You can submit your ideas on our [public roadmap](https://feedback.trigger.dev/). We're always looking for feedback on what to build next, so feel free to submit your ideas or vote on existing ones.


# Roadmap
Source: https://trigger.dev/docs/roadmap



See what's coming up next on our [public roadmap](https://feedback.trigger.dev/roadmap). We're always looking for feedback on what to build next, so feel free to submit your ideas or vote on existing ones.


# Run tests
Source: https://trigger.dev/docs/run-tests

You can use the dashboard to run a test of your tasks.

You can run a test for any of your tasks, in any environment, from the dashboard.

<Note>
  There is no longer a "Test" page in the sidebar. You test a task from the task itself: on the
  Tasks page open a task (or use the "Test" action next to a task in the Runs list) and press the
  "Test" button to open its test page.
</Note>

On a task's test page you can:

* Enter the run's input — a JSON **payload** and optional **metadata**. Scheduled tasks show timestamp fields instead of a payload.
* Configure run **options** like the machine size, version, queue, tags, retries, max duration, or a delay.
* Pre-populate the form from a previous run with **Recent runs**, or save and reuse a configuration with **Templates**.
* Press **Run test** to trigger the run.

<img alt="Test page" />


# Usage
Source: https://trigger.dev/docs/run-usage

Get compute duration and cost from inside a run, or for a specific block of code.

## Getting the run cost and duration

You can get the cost and duration of the current including retries of the same run.

```ts theme={"theme":"css-variables"}
import { task, usage, wait } from "@trigger.dev/sdk";

export const heavyTask = task({
  id: "heavy-task",
  machine: {
    preset: "medium-2x",
  },
  run: async (payload, { ctx }) => {
    // Do some compute
    const result = await convertVideo(payload.videoUrl);

    // Get the current cost and duration up until this line of code
    // This includes the compute time of the previous lines
    let currentUsage = usage.getCurrent();
    /* currentUsage = {
        compute: {
          attempt: {
            costInCents: 0.01700,
            durationMs: 1000,
          },
          total: {
            costInCents: 0.0255,
            durationMs: 1500,
          },
        },
        baseCostInCents: 0.0025,
        totalCostInCents: 0.028,
      } 
      */

    // In the cloud product we do not count waits towards the compute cost or duration.
    // We also don't include time between attempts or before the run starts executing your code.
    // So this line does not affect the cost or duration.
    await wait.for({ seconds: 5 });

    // This will give the same result as before the wait.
    currentUsage = usage.getCurrent();

    // Do more compute
    const result = await convertVideo(payload.videoUrl);

    // This would give a different value
    currentUsage = usage.getCurrent();
  },
});
```

<Note>
  In Trigger.dev cloud we do not include time between attempts, before your code executes, or waits
  towards the compute cost or duration.
</Note>

## Getting the run cost and duration from your backend

You can use [runs.retrieve()](/docs/management/runs/retrieve) to get a single run or [runs.list()](/docs/management/runs/list) to get a list of runs. The response will include `costInCents` `baseCostInCents` and `durationMs` fields.

```ts single run theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

const run = await runs.retrieve("run-id");
console.log(run.costInCents, run.baseCostInCents, run.durationMs);
const totalCost = run.costInCents + run.baseCostInCents;
```

```ts multiple runs theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

let totalCost = 0;
for await (const run of runs.list({ tag: "user_123456" })) {
  totalCost += run.costInCents + run.baseCostInCents;
  console.log(run.costInCents, run.baseCostInCents, run.durationMs);
}

console.log("Total cost", totalCost);
```

## Getting the cost and duration of a block of code

You can also wrap code with `usage.measure` to get the cost and duration of that block of code:

```ts theme={"theme":"css-variables"}
import { usage, logger } from "@trigger.dev/sdk";

// Inside a task run function, or inside a function that's called from there.
const { result, compute } = await usage.measure(async () => {
  //...Do something for 1 second
  return {
    foo: "bar",
  };
});

logger.info("Result", { result, compute });
/* result = {
    foo: "bar"
  }
  compute = {
    costInCents: 0.01700,
    durationMs: 1000,
  }
*/
```

This will work from inside the `run` function, our lifecycle hooks (like `onStart`, `onFailure`, `onSuccess`, etc.), or any function you're calling from the `run` function. It won't work for code that's not executed using Trigger.dev.


# Runs
Source: https://trigger.dev/docs/runs

Understanding the lifecycle of task run execution in Trigger.dev

In Trigger.dev, the concepts of runs and attempts are fundamental to understanding how tasks are executed and managed. This article explains these concepts in detail and provides insights into the various states a run can go through during its lifecycle.

## What are runs?

A run is created when you trigger a task (e.g. calling `yourTask.trigger({ foo: "bar" })`). It represents a single instance of a task being executed and contains the following key information:

* A unique run ID
* The current status of the run
* The payload (input data) for the task
* Lots of other metadata

## The run lifecycle

A run can go through **various** states during its lifecycle. The following diagram illustrates a typical state transition where a single run is triggered and completes successfully:

<img alt="Run Lifecycle" />

Runs can also find themselves in lots of other states depending on what's happening at any given time. The following sections describe all the possible states in more detail.

### Initial states

<Icon icon="rectangle-history" /> **Pending version**:
The task is waiting for a version update because it cannot execute without additional information (task, queue, etc.).

<Icon icon="clock" /> **Delayed**: When a run is triggered
with a delay, it enters this state until the specified delay period has passed.

<Icon icon="rectangle-history" /> **Queued**: The run is ready
to be executed and is waiting in the queue.

<Icon icon="rectangle-history" /> **Dequeued**: The task has been dequeued and is being sent to a worker to start executing.

### Execution states

<Icon icon="spinner-third" /> **Executing**: The task is
currently being executed by a worker.

<Icon icon="hourglass" /> **Waiting**: You have used a
[triggerAndWait()](/docs/triggering#yourtask-triggerandwait), [batchTriggerAndWait()](/docs/triggering#yourtask-batchtriggerandwait) or a [wait function](/docs/wait). When the wait is complete, the task will resume execution.

### Final states

<Icon icon="circle-check" /> **Completed**: The task has successfully
finished execution.

<Icon icon="ban" /> **Canceled**: The run was manually canceled
by the user.

<Icon icon="circle-xmark" /> **Failed**: The task has failed
to complete successfully due to an error in the task code.

<Icon icon="alarm-exclamation" /> **Timed out**: Task has
failed because it exceeded its `maxDuration`.

<Icon icon="fire" /> **Crashed**: The worker process crashed
during execution (likely due to an Out of Memory error) and won’t be retried.

<Icon icon="bug" /> **System failure**: An unrecoverable system
error has occurred.

<Icon icon="trash-can" /> **Expired**: The run's [Time-to-Live](#time-to-live-ttl)
(TTL) has passed before it could start executing.

## Attempts

An attempt represents a single execution of a task within a run. A run can have one or more attempts, depending on the task's retry settings and whether it fails. Each attempt has:

* A unique attempt ID
* A status
* An output (if successful) or an error (if failed)

When a task fails, it will be retried according to its retry settings, creating new attempts until it either succeeds or reaches the retry limit.

<img alt="Run with retries" />

## Run completion

A run is considered finished when:

1. The last attempt succeeds, or
2. The task has reached its retry limit and all attempts have failed

At this point, the run will have either an output (if successful) or an error (if failed).

## Boolean helpers

Run objects returned from the API and Realtime include convenient boolean helper methods to check the run's status:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

const run = await runs.retrieve("run_1234");

if (run.isCompleted) {
  console.log("Run completed successfully");
}
```

* **`isQueued`**: Returns `true` when the status is `QUEUED`, `PENDING_VERSION`, or `DELAYED`
* **`isExecuting`**: Returns `true` when the status is `EXECUTING` or `DEQUEUED`. These count against your concurrency limits.
* **`isWaiting`**: Returns `true` when the status is `WAITING`. These do not count against your concurrency limits.
* **`isCompleted`**: Returns `true` when the status is any of the completed statuses
* **`isCanceled`**: Returns `true` when the status is `CANCELED`
* **`isFailed`**: Returns `true` when the status is any of the failed statuses
* **`isSuccess`**: Returns `true` when the status is `COMPLETED`

These helpers are also available when subscribing to Realtime run updates:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

for await (const run of runs.subscribeToRun("run_1234")) {
  if (run.isCompleted) {
    console.log("Run completed successfully!");
    break;
  }
}
```

## Advanced run features

### Idempotency Keys

When triggering a task, you can provide an idempotency key to ensure the task is executed only once, even if triggered multiple times. This is useful for preventing duplicate executions in distributed systems.

```ts theme={"theme":"css-variables"}
await yourTask.trigger({ foo: "bar" }, { idempotencyKey: "unique-key" });
```

* If a run with the same idempotency key is already in progress, the new trigger will be ignored.
* If the run has already finished, the previous output or error will be returned.

See our [Idempotency docs](/docs/idempotency) for more information.

### Canceling runs

You can cancel an in-progress run using the API or the dashboard:

```ts theme={"theme":"css-variables"}
await runs.cancel(runId);
```

When a run is canceled:

– The task execution is stopped

– The run is marked as canceled

– The task will not be retried

– Any in-progress child runs are also canceled

### Time-to-live (TTL)

TTL is a time-to-live setting that defines the maximum duration a run can remain in a queued state before being automatically expired. You can set a TTL at three levels, with the following precedence:

1. **Per-trigger** (highest priority):

```ts theme={"theme":"css-variables"}
await yourTask.trigger({ foo: "bar" }, { ttl: "10m" });
```

2. **Task-level default**:

```ts theme={"theme":"css-variables"}
export const myTask = task({
  id: "my-task",
  ttl: "10m",
  run: async (payload) => {
    //...
  },
});
```

3. **Global config default** (lowest priority):

```ts trigger.config.ts theme={"theme":"css-variables"}
export default defineConfig({
  project: "<project ref>",
  ttl: "1h",
});
```

To opt out of a config-level or task-level TTL for a specific trigger, pass `ttl: 0`:

```ts theme={"theme":"css-variables"}
// This run will not have a TTL, even if the task or config defines one
await yourTask.trigger({ foo: "bar" }, { ttl: 0 });
```

Similarly, to opt out of a config-level TTL for a specific task, set `ttl: 0` on the task definition:

```ts theme={"theme":"css-variables"}
export const longRunningTask = task({
  id: "long-running-task",
  ttl: 0, // Opt out of the config-level TTL
  run: async (payload) => {
    //...
  },
});
```

<Note>
  Setting `ttl: 0` removes the config-level or task-level TTL, but the platform-level maximum still applies. See [Limits - Maximum run TTL](/docs/limits#maximum-run-ttl) for details.
</Note>

If the run hasn't started within the specified TTL, it will automatically expire, returning the status `Expired`. This is useful for time-sensitive tasks where immediate execution is important. For example, when you queue many runs simultaneously and exceed your concurrency limits, some runs might be delayed - using TTL ensures they only execute if they can start within your specified timeframe.

Dev runs automatically have a 10-minute TTL. On Trigger.dev Cloud, staging and production runs have a maximum TTL of 14 days applied automatically (runs without an explicit TTL get 14 days; longer TTLs are clamped). See [Limits — Maximum run TTL](/docs/limits#maximum-run-ttl) for details.

<img alt="Run with TTL" />

### Delayed runs

You can schedule a run to start after a specified delay:

```ts theme={"theme":"css-variables"}
await yourTask.trigger({ foo: "bar" }, { delay: "1h" });
```

This is useful for tasks that need to be executed at a specific time in the future.

<img alt="Run with delay" />

### Replaying runs

You can create a new run with the same payload as a previous run:

```ts theme={"theme":"css-variables"}
await runs.replay(runId);
```

This is useful for re-running a task with the same input, especially for debugging or recovering from failures. The new run will use the latest version of the task.

You can also replay runs from the dashboard using the same or different payload. Learn how to do this [here](/docs/replaying).

### Waiting for runs

#### triggerAndWait()

The `triggerAndWait()` function triggers a task and then lets you wait for the result before continuing. [Learn more about triggerAndWait()](/docs/triggering#yourtask-triggerandwait).

<img alt="Run with triggerAndWait" />

#### batchTriggerAndWait()

Similar to `triggerAndWait()`, the `batchTriggerAndWait()` function lets you batch trigger a task and wait for all the results [Learn more about batchTriggerAndWait()](/docs/triggering#yourtask-batchtriggerandwait).

<img alt="Run with batchTriggerAndWait" />

### Runs API

#### runs.list()

List runs in a specific environment. You can filter the runs by status, created at, task identifier, version, and more:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

// Get the first page of runs, returning up to 20 runs
let page = await runs.list({ limit: 20 });

for (const run of page.data) {
  console.log(run);
}

// Keep getting the next page until there are no more runs
while (page.hasNextPage()) {
  page = await page.getNextPage();
  // Do something with the next page of runs
}
```

You can also use an Async Iterator to get all runs:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

for await (const run of runs.list({ limit: 20 })) {
  console.log(run);
}
```

You can provide multiple filters to the `list()` function to narrow down the results:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

const response = await runs.list({
  status: ["QUEUED", "EXECUTING"], // Filter by status
  taskIdentifier: ["my-task", "my-other-task"], // Filter by task identifier
  from: new Date("2024-04-01T00:00:00Z"), // Filter by created at
  to: new Date(),
  version: "20241127.2", // Filter by deployment version,
  tag: ["tag1", "tag2"], // Filter by tags
  batch: "batch_1234", // Filter by batch ID
  schedule: "sched_1234", // Filter by schedule ID
});
```

#### runs.retrieve()

Fetch a single run by it's ID:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

const run = await runs.retrieve(runId);
```

You can provide the type of the task to correctly type the `run.payload` and `run.output`:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";
import type { myTask } from "./trigger/myTask";

const run = await runs.retrieve<typeof myTask>(runId);

console.log(run.payload.foo); // string
console.log(run.output.bar); // string
```

If you have just triggered a run, you can pass the entire response object to `retrieve()` and the response will already be typed:

```ts theme={"theme":"css-variables"}
import { runs, tasks } from "@trigger.dev/sdk";
import type { myTask } from "./trigger/myTask";

const response = await tasks.trigger<typeof myTask>({ foo: "bar" });
const run = await runs.retrieve(response);

console.log(run.payload.foo); // string
console.log(run.output.bar); // string
```

#### runs.cancel()

Cancel a run:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

await runs.cancel(runId);
```

#### runs.replay()

Replay a run:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

await runs.replay(runId);
```

#### runs.reschedule()

Updates a delayed run with a new delay. Only valid when the run is in the DELAYED state.

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

await runs.reschedule(runId, { delay: "1h" });
```

### Real-time updates

Subscribe to changes to a specific run in real-time:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

for await (const run of runs.subscribeToRun(runId)) {
  console.log(run);
}
```

Similar to `runs.retrieve()`, you can provide the type of the task to correctly type the `run.payload` and `run.output`:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";
import type { myTask } from "./trigger/myTask";

for await (const run of runs.subscribeToRun<typeof myTask>(runId)) {
  console.log(run.payload.foo); // string
  console.log(run.output?.bar); // string | undefined
}
```

For more on real-time updates, see the [Realtime](/docs/realtime) documentation.

### Triggering runs for undeployed tasks

It's possible to trigger a run for a task that hasn't been deployed yet. The run will enter the "Waiting for deploy" state until the task is deployed. Once deployed, the run will be queued and executed normally.
This feature is particularly useful in CI/CD pipelines where you want to trigger tasks before the deployment is complete.


# Bulk actions with the SDK
Source: https://trigger.dev/docs/runs/bulk-actions

Cancel or replay many runs from the SDK using run IDs or SDK run-list filters.

**Bulk actions let you cancel or replay many runs asynchronously from the SDK by selecting runs with run IDs or SDK run-list filters.**

A bulk action returns a handle immediately. Use the handle to retrieve progress, poll until completion, list previous actions, or abort pending work.

## Create a bulk replay

Use `runs.bulk.replay()` to replay every run that matches a filter.

```ts Your backend code theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

const action = await runs.bulk.replay({
  filter: {
    status: "FAILED",
    taskIdentifier: "sync-customer",
    period: "24h",
  },
  name: "Replay failed customer syncs",
  targetRegion: "eu-central-1",
});

const completed = await runs.bulk.poll(action.id);
console.log(completed.status, completed.counts);
```

`filter` accepts the SDK run-list filter fields, excluding pagination fields such as `limit`, `after`, and `before`. This is the TypeScript SDK shape used by `runs.list()` (`from`, `to`, and `period` are top-level fields), not the nested `filter[createdAt]` query shape shown in the raw HTTP list-runs endpoint. Provide at least one filter field; use `runIds` when you want to target specific runs. Relative time filters such as `period` are resolved when the bulk action is created, so later batches process the same fixed time range.

<Warning>
  Filters inherit the same time semantics as `runs.list()`: when you don't pass `from`, `to`, or `period`, the action defaults to the **last 7 days** and won't target older matching runs. To cover a wider range, pass an explicit `period` (such as `"30d"`), a `from` timestamp, or a `from`/`to` pair. This default only applies to `filter` selections; `runIds` selections are never time-bounded.
</Warning>

<ParamField type="BulkActionFilter">
  Selects runs using the SDK run-list filter shape, excluding `limit`, `after`, and `before`. In raw HTTP requests these fields are sent as JSON body properties, so time fields are top-level (`from`, `to`, `period`) instead of nested under `createdAt`.
</ParamField>

<ParamField type="string[]">
  Selects specific run IDs. Provide either `filter` or `runIds`, not both.
</ParamField>

<ParamField type="string">
  A name for the bulk action.
</ParamField>

<ParamField type="string">
  Replays matching runs in a specific region. When omitted, each replay keeps the original run's region. This option is only available for `runs.bulk.replay()`.
</ParamField>

## Create a bulk cancel

Use `runs.bulk.cancel()` to cancel every run that matches a filter, or specific run IDs.

```ts Your backend code theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

const action = await runs.bulk.cancel({
  runIds: ["run_1234", "run_5678"],
  name: "Cancel selected runs",
});

console.log(action.id);
```

Only runs that are still cancelable when the action reaches them are canceled. Runs that have already reached a final state count as failures in the bulk action summary.

## Retrieve progress

Use `runs.bulk.retrieve()` to read the current status and aggregate counts.

```ts Your backend code theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

const action = await runs.bulk.retrieve("bulk_1234");

console.log(action.status);
console.log(action.counts.total, action.counts.success, action.counts.failure);
```

The returned bulk action object has these fields:

<ResponseField name="id" type="string">
  The bulk action ID, starting with `bulk_`.
</ResponseField>

<ResponseField name="type" type="'CANCEL' | 'REPLAY'">
  The action being performed.
</ResponseField>

<ResponseField name="status" type="'PENDING' | 'COMPLETED' | 'ABORTED'">
  The current bulk action status.
</ResponseField>

<ResponseField name="counts" type="object">
  Aggregate processing counts.

  <Expandable title="properties">
    <ResponseField name="total" type="number">
      The number of runs selected when the bulk action was created.
    </ResponseField>

    <ResponseField name="success" type="number">
      The number of runs processed successfully.
    </ResponseField>

    <ResponseField name="failure" type="number">
      The number of runs that could not be processed.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="createdAt" type="Date">
  The date and time the bulk action was created.
</ResponseField>

<ResponseField name="completedAt" type="Date">
  The date and time the bulk action completed.
</ResponseField>

## Poll for completion

Use `runs.bulk.poll()` to wait until the bulk action leaves the `PENDING` state.

```ts Your backend code theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

const completed = await runs.bulk.poll("bulk_1234", {
  pollIntervalMs: 2_000,
});

console.log(completed.status);
```

## Abort a bulk action

Use `runs.bulk.abort()` to stop future batches from being processed.

```ts Your backend code theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

await runs.bulk.abort("bulk_1234");
```

Abort is best effort. Runs already being processed in the current batch may still finish.

<Note>
  Each environment can only run a limited number of bulk replays at the same time. If you start a replay while too many are still in progress, the call fails and returns an error. Wait for an in-progress replay to finish or abort one before starting another. Bulk cancels are not subject to this limit.
</Note>

## List bulk actions

Use `runs.bulk.list()` to page through previous bulk actions in the current environment.

```ts Your backend code theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

const page = await runs.bulk.list({ limit: 25 });

for (const action of page.data) {
  console.log(action.id, action.status);
}
```

List results support the same auto-pagination helpers as other management API list methods:

```ts Your backend code theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

for await (const action of runs.bulk.list({ limit: 25 })) {
  console.log(action.id, action.status);
}
```

## API reference

The SDK methods use the bulk actions HTTP API:

* [Create bulk action](/docs/management/bulk-actions/create)
* [List bulk actions](/docs/management/bulk-actions/list)
* [Retrieve bulk action](/docs/management/bulk-actions/retrieve)
* [Abort bulk action](/docs/management/bulk-actions/abort)


# Heartbeats
Source: https://trigger.dev/docs/runs/heartbeats

Keep long-running or CPU-heavy tasks from being marked as stalled.

We send a heartbeat from your task to the platform every 30 seconds. If we don't receive a heartbeat within 5 minutes, we mark the run as stalled and stop it with a `TASK_RUN_STALLED_EXECUTING` error.

Code that blocks the event loop for too long (for example, a tight loop doing synchronous work on a large dataset) can prevent heartbeats from being sent. In that case, use `heartbeats.yield()` inside the loop so the runtime can yield to the event loop and send a heartbeat. You can call it every iteration; the implementation only yields when needed.

```ts theme={"theme":"css-variables"}
import { task, heartbeats } from "@trigger.dev/sdk";

export const processLargeDataset = task({
  id: "process-large-dataset",
  run: async (payload: { items: string[] }) => {
    for (const row of payload.items) {
      await heartbeats.yield();
      processRow(row);
    }
    return { processed: payload.items.length };
  },
});

function processRow(row: string) {
  // synchronous CPU-heavy work
}
```

If you see `TASK_RUN_STALLED_EXECUTING`, see [Task run stalled executing](/docs/troubleshooting#task-run-stalled-executing) in the troubleshooting guide.

## Sending progress to Trigger.dev

To stream progress or status updates to the dashboard and your app, use [run metadata](/docs/runs/metadata). Call `metadata.set()` (or `metadata.append()`) as the task runs. The dashboard and [Realtime](/docs/realtime) (including `runs.subscribeToRun` and the React hooks) receive those updates as they happen. See [Progress monitoring](/docs/realtime/backend/subscribe#progress-monitoring) for a full example.

## Sending updates to your own system

Trigger.dev doesn’t push run updates to external services. To send progress or heartbeats to your own backend (for example Supabase Realtime), call your API or client from inside the task when you want to emit an update—e.g. in the same loop where you call `heartbeats.yield()` or `metadata.set()`. Use whatever your stack supports: HTTP, the Supabase client, or another SDK.


# Max duration
Source: https://trigger.dev/docs/runs/max-duration

Set a maximum duration for a task to run.

The `maxDuration` parameter sets a maximum compute time limit for tasks. When a task exceeds this duration, it will be automatically stopped. This helps prevent runaway tasks and manage compute resources effectively.

You must set a default maxDuration in your `trigger.config.ts` file, which will apply to all tasks unless overridden:

```ts /config/trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "proj_gtcwttqhhtlasxgfuhxs",
  maxDuration: 60, // 60 seconds or 1 minute
});
```

<Note>
  The minimum maxDuration is 5 seconds. If you want to avoid timeouts, set this value to a very large number of seconds.
</Note>

You can set the `maxDuration` for a run in the following ways:

* Across all your tasks in the [config](/docs/config/config-file#max-duration)
* On a specific task
* On a specific run when you [trigger a task](/docs/triggering#maxduration)

## How it works

The `maxDuration` is set in seconds, and is compared to the CPU time elapsed since the start of a single execution (which we call [attempts](/docs/runs#attempts)) of the task. The CPU time is the time that the task has been actively running on the CPU, and does not include time spent waiting during the following:

* `wait.for` calls
* `triggerAndWait` calls
* `batchTriggerAndWait` calls

You can inspect the CPU time of a task inside the run function with our `usage` utility:

```ts /trigger/max-duration.ts theme={"theme":"css-variables"}
import { task, usage } from "@trigger.dev/sdk";

export const maxDurationTask = task({
  id: "max-duration-task",
  maxDuration: 300, // 300 seconds or 5 minutes
  run: async (payload: any, { ctx }) => {
    let currentUsage = usage.getCurrent();

    currentUsage.attempt.durationMs; // The CPU time in milliseconds since the start of the run
  },
});
```

The above value will be compared to the `maxDuration` you set. If the task exceeds the `maxDuration`, it will be stopped with the following error:

<img alt="Max duration error" />

## Configuring for a task

You can set a `maxDuration` on a specific task:

```ts /trigger/max-duration-task.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

export const maxDurationTask = task({
  id: "max-duration-task",
  maxDuration: 300, // 300 seconds or 5 minutes
  run: async (payload: any, { ctx }) => {
    //...
  },
});
```

This will override the default `maxDuration` set in the config file. If you have a config file with a default `maxDuration` of 60 seconds, and you set a `maxDuration` of 300 seconds on a task, the task will run for 300 seconds.

You can "turn off" the Max duration set in your config file for a specific task like so:

```ts /trigger/max-duration-task.ts theme={"theme":"css-variables"}
import { task, timeout } from "@trigger.dev/sdk";

export const maxDurationTask = task({
  id: "max-duration-task",
  maxDuration: timeout.None, // No max duration
  run: async (payload: any, { ctx }) => {
    //...
  },
});
```

## Configuring for a run

You can set a `maxDuration` on a specific run when you trigger a task:

```ts /trigger/max-duration.ts theme={"theme":"css-variables"}
import { maxDurationTask } from "./trigger/max-duration-task";

// Trigger the task with a maxDuration of 300 seconds
const run = await maxDurationTask.trigger(
  { foo: "bar" },
  {
    maxDuration: 300, // 300 seconds or 5 minutes
  }
);
```

You can also set the `maxDuration` to `timeout.None` to turn off the max duration for a specific run:

```ts /trigger/max-duration.ts theme={"theme":"css-variables"}
import { maxDurationTask } from "./trigger/max-duration-task";
import { timeout } from "@trigger.dev/sdk";

// Trigger the task with no maxDuration
const run = await maxDurationTask.trigger(
  { foo: "bar" },
  {
    maxDuration: timeout.None, // No max duration
  }
);
```

## maxDuration in run context

You can access the `maxDuration` set for a run in the run context:

```ts /trigger/max-duration-task.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

export const maxDurationTask = task({
  id: "max-duration-task",
  maxDuration: 300, // 300 seconds or 5 minutes
  run: async (payload: any, { ctx }) => {
    console.log(ctx.run.maxDuration); // 300
  },
});
```

## maxDuration and lifecycle functions

When a task run exceeds the `maxDuration`, the lifecycle functions `cleanup`, `onSuccess`, and `onFailure` will not be called.


# Run metadata
Source: https://trigger.dev/docs/runs/metadata

Attach structured data to a run and update it as the task progresses. Use metadata for progress tracking, user context, intermediate results, and more.

**Metadata lets you attach up to 256KB of structured data to a run and update it while the task runs.** Subscribers (via [React hooks](/docs/realtime/react-hooks/subscribe) or [backend](/docs/realtime/backend/subscribe)) get those updates in real time, making metadata the simplest way to build progress bars, status indicators, and live dashboards.

You can access metadata from inside the run function, via the API, Realtime, and in the dashboard. Common uses: progress percentage, current step, user context, intermediate results.

## Usage

Add metadata to a run when triggering by passing it as an object to the `trigger` function:

```ts theme={"theme":"css-variables"}
const handle = await myTask.trigger(
  { message: "hello world" },
  { metadata: { user: { name: "Eric", id: "user_1234" } } }
);
```

You can get the current metadata at any time by calling `metadata.get()` or `metadata.current()` (only inside a run):

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload: { message: string }) => {
    // Get the whole metadata object
    const currentMetadata = metadata.current();
    console.log(currentMetadata);

    // Get a specific key
    const user = metadata.get("user");
    console.log(user.name); // "Eric"
  },
});
```

Any of these methods can be called anywhere "inside" the run function, or a function called from the run function:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload: { message: string }) => {
    doSomeWork();
  },
});

async function doSomeWork() {
  // Set the value of a specific key
  metadata.set("progress", 0.5);
}
```

If you call any of the metadata methods outside of the run function, they will have no effect:

```ts theme={"theme":"css-variables"}
import { metadata } from "@trigger.dev/sdk";

// Somewhere outside of the run function
function doSomeWork() {
  metadata.set("progress", 0.5); // This will do nothing
}
```

This means it's safe to call these methods anywhere in your code, and they will only have an effect when called inside the run function.

<Note>
  Calling `metadata.current()` or `metadata.get()` outside of the run function will always return
  undefined.
</Note>

These methods also work inside any task lifecycle hook, either attached to the specific task or the global hooks defined in your `trigger.config.ts` file.

<CodeGroup>
  ```ts myTasks.ts theme={"theme":"css-variables"}
  import { task, metadata } from "@trigger.dev/sdk";

  export const myTask = task({
    id: "my-task",
    run: async (payload: { message: string }) => {
      // Your run function work here
    },
    onStart: async () => {
      metadata.set("progress", 0.5);
    },
    onSuccess: async () => {
      metadata.set("progress", 1.0);
    },
  });
  ```

  ```ts trigger.config.ts theme={"theme":"css-variables"}
  import { defineConfig, metadata } from "@trigger.dev/sdk";

  export default defineConfig({
    project: "proj_1234",
    onStart: async () => {
      metadata.set("progress", 0.5);
    },
  });
  ```
</CodeGroup>

## Updates API

One of the more powerful features of metadata is the ability to update it as the run progresses. This is useful for tracking the progress of a run, storing intermediate results, or storing any other information that changes over time. (Combining metadata with [Realtime](/docs/realtime) can give you a live view of the progress of your runs.)

All metadata update methods (accept for `flush` and `stream`) are synchronous and will not block the run function. We periodically flush metadata to the database in the background, so you can safely update the metadata inside a run as often as you need to, without worrying about impacting the run's performance.

### set

Set the value of a key in the metadata object:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload: { message: string }) => {
    // Do some work
    metadata.set("progress", 0.1);

    // Do some more work
    metadata.set("progress", 0.5);

    // Do even more work
    metadata.set("progress", 1.0);
  },
});
```

### del

Delete a key from the metadata object:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload: { message: string }) => {
    // Do some work
    metadata.set("progress", 0.1);

    // Do some more work
    metadata.set("progress", 0.5);

    // Remove the progress key
    metadata.del("progress");
  },
});
```

### replace

Replace the entire metadata object with a new object:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload: { message: string }) => {
    // Do some work
    metadata.set("progress", 0.1);

    // Replace the metadata object
    metadata.replace({ user: { name: "Eric", id: "user_1234" } });
  },
});
```

### append

Append a value to an array in the metadata object:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload: { message: string }) => {
    // Do some work
    metadata.set("progress", 0.1);

    // Append a value to an array
    metadata.append("logs", "Step 1 complete");

    console.log(metadata.get("logs")); // ["Step 1 complete"]
  },
});
```

### remove

Remove a value from an array in the metadata object:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload: { message: string }) => {
    // Do some work
    metadata.set("progress", 0.1);

    // Append a value to an array
    metadata.append("logs", "Step 1 complete");

    // Remove a value from the array
    metadata.remove("logs", "Step 1 complete");

    console.log(metadata.get("logs")); // []
  },
});
```

### increment

Increment a numeric value in the metadata object:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload: { message: string }) => {
    // Do some work
    metadata.set("progress", 0.1);

    // Increment a value
    metadata.increment("progress", 0.4);

    console.log(metadata.get("progress")); // 0.5
  },
});
```

### decrement

Decrement a numeric value in the metadata object:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload: { message: string }) => {
    // Do some work
    metadata.set("progress", 0.5);

    // Decrement a value
    metadata.decrement("progress", 0.4);

    console.log(metadata.get("progress")); // 0.1
  },
});
```

### stream

<Note>
  As of SDK version **4.1.0**, `metadata.stream()` has been replaced by [Realtime Streams
  v2](/docs/tasks/streams). We recommend using the new `streams.pipe()` API for better reliability,
  unlimited stream length, and improved developer experience. The examples below are provided for
  backward compatibility.
</Note>

Capture a stream of values and make the stream available when using Realtime. See our [Realtime Streams v2](/docs/tasks/streams) documentation for the recommended approach.

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload: { message: string }) => {
    const readableStream = new ReadableStream({
      start(controller) {
        controller.enqueue("Step 1 complete");
        controller.enqueue("Step 2 complete");
        controller.enqueue("Step 3 complete");
        controller.close();
      },
    });

    // IMPORTANT: you must await the stream method
    const stream = await metadata.stream("logs", readableStream);

    // You can read from the returned stream locally
    for await (const value of stream) {
      console.log(value);
    }
  },
});
```

`metadata.stream` accepts any `AsyncIterable` or `ReadableStream` object. The stream will be captured and made available in the Realtime API. So for example, you could pass the body of a fetch response to `metadata.stream` to capture the response body and make it available in Realtime:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload: { url: string }) => {
    logger.info("Streaming response", { url });

    const response = await fetch(url);

    if (!response.body) {
      throw new Error("Response body is not readable");
    }

    const stream = await metadata.stream(
      "fetch",
      response.body.pipeThrough(new TextDecoderStream())
    );

    let text = "";

    for await (const chunk of stream) {
      logger.log("Received chunk", { chunk });

      text += chunk;
    }

    return { text };
  },
});
```

Or the results of a streaming call to the OpenAI SDK:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

export const myTask = task({
  id: "my-task",
  run: async (payload: { prompt: string }) => {
    const completion = await openai.chat.completions.create({
      messages: [{ role: "user", content: payload.prompt }],
      model: "gpt-3.5-turbo",
      stream: true,
    });

    const stream = await metadata.stream("openai", completion);

    let text = "";

    for await (const chunk of stream) {
      logger.log("Received chunk", { chunk });

      text += chunk.choices.map((choice) => choice.delta?.content).join("");
    }

    return { text };
  },
});
```

### flush

Flush the metadata to the database. The SDK will automatically flush the metadata periodically, so you don't need to call this method unless you need to ensure that the metadata is persisted immediately.

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload: { message: string }) => {
    // Do some work
    metadata.set("progress", 0.1);

    // Flush the metadata to the database
    await metadata.flush();
  },
});
```

## Fluent API

All of the update methods can be chained together in a fluent API:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload: { message: string }) => {
    metadata
      .set("progress", 0.1)
      .append("logs", "Step 1 complete")
      .increment("progress", 0.4)
      .decrement("otherProgress", 0.1);
  },
});
```

## Parent & root updates

Tasks that have been triggered by a parent task (a.k.a. a "child task") can update the metadata of the parent task. This is useful for propagating progress information up the task hierarchy. You can also update the metadata of the root task (root = the initial task that was triggered externally, like from your backend).

To update the parent task's metadata, use the `metadata.parent` accessor:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const myParentTask = task({
  id: "my-parent-task",
  run: async (payload: { message: string }) => {
    // Do some work
    metadata.set("progress", 0.1);

    // Trigger a child task
    await childTask.triggerAndWait({ message: "hello world" });
  },
});

export const childTask = task({
  id: "child-task",
  run: async (payload: { message: string }) => {
    // This will update the parent task's metadata
    metadata.parent.set("progress", 0.5);
  },
});
```

All of the update methods are available on `metadata.parent` and `metadata.root`:

```ts theme={"theme":"css-variables"}
metadata.parent.set("progress", 0.5);
metadata.parent.append("logs", "Step 1 complete");
metadata.parent.remove("logs", "Step 1 complete");
metadata.parent.increment("progress", 0.4);
metadata.parent.decrement("otherProgress", 0.1);
metadata.parent.stream("llm", readableStream); // Use streams.pipe() instead (v4.1+)

metadata.root.set("progress", 0.5);
metadata.root.append("logs", "Step 1 complete");
metadata.root.remove("logs", "Step 1 complete");
metadata.root.increment("progress", 0.4);
metadata.root.decrement("otherProgress", 0.1);
metadata.root.stream("llm", readableStream); // Use streams.pipe() instead (v4.1+)
```

You can also chain the update methods together:

```ts theme={"theme":"css-variables"}
metadata.parent
  .set("progress", 0.1)
  .append("logs", "Step 1 complete")
  .increment("progress", 0.4)
  .decrement("otherProgress", 0.1);
```

### Example

An example of where you might use parent and root updates is in a task that triggers multiple child tasks in parallel. You could use the parent metadata to track the progress of the child tasks and update the parent task's progress as each child task completes:

```ts theme={"theme":"css-variables"}
import { CSVRow, UploadedFileData, parseCSVFromUrl } from "@/utils";
import { batch, logger, metadata, schemaTask } from "@trigger.dev/sdk";

export const handleCSVRow = schemaTask({
  id: "handle-csv-row",
  schema: CSVRow,
  run: async (row, { ctx }) => {
    // Do some work with the row

    // Update the parent task's metadata with the progress of this row
    metadata.parent.increment("processedRows", 1).append("rowRuns", ctx.run.id);

    return row;
  },
});

export const handleCSVUpload = schemaTask({
  id: "handle-csv-upload",
  schema: UploadedFileData,
  run: async (file, { ctx }) => {
    metadata.set("status", "fetching");

    const rows = await parseCSVFromUrl(file.url);

    metadata.set("status", "processing").set("totalRows", rows.length);

    const results = await batch.triggerAndWait<typeof handleCSVRow>(
      rows.map((row) => ({ id: "handle-csv-row", payload: row }))
    );

    metadata.set("status", "complete");

    return {
      file,
      rows,
      results,
    };
  },
});
```

Combined with [Realtime](/docs/realtime), you could use this to show a live progress bar of the CSV processing in your frontend, like this:

<video />

## More metadata task examples

Using metadata updates in conjunction with our [Realtime React hooks](/docs/realtime/react-hooks/overview) can be a powerful way to build real-time UIs. Here are some example tasks demonstrating how to use metadata in your tasks to track progress, status, and more:

### Progress tracking

Track progress with percentage and current step:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const batchProcessingTask = task({
  id: "batch-processing",
  run: async (payload: { records: any[] }) => {
    for (let i = 0; i < payload.records.length; i++) {
      const record = payload.records[i];

      // Update progress
      metadata.set("progress", {
        step: i + 1,
        total: payload.records.length,
        percentage: Math.round(((i + 1) / payload.records.length) * 100),
        currentRecord: record.id,
      });

      await processRecord(record);
    }
  },
});
```

### Status updates with logs

Append log entries while maintaining status:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const deploymentTask = task({
  id: "deployment",
  run: async (payload: { version: string }) => {
    metadata.set("status", "initializing");
    metadata.append("logs", "Starting deployment...");

    // Step 1
    metadata.set("status", "building");
    metadata.append("logs", "Building application...");
    await buildApplication();

    // Step 2
    metadata.set("status", "deploying");
    metadata.append("logs", "Deploying to production...");
    await deployToProduction();

    // Step 3
    metadata.set("status", "verifying");
    metadata.append("logs", "Running health checks...");
    await runHealthChecks();

    metadata.set("status", "completed");
    metadata.append("logs", "Deployment successful!");
  },
});
```

### User context and notifications

Store user information and notification preferences:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const userTask = task({
  id: "user-task",
  run: async (payload: { userId: string; action: string }) => {
    // Set user context in metadata
    metadata.set("user", {
      id: payload.userId,
      action: payload.action,
      startedAt: new Date().toISOString(),
    });

    // Update status for user notifications
    metadata.set("notification", {
      type: "info",
      message: `Starting ${payload.action} for user ${payload.userId}`,
    });

    await performUserAction(payload);

    metadata.set("notification", {
      type: "success",
      message: `${payload.action} completed successfully`,
    });
  },
});
```

## Metadata propagation

Metadata is NOT propagated to child tasks. If you want to pass metadata to a child task, you must do so explicitly:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload: { message: string }) => {
    await metadata.set("progress", 0.5);
    await childTask.trigger(payload, { metadata: metadata.current() });
  },
});
```

## Type-safe metadata

The metadata APIs are currently loosely typed, accepting any object that is JSON-serializable:

```ts theme={"theme":"css-variables"}
// ❌ You can't pass a top-level array
const handle = await myTask.trigger(
  { message: "hello world" },
  { metadata: [{ user: { name: "Eric", id: "user_1234" } }] }
);

// ❌ You can't pass a string as the entire metadata:
const handle = await myTask.trigger(
  { message: "hello world" },
  { metadata: "this is the metadata" }
);

// ❌ You can't pass in a function or a class instance
const handle = await myTask.trigger(
  { message: "hello world" },
  { metadata: { user: () => "Eric", classInstance: new HelloWorld() } }
);

// ✅ You can pass in dates and other JSON-serializable objects
const handle = await myTask.trigger(
  { message: "hello world" },
  { metadata: { user: { name: "Eric", id: "user_1234" }, date: new Date() } }
);
```

<Note>
  If you pass in an object like a Date, it will be serialized to a string when stored in the
  metadata. That also means that when you retrieve it using `metadata.get()` or
  `metadata.current()`, you will get a string back. You will need to deserialize it back to a Date
  object if you need to use it as a Date.
</Note>

We recommend wrapping the metadata API in a [Zod](https://zod.dev) schema (or your validator library of choice) to provide type safety:

```ts theme={"theme":"css-variables"}
import { task, metadata } from "@trigger.dev/sdk";
import { z } from "zod";

const Metadata = z.object({
  user: z.object({
    name: z.string(),
    id: z.string(),
  }),
  date: z.coerce.date(), // Coerce the date string back to a Date object
});

type Metadata = z.infer<typeof Metadata>;

// Helper function to get the metadata object in a type-safe way
// Note: you would probably want to use .safeParse instead of .parse in a real-world scenario
function getMetadata() {
  return Metadata.parse(metadata.current());
}

export const myTask = task({
  id: "my-task",
  run: async (payload: { message: string }) => {
    const metadata = getMetadata();
    console.log(metadata.user.name); // "Eric"
    console.log(metadata.user.id); // "user_1234"
    console.log(metadata.date); // Date object
  },
});
```

## Inspecting metadata

### Dashboard

You can view the metadata for a run in the Trigger.dev dashboard. The metadata will be displayed in the run details view:

<img alt="View run metadata dashboard" />

### API

You can use the `runs.retrieve()` SDK function to get the metadata for a run:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

const run = await runs.retrieve("run_1234");

console.log(run.metadata);
```

See the [API reference](/docs/management/runs/retrieve) for more information.

## Size limit

The maximum size of the metadata object is 256KB. If you exceed this limit, the SDK will throw an error. If you are self-hosting Trigger.dev, you can increase this limit by setting the `TASK_RUN_METADATA_MAXIMUM_SIZE` environment variable. For example, to increase the limit to 16KB, you would set `TASK_RUN_METADATA_MAXIMUM_SIZE=16384`.


# Priority
Source: https://trigger.dev/docs/runs/priority

Specify a priority when triggering a run.

You can set a priority when you trigger a run. This allows you to prioritize some of your runs over others, so they are started sooner. This is very useful when:

* You have critical work that needs to start more quickly (and you have long queues).
* You want runs for your premium users to take priority over free users.

The value for priority is a time offset in seconds that determines the order of dequeuing.

<img alt="Priority runs" />

If you specify a priority of `10` the run will dequeue before runs that were triggered with no priority 8 seconds ago, like in this example:

```ts theme={"theme":"css-variables"}
// no priority = 0
await myTask.trigger({ foo: "bar" });

//... imagine 8s pass by

// this run will start before the run above that was triggered 8s ago (with no priority)
await myTask.trigger({ foo: "bar" }, { priority: 10 });
```

If you passed a value of `3600` the run would dequeue before runs that were triggered an hour ago (with no priority).

<Note>
  Setting a high priority will not allow you to beat runs from other organizations. It will only affect the order of your own runs.
</Note>


# Docker compose
Source: https://trigger.dev/docs/self-hosting/docker

You can self-host Trigger.dev on your own infrastructure using Docker.

The following instructions will use docker compose to spin up a Trigger.dev instance. Make sure to read the self-hosting [overview](/docs/self-hosting/overview) first.

As self-hosted deployments tend to have unique requirements and configurations, we don't provide specific advice for securing your deployment, scaling up, or improving reliability.

Should the burden ever get too much, we'd be happy to see you on [Trigger.dev cloud](https://trigger.dev/pricing) where we deal with these concerns for you.

**Warning:** This guide alone is unlikely to result in a production-ready deployment. Security, scaling, and reliability concerns are not fully addressed here.

## What's new?

Goodbye v3, hello v4! We made quite a few changes:

* **Much simpler setup.** The provider and coordinator are now combined into a single supervisor. No more startup scripts, just `docker compose up`.
* **Automatic container cleanup.** The supervisor will automatically clean up containers that are no longer needed.
* **Support for multiple worker machines.** This is a big one, and we're very excited about it! You can now scale your workers horizontally as needed.
* **Resource limits enforced by default.** This means that tasks will be limited to the total CPU and RAM of the machine preset, preventing noisy neighbours.
* **No direct Docker socket access.** The compose file now comes with [Docker Socket Proxy](https://github.com/Tecnativa/docker-socket-proxy) by default. Yes, you want this.
* **No host networking.** All containers are now running with network isolation, using only the network access they need.
* **No checkpoint support.** This was only ever experimental when self-hosting and not recommended. It caused a bunch of issues. We decided to focus on the core features instead.
* **Built-in container registry and object storage.** You can now deploy and execute tasks without needing third party services for this.
* **Improved CLI commands.** You don't need any additional flags to deploy anymore, and there's a new command to easily `switch` between profiles.
* **Whitelisting for GitHub OAuth.** Any whitelisted email addresses will now also apply to sign ins via GitHub, unlike v3 where they only applied to magic links.

## Requirements

These are the minimum requirements for running the webapp and worker components. They can run on the same, or on separate machines.

It's fine to run everything on the same machine for testing. To be able to scale your workers, you will want to run them separately.

### Prerequisites

To run the webapp and worker components, you will need:

* [Docker](https://docs.docker.com/get-docker/) 20.10.0+
* [Docker Compose](https://docs.docker.com/compose/install/) 2.20.0+

### Webapp

This machine will host the webapp, postgres, redis, and related services.

* 3+ vCPU
* 6+ GB RAM

### Worker

This machine will host the supervisor and all of the runs.

* 4+ vCPU
* 8+ GB RAM

How many workers and resources you need will depend on your workloads and concurrency requirements.

For example:

* 10 concurrency x `small-1x` (0.5 vCPU, 0.5 GB RAM) = 5 vCPU and 5 GB RAM
* 20 concurrency x `small-1x` (0.5 vCPU, 0.5 GB RAM) = 10 vCPU and 10 GB RAM
* 100 concurrency x `small-1x` (0.5 vCPU, 0.5 GB RAM) = 50 vCPU and 50 GB RAM
* 100 concurrency x `small-2x` (1 vCPU, 1 GB RAM) = 100 vCPU and 100 GB RAM

You may need to spin up multiple workers to handle peak concurrency. The good news is you don't have to know the exact numbers upfront. You can start with a single worker and add more as needed.

## Setup

### Webapp

1. Clone the repository

```bash theme={"theme":"css-variables"}
git clone --depth=1 https://github.com/triggerdotdev/trigger.dev
cd trigger.dev/hosting/docker
```

2. Create a `.env` file and generate secrets

```bash theme={"theme":"css-variables"}
cp .env.example .env

# Fills the required secrets in .env with strong, unique values.
# Safe to re-run - it never overwrites a secret you've already set.
./generate-secrets.sh
```

<Note>
  The stack ships no working default credentials. `generate-secrets.sh` fills the
  application secrets and the bundled datastore passwords with strong, unique values.
  Keep them safe - rotating the encryption key or session secret later will invalidate
  existing sessions and encrypted data.
</Note>

3. Start the webapp

```bash theme={"theme":"css-variables"}
cd webapp
docker compose up -d
```

4. Configure the webapp using the [environment variables](/docs/self-hosting/env/webapp) in your `.env` file, then apply the changes:

```bash theme={"theme":"css-variables"}
docker compose up -d
```

5. You should now be able to access the webapp at `http://localhost:8030`. When logging in, check the container logs for the magic link:

```bash theme={"theme":"css-variables"}
docker compose logs -f webapp
```

6. (optional) To initialize a new project, run the following command:

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest init -p <project-ref> -a http://localhost:8030
```

### Worker

1. Clone the repository

```bash theme={"theme":"css-variables"}
git clone --depth=1 https://github.com/triggerdotdev/trigger.dev
cd trigger.dev/hosting/docker
```

2. Create a `.env` file

```bash theme={"theme":"css-variables"}
cp .env.example .env
```

3. Start the worker

```bash theme={"theme":"css-variables"}
cd worker
docker compose up -d
```

4. Configure the supervisor using the [environment variables](/docs/self-hosting/env/supervisor) in your `.env` file, including the [worker token](#worker-token).

<Warning>
  For a split webapp/worker setup, set `MANAGED_WORKER_SECRET` on the worker to
  the **same** value as the webapp's `MANAGED_WORKER_SECRET`. Don't run
  `generate-secrets.sh` on the worker host - it would create a mismatched value
  and the worker would fail to authenticate.
</Warning>

5. Apply the changes:

```bash theme={"theme":"css-variables"}
docker compose up -d
```

6. Repeat as needed for additional workers.

### Combined

If you want to run the webapp and worker on the same machine, just replace the `up` command with the following:

```bash theme={"theme":"css-variables"}
# Run this from the /hosting/docker directory
docker compose -f webapp/docker-compose.yml -f worker/docker-compose.yml up -d
```

## Worker token

When running the combined stack, worker bootstrap is handled automatically. When running the webapp and worker separately, you will need to manually set the worker token.

On the first run, the webapp will generate a worker token and store it in a shared volume. It will also print the token to the console. It should look something like this:

```bash theme={"theme":"css-variables"}
==========================
Trigger.dev Bootstrap - Worker Token

WARNING: This will only be shown once. Save it now!

Worker group:
bootstrap

Token:
tr_wgt_fgfAEjsTmvl4lowBLTbP7Xo563UlnVa206mr9uW6

If using docker compose, set:
TRIGGER_WORKER_TOKEN=tr_wgt_fgfAEjsTmvl4lowBLTbP7Xo563UlnVa206mr9uW6

Or, if using a file:
TRIGGER_WORKER_TOKEN=file:///home/node/shared/worker_token

==========================
```

You can then uncomment and set the `TRIGGER_WORKER_TOKEN` environment variable in your `.env` file.

Don't forget to restart the worker container for the changes to take effect:

```bash theme={"theme":"css-variables"}
# Run this from the /hosting/docker/worker directory
docker compose down
docker compose up -d
```

### Creating additional worker groups

To create additional worker groups beyond the bootstrap group, use the admin API endpoint. This requires admin privileges.

**Making a user admin:**

* **New users**: Set `ADMIN_EMAILS` environment variable (regex pattern) before user creation.
* **Existing users**: Set `admin = true` in the `user` table in your database.

**Creating a worker group:**

```bash theme={"theme":"css-variables"}
api_url=http://localhost:8030
wg_name=my-worker
admin_pat=tr_pat_...

curl -X POST \
  "$api_url/admin/api/v1/workers" \
  -H "Authorization: Bearer $admin_pat" \
  -H "Content-Type: application/json" \
  -d "{\"name\": \"$wg_name\"}"
```

The response includes a `token` field if the worker group is newly created.

## Registry setup

The registry is used to store and pull deployment images. When testing the stack locally, the defaults should work out of the box.

When deploying to production, you will need to set the correct URL and generate secure credentials for the registry.

### Default settings

The default settings for the registry are:

* Registry: `localhost:5000`
* Username: `registry-user`
* Password: `very-secure-indeed`

You should change these before deploying to production, especially the password. You can find more information about how to do this in the official [registry docs](https://github.com/distribution/distribution/blob/735c161b53e7faf81a21ba94c55ac9edee081cd9/docs/deploying.md#native-basic-auth).

**Note:** This will require modifying the default `.htpasswd` file located at `./hosting/docker/registry/auth.htpasswd` of the repo root.

### Logging in

When self-hosting, builds run locally. You will have to login to the registry on every machine that runs the `deploy` command. You should only have to do this once:

```bash theme={"theme":"css-variables"}
docker login -u <username> <registry>
```

This will prompt for the password. Afterwards, the deploy command should work as expected.

## Object storage

This is mainly used for large payloads and outputs. There are a few simple steps to follow to get started.

### Default settings

The default settings for the object storage are:

* Endpoint: `http://localhost:9000`
* Username: `admin`
* Password: `very-safe-password`

You should change these before deploying to production, especially the password.

### Setup

<Note>
  The `packets` bucket is created by default. In case this doesn't work, you can create it manually.
</Note>

1. Login to the dashboard: `http://localhost:9001`

2. Create a bucket named `packets`.

3. For production, you will want to set up a dedicated user and not use the root credentials above.

## Authentication

The specific set of variables required will depend on your choice of email transport or alternative login methods like GitHub OAuth.

### Magic link

By default, magic link auth is the only login option. If the `EMAIL_TRANSPORT` env var is not set, the magic links will be logged by the webapp container and not sent via email.

#### Resend

```bash theme={"theme":"css-variables"}
EMAIL_TRANSPORT=resend
FROM_EMAIL=
REPLY_TO_EMAIL=
RESEND_API_KEY=<your_resend_api_key>
```

#### SMTP

Note that setting `SMTP_SECURE=false` does *not* mean the email is sent insecurely.
This simply means that the connection is secured using the modern STARTTLS protocol command instead of implicit TLS.
You should only set this to true when the SMTP server host directs you to do so (generally when using port 465)

```bash theme={"theme":"css-variables"}
EMAIL_TRANSPORT=smtp
FROM_EMAIL=
REPLY_TO_EMAIL=
SMTP_HOST=<your_smtp_server>
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=<your_smtp_username>
SMTP_PASSWORD=<your_smtp_password>
```

#### AWS SES

Credentials are to be supplied as with any other program using the AWS SDK.

In this scenario, you would likely either supply the additional environment variables `AWS_REGION`, `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` or, when running on AWS, use credentials supplied by the EC2 IMDS.

```bash theme={"theme":"css-variables"}
EMAIL_TRANSPORT=aws-ses
FROM_EMAIL=
REPLY_TO_EMAIL=
```

### GitHub OAuth

To authenticate with GitHub, you will need to set up a GitHub OAuth app. It needs a callback URL `https://<your_webapp_domain>/auth/github/callback` and you will have to set the following env vars:

```bash theme={"theme":"css-variables"}
AUTH_GITHUB_CLIENT_ID=<your_client_id>
AUTH_GITHUB_CLIENT_SECRET=<your_client_secret>
```

### Restricting access

All email addresses can sign up and log in this way. If you would like to restrict this, you can use the `WHITELISTED_EMAILS` env var. For example:

```bash theme={"theme":"css-variables"}
# every email that does not match this regex will be rejected
WHITELISTED_EMAILS="^(authorized@yahoo\.com|authorized@gmail\.com)$"
```

This will apply to all auth methods including magic link and GitHub OAuth.

## Version locking

There are several reasons to lock the version of your Docker images:

* **Backwards compatibility.** We try our best to maintain compatibility with older CLI versions, but it's not always possible. If you don't want to update your CLI, you can lock your Docker images to that specific version.
* **Ensuring full feature support.** Sometimes, new CLI releases will also require new or updated platform features. Running unlocked images can make any issues difficult to debug. Using a specific tag can help here as well.

By default, the images will point at the latest versioned release via the `latest` tag. You can override this by specifying a different tag in your `.env` file. For example:

```bash theme={"theme":"css-variables"}
TRIGGER_IMAGE_TAG=v4.5.0
```

We patch the latest released version line only, so keep an eye on new releases to receive security fixes. See [Security & vulnerability reporting](/docs/self-hosting/security).

You can also lock the versions of the bundled services, for example with `CLICKHOUSE_IMAGE_TAG`. If you do, or if you bring your own ClickHouse via `CLICKHOUSE_URL`, note that Trigger.dev requires ClickHouse 25.8 or newer.

<Note>
  The bundled ClickHouse uses the official `clickhouse/clickhouse-server` image, and your existing
  data volume carries over automatically on upgrade. If you pinned `CLICKHOUSE_IMAGE_TAG` to a
  Bitnami tag, switch it to an official tag (for example `26.2`) — Bitnami tags don't exist in the
  official repository.
</Note>

<Note>
  Trigger.dev 4.5.0 is the last version we officially support for running v3 (SDK v3) tasks. If
  you still have v3 tasks, pin `TRIGGER_IMAGE_TAG` to exactly `v4.5.0` or [migrate to
  v4](/docs/migrating-from-v3). 4.5.1 and later reject v3 triggers and deploys with an upgrade message.
</Note>

## Task events

By default, task events (timeline, logs, spans) are stored in PostgreSQL. For production deployments we recommend storing them in ClickHouse instead, it scales to much higher volumes and avoids unbounded growth of the `TaskEvent` table.

To enable, set on the webapp in your `.env`:

```bash theme={"theme":"css-variables"}
EVENT_REPOSITORY_DEFAULT_STORE=clickhouse_v2
```

This only affects new runs; existing runs continue to read from wherever their events were originally stored.

## Realtime streams

Realtime streams power AI-agent token streaming and run streams. They default to **v2**, backed by the bundled `s2` service — [s2-lite](https://s2.dev), the open-source, self-hostable S2 server. It stores stream data in a persistent volume and is preconfigured in the webapp compose file, so no setup is required.

To fall back to the Redis-backed **v1** streams, set on the webapp in your `.env`:

```bash theme={"theme":"css-variables"}
REALTIME_STREAMS_DEFAULT_VERSION=v1
```

To use a hosted S2 at [s2.dev](https://s2.dev) instead of the bundled s2-lite, point the endpoint at your basin and supply an access token:

```bash theme={"theme":"css-variables"}
REALTIME_STREAMS_S2_BASIN=your-basin
REALTIME_STREAMS_S2_ENDPOINT=https://your-basin.b.aws.s2.dev/v1
REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS=false
REALTIME_STREAMS_S2_ACCESS_TOKEN=your-access-token
```

See the [webapp environment variables](/docs/self-hosting/env/webapp) for the full list of realtime stream settings.

## Troubleshooting

* **Deployment fails at the push step.** The machine running `deploy` needs registry access. See the [registry setup](#registry-setup) section for more details.

* **Magic links don't arrive.** The webapp container needs to be able to send emails. You probably need to set up an email transport. See the [authentication](#authentication) section for more details.

  You should check the logs of the webapp container to see the magic link:

  ```bash theme={"theme":"css-variables"}
  # Run this from the /hosting/docker/webapp directory
  docker compose logs -f webapp
  ```

* **ClickHouse migrations say "no migrations to run" but schema is missing.** The goose migration tracker is out of sync. Exec into the webapp container, set the GOOSE env vars (from webapp startup logs), and run `goose reset && goose up`.

  <Warning>
    **Data Loss Warning:** The `goose reset` command is destructive and will drop the entire schema.
    Make sure to backup your data and confirm you are running this in a non-production environment
    before executing this command.
  </Warning>

## CLI usage

This section highlights some of the CLI commands and options that are useful when self-hosting. Please check the [CLI reference](/docs/cli-introduction) for more in-depth documentation.

### Login

To avoid being redirected to [Trigger.dev Cloud](https://cloud.trigger.dev) when using the CLI, you need to specify the URL of your self-hosted instance with the `--api-url` or `-a` flag. For example:

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest login -a http://trigger.example.com
```

Once you've logged in, you shouldn't have to specify the URL again with other commands.

### Profiles

You can specify a profile when logging in. This allows you to easily use the CLI with multiple instances of Trigger.dev. For example:

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest login -a http://trigger.example.com \
    --profile self-hosted
```

Logging in with a new profile will also make it the new default profile.

To use a specific profile, you can use the `--profile` flag with other commands:

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest dev --profile self-hosted
```

To list all your profiles, use the `list-profiles` command:

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest list-profiles
```

To remove a profile, use the `logout` command:

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest logout --profile self-hosted
```

To switch to a different profile, use the `switch` command:

```bash theme={"theme":"css-variables"}
# To run interactively
npx trigger.dev@latest switch

# To switch to a specific profile
npx trigger.dev@latest switch self-hosted
```

### Whoami

It can be useful to check you are logged into the correct instance. Running this will also show the API URL:

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest whoami
```

## CI / GitHub Actions

When running the CLI in a CI environment, your login profiles won't be available. Instead, you can use the `TRIGGER_API_URL` and `TRIGGER_ACCESS_TOKEN` environment
variables to point at your self-hosted instance and authenticate.

For more detailed instructions, see the [GitHub Actions guide](/docs/github-actions).

## Telemetry

By default, the Trigger.dev webapp sends telemetry data to our servers. This data is used to improve the product and is not shared with third parties. If you would like to opt-out of this, you can set the `TRIGGER_TELEMETRY_DISABLED` environment variable on the webapp container. The value doesn't matter, it just can't be empty. For example:

```yaml theme={"theme":"css-variables"}
services:
  webapp:
    ...
    environment:
      TRIGGER_TELEMETRY_DISABLED: 1
```


# Supervisor
Source: https://trigger.dev/docs/self-hosting/env/supervisor

Environment variables for the supervisor container.

| Name                                        | Required | Default     | Description                                                 |
| :------------------------------------------ | :------- | :---------- | :---------------------------------------------------------- |
| **Required settings**                       |          |             |                                                             |
| `TRIGGER_API_URL`                           | Yes      | —           | Trigger.dev API URL. Should point at the webapp.            |
| `TRIGGER_WORKER_TOKEN`                      | Yes      | —           | Worker token (can be a file path with file://).             |
| `MANAGED_WORKER_SECRET`                     | Yes      | —           | Managed worker secret. Needs to match webapp value.         |
| `OTEL_EXPORTER_OTLP_ENDPOINT`               | Yes      | —           | OTel exporter endpoint. Point at: `<webapp-url>/otel`       |
| **Worker instance**                         |          |             |                                                             |
| `TRIGGER_WORKER_INSTANCE_NAME`              | No       | random UUID | Worker instance name. Set to `spec.nodeName` on k8s.        |
| `TRIGGER_WORKER_HEARTBEAT_INTERVAL_SECONDS` | No       | 30          | Worker heartbeat interval (seconds).                        |
| **Workload API settings**                   |          |             |                                                             |
| `TRIGGER_WORKLOAD_API_ENABLED`              | No       | true        | Enable workload API. Runs use this to perform actions.      |
| `TRIGGER_WORKLOAD_API_PROTOCOL`             | No       | http        | Workload API protocol (http/https).                         |
| `TRIGGER_WORKLOAD_API_DOMAIN`               | No       | —           | Workload API domain. Keep empty for auto-detection.         |
| `TRIGGER_WORKLOAD_API_HOST_INTERNAL`        | No       | 0.0.0.0     | Workload API internal host.                                 |
| `TRIGGER_WORKLOAD_API_PORT_INTERNAL`        | No       | 8020        | Workload API internal port.                                 |
| `TRIGGER_WORKLOAD_API_PORT_EXTERNAL`        | No       | 8020        | Workload API external port.                                 |
| **Runner settings**                         |          |             |                                                             |
| `RUNNER_HEARTBEAT_INTERVAL_SECONDS`         | No       | —           | Runner heartbeat interval (seconds).                        |
| `RUNNER_SNAPSHOT_POLL_INTERVAL_SECONDS`     | No       | —           | Runner snapshot poll interval (seconds).                    |
| `RUNNER_ADDITIONAL_ENV_VARS`                | No       | —           | Additional runner env vars (CSV).                           |
| `RUNNER_PRETTY_LOGS`                        | No       | false       | Pretty logs for runner.                                     |
| **Dequeue settings**                        |          |             |                                                             |
| `TRIGGER_DEQUEUE_ENABLED`                   | No       | true        | Enable dequeue to pull runs from the queue.                 |
| `TRIGGER_DEQUEUE_INTERVAL_MS`               | No       | 250         | Dequeue interval (ms).                                      |
| `TRIGGER_DEQUEUE_IDLE_INTERVAL_MS`          | No       | 1000 (1s)   | Dequeue idle interval (ms).                                 |
| `TRIGGER_DEQUEUE_MAX_RUN_COUNT`             | No       | 10          | Max dequeue run count.                                      |
| `TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT`        | No       | 1           | Max dequeue consumer count.                                 |
| **Docker settings**                         |          |             |                                                             |
| `DOCKER_API_VERSION`                        | No       | v1.41       | Docker API version. You should probably not touch this.     |
| `DOCKER_STRIP_IMAGE_DIGEST`                 | No       | true        | Strip image digest in Docker. Turning off can cause issues. |
| `DOCKER_ENFORCE_MACHINE_PRESETS`            | No       | true        | Enforce Docker machine cpu and memory limits.               |
| `DOCKER_AUTOREMOVE_EXITED_CONTAINERS`       | No       | true        | Auto-remove exited containers.                              |
| `DOCKER_RUNNER_NETWORKS`                    | No       | host        | Docker runner networks (CSV).                               |
| **Registry auth**                           |          |             |                                                             |
| `DOCKER_REGISTRY_URL`                       | No       | —           | Docker registry URL, e.g. `docker.io`.                      |
| `DOCKER_REGISTRY_USERNAME`                  | No       | —           | Docker registry username.                                   |
| `DOCKER_REGISTRY_PASSWORD`                  | No       | —           | Docker registry password.                                   |
| **Kubernetes settings**                     |          |             |                                                             |
| `KUBERNETES_FORCE_ENABLED`                  | No       | false       | Force Kubernetes mode.                                      |
| `KUBERNETES_NAMESPACE`                      | No       | default     | The namespace that runs should be in.                       |
| `KUBERNETES_WORKER_NODETYPE_LABEL`          | No       | v4-worker   | Nodes for runs need `nodetype=<this>`. Empty: any node.     |
| `KUBERNETES_RUNNER_TOLERATIONS`             | No       | —           | Run pod tolerations. CSV: `key=value:effect`/`key:effect`.  |
| `KUBERNETES_IMAGE_PULL_SECRETS`             | No       | —           | Image pull secrets (CSV).                                   |
| `KUBERNETES_EPHEMERAL_STORAGE_SIZE_LIMIT`   | No       | 10Gi        | Ephemeral storage size limit. Applies to all runs.          |
| `KUBERNETES_EPHEMERAL_STORAGE_SIZE_REQUEST` | No       | 2Gi         | Ephemeral storage size request. Applies to all runs.        |
| **Metrics**                                 |          |             |                                                             |
| `METRICS_ENABLED`                           | No       | true        | Enable metrics.                                             |
| `METRICS_COLLECT_DEFAULTS`                  | No       | true        | Collect default metrics.                                    |
| `METRICS_HOST`                              | No       | 127.0.0.1   | Metrics host.                                               |
| `METRICS_PORT`                              | No       | 9090        | Metrics port.                                               |
| **Pod cleaner**                             |          |             |                                                             |
| `POD_CLEANER_ENABLED`                       | No       | true        | Enable pod cleaner.                                         |
| `POD_CLEANER_INTERVAL_MS`                   | No       | 10000 (10s) | Pod cleaner interval (ms). Best not to touch this.          |
| `POD_CLEANER_BATCH_SIZE`                    | No       | 500         | Pod cleaner batch size.                                     |
| **Failed pod handler**                      |          |             |                                                             |
| `FAILED_POD_HANDLER_ENABLED`                | No       | true        | Enable failed pod handler.                                  |
| `FAILED_POD_HANDLER_RECONNECT_INTERVAL_MS`  | No       | 1000 (1s)   | Failed pod handler reconnect interval (ms).                 |
| **Debug**                                   |          |             |                                                             |
| `DEBUG`                                     | No       | false       | Enable debug logs.                                          |
| `SEND_RUN_DEBUG_LOGS`                       | No       | false       | Send run debug logs to the platform.                        |
| **Not used for self-hosting**               |          |             |                                                             |
| `TRIGGER_WARM_START_URL`                    | No       | —           | Warm start URL.                                             |
| `TRIGGER_CHECKPOINT_URL`                    | No       | —           | Checkpoint URL.                                             |
| `TRIGGER_METADATA_URL`                      | No       | —           | Metadata URL.                                               |
| `RESOURCE_MONITOR_ENABLED`                  | No       | false       | Enable resource monitor.                                    |
| `RESOURCE_MONITOR_OVERRIDE_CPU_TOTAL`       | No       | —           | Override CPU total for resource monitor.                    |
| `RESOURCE_MONITOR_OVERRIDE_MEMORY_TOTAL_GB` | No       | —           | Override memory total (GB) for resource monitor.            |


# Webapp
Source: https://trigger.dev/docs/self-hosting/env/webapp

Environment variables for the webapp container.

| Name                                             | Required | Default                                        | Description                                                                                                                                                                                                                         |
| :----------------------------------------------- | :------- | :--------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Secrets**                                      |          |                                                |                                                                                                                                                                                                                                     |
| `SESSION_SECRET`                                 | Yes      | —                                              | Session encryption secret. Run: `openssl rand -hex 16`                                                                                                                                                                              |
| `MAGIC_LINK_SECRET`                              | Yes      | —                                              | Magic link encryption secret. Run: `openssl rand -hex 16`                                                                                                                                                                           |
| `ENCRYPTION_KEY`                                 | Yes      | —                                              | Secret store encryption key. Run: `openssl rand -hex 16`                                                                                                                                                                            |
| `MANAGED_WORKER_SECRET`                          | Yes      | —                                              | Managed worker secret. Must be set and match supervisor. Run: `openssl rand -hex 32`                                                                                                                                                |
| `ALLOW_INSECURE_DEFAULT_SECRETS`                 | No       | false                                          | Boot even if a secret is still a known-insecure published default. Temporary escape hatch for values you can't safely rotate yet (see [Secrets](/docs/self-hosting/kubernetes#secrets)).                                                 |
| **Domains & ports**                              |          |                                                |                                                                                                                                                                                                                                     |
| `REMIX_APP_PORT`                                 | No       | 3030                                           | Remix app port.                                                                                                                                                                                                                     |
| `APP_ORIGIN`                                     | Yes      | [http://localhost:3030](http://localhost:3030) | App origin URL.                                                                                                                                                                                                                     |
| `LOGIN_ORIGIN`                                   | Yes      | [http://localhost:3030](http://localhost:3030) | Login origin URL. Most likely the same as `APP_ORIGIN`.                                                                                                                                                                             |
| `API_ORIGIN`                                     | No       | `APP_ORIGIN`                                   | API origin URL.                                                                                                                                                                                                                     |
| `STREAM_ORIGIN`                                  | No       | `APP_ORIGIN`                                   | Realtime stream origin URL.                                                                                                                                                                                                         |
| `ELECTRIC_ORIGIN`                                | No       | [http://localhost:3060](http://localhost:3060) | Electric origin URL.                                                                                                                                                                                                                |
| **Postgres**                                     |          |                                                |                                                                                                                                                                                                                                     |
| `DATABASE_URL`                                   | Yes      | —                                              | PostgreSQL connection string.                                                                                                                                                                                                       |
| `DIRECT_URL`                                     | Yes      | —                                              | Direct DB connection string used for migrations etc.                                                                                                                                                                                |
| `DATABASE_CONNECTION_LIMIT`                      | No       | 10                                             | Max DB connections.                                                                                                                                                                                                                 |
| `DATABASE_POOL_TIMEOUT`                          | No       | 60                                             | DB pool timeout (s).                                                                                                                                                                                                                |
| `DATABASE_CONNECTION_TIMEOUT`                    | No       | 20                                             | DB connect timeout (s).                                                                                                                                                                                                             |
| `DATABASE_WRITER_POOL_TIMEOUT`                   | No       | `DATABASE_POOL_TIMEOUT`                        | Writer pool timeout (s); overrides the shared default for the writer only.                                                                                                                                                          |
| `DATABASE_WRITER_CONNECTION_TIMEOUT`             | No       | `DATABASE_CONNECTION_TIMEOUT`                  | Writer connect timeout (s); overrides the shared default for the writer only.                                                                                                                                                       |
| `DATABASE_READ_REPLICA_URL`                      | No       | `DATABASE_URL`                                 | Read-replica DB string.                                                                                                                                                                                                             |
| `DATABASE_READ_REPLICA_POOL_TIMEOUT`             | No       | `DATABASE_POOL_TIMEOUT`                        | Read-replica pool timeout (s); overrides the shared default for the replica only.                                                                                                                                                   |
| `DATABASE_READ_REPLICA_CONNECTION_TIMEOUT`       | No       | `DATABASE_CONNECTION_TIMEOUT`                  | Read-replica connect timeout (s); overrides the shared default for the replica only.                                                                                                                                                |
| **Redis**                                        |          |                                                |                                                                                                                                                                                                                                     |
| `REDIS_HOST`                                     | Yes      | —                                              | Redis host.                                                                                                                                                                                                                         |
| `REDIS_PORT`                                     | Yes      | —                                              | Redis port.                                                                                                                                                                                                                         |
| `REDIS_READER_HOST`                              | No       | `REDIS_HOST`                                   | Redis reader host.                                                                                                                                                                                                                  |
| `REDIS_READER_PORT`                              | No       | `REDIS_PORT`                                   | Redis reader port.                                                                                                                                                                                                                  |
| `REDIS_USERNAME`                                 | No       | —                                              | Redis username.                                                                                                                                                                                                                     |
| `REDIS_PASSWORD`                                 | No       | —                                              | Redis password.                                                                                                                                                                                                                     |
| `REDIS_TLS_DISABLED`                             | No       | —                                              | Disable Redis TLS.                                                                                                                                                                                                                  |
| **Auth**                                         |          |                                                |                                                                                                                                                                                                                                     |
| `WHITELISTED_EMAILS`                             | No       | —                                              | Whitelisted emails regex.                                                                                                                                                                                                           |
| `LOGIN_RATE_LIMITS_ENABLED`                      | No       | true                                           | Enable rate limiting on magic-link login.                                                                                                                                                                                           |
| `AUTH_GITHUB_CLIENT_ID`                          | No       | —                                              | GitHub client ID.                                                                                                                                                                                                                   |
| `AUTH_GITHUB_CLIENT_SECRET`                      | No       | —                                              | GitHub client secret.                                                                                                                                                                                                               |
| `CSP_IMG_SRC_ALLOWLIST`                          | No       | —                                              | Extra hosts the dashboard may load images from, e.g. an SSO or avatar host serving profile images. A comma-separated list of exact origins (`https://sso.example.com`); a wildcard host such as `https://*.example.com` is refused. |
| **Email**                                        |          |                                                |                                                                                                                                                                                                                                     |
| `EMAIL_TRANSPORT`                                | No       | —                                              | Email transport type. One of `resend`, `smtp`, `aws-ses`.                                                                                                                                                                           |
| `FROM_EMAIL`                                     | No       | —                                              | From email address.                                                                                                                                                                                                                 |
| `REPLY_TO_EMAIL`                                 | No       | —                                              | Reply-to email address.                                                                                                                                                                                                             |
| `RESEND_API_KEY`                                 | No       | —                                              | Resend API key.                                                                                                                                                                                                                     |
| `SMTP_HOST`                                      | No       | —                                              | SMTP host.                                                                                                                                                                                                                          |
| `SMTP_PORT`                                      | No       | —                                              | SMTP port.                                                                                                                                                                                                                          |
| `SMTP_SECURE`                                    | No       | —                                              | SMTP secure flag.                                                                                                                                                                                                                   |
| `SMTP_USER`                                      | No       | —                                              | SMTP user.                                                                                                                                                                                                                          |
| `SMTP_PASSWORD`                                  | No       | —                                              | SMTP password.                                                                                                                                                                                                                      |
| `AWS_REGION`                                     | No       | —                                              | AWS region for SES.                                                                                                                                                                                                                 |
| `AWS_ACCESS_KEY_ID`                              | No       | —                                              | AWS access key ID for SES.                                                                                                                                                                                                          |
| `AWS_SECRET_ACCESS_KEY`                          | No       | —                                              | AWS secret access key for SES.                                                                                                                                                                                                      |
| **Worker**                                       |          |                                                |                                                                                                                                                                                                                                     |
| `GRACEFUL_SHUTDOWN_TIMEOUT`                      | No       | 60000 (1m)                                     | Graceful shutdown timeout (ms). Affects shutdown time.                                                                                                                                                                              |
| **Concurrency limits**                           |          |                                                |                                                                                                                                                                                                                                     |
| `DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT`        | No       | 100                                            | Default env execution concurrency.                                                                                                                                                                                                  |
| `DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT`        | No       | 300                                            | Default org execution concurrency, needs to be 3x env concurrency.                                                                                                                                                                  |
| `DEFAULT_ENV_EXECUTION_CONCURRENCY_BURST_FACTOR` | No       | 1.0                                            | Burst factor for env concurrency.                                                                                                                                                                                                   |
| `DEFAULT_DEV_ENV_EXECUTION_ATTEMPTS`             | No       | 1                                              | Default max attempts for dev environment runs.                                                                                                                                                                                      |
| **Dev**                                          |          |                                                |                                                                                                                                                                                                                                     |
| `DEV_MAX_CONCURRENT_RUNS`                        | No       | 25                                             | Sets the max concurrency for dev runs via the CLI.                                                                                                                                                                                  |
| `DEV_OTEL_EXPORTER_OTLP_ENDPOINT`                | No       | `APP_ORIGIN/otel`                              | OTel endpoint for dev runs.                                                                                                                                                                                                         |
| **Rate limiting**                                |          |                                                |                                                                                                                                                                                                                                     |
| `API_RATE_LIMIT_REFILL_INTERVAL`                 | No       | 10s                                            | API rate limit refill interval.                                                                                                                                                                                                     |
| `API_RATE_LIMIT_MAX`                             | No       | 750                                            | API rate limit max.                                                                                                                                                                                                                 |
| `API_RATE_LIMIT_REFILL_RATE`                     | No       | 250                                            | API rate limit refill rate.                                                                                                                                                                                                         |
| `API_RATE_LIMIT_REQUEST_LOGS_ENABLED`            | No       | 0                                              | API rate limit request logs.                                                                                                                                                                                                        |
| `API_RATE_LIMIT_REJECTION_LOGS_ENABLED`          | No       | 1                                              | API rate limit rejection logs.                                                                                                                                                                                                      |
| `API_RATE_LIMIT_LIMITER_LOGS_ENABLED`            | No       | 0                                              | API rate limit limiter logs.                                                                                                                                                                                                        |
| `API_RATE_LIMIT_JWT_WINDOW`                      | No       | 1m                                             | API rate limit JWT window.                                                                                                                                                                                                          |
| `API_RATE_LIMIT_JWT_TOKENS`                      | No       | 60                                             | API rate limit JWT tokens.                                                                                                                                                                                                          |
| `DEPLOYMENT_RATE_LIMIT_REFILL_INTERVAL`          | No       | 10s                                            | Deployment endpoints rate limit refill interval.                                                                                                                                                                                    |
| `DEPLOYMENT_RATE_LIMIT_MAX`                      | No       | 1500                                           | Deployment endpoints rate limit max.                                                                                                                                                                                                |
| `DEPLOYMENT_RATE_LIMIT_REFILL_RATE`              | No       | 500                                            | Deployment endpoints rate limit refill rate.                                                                                                                                                                                        |
| `DEPLOYMENT_RATE_LIMIT_REQUEST_LOGS_ENABLED`     | No       | 0                                              | Deployment endpoints rate limit request logs.                                                                                                                                                                                       |
| `DEPLOYMENT_RATE_LIMIT_REJECTION_LOGS_ENABLED`   | No       | 1                                              | Deployment endpoints rate limit rejection logs.                                                                                                                                                                                     |
| `DEPLOYMENT_RATE_LIMIT_LIMITER_LOGS_ENABLED`     | No       | 0                                              | Deployment endpoints rate limit limiter logs.                                                                                                                                                                                       |
| **Deploy & Registry**                            |          |                                                |                                                                                                                                                                                                                                     |
| `DEPLOY_REGISTRY_HOST`                           | Yes      | —                                              | Deploy registry host.                                                                                                                                                                                                               |
| `DEPLOY_REGISTRY_USERNAME`                       | No       | —                                              | Deploy registry username.                                                                                                                                                                                                           |
| `DEPLOY_REGISTRY_PASSWORD`                       | No       | —                                              | Deploy registry password.                                                                                                                                                                                                           |
| `DEPLOY_REGISTRY_NAMESPACE`                      | No       | trigger                                        | Deploy registry namespace.                                                                                                                                                                                                          |
| `DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY`  | No       | —                                              | Raw IAM policy JSON applied via SetRepositoryPolicy to every ECR repo created by the webapp. Use to grant cross-account pull access to EKS workers when the ECR account is separate from the cluster account.                       |
| `DEPLOY_IMAGE_PLATFORM`                          | No       | linux/amd64                                    | Deploy image platform, same values as docker `--platform` flag.                                                                                                                                                                     |
| `DEPLOY_TIMEOUT_MS`                              | No       | 480000 (8m)                                    | Deploy timeout (ms).                                                                                                                                                                                                                |
| `DEPLOY_QUEUE_TIMEOUT_MS`                        | No       | 900000 (15m)                                   | Deploy queue timeout (ms).                                                                                                                                                                                                          |
| **Object store (S3)**                            |          |                                                |                                                                                                                                                                                                                                     |
| `OBJECT_STORE_BASE_URL`                          | No       | —                                              | Object store base URL (default provider).                                                                                                                                                                                           |
| `OBJECT_STORE_BUCKET`                            | No       | —                                              | Object store bucket name (default provider).                                                                                                                                                                                        |
| `OBJECT_STORE_ACCESS_KEY_ID`                     | No       | —                                              | Object store access key (default provider).                                                                                                                                                                                         |
| `OBJECT_STORE_SECRET_ACCESS_KEY`                 | No       | —                                              | Object store secret key (default provider).                                                                                                                                                                                         |
| `OBJECT_STORE_REGION`                            | No       | —                                              | Object store region (default provider).                                                                                                                                                                                             |
| `OBJECT_STORE_SERVICE`                           | No       | s3                                             | Object store service (default provider).                                                                                                                                                                                            |
| `OBJECT_STORE_DEFAULT_PROTOCOL`                  | No       | —                                              | Protocol for new uploads (e.g. `s3`, `r2`). Enables protocol-prefixed storage. See migration guide below.                                                                                                                           |
| `OBJECT_STORE_{PROTOCOL}_BASE_URL`               | No       | —                                              | Named provider base URL (replace `{PROTOCOL}`, e.g. `OBJECT_STORE_S3_BASE_URL`).                                                                                                                                                    |
| `OBJECT_STORE_{PROTOCOL}_ACCESS_KEY_ID`          | No       | —                                              | Named provider access key.                                                                                                                                                                                                          |
| `OBJECT_STORE_{PROTOCOL}_SECRET_ACCESS_KEY`      | No       | —                                              | Named provider secret key.                                                                                                                                                                                                          |
| `OBJECT_STORE_{PROTOCOL}_REGION`                 | No       | —                                              | Named provider region.                                                                                                                                                                                                              |
| `OBJECT_STORE_{PROTOCOL}_SERVICE`                | No       | —                                              | Named provider service.                                                                                                                                                                                                             |
| `ARTIFACTS_OBJECT_STORE_BUCKET`                  | No       | —                                              | Optional separate bucket for artifacts. If not set, uses main object store.                                                                                                                                                         |
| `ARTIFACTS_OBJECT_STORE_BASE_URL`                | No       | —                                              | Optional artifacts store base URL.                                                                                                                                                                                                  |
| `ARTIFACTS_OBJECT_STORE_ACCESS_KEY_ID`           | No       | —                                              | Optional artifacts store access key.                                                                                                                                                                                                |
| `ARTIFACTS_OBJECT_STORE_SECRET_ACCESS_KEY`       | No       | —                                              | Optional artifacts store secret key.                                                                                                                                                                                                |
| `ARTIFACTS_OBJECT_STORE_REGION`                  | No       | —                                              | Optional artifacts store region.                                                                                                                                                                                                    |
| **Alerts**                                       |          |                                                |                                                                                                                                                                                                                                     |
| `ORG_SLACK_INTEGRATION_CLIENT_ID`                | No       | —                                              | Slack client ID. Required for Slack alerts.                                                                                                                                                                                         |
| `ORG_SLACK_INTEGRATION_CLIENT_SECRET`            | No       | —                                              | Slack client secret. Required for Slack alerts.                                                                                                                                                                                     |
| `ALERT_EMAIL_TRANSPORT`                          | No       | —                                              | Alert email transport.                                                                                                                                                                                                              |
| `ALERT_FROM_EMAIL`                               | No       | —                                              | Alert from email.                                                                                                                                                                                                                   |
| `ALERT_REPLY_TO_EMAIL`                           | No       | —                                              | Alert reply-to email.                                                                                                                                                                                                               |
| `ALERT_RESEND_API_KEY`                           | No       | —                                              | Alert Resend API key.                                                                                                                                                                                                               |
| `ALERT_SMTP_HOST`                                | No       | —                                              | Alert SMTP host.                                                                                                                                                                                                                    |
| `ALERT_SMTP_PORT`                                | No       | —                                              | Alert SMTP port.                                                                                                                                                                                                                    |
| `ALERT_SMTP_SECURE`                              | No       | —                                              | Alert SMTP secure.                                                                                                                                                                                                                  |
| `ALERT_SMTP_USER`                                | No       | —                                              | Alert SMTP user.                                                                                                                                                                                                                    |
| `ALERT_SMTP_PASSWORD`                            | No       | —                                              | Alert SMTP password.                                                                                                                                                                                                                |
| **Limits**                                       |          |                                                |                                                                                                                                                                                                                                     |
| `TASK_PAYLOAD_OFFLOAD_THRESHOLD`                 | No       | 524288 (512KB)                                 | Max task payload size before offloading to S3.                                                                                                                                                                                      |
| `TASK_PAYLOAD_MAXIMUM_SIZE`                      | No       | 3145728 (3MB)                                  | Max task payload size.                                                                                                                                                                                                              |
| `BATCH_TASK_PAYLOAD_MAXIMUM_SIZE`                | No       | 1000000 (1MB)                                  | Max batch payload size.                                                                                                                                                                                                             |
| `BATCH_CONCURRENCY_LIMIT_DEFAULT`                | No       | 5                                              | Default concurrency for batch processing.                                                                                                                                                                                           |
| `BATCH_RATE_LIMIT_REFILL_RATE`                   | No       | 100                                            | Batch rate limit refill rate.                                                                                                                                                                                                       |
| `BATCH_RATE_LIMIT_MAX`                           | No       | 1200                                           | Batch rate limit max.                                                                                                                                                                                                               |
| `BATCH_RATE_LIMIT_REFILL_INTERVAL`               | No       | 10s                                            | Batch rate limit refill interval.                                                                                                                                                                                                   |
| `TASK_RUN_METADATA_MAXIMUM_SIZE`                 | No       | 262144 (256KB)                                 | Max metadata size.                                                                                                                                                                                                                  |
| `MAX_BATCH_V2_TRIGGER_ITEMS`                     | No       | 500                                            | Max batch size (legacy v2 API).                                                                                                                                                                                                     |
| `STREAMING_BATCH_MAX_ITEMS`                      | No       | 1000                                           | Max items in streaming batch (v3 API, requires SDK 4.3.1+).                                                                                                                                                                         |
| `STREAMING_BATCH_ITEM_MAXIMUM_SIZE`              | No       | 3145728 (3MB)                                  | Max size per item in streaming batch.                                                                                                                                                                                               |
| `STREAMING_BATCH_INGEST_CONCURRENCY`             | No       | 10                                             | Items ingested concurrently per streaming batch request. Peak memory ≈ this × item size. Set to 1 for sequential.                                                                                                                   |
| **OTel limits**                                  |          |                                                |                                                                                                                                                                                                                                     |
| `TRIGGER_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT`        | No       | 1024                                           | OTel span attribute count limit.                                                                                                                                                                                                    |
| `TRIGGER_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT`         | No       | 1024                                           | OTel log attribute count limit.                                                                                                                                                                                                     |
| `TRIGGER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT` | No       | 131072                                         | OTel span attribute value length limit.                                                                                                                                                                                             |
| `TRIGGER_OTEL_LOG_ATTRIBUTE_VALUE_LENGTH_LIMIT`  | No       | 131072                                         | OTel log attribute value length limit.                                                                                                                                                                                              |
| `TRIGGER_OTEL_SPAN_EVENT_COUNT_LIMIT`            | No       | 10                                             | OTel span event count limit.                                                                                                                                                                                                        |
| `TRIGGER_OTEL_LINK_COUNT_LIMIT`                  | No       | 2                                              | OTel link count limit.                                                                                                                                                                                                              |
| `TRIGGER_OTEL_ATTRIBUTE_PER_LINK_COUNT_LIMIT`    | No       | 10                                             | OTel attribute per link count limit.                                                                                                                                                                                                |
| `TRIGGER_OTEL_ATTRIBUTE_PER_EVENT_COUNT_LIMIT`   | No       | 10                                             | OTel attribute per event count limit.                                                                                                                                                                                               |
| `SERVER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT`  | No       | 8192                                           | OTel span attribute value length limit.                                                                                                                                                                                             |
| **Task events**                                  |          |                                                |                                                                                                                                                                                                                                     |
| `EVENT_REPOSITORY_DEFAULT_STORE`                 | No       | postgres                                       | Where to store task events. Set to `clickhouse_v2` to store in ClickHouse (recommended for production).                                                                                                                             |
| `EVENT_REPOSITORY_POSTGRES_WRITES_DISABLED`      | No       | 0                                              | Skip all PostgreSQL task-event writes (set to `1`). Only enable when `EVENT_REPOSITORY_DEFAULT_STORE` is `clickhouse_v2`, otherwise task events are lost.                                                                           |
| **Realtime**                                     |          |                                                |                                                                                                                                                                                                                                     |
| `REALTIME_STREAM_VERSION`                        | No       | v1                                             | Stream version exposed to tasks via the `TRIGGER_REALTIME_STREAM_VERSION` variable. Distinct from `REALTIME_STREAMS_DEFAULT_VERSION`. One of `v1`, `v2`.                                                                            |
| `REALTIME_STREAM_MAX_LENGTH`                     | No       | 1000                                           | Realtime stream max length.                                                                                                                                                                                                         |
| `REALTIME_STREAM_TTL`                            | No       | 86400 (1d)                                     | Realtime stream TTL (s).                                                                                                                                                                                                            |
| `REALTIME_STREAMS_DEFAULT_VERSION`               | No       | v1                                             | Server-side default the webapp uses when a stream request doesn't pin a version (modern SDKs request `v2`). The Docker and Helm self-hosting defaults set this to `v2`. One of `v1`, `v2`.                                          |
| `REALTIME_STREAMS_S2_BASIN`                      | No       | —                                              | S2 basin that holds v2 realtime streams. Required for `v2`. Must be at least 8 characters.                                                                                                                                          |
| `REALTIME_STREAMS_S2_ENDPOINT`                   | No       | —                                              | Custom S2 API endpoint, including the `/v1` suffix (e.g. `http://s2/v1` for the bundled s2-lite). Omit to use hosted S2 at s2.dev.                                                                                                  |
| `REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS`         | No       | false                                          | Skip minting per-stream access tokens. Set to `true` for s2-lite, which needs no authentication.                                                                                                                                    |
| `REALTIME_STREAMS_S2_ACCESS_TOKEN`               | No       | —                                              | S2 access token. Required for hosted S2 unless `REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS` is `true`.                                                                                                                                  |
| **Bootstrap**                                    |          |                                                |                                                                                                                                                                                                                                     |
| `TRIGGER_BOOTSTRAP_ENABLED`                      | No       | 0                                              | Trigger bootstrap enabled.                                                                                                                                                                                                          |
| `TRIGGER_BOOTSTRAP_WORKER_GROUP_NAME`            | No       | —                                              | Trigger bootstrap worker group name.                                                                                                                                                                                                |
| `TRIGGER_BOOTSTRAP_WORKER_TOKEN_PATH`            | No       | —                                              | Trigger bootstrap worker token path.                                                                                                                                                                                                |
| **Run engine**                                   |          |                                                |                                                                                                                                                                                                                                     |
| `RUN_ENGINE_WORKER_COUNT`                        | No       | 4                                              | Run engine worker count.                                                                                                                                                                                                            |
| `RUN_ENGINE_TASKS_PER_WORKER`                    | No       | 10                                             | Run engine tasks per worker.                                                                                                                                                                                                        |
| `RUN_ENGINE_WORKER_CONCURRENCY_LIMIT`            | No       | 10                                             | Run engine worker concurrency limit.                                                                                                                                                                                                |
| `RUN_ENGINE_WORKER_POLL_INTERVAL`                | No       | 100                                            | Run engine worker poll interval (ms).                                                                                                                                                                                               |
| `RUN_ENGINE_WORKER_IMMEDIATE_POLL_INTERVAL`      | No       | 100                                            | Run engine worker immediate poll interval (ms).                                                                                                                                                                                     |
| `RUN_ENGINE_WORKER_SHUTDOWN_TIMEOUT_MS`          | No       | 60000 (1m)                                     | Run engine worker shutdown timeout (ms).                                                                                                                                                                                            |
| `RUN_ENGINE_RATE_LIMIT_REFILL_INTERVAL`          | No       | 10s                                            | Run engine rate limit refill interval.                                                                                                                                                                                              |
| `RUN_ENGINE_RATE_LIMIT_MAX`                      | No       | 1200                                           | Run engine rate limit max.                                                                                                                                                                                                          |
| `RUN_ENGINE_RATE_LIMIT_REFILL_RATE`              | No       | 400                                            | Run engine rate limit refill rate.                                                                                                                                                                                                  |
| `RUN_ENGINE_RATE_LIMIT_REQUEST_LOGS_ENABLED`     | No       | 0                                              | Run engine rate limit request logs.                                                                                                                                                                                                 |
| `RUN_ENGINE_RATE_LIMIT_REJECTION_LOGS_ENABLED`   | No       | 1                                              | Run engine rate limit rejection logs.                                                                                                                                                                                               |
| `RUN_ENGINE_RATE_LIMIT_LIMITER_LOGS_ENABLED`     | No       | 0                                              | Run engine rate limit limiter logs.                                                                                                                                                                                                 |
| `RUN_ENGINE_DEFAULT_MAX_TTL`                     | No       | —                                              | Maximum TTL for all runs (e.g. "14d"). Runs without a TTL use this as default; runs with a larger TTL are clamped.                                                                                                                  |
| `MAXIMUM_DEV_QUEUE_SIZE`                         | No       | —                                              | Maximum queued runs per queue in development environments.                                                                                                                                                                          |
| `MAXIMUM_DEPLOYED_QUEUE_SIZE`                    | No       | —                                              | Maximum queued runs per queue in deployed (staging/prod) environments.                                                                                                                                                              |
| **Misc**                                         |          |                                                |                                                                                                                                                                                                                                     |
| `TRIGGER_TELEMETRY_DISABLED`                     | No       | —                                              | Disable telemetry.                                                                                                                                                                                                                  |
| `NODE_MAX_OLD_SPACE_SIZE`                        | No       | 8192                                           | Maximum memory allocation for Node.js heap in MiB (e.g. "4096" for 4GB).                                                                                                                                                            |
| `OPENAI_API_KEY`                                 | No       | —                                              | OpenAI API key.                                                                                                                                                                                                                     |
| `MACHINE_PRESETS_OVERRIDE_PATH`                  | No       | —                                              | Path to machine presets override file. See [machine overrides](/docs/self-hosting/overview#machine-overrides).                                                                                                                           |
| `APP_ENV`                                        | No       | `NODE_ENV`                                     | App environment. Used for things like the title tag.                                                                                                                                                                                |
| `ADMIN_EMAILS`                                   | No       | —                                              | Regex of user emails to automatically promote to admin on signup. Does not apply to existing users.                                                                                                                                 |
| `EVENT_LOOP_MONITOR_ENABLED`                     | No       | 1                                              | Node.js event loop lag monitor.                                                                                                                                                                                                     |

## Multi-Provider Object Storage

The object storage system supports multiple S3-compatible providers (R2, S3, GCS, MinIO, etc.) using protocol prefixes. This enables migrating between providers without breaking existing runs.

### How It Works

When data exceeds the configured threshold (`TASK_PAYLOAD_OFFLOAD_THRESHOLD`), it's uploaded to object storage. The storage location is saved in the database with an optional protocol prefix:

* **With protocol**: `s3://run_abc/payload.json` or `r2://batch_123/item_0/payload.json`
* **Without protocol** (legacy): `batch_123/item_0/payload.json` (uses default provider)

### Configuration

#### Default Provider (Backward Compatible)

The default provider is used for data without a protocol prefix:

```bash theme={"theme":"css-variables"}
# Default provider (backward compatible - no protocol prefix)
OBJECT_STORE_BASE_URL=https://r2.example.com
OBJECT_STORE_ACCESS_KEY_ID=...
OBJECT_STORE_SECRET_ACCESS_KEY=...
OBJECT_STORE_REGION=auto
OBJECT_STORE_SERVICE=s3
```

#### Named Providers

Named providers are accessed via protocol-prefixed URIs. Configure them using `OBJECT_STORE_{PROTOCOL}_*` variables:

```bash theme={"theme":"css-variables"}
# S3 provider (accessed via s3:// prefix)
OBJECT_STORE_S3_BASE_URL=https://s3.amazonaws.com
OBJECT_STORE_S3_ACCESS_KEY_ID=...
OBJECT_STORE_S3_SECRET_ACCESS_KEY=...
OBJECT_STORE_S3_REGION=us-east-1
OBJECT_STORE_S3_SERVICE=s3

# R2 provider (accessed via r2:// prefix)
OBJECT_STORE_R2_BASE_URL=https://...r2.cloudflarestorage.com
OBJECT_STORE_R2_ACCESS_KEY_ID=...
OBJECT_STORE_R2_SECRET_ACCESS_KEY=...
OBJECT_STORE_R2_REGION=auto
OBJECT_STORE_R2_SERVICE=s3
```

#### Default Protocol for New Uploads

Set `OBJECT_STORE_DEFAULT_PROTOCOL` to specify which provider to use for new uploads:

```bash theme={"theme":"css-variables"}
# Use S3 for new uploads (old data without prefix still uses default provider)
OBJECT_STORE_DEFAULT_PROTOCOL=s3
```

### Migration Guide

To migrate from R2 to S3 without breaking existing runs:

<Steps>
  <Step title="Configure S3 provider">
    Add S3 credentials as a named provider:

    ```bash theme={"theme":"css-variables"}
    OBJECT_STORE_S3_BASE_URL=https://s3.amazonaws.com
    OBJECT_STORE_S3_ACCESS_KEY_ID=...
    OBJECT_STORE_S3_SECRET_ACCESS_KEY=...
    OBJECT_STORE_S3_REGION=us-east-1
    ```

    Keep your existing `OBJECT_STORE_*` variables (R2) as the default provider.
  </Step>

  <Step title="Test the configuration">
    Restart the webapp and verify both providers work:

    * Old runs (no prefix) should still access R2
    * New runs with `s3://` prefix should use S3
  </Step>

  <Step title="Switch to S3 for new uploads">
    Set the default protocol to use S3 for new uploads:

    ```bash theme={"theme":"css-variables"}
    OBJECT_STORE_DEFAULT_PROTOCOL=s3
    ```

    After this change:

    * New data uses `s3://` prefix and goes to S3
    * Old data (no prefix) still uses R2
    * Data with explicit protocol uses the corresponding provider
  </Step>

  <Step title="Optionally decommission R2">
    Once all active runs using R2 data have completed (check your data retention policies), you can remove the R2 credentials. Keep `OBJECT_STORE_DEFAULT_PROTOCOL=s3` to ensure new data continues using S3.
  </Step>
</Steps>


# Kubernetes
Source: https://trigger.dev/docs/self-hosting/kubernetes

Deploy Trigger.dev to Kubernetes with our official Helm chart - one command for evaluation, external datastores for production.

The official Helm chart installs the full Trigger.dev stack into a Kubernetes namespace. Read the self-hosting [overview](/docs/self-hosting/overview) first.

Pick a path and stay on it. Run the **evaluation install** to try Trigger.dev with everything bundled in-cluster - one `helm install`, default values, no external dependencies. Run the **production install** for real workloads: external Postgres, Redis, ClickHouse, and object storage, your own secrets, and HA on the datastores you already operate. Each configuration decision below belongs to one path or the other.

This guide alone is unlikely to produce a hardened production deployment - securing, scaling, and reliability-tuning your cluster remain yours to own. Should the burden get too much, we'd be happy to see you on [Trigger.dev cloud](https://trigger.dev/pricing) where we deal with these concerns for you.

## Architecture

The chart deploys a webapp, a supervisor, and the datastores they depend on. The supervisor pulls runs from the webapp and schedules each one onto a worker node, which executes the task in a container sized by its machine preset.

```mermaid theme={"theme":"css-variables"}
flowchart TD
  webapp["webapp<br/>API + dashboard"]
  supervisor["supervisor"]
  workers["worker nodes<br/>task containers"]
  pg[("Postgres<br/>primary DB")]
  redis[("Redis<br/>queue / state")]
  ch[("ClickHouse<br/>task events v2")]
  obj[("object storage<br/>MinIO / external S3")]
  reg[("registry<br/>deploy images")]
  s2[("s2 / s2-lite<br/>realtime streams v2")]

  webapp --> pg
  webapp --> redis
  webapp --> ch
  webapp --> obj
  webapp --> s2
  webapp -->|worker token| supervisor
  supervisor --> workers
  workers --> reg
```

Each component maps to a top-level key in `values.yaml`: `webapp`, `supervisor`, `postgres`, `redis`, `electric`, `clickhouse`, `s3` (object storage), `s2`, and `registry`. The datastores default to `deploy: true` and run in-cluster; set `deploy: false` with an `external` block to bring your own. The registry is the exception - it defaults to `deploy: false`, so you point it at an external one. Webapp settings live under `webapp`, supervisor settings under `supervisor.config`, and each service takes `extraEnvVars` for anything else.

## Requirements

### Prerequisites

* Kubernetes cluster 1.19+
* Helm 3.8+
* Kubectl with cluster access

### Resources

The following are minimum requirements for running the entire stack on Kubernetes:

**Cluster resources:**

* 6+ vCPU total
* 12+ GB RAM total
* Persistent volume support

**Individual components:**

* **Webapp**: 1 vCPU, 2 GB RAM
* **Supervisor**: 1 vCPU, 1 GB RAM
* **PostgreSQL**: 1 vCPU, 2 GB RAM
* **Redis**: 0.5 vCPU, 1 GB RAM
* **ClickHouse**: 1 vCPU, 2 GB RAM
* **Object Storage**: 0.5 vCPU, 1 GB RAM
* **Workers**: Depending on concurrency and machine preset

These requirements scale based on your task concurrency and can be adjusted via the `resources` section in your `values.yaml`. For example:

```yaml theme={"theme":"css-variables"}
webapp:
  resources:
    requests:
      cpu: 500m
      memory: 1Gi
    limits:
      cpu: 2000m
      memory: 4Gi
```

## Evaluation install

Bundled datastores let you run the whole stack with a single command and no external services. The default values are insecure and are only suitable for testing - for real workloads, follow the [production install](#production-install) instead.

<Steps>
  <Step title="Install the chart with default values">
    ```bash theme={"theme":"css-variables"}
    helm upgrade -n trigger --install trigger \
      oci://ghcr.io/triggerdotdev/charts/trigger \
      --version "^4.5.0" \
      --create-namespace
    ```

    The chart deploys the webapp, supervisor, and bundled Postgres, Redis, Electric, ClickHouse, MinIO, and s2-lite. The registry is not deployed by default - configure an external one before deploying tasks.
  </Step>

  <Step title="Port-forward the webapp">
    ```bash theme={"theme":"css-variables"}
    kubectl port-forward svc/trigger-webapp 3040:3030 -n trigger
    ```

    The dashboard is now reachable at `http://localhost:3040`.
  </Step>

  <Step title="Log in with the magic link">
    Default installs print the login link to the webapp logs instead of sending email.

    ```bash theme={"theme":"css-variables"}
    kubectl logs -n trigger deployment/trigger-webapp | grep -A1 "magic link"
    ```

    Open the printed link to sign in.
  </Step>
</Steps>

### Verify it

Confirm every component reached a healthy state:

```bash theme={"theme":"css-variables"}
kubectl get pods -n trigger
```

The webapp, supervisor, and each bundled datastore pod should report `Running` with all containers `READY`. Migrations and bootstrap run inside the webapp pod rather than as separate jobs, so if the webapp stays unready, check its logs. If a pod is stuck in `Pending` or `CrashLoopBackOff`, jump to [Troubleshooting](#troubleshooting).

Once you can sign in and trigger a run, you've validated the stack. For a deployment that survives restarts, upgrades, and load, move on to the production install.

## Production install

Running external datastores is the requirement that separates a production install from an evaluation one. You supply your own Postgres, Redis, ClickHouse, and object storage, manage secrets yourself, and point the chart at each service. The steps below layer onto the same `helm upgrade` command, driven by a custom values file.

### Secrets

Application, control-plane, and bundled-datastore secrets left unset are generated on first install and **retained across `helm upgrade`** - they are never rotated automatically, so sessions, encrypted data, and datastore volumes survive upgrades. This auto-generation is the default and needs no configuration.

For production, supply your own Secret instead so credentials live outside the release. With `secrets.existingSecret` set, the chart generates nothing and reads every key from your Secret; a preflight check fails the install - before touching a running release - and lists any keys it can't find.

To pin values inline instead of letting the chart generate them - for example to share `MANAGED_WORKER_SECRET` with an external supervisor - set them under `secrets`:

```yaml theme={"theme":"css-variables"}
secrets:
  enabled: true
  sessionSecret: "your-32-char-hex-secret-1"
  magicLinkSecret: "your-32-char-hex-secret-2"
  # ...
```

To use an existing Secret instead, leave the values unset and set `existingSecret`. It must contain at least `SESSION_SECRET`, `MAGIC_LINK_SECRET`, `ENCRYPTION_KEY`, `PROVIDER_SECRET`, `COORDINATOR_SECRET`, and `MANAGED_WORKER_SECRET` - plus `s3-auth-access-key-id` and `s3-auth-secret-access-key` if you deploy the bundled MinIO with `s3.auth.existingSecret` cleared:

```yaml theme={"theme":"css-variables"}
secrets:
  enabled: false
  existingSecret: "your-existing-secret"
```

The chart wires `PROVIDER_SECRET` and `COORDINATOR_SECRET` into the webapp deployment, and the pre-upgrade check requires both, so an existing Secret must include them. For a deployment that already runs, add them before upgrading:

```bash theme={"theme":"css-variables"}
kubectl patch secret my-trigger-secrets -n trigger --type merge -p \
  "{\"stringData\":{\"PROVIDER_SECRET\":\"$(openssl rand -hex 16)\",\"COORDINATOR_SECRET\":\"$(openssl rand -hex 16)\"}}"
```

There is no clean migration for a compromised `ENCRYPTION_KEY`: changing it makes existing encrypted data unreadable. If a deployment is still running a previously published default and cannot rotate yet, set `ALLOW_INSECURE_DEFAULT_SECRETS=true` on the webapp to keep booting while you plan a migration.

<Warning>
  GitOps tools that render with `helm template` (e.g. Argo CD) cannot read the existing
  secret, so they regenerate auto-generated values on every sync - which rotates them. If you
  deploy via GitOps, always supply your own `secrets.existingSecret` (and datastore
  credentials) so nothing is generated in-cluster.
</Warning>

### Custom values

Most values map directly to the environment variables documented in the [webapp](/docs/self-hosting/env/webapp) and [supervisor](/docs/self-hosting/env/supervisor) environment variable overview. Environment variables use `UPPER_SNAKE_CASE`; Helm values use `camelCase`:

```bash theme={"theme":"css-variables"}
# Environment variable
APP_ORIGIN=https://trigger.example.com

# Becomes Helm value
webapp:
  appOrigin: "https://trigger.example.com"
```

Print the full default surface for the version you're deploying:

```bash theme={"theme":"css-variables"}
# Specific version
helm show values oci://ghcr.io/triggerdotdev/charts/trigger \
  --version "4.5.0"

# Latest v4
helm show values oci://ghcr.io/triggerdotdev/charts/trigger \
  --version "^4.5.0"
```

Create a `values-custom.yaml` that overrides the defaults with your URLs and resource requests:

```yaml theme={"theme":"css-variables"}
# Application URLs and resource limits
webapp:
  appOrigin: "https://trigger.example.com"
  loginOrigin: "https://trigger.example.com"
  apiOrigin: "https://trigger.example.com"
  resources:
    requests:
      cpu: 1000m
      memory: 2Gi
    limits:
      cpu: 2000m
      memory: 4Gi

supervisor:
  resources:
    requests:
      cpu: 200m
      memory: 512Mi
    limits:
      cpu: 1000m
      memory: 2Gi
```

Deploy with your custom values:

```bash theme={"theme":"css-variables"}
helm upgrade -n trigger --install trigger \
  oci://ghcr.io/triggerdotdev/charts/trigger \
  --version "^4.5.0" \
  --create-namespace \
  -f values-custom.yaml
```

Set extra environment variables on any service:

```yaml theme={"theme":"css-variables"}
webapp:
  extraEnvVars:
    - name: EXTRA_ENV_VAR
      value: "extra-value"
```

Set extra annotations on any service:

```yaml theme={"theme":"css-variables"}
webapp:
  podAnnotations:
    "my-annotation": "my-value"
```

### External services

Disable each built-in datastore and point the chart at your own. Every service supports direct configuration or an existing Kubernetes secret; use existing secrets to keep credentials out of `values.yaml` and Helm releases.

#### PostgreSQL

**Direct configuration:**

```yaml theme={"theme":"css-variables"}
postgres:
  deploy: false
  external:
    databaseUrl: "postgresql://user:password@host:5432/database?schema=public"
    directUrl: "" # Optional, defaults to databaseUrl
```

**Using existing secrets (recommended):**

```yaml theme={"theme":"css-variables"}
postgres:
  deploy: false
  external:
    existingSecret: "postgres-credentials"
    # Optional: Use secretKeys to specify the key names in the secret
    # secretKeys:
    #   databaseUrlKey: "postgres-database-url" # default
    #   directUrlKey: "postgres-direct-url"     # default
```

#### Redis

**Direct configuration:**

```yaml theme={"theme":"css-variables"}
redis:
  deploy: false
  external:
    host: "my-redis.example.com"
    port: 6379
    password: "my-password"
    tls:
      enabled: true
```

**Using existing secrets (recommended):**

```yaml theme={"theme":"css-variables"}
redis:
  deploy: false
  external:
    host: "my-redis.example.com"
    port: 6379
    existingSecret: "redis-credentials"
    # existingSecretPasswordKey: "redis-password" # default (optional)
    tls:
      enabled: true
```

#### ClickHouse

Migration from the old Bitnami subchart to the official `clickhouse/clickhouse-server` image is automatic - a normal upgrade moves you across with no manual step. The one exception: if you pinned `clickhouse.image` to a Bitnami tag, switch it to an official `clickhouse/clickhouse-server` tag first, since Bitnami tags don't exist in the official repository.

<Note>
  Trigger.dev requires ClickHouse 25.8 or newer. The bundled ClickHouse is single-node — run
  ClickHouse externally for a clustered or replicated setup.
</Note>

**Direct configuration:**

```yaml theme={"theme":"css-variables"}
clickhouse:
  deploy: false
  external:
    host: "my-clickhouse.example.com"
    httpPort: 8123
    username: "my-username"
    password: "my-password"
```

<Note>
  An inline external `username`/`password` is percent-encoded into the connection URL for you, so
  store the **raw** value - special characters like `@ : / %` are handled automatically.
  (Credentials from `existingSecret` are injected at runtime and are unaffected.)
</Note>

**Using existing secrets (recommended):**

```yaml theme={"theme":"css-variables"}
clickhouse:
  deploy: false
  external:
    host: "my-clickhouse.example.com"
    httpPort: 8123
    username: "my-username"
    existingSecret: "clickhouse-credentials"
    # existingSecretKey: "clickhouse-password" # default (optional)
```

#### Object storage

Object storage holds deploy artifacts and large payloads. See the [Docker object storage setup](/docs/self-hosting/docker#object-storage) for conceptual information. Disable the bundled MinIO and point at any S3-compatible endpoint.

**Direct configuration:**

```yaml theme={"theme":"css-variables"}
s3:
  deploy: false
  external:
    endpoint: "https://s3.amazonaws.com"
    accessKeyId: "my-access-key"
    secretAccessKey: "my-secret-key"
```

**Using existing secrets (recommended):**

```yaml theme={"theme":"css-variables"}
s3:
  deploy: false
  external:
    endpoint: "https://s3.amazonaws.com"
    existingSecret: "s3-credentials"
    # Optional: override the key names in the secret
    # existingSecretAccessKeyIdKey: "access-key-id"         # default
    # existingSecretSecretAccessKeyKey: "secret-access-key" # default
```

### PostgreSQL SSL with custom CA certificates

When connecting to PostgreSQL instances that require custom CA certificates (such as AWS RDS with SSL verification), mount the CA certificate as a volume and configure the webapp to use it:

```yaml theme={"theme":"css-variables"}
postgres:
  deploy: false
  external:
    databaseUrl: "postgresql://user:password@mydb.example.com:5432/triggerdb?schema=public&sslmode=require"
    # Alternatively, use an existing secret
    existingSecret: "postgres-credentials"
    # secretKeys:
    #   databaseUrlKey: "postgres-database-url" # default
  connection:
    sslMode: "require"

# Webapp configuration with SSL CA certificate
webapp:
  extraEnvVars:
    - name: NODE_EXTRA_CA_CERTS
      value: "/etc/ssl/certs/postgres-ca.crt"

  extraVolumes:
    - name: postgres-ca-cert
      secret:
        secretName: postgres-ca-secret
        items:
          - key: ca.crt
            path: postgres-ca.crt

  extraVolumeMounts:
    - name: postgres-ca-cert
      mountPath: /etc/ssl/certs
      readOnly: true
```

**Benefits:**

* No plaintext credentials in `values.yaml` or Helm releases
* Complete `DATABASE_URL` stored securely in Kubernetes secrets
* Compatible with secret management tools (External Secrets Operator, etc.)
* Follows Kubernetes security best practices

### Registry setup

Built deploy images are pushed to a registry. See the [Docker registry setup](/docs/self-hosting/docker#registry-setup) for conceptual information. Configure an external registry in your `values.yaml`:

```yaml theme={"theme":"css-variables"}
# Use external registry (recommended)
registry:
  deploy: false
  # Part of deployment image ref, for example: your-registry.example.com/your-company/proj_123:20250625.1.prod
  repositoryNamespace: "your-company"
  external:
    host: "your-registry.example.com:5000"
    auth:
      enabled: true
      username: "your-username"
      password: "your-password"
```

<Note>
  The internal registry (`registry.deploy: true`) is experimental and requires proper TLS setup
  and additional cluster configuration. Use an external registry for production.
</Note>

### DNS performance

For production clusters we recommend deploying [NodeLocal DNSCache](https://kubernetes.io/docs/tasks/administer-cluster/nodelocaldns/). DNS queries — especially to managed Postgres or Redis endpoints — can be very slow under Kubernetes' default resolver, and a node-local cache typically gives a large step change in latency and throughput across the cluster.

The default `ndots: 5` setting also forces every cluster search domain to be tried before resolving hostnames with fewer dots (the case for most external database hosts). Lowering `ndots` to `1` on the webapp and supervisor pods avoids those extra round-trips.

### Authentication

Authentication options are identical to the [Docker-based installation](/docs/self-hosting/docker#authentication). Configure the provider in your `values.yaml`:

**GitHub OAuth:**

```yaml theme={"theme":"css-variables"}
webapp:
  extraEnvVars:
    - name: AUTH_GITHUB_CLIENT_ID
      value: "your-github-client-id"
    - name: AUTH_GITHUB_CLIENT_SECRET
      value: "your-github-client-secret"
```

**Email authentication (Resend):**

```yaml theme={"theme":"css-variables"}
webapp:
  extraEnvVars:
    - name: EMAIL_TRANSPORT
      value: "resend"
    - name: FROM_EMAIL
      value: "noreply@yourdomain.com"
    - name: REPLY_TO_EMAIL
      value: "support@yourdomain.com"
    - name: RESEND_API_KEY
      value: "your-resend-api-key"
```

**Restricting access:**

```yaml theme={"theme":"css-variables"}
webapp:
  extraEnvVars:
    - name: WHITELISTED_EMAILS
      value: "^(user1@company\\.com|user2@company\\.com)$"
```

### Worker token

The supervisor authenticates to the webapp with a worker token. The default bootstrap path generates one for you; supply your own token to manage workers separately.

**Bootstrap (default):** the webapp generates a worker token and makes it available to the supervisor via a shared volume.

```yaml theme={"theme":"css-variables"}
webapp:
  bootstrap:
    enabled: true
    workerGroupName: "bootstrap"
```

**Manual:** to set up workers separately or use a custom token:

<Steps>
  <Step title="Read the worker token from the webapp logs">
    ```bash theme={"theme":"css-variables"}
    kubectl logs deployment/trigger-webapp -n trigger | grep -A15 "Worker Token"
    ```
  </Step>

  <Step title="Create a secret with the token">
    ```bash theme={"theme":"css-variables"}
    kubectl create secret generic worker-token \
      --from-literal=token=tr_wgt_your_token_here \
      -n trigger
    ```
  </Step>

  <Step title="Point the supervisor at the secret">
    ```yaml theme={"theme":"css-variables"}
    supervisor:
      bootstrap:
        enabled: false
        workerToken:
          secret:
            name: "worker-token"
            key: "token"
    ```
  </Step>
</Steps>

With external datastores, secrets, registry, and authentication in place, you have a production install. If you only wanted to try Trigger.dev, the [evaluation install](#evaluation-install) gets you there in one command.

## Operating your deployment

### Upgrading

`helm upgrade` is safe to run as-is. Your generated secrets, data volumes, and the bundled ClickHouse are preserved and migrated automatically, so a normal upgrade needs no preparation. A few situations need one manual step first — expand any that apply to you.

<AccordionGroup>
  <Accordion title="You supply your own secret (secrets.existingSecret)">
    With `secrets.existingSecret` set, the chart reads every key from your Secret and generates none, so any key the app needs must already be present there.

    As of chart 4.5.6 the webapp deployment references `PROVIDER_SECRET` and `COORDINATOR_SECRET`, and the pre-upgrade check requires both, so an existing Secret must carry them. Add both before upgrading:

    ```bash theme={"theme":"css-variables"}
    kubectl patch secret my-trigger-secrets -n trigger --type merge -p \
      "{\"stringData\":{\"PROVIDER_SECRET\":\"$(openssl rand -hex 16)\",\"COORDINATOR_SECRET\":\"$(openssl rand -hex 16)\"}}"
    ```

    If a required key is missing, the upgrade stops before touching your running release and tells you which to add.
  </Accordion>

  <Accordion title="Something outside the chart reads a datastore password">
    The bundled datastore passwords live in the `trigger-datastore` Secret. If a maintenance job, a dashboard, or a secret sync reads one directly, point it there:

    ```bash theme={"theme":"css-variables"}
    kubectl get secret trigger-datastore -n trigger \
      -o jsonpath='{.data.clickhouse-admin-password}' | base64 -d
    ```

    The keys are `clickhouse-admin-password`, `postgres-password`, and `minio-root-user` / `minio-root-password`.
  </Accordion>

  <Accordion title="You render manifests without cluster access (Argo CD, plain helm template)">
    During an upgrade the chart reads the cluster to keep your data and to check your Secret. Renderers that run offline can't do that, so set these yourself:

    * Point `clickhouse.persistence.existingClaim` at your ClickHouse data volume (`data-<release>-clickhouse-shard0-0`) and keep it set on future syncs, so ClickHouse reuses its data instead of starting on an empty volume.
    * Confirm `PROVIDER_SECRET` and `COORDINATOR_SECRET` are already in your Secret before syncing.
  </Accordion>
</AccordionGroup>

### Version locking

<Note>
  Trigger.dev 4.5.0 is the last version we officially support for running v3 (SDK v3) tasks. If
  you still have v3 tasks, pin to exactly 4.5.0 or [migrate to v4](/docs/migrating-from-v3). 4.5.1 and
  later reject v3 triggers and deploys with an upgrade message.
</Note>

Lock versions in two ways.

**Helm chart version (recommended):**

```bash theme={"theme":"css-variables"}
# Pin to a specific version for production
helm upgrade -n trigger --install trigger \
  oci://ghcr.io/triggerdotdev/charts/trigger \
  --version "4.5.0"

# The app version will be different from the chart version
# This is the version of the Trigger.dev webapp and supervisor
# ..and should always match your Trigger.dev CLI version
helm show chart \
  oci://ghcr.io/triggerdotdev/charts/trigger \
  --version "4.5.0" | grep appVersion
```

**Specific image tags:**

```yaml theme={"theme":"css-variables"}
webapp:
  image:
    tag: "v4.5.0"

supervisor:
  image:
    tag: "v4.5.0"
```

The chart version's `appVersion` field determines the default image tags. Newer image tags may be incompatible with older chart versions and vice versa.

### Troubleshooting

**Check logs:**

```bash theme={"theme":"css-variables"}
# Webapp logs
kubectl logs deployment/trigger-webapp -n trigger -f

# Supervisor logs
kubectl logs deployment/trigger-supervisor -n trigger -f

# All pods
kubectl logs -l app.kubernetes.io/instance=trigger -n trigger -f
```

**Check pod status:**

```bash theme={"theme":"css-variables"}
kubectl get pods -n trigger
kubectl describe pod <pod-name> -n trigger
```

**Start from scratch:**

```bash theme={"theme":"css-variables"}
# Delete the release
helm uninstall trigger -n trigger

# Delete persistent volumes (optional)
# WARNING: This will delete all your data!
kubectl delete pvc -l app.kubernetes.io/instance=trigger -n trigger

# Delete the namespace (optional)
kubectl delete namespace trigger
```

**Common issues:**

* **Magic links not working**: Check webapp logs for email delivery errors
* **Deploy fails**: Verify registry access and authentication
* **Pods stuck pending**: Describe the pod and check the events
* **Worker token issues**: Check webapp and supervisor logs for errors

See the [Docker troubleshooting](/docs/self-hosting/docker#troubleshooting) section for more information.

### Task events

By default, task events (timeline, logs, spans) are stored in PostgreSQL. For production deployments we recommend storing them in ClickHouse instead, it scales to much higher volumes and avoids unbounded growth of the `TaskEvent` table.

ClickHouse is already deployed by the chart, so no extra services are required. To enable, set `EVENT_REPOSITORY_DEFAULT_STORE` on the webapp via `extraEnvVars`:

```yaml theme={"theme":"css-variables"}
webapp:
  extraEnvVars:
    - name: EVENT_REPOSITORY_DEFAULT_STORE
      value: "clickhouse_v2"
```

This only affects new runs; existing runs continue to read from wherever their events were originally stored.

### Realtime streams

Realtime streams power AI-agent token streaming and run streams. They default to **v2**, backed by the bundled `s2` deployment — [s2-lite](https://s2.dev), the open-source, self-hostable S2 server. The chart deploys it with a persistent volume, so no extra services are required.

To fall back to the Redis-backed **v1** streams, set the default version to `v1`:

```yaml theme={"theme":"css-variables"}
s2:
  defaultStreamVersion: "v1"
```

To use a hosted S2 at [s2.dev](https://s2.dev) instead of the bundled s2-lite, disable the bundled deployment and point at your basin. Supply the access token via an existing secret:

```yaml theme={"theme":"css-variables"}
s2:
  deploy: false
  skipAccessTokens: false
  external:
    endpoint: "https://your-basin.b.aws.s2.dev/v1"
    existingSecret: "s2-credentials"
    existingSecretAccessTokenKey: "access-token"
  basin: "your-basin"
```

To disable realtime streams v2 entirely and use v1, set `s2.deploy: false` with no external endpoint. See `helm show values` for all `s2` options.

### CLI usage

See the [Docker CLI usage](/docs/self-hosting/docker#cli-usage) section, the commands are identical regardless of deployment method.

### CI / GitHub Actions

When running the CLI in a CI environment, your login profiles won't be available. Instead, use the `TRIGGER_API_URL` and `TRIGGER_ACCESS_TOKEN` environment variables to point at your self-hosted instance and authenticate.

For more detailed instructions, see the [GitHub Actions guide](/docs/github-actions).

### Telemetry

By default, the Trigger.dev webapp sends telemetry data to our servers. This data is used to improve the product and is not shared with third parties. To disable telemetry, set in your `values.yaml`:

```yaml theme={"theme":"css-variables"}
telemetry:
  enabled: false
```


# Self-hosting overview
Source: https://trigger.dev/docs/self-hosting/overview

You can self-host Trigger.dev on your own infrastructure.

Self-hosting Trigger.dev means you run and manage the platform on your own infrastructure, giving you full control over your environment, deployment process, and the URLs you expose the service on.

You are responsible for provisioning resources, handling updates, and managing any security, scaling or reliability challenges that arise. See [Security & vulnerability reporting](/docs/self-hosting/security) for how to report issues and stay informed about security releases.

We provide version-tagged releases for self-hosted deployments. It's highly advised to use these tags exclusively and keep them locked with your CLI version.

<Warning>
  **v3 is end of life.** Trigger.dev 4.5.0 is the last version we officially support for running
  v3 (SDK v3) tasks. If you still have v3 tasks, stay on 4.5.0 or [migrate to
  v4](/docs/migrating-from-v3). 4.5.1 and later reject v3 triggers, batch triggers, and deploys with an
  upgrade message; v4 workloads are unaffected.
</Warning>

## Should you self-host?

Trigger.dev Cloud is fully managed, scalable, and comes with dedicated support. For most users, it offers the best experience. However, if you have specific requirements around data residency, compliance, or infrastructure control, self-hosting may be the right choice for you.

The self-hosted version is functionally the same as Trigger.dev Cloud with [some exceptions](#feature-comparison), but our managed Cloud infrastructure is designed for high availability, security, and scale.

Because we don't manage self-hosted instances, we cannot guarantee how Trigger.dev will perform on your infrastructure. You assume all responsibility and risk for your deployment, including security, uptime, and data integrity.

For more details, carry on reading and follow our guides for instructions on setting up a self-hosted Trigger.dev instance. If you prefer a managed experience, you can [sign up](https://cloud.trigger.dev/login) for our Cloud offering instead - we have a generous [free tier](https://trigger.dev/pricing) for you to try it out.

## Architecture

The self-hosted version is a set of containers running on your own infrastructure. It's split into two parts that can be scaled independently:

* **Webapp**: includes the dashboard and other services like Redis and Postgres.
* **Worker**: includes the supervisor and the runners that execute your tasks.

<img alt="Self-hosting architecture" />

## Feature comparison

While [limits](#limits) are generally configurable when self-hosting, some features are only available on Trigger.dev Cloud:

| Feature           | Cloud | Self-hosted | Description                             |
| :---------------- | :---- | :---------- | :-------------------------------------- |
| Warm starts       | ✅     | ❌           | Faster startups for consecutive runs    |
| Auto-scaling      | ✅     | ❌           | No need for manual worker node scaling  |
| Checkpoints       | ✅     | ❌           | Non-blocking waits, less resource usage |
| Dedicated support | ✅     | ❌           | Direct access to our support team       |
| Community support | ✅     | ✅           | Access to our Discord community         |
| ARM support       | ✅     | ✅           | ARM-based deployments                   |

## Limits

Most of the [limits](/docs/limits) are configurable when self-hosting, with some hardcoded exceptions. You can configure them via environment variables on the [webapp](/docs/self-hosting/env/webapp) container.

| Limit             | Configurable | Hardcoded value |
| :---------------- | :----------- | :-------------- |
| Concurrency       | ✅            | —               |
| Rate limits       | ✅            | —               |
| Queued tasks      | ✅            | —               |
| Task payloads     | ✅            | —               |
| Batch payloads    | ✅            | —               |
| Task outputs      | ✅            | —               |
| Batch size        | ✅            | —               |
| Log size          | ✅            | —               |
| Machines          | ✅            | —               |
| OTel limits       | ✅            | —               |
| Log retention     | —            | Never deleted   |
| I/O packet length | ❌            | 128KB           |
| Alerts            | ❌            | 100M            |
| Schedules         | ❌            | 100M            |
| Team members      | ❌            | 100M            |
| Preview branches  | ❌            | 100M            |

### Machine overrides

You can override the machine type for a task by setting the `MACHINE_PRESETS_OVERRIDE_PATH` environment variable to a JSON file with the following structure.

```json theme={"theme":"css-variables"}
{
  "defaultMachine": "small-1x",
  "machines": {
    "micro": { "cpu": 0.25, "memory": 0.25 },
    "small-1x": { "cpu": 0.5, "memory": 0.5 },
    "small-2x": { "cpu": 1, "memory": 1 }
    // ...etc
  }
}
```

All fields are optional. Partial overrides are supported:

```json theme={"theme":"css-variables"}
{
  "defaultMachine": "small-2x",
  "machines": {
    "small-1x": { "memory": 2 }
  }
}
```

## Community support

It's dangerous to go alone! Join the self-hosting channel on our [Discord server](https://discord.gg/NQTxt5NA7s).

## Next steps

<CardGroup>
  <Card title="Docker compose" icon="docker" href="/self-hosting/docker">
    Learn how to self-host Trigger.dev with Docker compose.
  </Card>

  <Card title="Kubernetes" icon="dharmachakra" href="/self-hosting/kubernetes">
    Learn how to self-host Trigger.dev with Kubernetes.
  </Card>

  <Card title="Security" icon="shield-halved" href="/self-hosting/security">
    Report vulnerabilities and stay informed about security releases.
  </Card>
</CardGroup>


# Security & vulnerability reporting
Source: https://trigger.dev/docs/self-hosting/security

How to report security issues in Trigger.dev, our response targets, and how self-hosters receive security notices.

We take the security of Trigger.dev seriously, for both Cloud and self-hosted deployments. This page covers how to report a vulnerability, what to expect, and how to stay informed about security releases.

<Warning>
  Do not report security vulnerabilities through public GitHub issues, pull requests, or Discord. Use one of the private channels below.
</Warning>

## Reporting a vulnerability

<Steps>
  <Step title="Choose a private channel">
    * **GitHub (preferred):** open a private report from the repository's **Security** tab using **"Report a vulnerability"** ([direct link](https://github.com/triggerdotdev/trigger.dev/security/advisories/new)).
    * **Email:** `security-advisories@trigger.dev`
  </Step>

  <Step title="Include the details">
    A description and impact, steps to reproduce (a proof of concept helps), affected versions/components, and any suggested fix.
  </Step>

  <Step title="We track it privately">
    Every report is tracked in a private GitHub Security Advisory. If you email us, we open the advisory on your behalf.
  </Step>
</Steps>

## What to expect

| Stage                                     | Target                 |
| ----------------------------------------- | ---------------------- |
| Acknowledgement                           | within 3 business days |
| Validation + CVSS 3.1 severity assessment | within 1 week          |

We score issues with CVSS 3.1 and prioritise remediation by severity:

| Severity (CVSS 3.1) | Target time to resolve |
| ------------------- | ---------------------- |
| Critical (9.0–10.0) | 7 days                 |
| High (7.0–8.9)      | 30 days                |
| Medium (4.0–6.9)    | 90 days                |
| Low (0.1–3.9)       | As needed              |

<Note>
  These are best-effort targets measured from when we validate and accept a report, not guarantees. We follow coordinated disclosure with a default 90-day window, and publish a GitHub Security Advisory (requesting a CVE where applicable) once a fix ships.
</Note>

## Supported versions

We patch the **latest released version line** only. Run the latest version-tagged release to receive security fixes — see [Self-hosting overview](/docs/self-hosting/overview).


# Skills
Source: https://trigger.dev/docs/skills

Install Trigger.dev agent skills to teach any AI coding assistant how to write tasks, realtime frontends, and chat.agent AI agents.

## What are agent skills?

Skills are portable instruction sets that teach AI coding assistants how to use Trigger.dev effectively. Unlike vendor-specific config files (`.cursor/rules`, `CLAUDE.md`), skills use an open standard that works across all major AI assistants, so Cursor users and Claude Code users get the same knowledge from a single install.

Each skill is a directory containing a `SKILL.md` file: YAML frontmatter (name, description) plus markdown instructions with patterns, examples, and common mistakes that AI assistants discover on demand and follow.

<Note>
  Skills pair with the [MCP Server](/docs/mcp-introduction), which gives your assistant live access to
  your project (deploy, trigger, monitor). Skills teach it how to write the code; the MCP server
  lets it act on your project. See [how they fit together](/docs/building-with-ai#skills-and-the-mcp-server).
</Note>

## Installation

Run the installer with the CLI:

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest skills
```

The CLI detects your installed AI tools, lets you pick which skills to install, and writes each one into that tool's native skills directory (`.claude/skills/`, `.cursor/skills/`, `.github/skills/`, `.agents/skills/`). It also adds a one-line pointer to your primary instructions file (`CLAUDE.md`, `.cursor/rules`, etc.) so your assistant always knows the skills are there and loads the right one on demand.

When you run `trigger dev` for the first time, the CLI offers to install the skills for you.

The installed skills are lightweight. Most point to the full, version-pinned reference that ships inside `@trigger.dev/sdk`, which your assistant reads straight from `node_modules`. The API guidance it follows always matches the `@trigger.dev/sdk` version installed in your project, with no extra step on your part. The `trigger-getting-started` skill is self-contained, since it runs before the SDK is installed.

### Non-interactive install

Pass `--target` to choose tools and `-y` to install every skill without prompting (useful in scripts and CI):

```bash theme={"theme":"css-variables"}
npx trigger.dev@latest skills --target claude-code --target cursor -y
```

## Available skills

| Skill                           | Use for                                          | Covers                                                                                                    |
| ------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| `trigger-authoring-tasks`       | Writing background and scheduled tasks           | `task`/`schemaTask`, retries, queues, waits, idempotency, metadata, triggering, cron, `trigger.config.ts` |
| `trigger-realtime-and-frontend` | Live run updates and triggering from the browser | `runs.subscribeToRun`, `@trigger.dev/react-hooks`, public access tokens, streams                          |
| `trigger-authoring-chat-agent`  | Building durable AI chat agents                  | `chat.agent` run loop, `toStreamTextOptions`, server actions, `useChat` transport, tools, lifecycle hooks |
| `trigger-chat-agent-advanced`   | Advanced chat.agent patterns                     | sessions, human-in-the-loop, sub-agents, compaction, fast starts, resilience, version upgrades            |
| `trigger-cost-savings`          | Auditing and reducing compute spend              | right-sizing machines, `maxDuration`, batch vs sequential, debounce, schedule frequency, MCP run analysis |

Not sure which to install? Pick `trigger-authoring-tasks`; it covers the most common patterns for writing Trigger.dev tasks.

## Supported AI assistants

Skills use the open [Agent Skills standard](https://agentskills.io). The CLI installs natively into the tools that support a skills directory today:

| Assistant                                                       | Installs to       |
| --------------------------------------------------------------- | ----------------- |
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code)   | `.claude/skills/` |
| [Cursor](https://cursor.com)                                    | `.cursor/skills/` |
| [GitHub Copilot (VS Code)](https://github.com/features/copilot) | `.github/skills/` |
| [Codex CLI](https://github.com/openai/codex), Jules, OpenCode   | `.agents/skills/` |

Using a tool that does not support skills yet? Select "Unsupported target" in the installer for manual setup instructions.

## Keeping skills updated

The API guidance updates on its own: it lives in `@trigger.dev/sdk` and is read from `node_modules`, so upgrading the SDK in your project upgrades the guidance with it. Re-run `npx trigger.dev@latest skills` (or accept the prompt that `trigger dev` shows when a newer version is available) only to add skills or refresh the installed pointer files. Re-running overwrites them in place without creating duplicates.

## Next steps

<CardGroup>
  <Card title="MCP Server" icon="sparkles" href="/mcp-introduction">
    Give your AI assistant direct access to Trigger.dev tools and APIs.
  </Card>

  <Card title="Building with AI" icon="layer-group" href="/building-with-ai">
    See how skills and the MCP server compare, plus a copy-paste context snippet.
  </Card>

  <Card title="Writing tasks" icon="code" href="/tasks/overview">
    Learn the task patterns that skills teach your AI assistant.
  </Card>

  <Card title="AI agents" icon="robot" href="/ai-chat/overview">
    Build durable chat agents with chat.agent, the focus of two of the skills.
  </Card>
</CardGroup>


# Tags
Source: https://trigger.dev/docs/tags

Tags allow you to easily filter runs in the dashboard and when using the SDK.

## What are tags?

We support up to 10 tags per run. Each one must be a string between 1 and 128 characters long.

We recommend prefixing your tags with their type and then an underscore or colon. For example, `user_123456` or `video:123`.

<Info>
  Many great APIs, like Stripe, already prefix their IDs with the type and an underscore. Like
  `cus_123456` for a customer.
</Info>

We don't enforce prefixes but if you use them you'll find it easier to filter and it will be clearer what the tag represents.

## How to add tags

There are two ways to add tags to a run:

1. When triggering the run.
2. Inside the `run` function, using `tags.add()`.

### 1. Adding tags when triggering the run

You can add tags when triggering a run using the `tags` option. All the different [trigger](/docs/triggering) methods support this.

<CodeGroup>
  ```ts trigger theme={"theme":"css-variables"}
  const handle = await myTask.trigger(
    { message: "hello world" },
    { tags: ["user_123456", "org_abcdefg"] }
  );
  ```

  ```ts batchTrigger theme={"theme":"css-variables"}
  const batch = await myTask.batchTrigger([
    {
      payload: { message: "foo" },
      options: { tags: "product_123456" },
    },
    {
      payload: { message: "bar" },
      options: { tags: ["user_123456", "product_3456789"] },
    },
  ]);
  ```
</CodeGroup>

This will create a run with the tags `user_123456` and `org_abcdefg`. They look like this in the runs table:

<img alt="How tags appear in the dashboard" />

### 2. Adding tags inside the `run` function

Use the `tags.add()` function to add tags to a run from inside the `run` function. This will add the tag `product_1234567` to the run:

```ts theme={"theme":"css-variables"}
import { task, tags } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload: { message: string }, { ctx }) => {
    // Get the tags from when the run was triggered using the context
    // This is not updated if you add tags during the run
    logger.log("Tags from the run context", { tags: ctx.run.tags });

    // Add tags during the run (a single string or array of strings)
    await tags.add("product_1234567");
  },
});
```

Reminder: you can only have up to 10 tags per run. If you call `tags.add()` and the total number of tags will be more than 10 we log an error and ignore the new tags. That includes tags from triggering and from inside the run function.

### Propagating tags to child runs

Tags do not propagate to child runs automatically. By default runs have no tags and you have to set them explicitly.

It's easy to propagate tags if you want:

```ts theme={"theme":"css-variables"}
export const myTask = task({
  id: "my-task",
  run: async (payload: Payload, { ctx }) => {
    // Pass the tags from ctx into the child run
    const { id } = await otherTask.trigger(
      { message: "triggered from myTask" },
      { tags: ctx.run.tags }
    );
  },
});
```

## Filtering runs by tags

You can filter runs by tags in the dashboard and in the SDK.

### In the dashboard

On the Runs page open the filter menu, choose "Tags" and then start typing in the name of the tag you want to filter by. You can select it and it will restrict the results to only runs with that tag. You can add multiple tags to filter by more than one.

<img alt="Filter by tags" />

### Using `runs.list()`

You can provide filters to the `runs.list` SDK function, including an array of tags.

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

// Loop through all runs with the tag "user_123456" that have completed
for await (const run of runs.list({ tag: "user_123456", status: ["COMPLETED"] })) {
  console.log(run.id, run.taskIdentifier, run.finishedAt, run.tags);
}
```


# Tasks: Overview
Source: https://trigger.dev/docs/tasks/overview

Tasks are functions that can run for a long time and provide strong resilience to failure.

There are different types of tasks including regular tasks and [scheduled tasks](/docs/tasks/scheduled).

## Hello world task and how to trigger it

Here's an incredibly simple task:

```ts /trigger/hello-world.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";

const helloWorld = task({
  //1. Use a unique id for each task
  id: "hello-world",
  //2. The run function is the main function of the task
  run: async (payload: { message: string }) => {
    //3. You can write code that runs for a long time here, there are no timeouts
    console.log(payload.message);
  },
});
```

You can trigger this in two ways:

1. From the dashboard [using the "Test" feature](/docs/run-tests).
2. Trigger it from your backend code. See the [full triggering guide here](/docs/triggering).

Here's how to trigger a single run from elsewhere in your code:

```ts Your backend code theme={"theme":"css-variables"}
import { helloWorld } from "./trigger/hello-world";

async function triggerHelloWorld() {
  //This triggers the task and returns a handle
  const handle = await helloWorld.trigger({ message: "Hello world!" });

  //You can use the handle to check the status of the task, cancel and retry it.
  console.log("Task is running with handle", handle.id);
}
```

You can also [trigger a task from another task](/docs/triggering), and wait for the result.

## Defining a `task`

The task function takes an object with the following fields.

### The `id` field

This is used to identify your task so it can be triggered, managed, and you can view runs in the dashboard. This must be unique in your project – we recommend making it descriptive and unique.

### The `run` function

Your custom code inside `run()` will be executed when your task is triggered. It’s an async function that has two arguments:

1. The run payload - the data that you pass to the task when you trigger it.
2. An object with `ctx` about the run (Context), and any output from the optional `init` function that runs before every run attempt.

Anything you return from the `run` function will be the result of the task. Data you return must be JSON serializable: strings, numbers, booleans, arrays, objects, and null.

### `retry` options

A task is retried if an error is thrown. By default, we retry 3 times.

You can set the number of retries and the delay between retries in the `retry` field:

```ts /trigger/retry.ts theme={"theme":"css-variables"}
export const taskWithRetries = task({
  id: "task-with-retries",
  retry: {
    maxAttempts: 10,
    factor: 1.8,
    minTimeoutInMs: 500,
    maxTimeoutInMs: 30_000,
    randomize: false,
  },
  run: async (payload: any, { ctx }) => {
    //...
  },
});
```

| Option           | What it does                                                                                                                                                      |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `maxAttempts`    | Total number of attempts (including the first). Default: 3                                                                                                        |
| `factor`         | Exponential backoff multiplier. Each retry delay = previous delay x factor. With `factor: 1.8` and `minTimeoutInMs: 500`, retries wait 500ms, 900ms, 1620ms, etc. |
| `minTimeoutInMs` | Delay before the first retry                                                                                                                                      |
| `maxTimeoutInMs` | Cap on the delay between retries                                                                                                                                  |
| `randomize`      | Add jitter to retry delays to prevent multiple failing tasks from retrying in lockstep                                                                            |

<Note>Task-level retry settings override the defaults in your `trigger.config` file.</Note>

For more information read [the retrying guide](/docs/errors-retrying).

It's also worth mentioning that you can [retry a block of code](/docs/errors-retrying) inside your tasks as well.

### `queue` options

Queues allow you to control the concurrency of your tasks. This allows you to have one-at-a-time execution and parallel executions. There are also more advanced techniques like having different concurrencies for different sets of your users. For more information read [the concurrency & queues guide](/docs/queue-concurrency).

```ts /trigger/one-at-a-time.ts theme={"theme":"css-variables"}
export const oneAtATime = task({
  id: "one-at-a-time",
  queue: {
    concurrencyLimit: 1,
  },
  run: async (payload: any, { ctx }) => {
    //...
  },
});
```

### `machine` options

Some tasks require more vCPUs or GBs of RAM. You can specify these requirements in the `machine` field. For more information read [the machines guide](/docs/machines).

```ts /trigger/heavy-task.ts theme={"theme":"css-variables"}
export const heavyTask = task({
  id: "heavy-task",
  machine: {
    preset: "large-1x", // 4 vCPU, 8 GB RAM
  },
  run: async (payload: any, { ctx }) => {
    //...
  },
});
```

### `maxDuration` option

By default tasks can execute indefinitely, which can be great! But you also might want to set a `maxDuration` to prevent a task from running too long. You can set the `maxDuration` on a task, and all runs of that task will be stopped if they exceed the duration.

```ts /trigger/long-task.ts theme={"theme":"css-variables"}
export const longTask = task({
  id: "long-task",
  maxDuration: 300, // 300 seconds or 5 minutes
  run: async (payload: any, { ctx }) => {
    //...
  },
});
```

See our [maxDuration guide](/docs/runs/max-duration) for more information.

### `ttl` option

You can set a default time-to-live (TTL) on a task. If a run is not dequeued within this time, it will expire and never execute. This is useful for time-sensitive tasks where stale runs should be discarded.

```ts /trigger/time-sensitive-task.ts theme={"theme":"css-variables"}
export const timeSensitiveTask = task({
  id: "time-sensitive-task",
  ttl: "10m", // Also accepts a number of seconds, e.g. 600
  run: async (payload: any, { ctx }) => {
    //...
  },
});
```

You can override this per-trigger by passing `ttl` in the trigger options, or set a project-wide default in your [trigger.config.ts](/docs/config/config-file#ttl). Set `ttl: 0` to opt out of a config-level TTL. See [Time-to-live (TTL)](/docs/runs#time-to-live-ttl) for more information.

## Global lifecycle hooks

<Note>When specifying global lifecycle hooks, we recommend using the `init.ts` file.</Note>

You can register global lifecycle hooks that are executed for all runs, regardless of the task. While you can still define these in the `trigger.config.ts` file, you can also register them anywhere in your codebase:

```typescript theme={"theme":"css-variables"}
import { tasks } from "@trigger.dev/sdk";

tasks.onStartAttempt(({ ctx, payload, task }) => {
  console.log("Run started", ctx.run);
});

tasks.onSuccess(({ ctx, output }) => {
  console.log("Run finished", ctx.run);
});

tasks.onFailure(({ ctx, error }) => {
  console.log("Run failed", ctx.run);
});
```

### `init.ts`

If you create an `init.ts` file at the root of your trigger directory, it will be automatically loaded when a task is executed. This is useful for registering global lifecycle hooks, initializing a database connection, etc.

```ts init.ts theme={"theme":"css-variables"}
import { tasks } from "@trigger.dev/sdk";

tasks.onStartAttempt(({ ctx, payload, task }) => {
  console.log("Run started", ctx.run);
});
```

## Lifecycle functions

<img alt="Lifecycle functions" />

### `middleware` and `locals` functions

Our task middleware system runs at the top level, executing before and after all lifecycle hooks. This allows you to wrap the entire task execution lifecycle with custom logic.

<Info>
  An error thrown in `middleware` is just like an uncaught error in the run function: it will
  propagate through to `catchError()` function and then will fail the attempt (either causing a
  retry or failing the run).
</Info>

The `locals` API allows you to share data between middleware and hooks.

```ts db.ts theme={"theme":"css-variables"}
import { locals } from "@trigger.dev/sdk";
import { logger, tasks } from "@trigger.dev/sdk";

// This would be type of your database client here
const DbLocal = locals.create<{ connect: () => Promise<void>; disconnect: () => Promise<void> }>(
  "db"
);

export function getDb() {
  return locals.getOrThrow(DbLocal);
}

export function setDb(db: { connect: () => Promise<void> }) {
  locals.set(DbLocal, db);
}

tasks.middleware("db", async ({ ctx, payload, next, task }) => {
  // This would be your database client here
  const db = locals.set(DbLocal, {
    connect: async () => {
      logger.info("Connecting to the database");
    },
    disconnect: async () => {
      logger.info("Disconnecting from the database");
    },
  });

  await db.connect();

  await next();

  await db.disconnect();
});

// Disconnect when the run is paused
tasks.onWait("db", async ({ ctx, payload, task }) => {
  const db = getDb();
  await db.disconnect();
});

// Reconnect when the run is resumed
tasks.onResume("db", async ({ ctx, payload, task }) => {
  const db = getDb();
  await db.connect();
});
```

You can access the database client using `getDb()` in your tasks `run` function and all your hooks (global or task specific):

```typescript theme={"theme":"css-variables"}
import { getDb } from "./db";

export const myTask = task({
  run: async (payload: any, { ctx }) => {
    const db = getDb();
    await db.query("SELECT 1");
  },
});
```

#### Per-task middleware

You can also define middleware per task by passing the `middleware` option in the task definition. This runs after global middleware and before the `run` function. Use it when only specific tasks need certain locals or setup:

```typescript theme={"theme":"css-variables"}
import { task, locals } from "@trigger.dev/sdk";

const myLocal = locals.create<string>("myLocal");

export const myTask = task({
  id: "my-task",
  middleware: async ({ payload, ctx, next }) => {
    locals.set(myLocal, "some-value");
    await next();
  },
  run: async (payload) => {
    const value = locals.getOrThrow(myLocal);
    // ...
  },
});
```

### `onStartAttempt` function

<Info>The `onStartAttempt` function was introduced in v4.1.0</Info>

Before a task run attempt starts, the `onStartAttempt` function is called. It's useful for sending notifications, logging, and other side effects.

```ts /trigger/on-start.ts theme={"theme":"css-variables"}
export const taskWithOnStartAttempt = task({
  id: "task-with-on-start-attempt",
  onStartAttempt: async ({ payload, ctx }) => {
    //...
  },
  run: async (payload: any, { ctx }) => {
    //...
  },
});
```

You can also define a global `onStartAttempt` function using `tasks.onStartAttempt()`.

```ts init.ts theme={"theme":"css-variables"}
import { tasks } from "@trigger.dev/sdk";

tasks.onStartAttempt(({ ctx, payload, task }) => {
  console.log(
    `Run ${ctx.run.id} started on task ${task} attempt ${ctx.run.attempt.number}`,
    ctx.run
  );
});
```

<Info>Errors thrown in the `onStartAttempt` function will cause the attempt to fail.</Info>

If you want to execute code before just the first attempt, you can use the `onStartAttempt` function and check `ctx.run.attempt.number === 1`:

```ts /trigger/on-start-attempt.ts theme={"theme":"css-variables"}
export const taskWithOnStartAttempt = task({
  id: "task-with-on-start-attempt",
  onStartAttempt: async ({ payload, ctx }) => {
    if (ctx.run.attempt.number === 1) {
      console.log("Run started on attempt 1", ctx.run);
    }
  },
});
```

### `onWait` and `onResume` functions

These lifecycle hooks allow you to run code when a run is paused or resumed because of a wait:

```typescript theme={"theme":"css-variables"}
export const myTask = task({
  id: "my-task",
  onWait: async ({ wait }) => {
    console.log("Run paused", wait);
  },
  onResume: async ({ wait }) => {
    console.log("Run resumed", wait);
  },
  run: async (payload: any, { ctx }) => {
    console.log("Run started", ctx.run);

    await wait.for({ seconds: 10 });

    console.log("Run finished", ctx.run);
  },
});
```

You can also define global `onWait` and `onResume` functions using `tasks.onWait()` and `tasks.onResume()`:

```ts init.ts theme={"theme":"css-variables"}
import { tasks } from "@trigger.dev/sdk";

tasks.onWait(({ ctx, payload, wait, task }) => {
  console.log("Run paused", ctx.run, wait);
});

tasks.onResume(({ ctx, payload, wait, task }) => {
  console.log("Run resumed", ctx.run, wait);
});
```

### `onSuccess` function

When a task run succeeds, the `onSuccess` function is called. It's useful for sending notifications, logging, syncing state to your database, or other side effects.

```ts /trigger/on-success.ts theme={"theme":"css-variables"}
export const taskWithOnSuccess = task({
  id: "task-with-on-success",
  onSuccess: async ({ payload, output, ctx }) => {
    //...
  },
  run: async (payload: any, { ctx }) => {
    //...
  },
});
```

You can also define a global `onSuccess` function using `tasks.onSuccess()`.

```ts init.ts theme={"theme":"css-variables"}
import { tasks } from "@trigger.dev/sdk";

tasks.onSuccess(({ ctx, payload, output }) => {
  console.log("Task succeeded", ctx.task.id);
});
```

<Info>
  Errors thrown in the `onSuccess` function will be ignored, but you will still be able to see them
  in the dashboard.
</Info>

### `onComplete` function

This hook is executed when a run completes, regardless of whether it succeeded or failed:

```ts /trigger/on-complete.ts theme={"theme":"css-variables"}
export const taskWithOnComplete = task({
  id: "task-with-on-complete",
  onComplete: async ({ payload, output, ctx }) => {
  if (result.ok) {
    console.log("Run succeeded", result.data);
  } else {
    console.log("Run failed", result.error);
  }
});
```

You can also define a global `onComplete` function using `tasks.onComplete()`.

```ts init.ts theme={"theme":"css-variables"}
import { tasks } from "@trigger.dev/sdk";

tasks.onComplete(({ ctx, payload, output }) => {
  console.log("Task completed", ctx.task.id);
});
```

<Info>
  Errors thrown in the `onComplete` function will be ignored, but you will still be able to see them
  in the dashboard.
</Info>

### `onFailure` function

When a task run fails, the `onFailure` function is called. It's useful for sending notifications, logging, or other side effects. It will only be executed once the task run has exhausted all its retries.

```ts /trigger/on-failure.ts theme={"theme":"css-variables"}
export const taskWithOnFailure = task({
  id: "task-with-on-failure",
  onFailure: async ({ payload, error, ctx }) => {
    //...
  },
  run: async (payload: any, { ctx }) => {
    //...
  },
});
```

You can also define a global `onFailure` function using `tasks.onFailure()`.

```ts init.ts theme={"theme":"css-variables"}
import { tasks } from "@trigger.dev/sdk";

tasks.onFailure(({ ctx, payload, error }) => {
  console.log("Task failed", ctx.task.id);
});
```

<Info>
  Errors thrown in the `onFailure` function will be ignored, but you will still be able to see them
  in the dashboard.
</Info>

<Note>
  `onFailure` doesn’t fire for some of the run statuses like `Crashed`, `System failures`, and
  `Canceled`.
</Note>

### `catchError` functions

You can define a function that will be called when an error is thrown in the `run` function, that allows you to control how the error is handled and whether the task should be retried.

Read more about `catchError` in our [Errors and Retrying guide](/docs/errors-retrying).

<Info>Uncaught errors will throw a special internal error of the type `HANDLE_ERROR_ERROR`.</Info>

### `onCancel` function

You can define an `onCancel` hook that is called when a run is cancelled. This is useful if you want to clean up any resources that were allocated for the run.

```typescript theme={"theme":"css-variables"}
tasks.onCancel(({ ctx, signal }) => {
  console.log("Run cancelled", signal);
});
```

You can use the `onCancel` hook along with the `signal` passed into the run function to interrupt a call to an external service, for example using the [streamText](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) function from the AI SDK:

```typescript theme={"theme":"css-variables"}
import { logger, tasks, schemaTask } from "@trigger.dev/sdk";
import { streamText } from "ai";
import { z } from "zod";

export const interruptibleChat = schemaTask({
  id: "interruptible-chat",
  description: "Chat with the AI",
  schema: z.object({
    prompt: z.string().describe("The prompt to chat with the AI"),
  }),
  run: async ({ prompt }, { signal }) => {
    const chunks: TextStreamPart<{}>[] = [];

    // 👇 This is a global onCancel hook, but it's inside of the run function
    tasks.onCancel(async () => {
      // We have access to the chunks here, and can save them to the database
      await saveChunksToDatabase(chunks);
    });

    try {
      const result = streamText({
        model: getModel(),
        prompt,
        experimental_telemetry: {
          isEnabled: true,
        },
        tools: {},
        abortSignal: signal, // 👈 Pass the signal to the streamText function, which aborts with the run is cancelled
        onChunk: ({ chunk }) => {
          chunks.push(chunk);
        },
      });

      const textParts = [];

      for await (const part of result.textStream) {
        textParts.push(part);
      }

      return textParts.join("");
    } catch (error) {
      if (error instanceof Error && error.name === "AbortError") {
        // streamText will throw an AbortError if the signal is aborted, so we can handle it here
      } else {
        throw error;
      }
    }
  },
});
```

The `onCancel` hook can optionally wait for the `run` function to finish, and access the output of the run:

```typescript theme={"theme":"css-variables"}
import { logger, task } from "@trigger.dev/sdk";
import { setTimeout } from "node:timers/promises";

export const cancelExampleTask = task({
  id: "cancel-example",
  // Signal will be aborted when the task is cancelled 👇
  run: async (payload: { message: string }, { signal }) => {
    try {
      // We pass the signal to setTimeout to abort the timeout if the task is cancelled
      await setTimeout(10_000, undefined, { signal });
    } catch (error) {
      // Ignore the abort error
    }

    // Do some more work here

    return {
      message: "Hello, world!",
    };
  },
  onCancel: async ({ runPromise }) => {
    // You can await the runPromise to get the output of the task
    const output = await runPromise;
  },
});
```

<Note>
  You will have up to 30 seconds to complete the `runPromise` in the `onCancel` hook. After that
  point the process will be killed.
</Note>

<Warning>
  `onCancel` only runs if the run is actively executing. If a run is cancelled while queued or
  suspended (e.g. waiting for a token), no machine is spun up and `onCancel` will not be called.
  This is a known limitation we're planning to address. Follow the progress on our [feedback
  board](https://feedback.trigger.dev/p/call-the-onfailure-hook-for-runs-that-were-canceled-expired).
</Warning>

### `onStart` function (deprecated)

<Info>The `onStart` function was deprecated in v4.1.0. Use `onStartAttempt` instead.</Info>

When a task run starts, the `onStart` function is called. It's useful for sending notifications, logging, and other side effects.

<Warning>
  This function will only be called once per run (not per attempt). If you want to run code before
  each attempt, use a middleware function or the `onStartAttempt` function.
</Warning>

```ts /trigger/on-start.ts theme={"theme":"css-variables"}
export const taskWithOnStart = task({
  id: "task-with-on-start",
  onStart: async ({ payload, ctx }) => {
    //...
  },
  run: async (payload: any, { ctx }) => {
    //...
  },
});
```

You can also define a global `onStart` function using `tasks.onStart()`.

```ts init.ts theme={"theme":"css-variables"}
import { tasks } from "@trigger.dev/sdk";

tasks.onStart(({ ctx, payload, task }) => {
  console.log(`Run ${ctx.run.id} started on task ${task}`, ctx.run);
});
```

<Info>Errors thrown in the `onStart` function will cause the attempt to fail.</Info>

### `init` function (deprecated)

<Warning>
  The `init` hook is deprecated and will be removed in the future. Use
  [middleware](/docs/tasks/overview#middleware-and-locals-functions) instead.
</Warning>

This function is called before a run attempt:

```ts /trigger/init.ts theme={"theme":"css-variables"}
export const taskWithInit = task({
  id: "task-with-init",
  init: async ({ payload, ctx }) => {
    //...
  },
  run: async (payload: any, { ctx }) => {
    //...
  },
});
```

You can also return data from the `init` function that will be available in the params of the `run`, `cleanup`, `onSuccess`, and `onFailure` functions.

```ts /trigger/init-return.ts theme={"theme":"css-variables"}
export const taskWithInitReturn = task({
  id: "task-with-init-return",
  init: async ({ payload, ctx }) => {
    return { someData: "someValue" };
  },
  run: async (payload: any, { ctx, init }) => {
    console.log(init.someData); // "someValue"
  },
});
```

<Info>Errors thrown in the `init` function will cause the attempt to fail.</Info>

### `cleanup` function (deprecated)

<Warning>
  The `cleanup` hook is deprecated and will be removed in the future. Use
  [middleware](/docs/tasks/overview#middleware-and-locals-functions) instead.
</Warning>

This function is called after the `run` function is executed, regardless of whether the run was successful or not. It's useful for cleaning up resources, logging, or other side effects.

```ts /trigger/cleanup.ts theme={"theme":"css-variables"}
export const taskWithCleanup = task({
  id: "task-with-cleanup",
  cleanup: async ({ payload, ctx }) => {
    //...
  },
  run: async (payload: any, { ctx }) => {
    //...
  },
});
```

<Info>Errors thrown in the `cleanup` function will cause the attempt to fail.</Info>

## Next steps

<CardGroup>
  <Card title="Triggering" icon="bolt" href="/triggering">
    Learn how to trigger your tasks from your code.
  </Card>

  <Card title="Writing tasks" icon="wand-magic-sparkles" href="/writing-tasks-introduction">
    Tasks are the core of Trigger.dev. Learn how to write them.
  </Card>
</CardGroup>


# Scheduled tasks (cron)
Source: https://trigger.dev/docs/tasks/scheduled

A task that is triggered on a recurring schedule using cron syntax.

<Note>
  Scheduled tasks are only for recurring tasks. If you want to trigger a one-off task at a future
  time, you should [use the delay option](/docs/triggering#delay).
</Note>

## Defining a scheduled task

This task will run when any of the attached schedules trigger. They have a predefined payload with some useful properties:

```ts theme={"theme":"css-variables"}
import { schedules } from "@trigger.dev/sdk";

export const firstScheduledTask = schedules.task({
  id: "first-scheduled-task",
  run: async (payload) => {
    //when the task was scheduled to run
    //note this will be slightly different from new Date() because it takes a few ms to run the task
    console.log(payload.timestamp); //is a Date object

    //when the task was last run
    //this can be undefined if it's never been run
    console.log(payload.lastTimestamp); //is a Date object or undefined

    //the timezone the schedule was registered with, defaults to "UTC"
    //this is in IANA format, e.g. "America/New_York"
    //See the full list here: https://cloud.trigger.dev/timezones
    console.log(payload.timezone); //is a string

    //If you want to output the time in the user's timezone do this:
    const formatted = payload.timestamp.toLocaleString("en-US", {
      timeZone: payload.timezone,
    });

    //the schedule id (you can have many schedules for the same task)
    //using this you can remove the schedule, update it, etc
    console.log(payload.scheduleId); //is a string

    //you can optionally provide an external id when creating the schedule
    //usually you would set this to a userId or some other unique identifier
    //this can be undefined if you didn't provide one
    console.log(payload.externalId); //is a string or undefined

    //the next 5 dates this task is scheduled to run
    console.log(payload.upcoming); //is an array of Date objects
  },
});
```

You can see from the comments that the payload has several useful properties:

* `timestamp` - the time the task was scheduled to run, as a UTC date.
* `lastTimestamp` - the time the task was last run, as a UTC date.
* `timezone` - the timezone the schedule was registered with, defaults to "UTC". In IANA format, e.g. "America/New\_York".
* `scheduleId` - the id of the schedule that triggered the task
* `externalId` - the external id you (optionally) provided when creating the schedule
* `upcoming` - the next 5 times the task is scheduled to run

<Note>
  This task will NOT get triggered on a schedule until you attach a schedule to it. Read on for how
  to do that.
</Note>

Like all tasks they don't have timeouts, they should be placed inside a [/trigger folder](/docs/config/config-file), and you [can configure them](/docs/tasks/overview#defining-a-task).

You can set a [TTL](/docs/runs#time-to-live-ttl) on a scheduled task to automatically expire runs that aren't dequeued in time. This is useful when a schedule fires while the previous run is still executing - rather than queueing up stale runs, they'll expire:

```ts theme={"theme":"css-variables"}
export const frequentTask = schedules.task({
  id: "frequent-task",
  ttl: "5m",
  run: async (payload) => {
    //...
  },
});
```

## How to attach a schedule

Now that we've defined a scheduled task, we need to define when it will actually run. To do this we need to attach one or more schedules.

There are two ways of doing this:

* **Declarative:** defined on your `schedules.task`. They sync when you run the dev command or deploy.
* **Imperative:** created from the dashboard or by using the imperative SDK functions like `schedules.create()`.

<Info>
  A scheduled task can have multiple schedules attached to it, including a declarative schedule
  and/or many imperative schedules.
</Info>

### Declarative schedules

These sync when you run the [dev](/docs/cli-dev-commands) or [deploy](/docs/cli-deploy-commands) commands.

To create them you add the `cron` property to your `schedules.task()`. This property is optional and is only used if you want to add a declarative schedule to your task:

```ts theme={"theme":"css-variables"}
export const firstScheduledTask = schedules.task({
  id: "first-scheduled-task",
  //every two hours (UTC timezone)
  cron: "0 */2 * * *",
  run: async (payload, { ctx }) => {
    //do something
  },
});
```

If you use a string it will be in UTC. Alternatively, you can specify a timezone like this:

```ts theme={"theme":"css-variables"}
export const secondScheduledTask = schedules.task({
  id: "second-scheduled-task",
  cron: {
    //5am every day Tokyo time
    pattern: "0 5 * * *",
    timezone: "Asia/Tokyo",
    //optional, defaults to all environments
    //possible values are "PRODUCTION", "STAGING", "PREVIEW" and "DEVELOPMENT"
    environments: ["PRODUCTION", "STAGING"],
  },
  run: async (payload) => {},
});
```

When you run the [dev](/docs/cli-dev-commands) or [deploy](/docs/cli-deploy-commands) commands, declarative schedules will be synced. If you add, delete or edit the `cron` property it will be updated when you run these commands. You can view your synced schedules in the dashboard: open the task on the Tasks page and check its "Schedules" tab.

### Imperative schedules

Alternatively you can explicitly attach schedules to a `schedules.task`. You can do this in the dashboard from the scheduled task's page by pressing the "Create schedule" button, or you can use the SDK to create schedules.

The advantage of imperative schedules is that they can be created dynamically, for example, you could create a schedule for each user in your database. They can also be activated, disabled, edited, and deleted without deploying new code by using the SDK or dashboard.

To use imperative schedules you need to do two things:

1. Define a task in your code using `schedules.task()`.
2. Attach 1+ schedules to the task either using the dashboard or the SDK.

## Supported cron syntax

```
*    *    *    *    *
┬    ┬    ┬    ┬    ┬
│    │    │    │    |
│    │    │    │    └ day of week (0 - 7, 1L - 7L) (0 or 7 is Sun)
│    │    │    └───── month (1 - 12)
│    │    └────────── day of month (1 - 31, L)
│    └─────────────── hour (0 - 23)
└──────────────────── minute (0 - 59)
```

"L" means the last. In the "day of week" field, 1L means the last Monday of the month. In the "day of month" field, L means the last day of the month.

We do not support seconds in the cron syntax.

## When schedules won't trigger

There are two situations when a scheduled task won't trigger:

* For Dev environments scheduled tasks will only trigger if you're running the dev CLI.
* For Staging/Production environments scheduled tasks will only trigger if the task is in the current deployment (latest version). We won't trigger tasks from previous deployments.

## Attaching schedules in the dashboard

You need to attach a schedule to a task before it will run on a schedule. You can attach imperative schedules in the dashboard:

<Note>
  **The Schedules page has moved.** There is no longer a standalone "Schedules" page in the
  sidebar. Schedules now live on the Tasks page: open a scheduled task to create, view, edit,
  enable/disable, and delete its schedules. The old `/schedules` URL redirects to the Tasks page.

  The scheduled task must already exist first — define it in your code with `schedules.task()` and
  sync it to the environment by running the [dev](/docs/cli-dev-commands) or
  [deploy](/docs/cli-deploy-commands) command so it appears on the Tasks page. A project with no tasks
  yet will only show the deploy onboarding.
</Note>

<Steps>
  <Step title="Open the scheduled task">
    In the sidebar select the "Tasks" page, then select the scheduled task you want to attach a
    schedule to (scheduled tasks have a clock icon, and you can filter the list to Scheduled).

    <img alt="Scheduled task page" />
  </Step>

  <Step title="Create your schedule">
    Press the "Create schedule" button, fill in the form, and press "Create schedule" when you're
    done. <img alt="Create schedule form" />

    These are the options when creating a schedule:

    | Name              | Description                                                                                                                            |
    | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
    | Task              | The id of the task you want to attach to.                                                                                              |
    | Cron pattern      | The schedule in cron format. You can also describe it in natural language and press "Generate" to fill this in.                        |
    | Timezone          | The timezone the schedule will run in. Defaults to "UTC"                                                                               |
    | External id       | An optional external id, usually you'd use a userId.                                                                                   |
    | Deduplication key | An optional deduplication key. If you pass the same value, it will update rather than create. Scoped per project, not per environment. |
    | Environments      | The environments this schedule will run in.                                                                                            |
  </Step>
</Steps>

## Managing schedules in the dashboard

Open the scheduled task and switch to the "Schedules" tab to see every schedule attached to it — both declarative and imperative — with its type, cron pattern, external id, next and last run, and status.

Click a schedule to open the inspector, where you can **enable/disable**, **edit**, or **delete** imperative schedules without deploying new code. Declarative schedules are managed in your code, so they can't be edited or deleted from here.

## Attaching schedules with the SDK

You call `schedules.create()` to create a schedule from your code. Here's the simplest possible example:

```ts theme={"theme":"css-variables"}
const createdSchedule = await schedules.create({
  //The id of the scheduled task you want to attach to.
  task: firstScheduledTask.id,
  //The schedule in cron format.
  cron: "0 0 * * *",
  //this is required, it prevents you from creating duplicate schedules. It will update the schedule if it already exists.
  deduplicationKey: "my-deduplication-key",
});
```

<Note>The `task` id must be a task that you defined using `schedules.task()`.</Note>

You can create many schedules with the same `task`, `cron`, and `externalId` but only one with the same `deduplicationKey`.

<Note>
  The deduplication key is **per project**, not per environment. Using the same key in Production and Staging creates a single schedule; the last create/update decides which environment it appears in. For fixed schedules, prefer **declarative** (cron on the task). If using imperative across environments, use a different deduplication key per environment (e.g. include the env name in the key).
</Note>

This means you can have thousands of schedules attached to a single task, but only one schedule per `deduplicationKey`. Here's an example with all the options:

```ts theme={"theme":"css-variables"}
const createdSchedule = await schedules.create({
  //The id of the scheduled task you want to attach to.
  task: firstScheduledTask.id,
  //The schedule in cron format.
  cron: "0 0 * * *",
  // Optional, it defaults to "UTC". In IANA format, e.g. "America/New_York".
  // In this case, the task will run at midnight every day in New York time.
  // If you specify a timezone it will automatically work with daylight saving time.
  timezone: "America/New_York",
  //Optionally, you can specify your own IDs (like a user ID) and then use it inside the run function of your task.
  //This allows you to have per-user cron tasks.
  externalId: "user_123456",
  //You can only create one schedule with this key.
  //If you use it twice, the second call will update the schedule.
  //This is useful because you don't want to create duplicate schedules for a user.
  deduplicationKey: "user_123456-todo_reminder",
});
```

See [the SDK reference](/docs/management/schedules/create) for full details.

### Dynamic schedules (or multi-tenant schedules)

By using the `externalId` you can have schedules for your users. This is useful for things like reminders, where you want to have a schedule for each user.

A reminder task:

```ts /trigger/reminder.ts theme={"theme":"css-variables"}
import { schedules } from "@trigger.dev/sdk";

//this task will run when any of the attached schedules trigger
export const reminderTask = schedules.task({
  id: "todo-reminder",
  run: async (payload) => {
    if (!payload.externalId) {
      throw new Error("externalId is required");
    }

    //get user using the externalId you used when creating the schedule
    const user = await db.getUser(payload.externalId);

    //send a reminder email
    await sendReminderEmail(user);
  },
});
```

Then in your backend code, you can create a schedule for each user:

```ts Next.js API route theme={"theme":"css-variables"}
import { reminderTask } from "~/trigger/reminder";

//app/reminders/route.ts
export async function POST(request: Request) {
  //get the JSON from the request
  const data = await request.json();

  //create a schedule for the user
  const createdSchedule = await schedules.create({
    task: reminderTask.id,
    //8am every day
    cron: "0 8 * * *",
    //the user's timezone
    timezone: data.timezone,
    //the user id
    externalId: data.userId,
    //this makes it impossible to have two reminder schedules for the same user
    deduplicationKey: `${data.userId}-reminder`,
  });

  //return a success response with the schedule
  return Response.json(createdSchedule);
}
```

You can also retrieve, list, delete, deactivate and re-activate schedules using the SDK. More on that later.

## Testing schedules

You can test a scheduled task in the dashboard. Note that the `scheduleId` will always come through as `sched_1234` to the run.

<Note>
  There is no longer a standalone "Test" page in the sidebar. You test a task from the task itself —
  open it on the Tasks page and press the "Test schedule" button.
</Note>

<Steps>
  <Step title="Open the test page for your task">
    On the "Tasks" page, open your scheduled task and press the "Test schedule" button.

    <img alt="Scheduled task page" />
  </Step>

  <Step title="Run the test">
    Fill in the form \[1]. You can select from a recent run \[2] to pre-populate the fields. Press "Run
    test" when you're ready <img alt="Schedule test form" />
  </Step>
</Steps>

## Managing schedules with the SDK

### Retrieving an existing schedule

```ts theme={"theme":"css-variables"}
const retrievedSchedule = await schedules.retrieve(scheduleId);
```

See [the SDK reference](/docs/management/schedules/retrieve) for full details.

### Listing schedules

```ts theme={"theme":"css-variables"}
const allSchedules = await schedules.list();
```

See [the SDK reference](/docs/management/schedules/list) for full details.

### Updating a schedule

```ts theme={"theme":"css-variables"}
const updatedSchedule = await schedules.update(scheduleId, {
  task: firstScheduledTask.id,
  cron: "0 0 1 * *",
  externalId: "ext_1234444",
  deduplicationKey: "my-deduplication-key",
});
```

See [the SDK reference](/docs/management/schedules/update) for full details.

### Deactivating a schedule

```ts theme={"theme":"css-variables"}
const deactivatedSchedule = await schedules.deactivate(scheduleId);
```

See [the SDK reference](/docs/management/schedules/deactivate) for full details.

### Activating a schedule

```ts theme={"theme":"css-variables"}
const activatedSchedule = await schedules.activate(scheduleId);
```

See [the SDK reference](/docs/management/schedules/activate) for full details.

### Deleting a schedule

```ts theme={"theme":"css-variables"}
const deletedSchedule = await schedules.del(scheduleId);
```

See [the SDK reference](/docs/management/schedules/delete) for full details.

### Getting possible timezones

You might want to show a dropdown menu in your UI so your users can select their timezone. You can get a list of all possible timezones using the SDK:

```ts theme={"theme":"css-variables"}
const timezones = await schedules.timezones();
```

See [the SDK reference](/docs/management/schedules/timezones) for full details.


# schemaTask
Source: https://trigger.dev/docs/tasks/schemaTask

Define tasks with a runtime payload schema and validate the payload before running the task.

The `schemaTask` function allows you to define a task with a runtime payload schema. This schema is used to validate the payload before running the task or when triggering a task directly. If the payload does not match the schema, the task will not execute.

## Usage

```ts theme={"theme":"css-variables"}
import { schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";

const myTask = schemaTask({
  id: "my-task",
  schema: z.object({
    name: z.string(),
    age: z.number(),
  }),
  run: async (payload) => {
    console.log(payload.name, payload.age);
  },
});
```

`schemaTask` takes all the same options as [task](/docs/tasks/overview), with the addition of a `schema` field. The `schema` field is a schema parser function from a schema library or or a custom parser function.

<Note>
  We will probably eventually combine `task` and `schemaTask` into a single function, but because
  that would be a breaking change, we are keeping them separate for now.
</Note>

When you trigger the task directly, the payload will be validated against the schema before the [run](/docs/runs) is created:

```ts theme={"theme":"css-variables"}
import { tasks } from "@trigger.dev/sdk";
import { myTask } from "./trigger/myTasks";

// This will call the schema parser function and validate the payload
await myTask.trigger({ name: "Alice", age: "oops" }); // this will throw an error

// This will NOT call the schema parser function
await tasks.trigger<typeof myTask>("my-task", { name: "Alice", age: "oops" }); // this will not throw an error
```

The error thrown when the payload does not match the schema will be the same as the error thrown by the schema parser function. For example, if you are using Zod, the error will be a `ZodError`.

We will also validate the payload every time before the task is run, so you can be sure that the payload is always valid. In the example above, the task would fail with a `TaskPayloadParsedError` error and skip retrying if the payload does not match the schema.

## Input/output schemas

Certain schema libraries, like Zod, split their type inference into "schema in" and "schema out". This means that you can define a single schema that will produce different types when triggering the task and when running the task. For example, you can define a schema that has a default value for a field, or a string coerced into a date:

```ts theme={"theme":"css-variables"}
import { schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";

const myTask = schemaTask({
  id: "my-task",
  schema: z.object({
    name: z.string().default("John"),
    age: z.number(),
    dob: z.coerce.date(),
  }),
  run: async (payload) => {
    console.log(payload.name, payload.age);
  },
});
```

In this case, the trigger payload type is `{ name?: string, age: number; dob: string }`, but the run payload type is `{ name: string, age: number; dob: Date }`. So you can trigger the task with a payload like this:

```ts theme={"theme":"css-variables"}
await myTask.trigger({ age: 30, dob: "2020-01-01" }); // this is valid
await myTask.trigger({ name: "Alice", age: 30, dob: "2020-01-01" }); // this is also valid
```

## Task-backed AI tools

Use a `schemaTask` as the implementation of a Vercel [AI SDK](https://vercel.com/docs/ai-sdk) tool: the model calls the tool, and Trigger runs your task as a **subtask** with tool-call metadata, optional [chat context](/docs/ai-chat/patterns/sub-agents), and the same payload validation as a normal trigger.

### Recommended: `ai.toolExecute` with `tool()`

Prefer building the tool with the AI SDK’s [`tool()`](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling) and passing **`execute: ai.toolExecute(yourTask)`**. You keep full control of `description`, `inputSchema`, and AI-SDK-only options (for example `experimental_toToolResultContent`), and your types follow the `ai` version installed in **your** app.

```ts theme={"theme":"css-variables"}
import { ai } from "@trigger.dev/sdk/ai";
import { schemaTask } from "@trigger.dev/sdk";
import { tool, generateText } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";

const myToolTask = schemaTask({
  id: "my-tool-task",
  schema: z.object({
    foo: z.string(),
  }),
  run: async ({ foo }) => {
    return { bar: foo.toUpperCase() };
  },
});

const myTool = tool({
  description: myToolTask.description ?? "",
  inputSchema: myToolTask.schema!,
  execute: ai.toolExecute(myToolTask),
});

export const myAiTask = schemaTask({
  id: "my-ai-task",
  schema: z.object({
    text: z.string(),
  }),
  run: async ({ text }) => {
    const { text: reply } = await generateText({
      prompt: text,
      model: openai("gpt-4o"),
      tools: {
        myTool,
      },
    });
    return reply;
  },
});
```

`experimental_toToolResultContent` and other tool-level options belong on **`tool({ ... })`**, not on `ai.toolExecute`:

```ts theme={"theme":"css-variables"}
import { openai } from "@ai-sdk/openai";
import { Sandbox } from "@e2b/code-interpreter";
import { ai } from "@trigger.dev/sdk/ai";
import { schemaTask } from "@trigger.dev/sdk";
import { generateObject, tool } from "ai";
import { z } from "zod";

const chartTask = schemaTask({
  id: "chart",
  description: "Generate a chart using natural language",
  schema: z.object({
    input: z.string().describe("The chart to generate"),
  }),
  run: async ({ input }) => {
    const code = await generateObject({
      model: openai("gpt-4o"),
      schema: z.object({
        code: z.string().describe("The Python code to execute"),
      }),
      system: `You are a helpful assistant that generates matplotlib code. End with plt.show().`,
      prompt: input,
    });

    const sandbox = await Sandbox.create();
    const execution = await sandbox.runCode(code.object.code);
    const firstResult = execution.results[0];

    if (firstResult.png) {
      return { chart: firstResult.png };
    }
    throw new Error("No chart generated");
  },
});

export const chartTool = tool({
  description: chartTask.description ?? "",
  inputSchema: chartTask.schema!,
  execute: ai.toolExecute(chartTask),
  experimental_toToolResultContent: (result) => [
    { type: "image", data: result.chart, mimeType: "image/png" },
  ],
});
```

Inside the task run, you can read tool execution context with **`ai.currentToolOptions()`** (and helpers like `ai.toolCallId()`, `ai.chatContext()` when running inside a [`chat.agent`](/docs/ai-chat/overview)):

```ts theme={"theme":"css-variables"}
import { ai } from "@trigger.dev/sdk/ai";
import { schemaTask } from "@trigger.dev/sdk";
import { tool } from "ai";
import { z } from "zod";

const myToolTask = schemaTask({
  id: "my-tool-task",
  schema: z.object({
    foo: z.string(),
  }),
  run: async ({ foo }) => {
    const toolOptions = ai.currentToolOptions();
    console.log(toolOptions);
    return { foo };
  },
});

export const myTool = tool({
  description: myToolTask.description ?? "",
  inputSchema: myToolTask.schema!,
  execute: ai.toolExecute(myToolTask),
});
```

See the [AI SDK tool execution options](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling#tool-execution-options) for fields passed through the runtime.

<Note>
  `ai.toolExecute` works with `schemaTask` definitions that use Zod, ArkType, or any schema that provides a JSON schema via `.toJsonSchema()` (same coverage as the legacy `ai.tool` wrapper).
</Note>

### Deprecated: `ai.tool`

The **`ai.tool(task, options?)`** helper is **deprecated**. It constructs an AI SDK `Tool` for you (using `tool()` for Zod-like schemas and `dynamicTool()` otherwise) and may be removed in a future major version. New code should use **`tool({ ..., execute: ai.toolExecute(task) })`** as shown above.

### Legacy `ai.tool` example (deprecated)

```ts theme={"theme":"css-variables"}
import { ai } from "@trigger.dev/sdk/ai";
import { schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";

const myToolTask = schemaTask({
  id: "my-tool-task",
  schema: z.object({ foo: z.string() }),
  run: async ({ foo }) => ({ foo }),
});

// Deprecated — prefer tool({ execute: ai.toolExecute(myToolTask), ... })
const myTool = ai.tool(myToolTask);
```

## Supported schema types

### Zod

You can use the [Zod](https://zod.dev) schema library to define your schema. The schema will be validated using Zod's `parse` function.

```ts theme={"theme":"css-variables"}
import { schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";

export const zodTask = schemaTask({
  id: "types/zod",
  schema: z.object({
    bar: z.string(),
    baz: z.string().default("foo"),
  }),
  run: async (payload) => {
    console.log(payload.bar, payload.baz);
  },
});
```

### Yup

```ts theme={"theme":"css-variables"}
import { schemaTask } from "@trigger.dev/sdk";
import * as yup from "yup";

export const yupTask = schemaTask({
  id: "types/yup",
  schema: yup.object({
    bar: yup.string().required(),
    baz: yup.string().default("foo"),
  }),
  run: async (payload) => {
    console.log(payload.bar, payload.baz);
  },
});
```

### Superstruct

```ts theme={"theme":"css-variables"}
import { schemaTask } from "@trigger.dev/sdk";
import { object, string } from "superstruct";

export const superstructTask = schemaTask({
  id: "types/superstruct",
  schema: object({
    bar: string(),
    baz: string(),
  }),
  run: async (payload) => {
    console.log(payload.bar, payload.baz);
  },
});
```

### ArkType

```ts theme={"theme":"css-variables"}
import { schemaTask } from "@trigger.dev/sdk";
import { type } from "arktype";

export const arktypeTask = schemaTask({
  id: "types/arktype",
  schema: type({
    bar: "string",
    baz: "string",
  }).assert,
  run: async (payload) => {
    console.log(payload.bar, payload.baz);
  },
});
```

### @effect/schema

```ts theme={"theme":"css-variables"}
import { schemaTask } from "@trigger.dev/sdk";
import * as Schema from "@effect/schema/Schema";

// For some funny typescript reason, you cannot pass the Schema.decodeUnknownSync directly to schemaTask
const effectSchemaParser = Schema.decodeUnknownSync(
  Schema.Struct({ bar: Schema.String, baz: Schema.String })
);

export const effectTask = schemaTask({
  id: "types/effect",
  schema: effectSchemaParser,
  run: async (payload) => {
    console.log(payload.bar, payload.baz);
  },
});
```

### runtypes

```ts theme={"theme":"css-variables"}
import { schemaTask } from "@trigger.dev/sdk";
import * as T from "runtypes";

export const runtypesTask = schemaTask({
  id: "types/runtypes",
  schema: T.Record({
    bar: T.String,
    baz: T.String,
  }),
  run: async (payload) => {
    console.log(payload.bar, payload.baz);
  },
});
```

### valibot

```ts theme={"theme":"css-variables"}
import { schemaTask } from "@trigger.dev/sdk";

import * as v from "valibot";

// For some funny typescript reason, you cannot pass the v.parser directly to schemaTask
const valibotParser = v.parser(
  v.object({
    bar: v.string(),
    baz: v.string(),
  })
);

export const valibotTask = schemaTask({
  id: "types/valibot",
  schema: valibotParser,
  run: async (payload) => {
    console.log(payload.bar, payload.baz);
  },
});
```

### typebox

```ts theme={"theme":"css-variables"}
import { schemaTask } from "@trigger.dev/sdk";
import { Type } from "@sinclair/typebox";
import { wrap } from "@typeschema/typebox";

export const typeboxTask = schemaTask({
  id: "types/typebox",
  schema: wrap(
    Type.Object({
      bar: Type.String(),
      baz: Type.String(),
    })
  ),
  run: async (payload) => {
    console.log(payload.bar, payload.baz);
  },
});
```

### Custom parser function

You can also define a custom parser function that will be called with the payload before the task is run. The parser function should return the parsed payload or throw an error if the payload is invalid.

```ts theme={"theme":"css-variables"}
import { schemaTask } from "@trigger.dev/sdk";

export const customParserTask = schemaTask({
  id: "types/custom-parser",
  schema: (data: unknown) => {
    // This is a custom parser, and should do actual parsing (not just casting)
    if (typeof data !== "object") {
      throw new Error("Invalid data");
    }

    const { bar, baz } = data as { bar: string; baz: string };

    return { bar, baz };
  },
  run: async (payload) => {
    console.log(payload.bar, payload.baz);
  },
});
```


# Streaming data from tasks
Source: https://trigger.dev/docs/tasks/streams

Pipe continuous data from your Trigger.dev tasks to frontend or backend clients in real time. Stream AI completions, file chunks, progress updates, and more.

**Streams let you pipe data from a running task to your frontend or backend as it's produced.** Think AI completions token by token, progress updates, or file chunks. You can also **send data into** running tasks with [Input Streams](#input-streams) for bidirectional flows (cancel buttons, approvals).

For subscribing to **run state changes** (status, metadata, tags) instead, see [Realtime](/docs/realtime/overview).

<Note>
  Streams require SDK version **4.1.0 or later** (`@trigger.dev/sdk` and `@trigger.dev/react-hooks`).
  This doc describes the current streams behavior (v2 is the default). For pre-4.1.0 streams, see
  [Pre-4.1.0 streams (legacy)](#pre-410-streams-legacy) below.
</Note>

## Overview

Streams provide:

* **Unlimited stream length** (previously capped at 2000 chunks)
* **Unlimited active streams per run** (previously 5)
* **Improved reliability** with automatic resumption on connection loss
* **28-day stream retention** (previously 1 day)
* **Multiple client streams** can pipe to a single stream
* **Enhanced dashboard visibility** for viewing stream data in real-time

Streams v2 is the **default** when using SDK 4.1.0 or later. If you trigger tasks outside the SDK, set the `x-trigger-realtime-streams-version=v2` header. To opt out, use `auth.configure({ future: { v2RealtimeStreams: false } })` or `TRIGGER_V2_REALTIME_STREAMS=0`.

## Limits Comparison

| Limit                            | Legacy (pre-4.1.0) | Current   |
| -------------------------------- | ------------------ | --------- |
| Maximum stream length            | 2000               | Unlimited |
| Number of active streams per run | 5                  | Unlimited |
| Maximum streams per run          | 10                 | Unlimited |
| Maximum stream TTL               | 1 day              | 28 days   |
| Maximum stream size              | 10MB               | 300 MiB   |

## Quick Start

The recommended workflow for **output** streams (data from task to client):

1. **Define your streams** in a shared location using `streams.define()`
2. **Use the defined stream** in your tasks with `.pipe()`, `.append()`, or `.writer()`
3. **Read from the stream** using `.read()` or the `useRealtimeStream` hook in React

This approach gives you full type safety, better code organization, and easier maintenance as your application grows. For **input** streams (sending data into a running task), see [Input Streams](#input-streams) below.

## Defining Typed Streams (Recommended)

The recommended way to work with streams is to define them once with `streams.define()`. This allows you to specify the chunk type and stream ID in one place, and then reuse that definition throughout your codebase with full type safety.

### Creating a Defined Stream

Define your streams in a shared location (like `app/streams.ts` or `trigger/streams.ts`):

```ts theme={"theme":"css-variables"}
import { streams, InferStreamType } from "@trigger.dev/sdk";

// Define a stream with a specific type
export const aiStream = streams.define<string>({
  id: "ai-output",
});

// Export the type for use in frontend components
export type AIStreamPart = InferStreamType<typeof aiStream>;
```

You can define streams for any JSON-serializable type:

```ts theme={"theme":"css-variables"}
import { streams, InferStreamType } from "@trigger.dev/sdk";
import { UIMessageChunk } from "ai";

// Stream for AI UI message chunks
export const aiStream = streams.define<UIMessageChunk>({
  id: "ai",
});

// Stream for progress updates
export const progressStream = streams.define<{ step: string; percent: number }>({
  id: "progress",
});

// Stream for simple text
export const logStream = streams.define<string>({
  id: "logs",
});

// Export types
export type AIStreamPart = InferStreamType<typeof aiStream>;
export type ProgressStreamPart = InferStreamType<typeof progressStream>;
export type LogStreamPart = InferStreamType<typeof logStream>;
```

### Using Defined Streams in Tasks

Once defined, you can use all stream methods on your defined stream:

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { aiStream } from "./streams";

export const streamTask = task({
  id: "stream-task",
  run: async (payload: { prompt: string }) => {
    // Get a stream from an AI service, database, etc.
    const stream = await getAIStream(payload.prompt);

    // Pipe the stream using your defined stream
    const { stream: readableStream, waitUntilComplete } = aiStream.pipe(stream);

    // Option A: Iterate over the stream locally
    for await (const chunk of readableStream) {
      console.log("Received chunk:", chunk);
    }

    // Option B: Wait for the stream to complete
    await waitUntilComplete();

    return { message: "Stream completed" };
  },
});
```

#### Reading from a Stream

Use the defined stream's `read()` method to consume data from anywhere (frontend, backend, or another task):

```ts theme={"theme":"css-variables"}
import { aiStream } from "./streams";

const stream = await aiStream.read(runId);

for await (const chunk of stream) {
  console.log(chunk); // chunk is typed as the stream's chunk type
}
```

With options:

```ts theme={"theme":"css-variables"}
const stream = await aiStream.read(runId, {
  timeoutInSeconds: 60, // Stop if no data for 60 seconds
  startIndex: 10, // Start from the 10th chunk
});
```

#### Appending to a Stream

Use the defined stream's `append()` method to add a single chunk:

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { aiStream, progressStream, logStream } from "./streams";

export const appendTask = task({
  id: "append-task",
  run: async (payload) => {
    // Append to different streams with full type safety
    await logStream.append("Processing started");
    await progressStream.append({ step: "Initialization", percent: 0 });

    // Do some work...

    await progressStream.append({ step: "Processing", percent: 50 });
    await logStream.append("Step 1 complete");

    // Do more work...

    await progressStream.append({ step: "Complete", percent: 100 });
    await logStream.append("All steps complete");
  },
});
```

#### Writing Multiple Chunks

Use the defined stream's `writer()` method for more complex stream writing:

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { logStream } from "./streams";

export const writerTask = task({
  id: "writer-task",
  run: async (payload) => {
    const { waitUntilComplete } = logStream.writer({
      execute: ({ write, merge }) => {
        // Write individual chunks
        write("Chunk 1");
        write("Chunk 2");

        // Merge another stream
        const additionalStream = ReadableStream.from(["Chunk 3", "Chunk 4", "Chunk 5"]);
        merge(additionalStream);
      },
    });

    await waitUntilComplete();
  },
});
```

### Using Defined Streams in React

Defined streams work seamlessly with the `useRealtimeStream` hook:

```tsx theme={"theme":"css-variables"}
"use client";

import { useRealtimeStream } from "@trigger.dev/react-hooks";
import { aiStream } from "@/app/streams";

export function StreamViewer({ accessToken, runId }: { accessToken: string; runId: string }) {
  // Pass the defined stream directly - full type safety!
  const { parts, error } = useRealtimeStream(aiStream, runId, {
    accessToken,
    timeoutInSeconds: 600,
  });

  if (error) return <div>Error: {error.message}</div>;
  if (!parts) return <div>Loading...</div>;

  return (
    <div>
      {parts.map((part, i) => (
        <span key={i}>{part}</span>
      ))}
    </div>
  );
}
```

## Direct Stream Methods (Without Defining)

<Warning>
  We strongly recommend using `streams.define()` instead of direct methods. Defined streams provide
  better organization, full type safety, and make it easier to maintain your codebase as it grows.
</Warning>

If you have a specific reason to avoid defined streams, you can use stream methods directly by specifying the stream key each time.

### Direct Piping

```ts theme={"theme":"css-variables"}
import { streams, task } from "@trigger.dev/sdk";

export const directStreamTask = task({
  id: "direct-stream",
  run: async (payload: { prompt: string }) => {
    const stream = await getAIStream(payload.prompt);

    // Specify the stream key directly
    const { stream: readableStream, waitUntilComplete } = streams.pipe("ai-output", stream);

    await waitUntilComplete();
  },
});
```

### Direct Reading

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";

// Specify the stream key when reading
const stream = await streams.read(runId, "ai-output");

for await (const chunk of stream) {
  console.log(chunk);
}
```

### Direct Appending

```ts theme={"theme":"css-variables"}
import { streams, task } from "@trigger.dev/sdk";

export const directAppendTask = task({
  id: "direct-append",
  run: async (payload) => {
    // Specify the stream key each time
    await streams.append("logs", "Processing started");
    await streams.append("progress", "50%");
    await streams.append("logs", "Complete");
  },
});
```

### Direct Writing

```ts theme={"theme":"css-variables"}
import { streams, task } from "@trigger.dev/sdk";

export const directWriterTask = task({
  id: "direct-writer",
  run: async (payload) => {
    const { waitUntilComplete } = streams.writer("output", {
      execute: ({ write, merge }) => {
        write("Chunk 1");
        write("Chunk 2");
      },
    });

    await waitUntilComplete();
  },
});
```

## Default Stream

Every run has a "default" stream, allowing you to skip the stream key entirely. This is useful for simple cases where you only need one stream per run.

Using direct methods:

```ts theme={"theme":"css-variables"}
import { streams, task } from "@trigger.dev/sdk";

export const defaultStreamTask = task({
  id: "default-stream",
  run: async (payload) => {
    const stream = getDataStream();

    // No stream key needed - uses "default"
    const { waitUntilComplete } = streams.pipe(stream);

    await waitUntilComplete();
  },
});

// Reading from the default stream
const readStream = await streams.read(runId);
```

## Targeting Different Runs

You can pipe streams to parent, root, or any other run using the `target` option. This works with both defined streams and direct methods.

### With Defined Streams

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { logStream } from "./streams";

export const childTask = task({
  id: "child-task",
  run: async (payload, { ctx }) => {
    const stream = getDataStream();

    // Pipe to parent run
    logStream.pipe(stream, { target: "parent" });

    // Pipe to root run
    logStream.pipe(stream, { target: "root" });

    // Pipe to self (default behavior)
    logStream.pipe(stream, { target: "self" });

    // Pipe to a specific run ID
    logStream.pipe(stream, { target: payload.otherRunId });
  },
});
```

### With Direct Methods

```ts theme={"theme":"css-variables"}
import { streams, task } from "@trigger.dev/sdk";

export const childTask = task({
  id: "child-task",
  run: async (payload, { ctx }) => {
    const stream = getDataStream();

    // Pipe to parent run
    streams.pipe("output", stream, { target: "parent" });

    // Pipe to root run
    streams.pipe("output", stream, { target: "root" });

    // Pipe to a specific run ID
    streams.pipe("output", stream, { target: payload.otherRunId });
  },
});
```

## Streaming from Outside a Task

If you specify a `target` run ID, you can pipe streams from anywhere (like a Next.js API route):

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";

export async function POST(req: Request) {
  const { messages, runId } = await req.json();

  const result = streamText({
    model: openai("gpt-4o"),
    messages,
  });

  // Pipe AI stream to a Trigger.dev run
  const { stream } = streams.pipe("ai-stream", result.toUIMessageStream(), {
    target: runId,
  });

  return new Response(stream as any, {
    headers: { "Content-Type": "text/event-stream" },
  });
}
```

## React Hook

Use the `useRealtimeStream` hook to subscribe to streams in your React components.

### With Defined Streams (Recommended)

```tsx theme={"theme":"css-variables"}
"use client";

import { useRealtimeStream } from "@trigger.dev/react-hooks";
import { aiStream } from "@/app/streams";

export function StreamViewer({ accessToken, runId }: { accessToken: string; runId: string }) {
  // Pass the defined stream directly for full type safety
  const { parts, error } = useRealtimeStream(aiStream, runId, {
    accessToken,
    timeoutInSeconds: 600,
    onData: (chunk) => {
      console.log("New chunk:", chunk); // chunk is typed!
    },
  });

  if (error) return <div>Error: {error.message}</div>;
  if (!parts) return <div>Loading...</div>;

  return (
    <div>
      {parts.map((part, i) => (
        <span key={i}>{part}</span>
      ))}
    </div>
  );
}
```

### With Direct Stream Keys

If you prefer not to use defined streams, you can specify the stream key directly:

```tsx theme={"theme":"css-variables"}
"use client";

import { useRealtimeStream } from "@trigger.dev/react-hooks";

export function StreamViewer({ accessToken, runId }: { accessToken: string; runId: string }) {
  const { parts, error } = useRealtimeStream<string>(runId, "ai-output", {
    accessToken,
    timeoutInSeconds: 600,
  });

  if (error) return <div>Error: {error.message}</div>;
  if (!parts) return <div>Loading...</div>;

  return (
    <div>
      {parts.map((part, i) => (
        <span key={i}>{part}</span>
      ))}
    </div>
  );
}
```

### Using Default Stream

```tsx theme={"theme":"css-variables"}
// Omit stream key to use the default stream
const { parts, error } = useRealtimeStream<string>(runId, {
  accessToken,
});
```

### Hook Options

```tsx theme={"theme":"css-variables"}
const { parts, error } = useRealtimeStream(streamDef, runId, {
  accessToken: "pk_...", // Required: Public access token
  baseURL: "https://api.trigger.dev", // Optional: Custom API URL
  timeoutInSeconds: 60, // Optional: Timeout (default: 60)
  startIndex: 0, // Optional: Start from specific chunk
  throttleInMs: 16, // Optional: Throttle updates (default: 16ms)
  onData: (chunk) => {}, // Optional: Callback for each chunk
});
```

## Input Streams

Input Streams let you send data **into** a running task from your backend or frontend. While output streams (above) send data out of tasks, input streams complete the loop — enabling bidirectional communication.

<Note>
  Input Streams require SDK version **4.4.2 or later** and use the same streams infrastructure (v2 is the default). If you're on an older SDK, calling `.on()` or `.once()` will throw with instructions to enable v2 streams. See [Pre-4.1.0 streams (legacy)](#pre-410-streams-legacy) for the older metadata-based API.
</Note>

### Input Streams overview

Input Streams solve three common problems:

* **Cancelling AI streams mid-generation.** When you use AI SDK's `streamText` inside a task, the LLM keeps generating until it's done — even if the user clicked "Stop." With input streams, your frontend sends a cancel signal and the task aborts the LLM call immediately.
* **Human-in-the-loop workflows.** A task generates a draft, then pauses and waits for the user to approve or edit it before continuing.
* **Interactive agents.** An AI agent running as a task needs follow-up information from the user mid-execution — clarifying a question, choosing between options, or providing additional context.

### Quick Start (Input Streams)

1. **Define** input streams in a shared file with `streams.input<T>({ id: "..." })`.
2. **Receive** in your task with `.wait()`, `.once()`, `.on()`, or `.peek()`.
3. **Send** from your backend with `.send(runId, data)` or from the frontend with the `useInputStreamSend` hook (see [Realtime React hooks](/docs/realtime/react-hooks/streams#useinputstreamsend)).

### Defining Input Streams

Use `streams.input()` to define a typed input stream. The generic parameter controls the shape of data that can be sent:

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";

export const cancelSignal = streams.input<{ reason?: string }>({
  id: "cancel",
});

export const approval = streams.input<{ approved: boolean; reviewer: string }>({
  id: "approval",
});

export const userResponse = streams.input<{
  action: "approve" | "reject" | "edit";
  message?: string;
  edits?: Record<string, string>;
}>({
  id: "user-response",
});
```

Type safety is enforced through the generic — both `.send()` and the receiving methods (`.wait()`, `.once()`, `.on()`, `.peek()`) share the same type.

### Receiving data inside a task

| Method         | Task suspended? | Compute cost while waiting | Best for                                                             |
| -------------- | --------------- | -------------------------- | -------------------------------------------------------------------- |
| `.wait()`      | **Yes**         | **None** — process freed   | Approval gates, human-in-the-loop, long waits                        |
| `.once()`      | No              | Full — process stays alive | Short waits, concurrent work; returns result object with `.unwrap()` |
| `.on(handler)` | No              | Full — process stays alive | Continuous listening (cancel signals, live updates)                  |
| `.peek()`      | No              | None                       | Non-blocking check for latest buffered value                         |

#### `wait()` — Suspend until data arrives

Suspends the task entirely, freeing compute resources. The task resumes when data arrives via `.send()`. Returns a [`ManualWaitpointPromise`](/docs/wait-for-token) — the same type as `wait.forToken()`.

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { approval } from "./streams";

export const publishPost = task({
  id: "publish-post",
  run: async (payload: { postId: string }) => {
    const draft = await prepareDraft(payload.postId);
    await notifyReviewer(draft);

    const result = await approval.wait({ timeout: "7d" });

    if (result.ok) {
      if (result.output.approved) {
        await publish(draft);
        return { published: true, reviewer: result.output.reviewer };
      }
      return { published: false, reviewer: result.output.reviewer };
    }
    return { published: false, timedOut: true };
  },
});
```

Use `.unwrap()` to throw on timeout: `const data = await approval.wait({ timeout: "24h" }).unwrap();`

**Options:** `timeout` (e.g. `"30s"`, `"5m"`, `"24h"`, `"7d"`), `idempotencyKey`, `idempotencyKeyTTL`, `tags`. Use `idempotencyKey` when your task has retries so the same waitpoint is resumed across retries.

#### `once()` — Wait for the next value (non-suspending)

Blocks until data arrives but keeps the task process alive. Returns a result object; use `.unwrap()` to get the data or throw on timeout.

```ts theme={"theme":"css-variables"}
const result = await approval.once({ timeoutMs: 300_000 });
if (result.ok) {
  console.log(result.output.approved);
}
// Or: const data = await approval.once({ timeoutMs: 300_000 }).unwrap();
```

`once()` also accepts a `signal` (e.g. `AbortController.signal`) for cancellation.

#### `on()` — Listen for every value

Registers a persistent handler that fires on every piece of data. Handlers are automatically cleaned up when the task run completes. Call `.off()` on the returned subscription to stop listening early.

```ts theme={"theme":"css-variables"}
const controller = new AbortController();
cancelSignal.on((data) => {
  console.log("Cancelled:", data.reason);
  controller.abort();
});
const result = streamText({ ..., abortSignal: controller.signal });
```

#### `peek()` — Non-blocking check

Returns the most recent buffered value without waiting, or `undefined` if nothing has been received yet.

```ts theme={"theme":"css-variables"}
const latest = cancelSignal.peek();
if (latest) {
  // A cancel was already sent before we checked
}
```

### Sending data to a running task

Use `.send(runId, data)` from your backend to push data into a running task. See the [backend input streams guide](/docs/realtime/backend/input-streams) for API route patterns.

```ts theme={"theme":"css-variables"}
import { cancelSignal, approval } from "./trigger/streams";

await cancelSignal.send(runId, { reason: "User clicked stop" });
await approval.send(runId, { approved: true, reviewer: "alice@example.com" });
```

### Complete example: Cancellable AI streaming

Stream an AI response while allowing the user to cancel mid-generation.

**Define the streams:**

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";

export const aiOutput = streams.define<string>({ id: "ai" });
export const cancelStream = streams.input<{ reason?: string }>({ id: "cancel" });
```

**Task:** Register `cancelStream.on()` to abort an `AbortController`, then pipe `streamText(...).textStream` to `aiOutput`. **Backend:** POST to an API route that calls `cancelStream.send(runId, { reason: "User clicked stop" })`. **Frontend:** Use `useRealtimeStream(aiOutput, runId, { accessToken })` and a button that calls your cancel API (or use the `useInputStreamSend` hook; see [Realtime React hooks](/docs/realtime/react-hooks/streams#useinputstreamsend)).

**Important notes (input streams):** You cannot send to a completed, failed, or canceled run. Max payload per `.send()` is 1MB. Data sent before a listener is registered is buffered and delivered when a listener attaches; `.wait()` handles the buffering race automatically. Use `.wait()` for long waits to free compute; use `.once()` for short waits or concurrent work. Define input streams in a shared location and combine with output streams for full bidirectional communication.

## Complete Example: AI Streaming

### Define the stream

```ts theme={"theme":"css-variables"}
// app/streams.ts
import { streams, InferStreamType } from "@trigger.dev/sdk";
import { UIMessageChunk } from "ai";

export const aiStream = streams.define<UIMessageChunk>({
  id: "ai",
});

export type AIStreamPart = InferStreamType<typeof aiStream>;
```

### Create the task

```ts theme={"theme":"css-variables"}
// trigger/ai-task.ts
import { task } from "@trigger.dev/sdk";
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
import { aiStream } from "@/app/streams";

export const generateAI = task({
  id: "generate-ai",
  run: async (payload: { prompt: string }) => {
    const result = streamText({
      model: openai("gpt-4o"),
      prompt: payload.prompt,
    });

    const { waitUntilComplete } = aiStream.pipe(result.toUIMessageStream());

    await waitUntilComplete();

    return { success: true };
  },
});
```

### Frontend component

```tsx theme={"theme":"css-variables"}
// components/ai-stream.tsx
"use client";

import { useRealtimeStream } from "@trigger.dev/react-hooks";
import { aiStream } from "@/app/streams";

export function AIStream({ accessToken, runId }: { accessToken: string; runId: string }) {
  const { parts, error } = useRealtimeStream(aiStream, runId, {
    accessToken,
    timeoutInSeconds: 300,
  });

  if (error) return <div>Error: {error.message}</div>;
  if (!parts) return <div>Loading...</div>;

  return (
    <div className="prose">
      {parts.map((part, i) => (
        <span key={i}>{part}</span>
      ))}
    </div>
  );
}
```

## Migration from v1

If you're using the old `metadata.stream()` API, here's how to migrate to the recommended v2 approach:

### Step 1: Define Your Streams

Create a shared streams definition file:

```ts theme={"theme":"css-variables"}
// app/streams.ts or trigger/streams.ts
import { streams, InferStreamType } from "@trigger.dev/sdk";

export const myStream = streams.define<string>({
  id: "my-stream",
});

export type MyStreamPart = InferStreamType<typeof myStream>;
```

### Step 2: Update Your Tasks

Replace `metadata.stream()` with the defined stream's `pipe()` method:

```ts theme={"theme":"css-variables"}
// Before (v1)
import { metadata, task } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload) => {
    const stream = getDataStream();
    await metadata.stream("my-stream", stream);
  },
});
```

```ts theme={"theme":"css-variables"}
// After (v2 - Recommended)
import { task } from "@trigger.dev/sdk";
import { myStream } from "./streams";

export const myTask = task({
  id: "my-task",
  run: async (payload) => {
    const stream = getDataStream();

    // Don't await - returns immediately
    const { waitUntilComplete } = myStream.pipe(stream);

    // Optionally wait for completion
    await waitUntilComplete();
  },
});
```

### Step 3: Update Your Frontend

Use the defined stream with `useRealtimeStream`:

```tsx theme={"theme":"css-variables"}
// Before
const { parts, error } = useRealtimeStream<string>(runId, "my-stream", {
  accessToken,
});
```

```tsx theme={"theme":"css-variables"}
// After
import { myStream } from "@/app/streams";

const { parts, error } = useRealtimeStream(myStream, runId, {
  accessToken,
});
```

### Alternative: Direct Methods (Not Recommended)

If you prefer not to use defined streams, you can use direct methods:

```ts theme={"theme":"css-variables"}
import { streams, task } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload) => {
    const stream = getDataStream();
    const { waitUntilComplete } = streams.pipe("my-stream", stream);
    await waitUntilComplete();
  },
});
```

## Reliability Features

Streams v2 includes automatic reliability improvements:

* **Automatic resumption**: If a connection is lost, both appending and reading will automatically resume from the last successful chunk
* **No data loss**: Network issues won't cause stream data to be lost
* **Idempotent operations**: Duplicate chunks are automatically handled

These improvements happen automatically - no code changes needed.

## Dashboard Integration

Streams are now visible in the Trigger.dev dashboard, allowing you to:

* View stream data in real-time as it's generated
* Inspect historical stream data for completed runs
* Debug streaming issues with full visibility into chunk delivery

<video />

## Best Practices

1. **Always use `streams.define()`**: Define your streams in a shared location for better organization, type safety, and code reusability. This is the recommended approach for all streams.
2. **Export stream types**: Use `InferStreamType` to export types for your frontend components
3. **Handle errors gracefully**: Always check for errors when reading streams in your UI
4. **Set appropriate timeouts**: Adjust `timeoutInSeconds` based on your use case (AI completions may need longer timeouts)
5. **Target parent runs**: When orchestrating with child tasks, pipe to parent runs for easier consumption
6. **Throttle frontend updates**: Use `throttleInMs` in `useRealtimeStream` to prevent excessive re-renders
7. **Use descriptive stream IDs**: Choose clear, descriptive IDs like `"ai-output"` or `"progress"` instead of generic names

## Pre-4.1.0 streams (legacy)

Prior to SDK 4.1.0, streams used the older metadata-based API. If you're on an earlier version, see [metadata.stream()](/docs/runs/metadata#stream) for legacy usage. With 4.4.2+, [Input Streams](#input-streams) are available and documented in this page.

## Troubleshooting

### Stream not appearing in dashboard

* Verify your task is actually writing to the stream
* Check that the stream key matches between writing and reading

### Stream timeout errors

* Increase `timeoutInSeconds` in your `read()` or `useRealtimeStream()` calls
* Ensure your stream source is actively producing data
* Check network connectivity between your application and Trigger.dev

### Missing chunks

* With the current streams implementation, chunks should not be lost due to automatic resumption
* Verify you're reading from the correct stream key
* Check the `startIndex` option if you're not seeing expected chunks

### Input streams not working

* Input streams require SDK **4.4.2 or later** and the default streams (v2) infrastructure. Ensure you're on a recent SDK and not using the legacy metadata.stream() API.
* If `.on()` or `.once()` throw, follow the error message to enable v2 streams (they are default in 4.1.0+).

### "Stream is being deleted" during long waits

If a stream is created but stays empty for \~1 hour (for example, during a long `wait.forToken()` or `wait.for()`), the streams backend may garbage-collect it. When the run resumes and tries to use the stream, you'll see `S2Error: Stream is being deleted` and the task retries from the beginning.

Two ways to avoid this:

* Close the stream before the wait and open a new one when the run resumes.
* Write a heartbeat record to the stream every 20–30 minutes during the wait so it's never empty long enough to be deleted.


# Triggering
Source: https://trigger.dev/docs/triggering

Tasks need to be triggered in order to run.

## Trigger functions

Trigger tasks **from your backend**:

| Function               | What it does                                                                                     |                             |
| :--------------------- | :----------------------------------------------------------------------------------------------- | --------------------------- |
| `tasks.trigger()`      | Triggers a task and returns a handle you can use to fetch and manage the run.                    | [Docs](#tasks-trigger)      |
| `tasks.batchTrigger()` | Triggers a single task in a batch and returns a handle you can use to fetch and manage the runs. | [Docs](#tasks-batchtrigger) |
| `batch.trigger()`      | Similar to `tasks.batchTrigger` but allows running multiple different tasks                      | [Docs](#batch-trigger)      |

Trigger tasks **from inside a another task**:

| Function                         | What it does                                                                                                                       |                                       |
| :------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| `yourTask.trigger()`             | Triggers a task and gets a handle you can use to monitor and manage the run. It does not wait for the result.                      | [Docs](#yourtask-trigger)             |
| `yourTask.batchTrigger()`        | Triggers a task multiple times and gets a handle you can use to monitor and manage the runs. It does not wait for the results.     | [Docs](#yourtask-batchtrigger)        |
| `yourTask.triggerAndWait()`      | Triggers a task and then waits until it's complete. You get the result data to continue with.                                      | [Docs](#yourtask-triggerandwait)      |
| `yourTask.batchTriggerAndWait()` | Triggers a task multiple times in parallel and then waits until they're all complete. You get the resulting data to continue with. | [Docs](#yourtask-batchtriggerandwait) |
| `batch.triggerAndWait()`         | Similar to `batch.trigger` but will wait on the triggered tasks to finish and return the results.                                  | [Docs](#batch-triggerandwait)         |
| `batch.triggerByTask()`          | Similar to `batch.trigger` but allows passing in task instances instead of task IDs.                                               | [Docs](#batch-triggerbytask)          |
| `batch.triggerByTaskAndWait()`   | Similar to `batch.triggerbyTask` but will wait on the triggered tasks to finish and return the results.                            | [Docs](#batch-triggerbytaskandwait)   |

## Triggering from your backend

When you trigger a task from your backend code, you need to set the `TRIGGER_SECRET_KEY` environment variable. If you're [using a preview branch](/docs/deployment/preview-branches), you also need to set the `TRIGGER_PREVIEW_BRANCH` environment variable. You can find the value on the API keys page in the Trigger.dev dashboard. [More info on API keys](/docs/apikeys).

If a single process needs to trigger across multiple projects, environments, or preview branches, use [`new TriggerClient({...})`](/docs/management/multiple-clients) for each target instead of relying on the global env vars.

<Note>
  **Which trigger pattern should I use?** If your triggering code can import the task definition (same codebase), use `yourTask.trigger()` for full type safety. Use `tasks.trigger()` with a type-only import when the task runs in a separate service or you need to avoid bundling task code into your app (common in Next.js). Both do the same thing at runtime.
</Note>

<Note>
  If you are using Next.js Server Actions [you'll need to be careful with
  bundling](/docs/guides/frameworks/nextjs#triggering-your-task-in-next-js).
</Note>

### tasks.trigger()

Triggers a single run of a task with the payload you pass in, and any options you specify, without needing to import the task.

<Note>
  By using `tasks.trigger()`, you can pass in the task type as a generic argument, giving you full
  type checking. Make sure you use a `type` import so that your task code is not imported into your
  application.
</Note>

```ts Your backend theme={"theme":"css-variables"}
import { tasks } from "@trigger.dev/sdk";
import type { emailSequence } from "~/trigger/emails";
//     👆 **type-only** import

//app/email/route.ts
export async function POST(request: Request) {
  //get the JSON from the request
  const data = await request.json();

  // Pass the task type to `trigger()` as a generic argument, giving you full type checking
  const handle = await tasks.trigger<typeof emailSequence>("email-sequence", {
    to: data.email,
    name: data.name,
  });

  //return a success response with the handle
  return Response.json(handle);
}
```

You can pass in options to the task using the third argument:

```ts Your backend theme={"theme":"css-variables"}
import { tasks } from "@trigger.dev/sdk";
import type { emailSequence } from "~/trigger/emails";

//app/email/route.ts
export async function POST(request: Request) {
  //get the JSON from the request
  const data = await request.json();

  // Pass the task type to `trigger()` as a generic argument, giving you full type checking
  const handle = await tasks.trigger<typeof emailSequence>(
    "email-sequence",
    {
      to: data.email,
      name: data.name,
    },
    { delay: "1h" } // 👈 Pass in the options here
  );

  //return a success response with the handle
  return Response.json(handle);
}
```

### tasks.batchTrigger()

Triggers multiple runs of a single task with the payloads you pass in, and any options you specify, without needing to import the task.

```ts Your backend theme={"theme":"css-variables"}
import { tasks } from "@trigger.dev/sdk";
import type { emailSequence } from "~/trigger/emails";
//     👆 **type-only** import

//app/email/route.ts
export async function POST(request: Request) {
  //get the JSON from the request
  const data = await request.json();

  // Pass the task type to `batchTrigger()` as a generic argument, giving you full type checking
  const batchHandle = await tasks.batchTrigger<typeof emailSequence>(
    "email-sequence",
    data.users.map((u) => ({ payload: { to: u.email, name: u.name } }))
  );

  //return a success response with the handle
  return Response.json(batchHandle);
}
```

You can pass in options to the `batchTrigger` function using the third argument:

```ts Your backend theme={"theme":"css-variables"}
import { tasks } from "@trigger.dev/sdk";
import type { emailSequence } from "~/trigger/emails";

//app/email/route.ts
export async function POST(request: Request) {
  //get the JSON from the request
  const data = await request.json();

  // Pass the task type to `batchTrigger()` as a generic argument, giving you full type checking
  const batchHandle = await tasks.batchTrigger<typeof emailSequence>(
    "email-sequence",
    data.users.map((u) => ({ payload: { to: u.email, name: u.name } })),
    { idempotencyKey: "my-idempotency-key" } // 👈 Pass in the options here
  );

  //return a success response with the handle
  return Response.json(batchHandle);
}
```

You can also pass in options for each run in the batch:

```ts Your backend theme={"theme":"css-variables"}
import { tasks } from "@trigger.dev/sdk";
import type { emailSequence } from "~/trigger/emails";

//app/email/route.ts
export async function POST(request: Request) {
  //get the JSON from the request
  const data = await request.json();

  // Pass the task type to `batchTrigger()` as a generic argument, giving you full type checking
  const batchHandle = await tasks.batchTrigger<typeof emailSequence>(
    "email-sequence",
    data.users.map((u) => ({ payload: { to: u.email, name: u.name }, options: { delay: "1h" } })) // 👈 Pass in options to each item like so
  );

  //return a success response with the handle
  return Response.json(batchHandle);
}
```

### batch.trigger()

Triggers multiple runs of different tasks with the payloads you pass in, and any options you specify. This is useful when you need to trigger multiple tasks at once.

```ts Your backend theme={"theme":"css-variables"}
import { batch } from "@trigger.dev/sdk";
import type { myTask1, myTask2 } from "~/trigger/myTasks";

export async function POST(request: Request) {
  //get the JSON from the request
  const data = await request.json();

  // Pass a union of the tasks to `trigger()` as a generic argument, giving you full type checking
  const result = await batch.trigger<typeof myTask1 | typeof myTask2>([
    // Because we're using a union, we can pass in multiple tasks by ID
    { id: "my-task-1", payload: { some: data.some } },
    { id: "my-task-2", payload: { other: data.other } },
  ]);

  //return a success response with the result
  return Response.json(result);
}
```

## Triggering from inside another task

The following functions should only be used when running inside a task, for one of the following reasons:

* You need to **wait** for the result of the triggered task.
* You need to import the task instance. Importing a task instance from your backend code is not recommended, as it can pull in a lot of unnecessary code and dependencies.

### yourTask.trigger()

Triggers a single run of a task with the payload you pass in, and any options you specify.

<Note>
  If you need to call `trigger()` on a task in a loop, use
  [`batchTrigger()`](#yourTask-batchtrigger) instead which will trigger up to 1,000 runs in a single
  call with SDK 4.3.1+ (500 runs in prior versions).
</Note>

```ts ./trigger/my-task.ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";
import { myOtherTask } from "~/trigger/my-other-task";

export const myTask = task({
  id: "my-task",
  run: async (payload: string) => {
    const handle = await myOtherTask.trigger({ foo: "some data" });

    const run = await runs.retrieve(handle);
    // Do something with the run
  },
});
```

To pass options to the triggered task, you can use the second argument:

```ts ./trigger/my-task.ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";
import { myOtherTask } from "~/trigger/my-other-task";

export const myTask = task({
  id: "my-task",
  run: async (payload: string) => {
    const handle = await myOtherTask.trigger({ foo: "some data" }, { delay: "1h" });

    const run = await runs.retrieve(handle);
    // Do something with the run
  },
});
```

### yourTask.batchTrigger()

Triggers multiple runs of a single task with the payloads you pass in, and any options you specify.

```ts /trigger/my-task.ts theme={"theme":"css-variables"}
import { batch } from "@trigger.dev/sdk";
import { myOtherTask } from "~/trigger/my-other-task";

export const myTask = task({
  id: "my-task",
  run: async (payload: string) => {
    const batchHandle = await myOtherTask.batchTrigger([{ payload: "some data" }]);

    //...do other stuff
    const batch = await batch.retrieve(batchHandle.id);
  },
});
```

If you need to pass options to `batchTrigger`, you can use the second argument:

```ts /trigger/my-task.ts theme={"theme":"css-variables"}
import { batch } from "@trigger.dev/sdk";
import { myOtherTask } from "~/trigger/my-other-task";

export const myTask = task({
  id: "my-task",
  run: async (payload: string) => {
    const batchHandle = await myOtherTask.batchTrigger([{ payload: "some data" }], {
      idempotencyKey: "my-task-key",
    });

    //...do other stuff
    const batch = await batch.retrieve(batchHandle.id);
  },
});
```

You can also pass in options for each run in the batch:

```ts /trigger/my-task.ts theme={"theme":"css-variables"}
import { batch } from "@trigger.dev/sdk";
import { myOtherTask } from "~/trigger/my-other-task";

export const myTask = task({
  id: "my-task",
  run: async (payload: string) => {
    const batchHandle = await myOtherTask.batchTrigger([
      { payload: "some data", options: { delay: "1h" } },
    ]);

    //...do other stuff
    const batch = await batch.retrieve(batchHandle.id);
  },
});
```

### yourTask.triggerAndWait()

This is where it gets interesting. You can trigger a task and then wait for the result. This is useful when you need to call a different task and then use the result to continue with your task.

<Accordion title="Don't use this in parallel, e.g. with `Promise.all()`">
  Instead, use `batchTriggerAndWait()` if you can, or a for loop if you can't.

  To control concurrency using batch triggers, you can set `queue.concurrencyLimit` on the child task.

  <CodeGroup>
    ```ts /trigger/batch.ts theme={"theme":"css-variables"}
    export const batchTask = task({
      id: "batch-task",
      run: async (payload: string) => {
        const results = await childTask.batchTriggerAndWait([
          { payload: "item1" },
          { payload: "item2" },
        ]);
        console.log("Results", results);

        //...do stuff with the results
      },
    });
    ```

    ```ts /trigger/loop.ts theme={"theme":"css-variables"}
    export const loopTask = task({
      id: "loop-task",
      run: async (payload: string) => {
        //this will be slower than the batch version
        //as we have to resume the parent after each iteration
        for (let i = 0; i < 2; i++) {
          const result = await childTask.triggerAndWait(`item${i}`);
          console.log("Result", result);

          //...do stuff with the result
        }
      },
    });
    ```
  </CodeGroup>
</Accordion>

```ts /trigger/parent.ts theme={"theme":"css-variables"}
export const parentTask = task({
  id: "parent-task",
  run: async (payload: string) => {
    const result = await childTask.triggerAndWait("some-data");
    console.log("Result", result);

    //...do stuff with the result
  },
});
```

The `result` object is a "Result" type that needs to be checked to see if the child task run was successful:

```ts /trigger/parent.ts theme={"theme":"css-variables"}
export const parentTask = task({
  id: "parent-task",
  run: async (payload: string) => {
    const result = await childTask.triggerAndWait("some-data");

    if (result.ok) {
      console.log("Result", result.output); // result.output is the typed return value of the child task
    } else {
      console.error("Error", result.error); // result.error is the error that caused the run to fail
    }
  },
});
```

If instead you just want to get the output of the child task, and throw an error if the child task failed, you can use the `unwrap` method:

```ts /trigger/parent.ts theme={"theme":"css-variables"}
export const parentTask = task({
  id: "parent-task",
  run: async (payload: string) => {
    const output = await childTask.triggerAndWait("some-data").unwrap();
    console.log("Output", output);
  },
});
```

You can also catch the error if the child task fails and get more information about the error:

```ts /trigger/parent.ts theme={"theme":"css-variables"}
import { task, SubtaskUnwrapError } from "@trigger.dev/sdk";
export const parentTask = task({
  id: "parent-task",
  run: async (payload: string) => {
    try {
      const output = await childTask.triggerAndWait("some-data").unwrap();
      console.log("Output", output);
    } catch (error) {
      if (error instanceof SubtaskUnwrapError) {
        console.error("Error in fetch-post-task", {
          runId: error.runId,
          taskId: error.taskId,
          cause: error.cause,
        });
      }
    }
  },
});
```

<Warning>
  This method should only be used inside a task. If you use it outside a task, it will throw an
  error.
</Warning>

### yourTask.batchTriggerAndWait()

You can batch trigger a task and wait for all the results. This is useful for the fan-out pattern, where you need to call a task multiple times and then wait for all the results to continue with your task.

<Accordion title="Don't use this in parallel, e.g. with `Promise.all()`">
  Instead, pass in all items at once and set an appropriate `maxConcurrency`. Alternatively, use sequentially with a for loop.

  To control concurrency, you can set `queue.concurrencyLimit` on the child task.

  <CodeGroup>
    ```ts /trigger/batch.ts theme={"theme":"css-variables"}
    export const batchTask = task({
      id: "batch-task",
      run: async (payload: string) => {
        const results = await childTask.batchTriggerAndWait([
          { payload: "item1" },
          { payload: "item2" },
        ]);
        console.log("Results", results);

        //...do stuff with the results
      },
    });
    ```

    ```ts /trigger/loop.ts theme={"theme":"css-variables"}
    export const loopTask = task({
      id: "loop-task",
      run: async (payload: string) => {
        //this will be slower than a single batchTriggerAndWait()
        //as we have to resume the parent after each iteration
        for (let i = 0; i < 2; i++) {
          const result = await childTask.batchTriggerAndWait([
            { payload: `itemA${i}` },
            { payload: `itemB${i}` },
          ]);
          console.log("Result", result);

          //...do stuff with the result
        }
      },
    });
    ```
  </CodeGroup>
</Accordion>

<Accordion title="How to handle run failures">
  When using `batchTriggerAndWait`, you have full control over how to handle failures within the batch. The method returns an array of run results, allowing you to inspect each run's outcome individually and implement custom error handling.

  Here's how you can manage run failures:

  1. **Inspect individual run results**: Each run in the returned array has an `ok` property indicating success or failure.

  2. **Access error information**: For failed runs, you can examine the `error` property to get details about the failure.

  3. **Choose your failure strategy**: You have two main options:

     * **Fail the entire batch**: Throw an error if any run fails, causing the parent task to reattempt.
     * **Continue despite failures**: Process the results without throwing an error, allowing the parent task to continue.

  4. **Implement custom logic**: You can create sophisticated handling based on the number of failures, types of errors, or other criteria.

  Here's an example of how you might handle run failures:

  <CodeGroup>
    ```ts /trigger/batchTriggerAndWait.ts theme={"theme":"css-variables"}
    const result = await batchChildTask.batchTriggerAndWait([
      { payload: "item1" },
      { payload: "item2" },
      { payload: "item3" },
    ]);

    // Result will contain the finished runs.
    // They're only finished if they have succeeded or failed.
    // "Failed" means all attempts failed

    for (const run of result.runs) {
      // Check if the run succeeded
      if (run.ok) {
        logger.info("Batch task run succeeded", { output: run.output });
      } else {
        logger.error("Batch task run error", { error: run.error });

        //You can choose if you want to throw an error and fail the entire run
        throw new Error(`Fail the entire run because ${run.id} failed`);
      }
    }
    ```
  </CodeGroup>
</Accordion>

```ts /trigger/nested.ts theme={"theme":"css-variables"}
export const batchParentTask = task({
  id: "parent-task",
  run: async (payload: string) => {
    const results = await childTask.batchTriggerAndWait([
      { payload: "item4" },
      { payload: "item5" },
      { payload: "item6" },
    ]);
    console.log("Results", results);

    //...do stuff with the result
  },
});
```

<Warning>
  This method should only be used inside a task. If you use it outside a task, it will throw an
  error.
</Warning>

### batch.triggerAndWait()

You can batch trigger multiple different tasks and wait for all the results:

```ts /trigger/batch.ts theme={"theme":"css-variables"}
import { batch, task } from "@trigger.dev/sdk";

export const parentTask = task({
  id: "parent-task",
  run: async (payload: string) => {
    //                                         👇 Pass a union of all the tasks you want to trigger
    const results = await batch.triggerAndWait<typeof childTask1 | typeof childTask2>([
      { id: "child-task-1", payload: { foo: "World" } }, // 👈 The payload is typed correctly based on the task `id`
      { id: "child-task-2", payload: { bar: 42 } }, // 👈 The payload is typed correctly based on the task `id`
    ]);

    for (const result of results.runs) {
      if (result.ok) {
        // 👇 Narrow the type of the result based on the taskIdentifier
        switch (result.taskIdentifier) {
          case "child-task-1":
            console.log("Child task 1 output", result.output); // 👈 result.output is typed as a string
            break;
          case "child-task-2":
            console.log("Child task 2 output", result.output); // 👈 result.output is typed as a number
            break;
        }
      } else {
        console.error("Error", result.error); // 👈 result.error is the error that caused the run to fail
      }
    }
  },
});

export const childTask1 = task({
  id: "child-task-1",
  run: async (payload: { foo: string }) => {
    return `Hello ${payload}`;
  },
});

export const childTask2 = task({
  id: "child-task-2",
  run: async (payload: { bar: number }) => {
    return bar + 1;
  },
});
```

### batch.triggerByTask()

You can batch trigger multiple different tasks by passing in the task instances. This function is especially useful when you have a static set of tasks you want to trigger:

```ts /trigger/batch.ts theme={"theme":"css-variables"}
import { batch, task, runs } from "@trigger.dev/sdk";

export const parentTask = task({
  id: "parent-task",
  run: async (payload: string) => {
    const results = await batch.triggerByTask([
      { task: childTask1, payload: { foo: "World" } }, // 👈 The payload is typed correctly based on the task instance
      { task: childTask2, payload: { bar: 42 } }, // 👈 The payload is typed correctly based on the task instance
    ]);

    // 👇 results.runs is a tuple, allowing you to get type safety without needing to narrow
    const run1 = await runs.retrieve(results.runs[0]); // 👈 run1 is typed as the output of childTask1
    const run2 = await runs.retrieve(results.runs[1]); // 👈 run2 is typed as the output of childTask2
  },
});

export const childTask1 = task({
  id: "child-task-1",
  run: async (payload: { foo: string }) => {
    return `Hello ${payload}`;
  },
});

export const childTask2 = task({
  id: "child-task-2",
  run: async (payload: { bar: number }) => {
    return bar + 1;
  },
});
```

### batch.triggerByTaskAndWait()

You can batch trigger multiple different tasks by passing in the task instances, and wait for all the results. This function is especially useful when you have a static set of tasks you want to trigger:

```ts /trigger/batch.ts theme={"theme":"css-variables"}
import { batch, task, runs } from "@trigger.dev/sdk";

export const parentTask = task({
  id: "parent-task",
  run: async (payload: string) => {
    const { runs } = await batch.triggerByTaskAndWait([
      { task: childTask1, payload: { foo: "World" } }, // 👈 The payload is typed correctly based on the task instance
      { task: childTask2, payload: { bar: 42 } }, // 👈 The payload is typed correctly based on the task instance
    ]);

    if (runs[0].ok) {
      console.log("Child task 1 output", runs[0].output); // 👈 runs[0].output is typed as the output of childTask1
    }

    if (runs[1].ok) {
      console.log("Child task 2 output", runs[1].output); // 👈 runs[1].output is typed as the output of childTask2
    }

    // 💭 A nice alternative syntax is to destructure the runs array:
    const {
      runs: [run1, run2],
    } = await batch.triggerByTaskAndWait([
      { task: childTask1, payload: { foo: "World" } }, // 👈 The payload is typed correctly based on the task instance
      { task: childTask2, payload: { bar: 42 } }, // 👈 The payload is typed correctly based on the task instance
    ]);

    if (run1.ok) {
      console.log("Child task 1 output", run1.output); // 👈 run1.output is typed as the output of childTask1
    }

    if (run2.ok) {
      console.log("Child task 2 output", run2.output); // 👈 run2.output is typed as the output of childTask2
    }
  },
});

export const childTask1 = task({
  id: "child-task-1",
  run: async (payload: { foo: string }) => {
    return `Hello ${payload}`;
  },
});

export const childTask2 = task({
  id: "child-task-2",
  run: async (payload: { bar: number }) => {
    return bar + 1;
  },
});
```

## Triggering from your frontend

If you want to trigger a task directly from a frontend application, you can use our [React
hooks](/docs/realtime/react-hooks/triggering).

## Options

All of the above functions accept an options object:

```ts theme={"theme":"css-variables"}
await myTask.trigger({ some: "data" }, { delay: "1h", ttl: "1h" });
await myTask.batchTrigger([{ payload: { some: "data" }, options: { delay: "1h" } }]);
```

The following options are available:

### `delay`

When you want to trigger a task now, but have it run at a later time, you can use the `delay` option:

```ts theme={"theme":"css-variables"}
// Delay the task run by 1 hour
await myTask.trigger({ some: "data" }, { delay: "1h" });
// Delay the task run by 88 seconds
await myTask.trigger({ some: "data" }, { delay: "88s" });
// Delay the task run by 1 hour and 52 minutes and 18 seconds
await myTask.trigger({ some: "data" }, { delay: "1h52m18s" });
// Delay until a specific time
await myTask.trigger({ some: "data" }, { delay: "2024-12-01T00:00:00" });
// Delay using a Date object
await myTask.trigger({ some: "data" }, { delay: new Date(Date.now() + 1000 * 60 * 60) });
// Delay using a timezone
await myTask.trigger({ some: "data" }, { delay: new Date("2024-07-23T11:50:00+02:00") });
```

Runs that are delayed and have not been enqueued yet will display in the dashboard with a "Delayed" status:

<img alt="Delayed run in the dashboard" />

<Note>
  Delayed runs will be enqueued at the time specified, and will run as soon as possible after that
  time, just as a normally triggered run would. They execute on the currently deployed version when
  they start, not the version that was active when they were enqueued.
</Note>

You can cancel a delayed run using the `runs.cancel` SDK function:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

await runs.cancel("run_1234");
```

You can also reschedule a delayed run using the `runs.reschedule` SDK function:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

// The delay option here takes the same format as the trigger delay option
await runs.reschedule("run_1234", { delay: "1h" });
```

The `delay` option is also available when using `batchTrigger`:

```ts theme={"theme":"css-variables"}
await myTask.batchTrigger([{ payload: { some: "data" }, options: { delay: "1h" } }]);
```

<Note>
  If your payload contains Date objects, pass them directly rather than manually stringifying with
  `JSON.stringify()`. The SDK handles Date serialization automatically. If you need to stringify
  manually, convert Dates to ISO strings first (e.g., `date.toISOString()`).
</Note>

### `ttl`

You can set a TTL (time to live) when triggering a task, which will automatically expire the run if it hasn't started within the specified time. This is useful for ensuring that a run doesn't get stuck in the queue for too long.

<Note>
  All runs in development have a default `ttl` of 10 minutes. You can disable this by setting the
  `ttl` option.
</Note>

```ts theme={"theme":"css-variables"}
import { myTask } from "./trigger/myTasks";

// Expire the run if it hasn't started within 1 hour
await myTask.trigger({ some: "data" }, { ttl: "1h" });

// If you specify a number, it will be treated as seconds
await myTask.trigger({ some: "data" }, { ttl: 3600 }); // 1 hour
```

When a run is expired, it will be marked as "Expired" in the dashboard:

<img alt="Expired runs in the dashboard" />

When you use both `delay` and `ttl`, the TTL will start counting down from the time the run is enqueued, not from the time the run is triggered.

So for example, when using the following code:

```ts theme={"theme":"css-variables"}
await myTask.trigger({ some: "data" }, { delay: "10m", ttl: "1h" });
```

The timeline would look like this:

1. The run is created at 12:00:00
2. The run is enqueued at 12:10:00
3. The TTL starts counting down from 12:10:00
4. If the run hasn't started by 13:10:00, it will be expired

For this reason, the `ttl` option only accepts durations and not absolute timestamps.

<Note>
  On [Trigger.dev Cloud](https://trigger.dev), there is a maximum TTL of 14 days. If you don't specify a TTL in staging or production, runs automatically get a 14-day TTL. If you specify a TTL longer than 14 days, it is clamped to 14 days. See [Limits — Maximum run TTL](/docs/limits#maximum-run-ttl) for details.
</Note>

### `idempotencyKey`

You can provide an `idempotencyKey` to ensure that a task is only triggered once with the same key. This is useful if you are triggering a task within another task that might be retried:

```typescript theme={"theme":"css-variables"}
import { idempotencyKeys, task } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  retry: {
    maxAttempts: 4,
  },
  run: async (payload: any) => {
    // By default, idempotency keys generated are unique to the run, to prevent retries from duplicating child tasks
    const idempotencyKey = await idempotencyKeys.create("my-task-key");

    // childTask will only be triggered once with the same idempotency key
    await childTask.trigger(payload, { idempotencyKey });

    // Do something else, that may throw an error and cause the task to be retried
  },
});
```

For more information, see our [Idempotency](/docs/idempotency) documentation.

### `idempotencyKeyTTL`

Idempotency keys automatically expire after 30 days, but you can set a custom TTL for an idempotency key when triggering a task:

```typescript theme={"theme":"css-variables"}
import { idempotencyKeys, task } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  retry: {
    maxAttempts: 4,
  },
  run: async (payload: any) => {
    // By default, idempotency keys generated are unique to the run, to prevent retries from duplicating child tasks
    const idempotencyKey = await idempotencyKeys.create("my-task-key");

    // childTask will only be triggered once with the same idempotency key
    await childTask.trigger(payload, { idempotencyKey, idempotencyKeyTTL: "1h" });

    // Do something else, that may throw an error and cause the task to be retried
  },
});
```

For more information, see our [Idempotency](/docs/idempotency) documentation.

### `debounce`

You can debounce task triggers to consolidate multiple trigger calls into a single delayed run. When a run with the same debounce key already exists in the delayed state, subsequent triggers "push" the existing run's execution time later rather than creating new runs.

This is useful for scenarios like:

* Real-time document indexing where you want to wait for the user to finish typing
* Aggregating webhook events from the same source
* Rate limiting expensive operations while still processing the final request

```ts theme={"theme":"css-variables"}
// First trigger creates a new run, delayed by 5 seconds
await myTask.trigger({ some: "data" }, { debounce: { key: "user-123", delay: "5s" } });

// If triggered again within 5 seconds, the existing run is pushed later
await myTask.trigger({ updated: "data" }, { debounce: { key: "user-123", delay: "5s" } });

// The run only executes after 5 seconds of no new triggers
// Note: The first payload is used (first trigger wins)
```

<Note>
  Debounce keys are scoped to the task identifier, so different tasks can use the same key without
  conflicts.
</Note>

The `debounce` option accepts:

* `key` - A unique string to identify the debounce group (scoped to the task)
* `delay` - Duration string specifying how long to delay. Supported units: `s` (seconds), `m` (minutes), `h`/`hr` (hours), `d` (days), `w` (weeks). Minimum is 1 second. Examples: `"5s"`, `"1m"`, `"2h30m"`
* `mode` - Optional. Controls which trigger's data is used: `"leading"` (default) or `"trailing"`
* `maxDelay` - Optional. Maximum total time from the first trigger before the run must execute. Uses the same duration format as `delay`. Not set by default

**How it works:**

1. First trigger with a debounce key creates a new delayed run
2. Subsequent triggers with the same key (while the run is still delayed) push the execution time further
3. Once no new triggers occur within the delay duration, the run executes
4. After the run starts executing, a new trigger with the same key will create a new run

<Warning>
  There is no time limit on step 2. While triggers keep arriving on the same key, the run keeps
  being pushed back and never executes. A key triggered every 10 seconds with a `delay` of `"30s"`
  runs 30 seconds after the triggers stop, however long that takes. Set `maxDelay` whenever the
  work needs to happen eventually. This matters most with `triggerAndWait`: every parent waiting
  on a debounced run stays blocked, and holds its concurrency, for as long as the run keeps being
  pushed back.
</Warning>

**Limiting total delay with `maxDelay`:**

The `maxDelay` option sets an upper bound on the total delay from the first trigger, ensuring the run eventually executes even with constant activity.

```ts theme={"theme":"css-variables"}
await summarizeChat.trigger(
  { conversationId: "123" },
  {
    debounce: {
      key: "conversation-123",
      delay: "10s",    // Wait 10s after each message
      maxDelay: "5m",  // But always run within 5 minutes of first trigger
    },
  }
);
```

This is useful for scenarios like:

* Summarizing AI chat threads that need periodic updates even during active conversations
* Syncing data that should happen regularly despite continuous changes
* Any case where you want debouncing but also guarantee timely execution

**Timeline example with `maxDelay`:**

Consider `delay: "5s"` and `maxDelay: "30s"` with triggers arriving every 2 seconds:

| Time | Event      | Result                                                                             |
| :--- | :--------- | :--------------------------------------------------------------------------------- |
| 0s   | Trigger 1  | Run A created, scheduled for 5s                                                    |
| 2s   | Trigger 2  | Run A rescheduled to 7s                                                            |
| 4s   | Trigger 3  | Run A rescheduled to 9s                                                            |
| ...  | ...        | ...                                                                                |
| 26s  | Trigger 14 | Run A rescheduled to 31s                                                           |
| 28s  | Trigger 15 | Would reschedule to 33s, but exceeds maxDelay (30s). Run A executes, Run B created |
| 30s  | Trigger 16 | Run B rescheduled to 35s                                                           |

Without `maxDelay`, continuous triggers would prevent the run from ever executing. With `maxDelay: "30s"`, execution is guaranteed within 30 seconds of the first trigger.

Keep `delay` well below `maxDelay`. A run is only pushed back while its new execution time stays inside `maxDelay`, so the room you have to push is `maxDelay` minus `delay`. Setting them equal, or setting `delay` higher, leaves no room at all, and the trigger is rejected rather than accepted as a debounce that could never collapse anything.

<Note>
  The `maxDelay` value is evaluated from each trigger call, not stored with the original run. This
  means if you pass different `maxDelay` values for the same debounce key, each trigger uses its own
  `maxDelay` to check against the original run's creation time. A trigger that omits `maxDelay`
  has no bound at all, so a single call without it can push the run past the limit the other calls
  set. Use the same `maxDelay` value for every trigger with the same debounce key.
</Note>

**Leading vs Trailing mode:**

By default, debounce uses **leading mode** - the run executes with data from the **first** trigger.

With **trailing mode**, each subsequent trigger updates the run's data (payload, metadata, tags, maxAttempts, maxDuration, and machine), so the run executes with data from the **last** trigger:

```ts theme={"theme":"css-variables"}
// Leading mode (default): runs with first payload
await myTask.trigger({ count: 1 }, { debounce: { key: "user-123", delay: "5s" } });
await myTask.trigger({ count: 2 }, { debounce: { key: "user-123", delay: "5s" } });
// After 5 seconds, runs with { count: 1 }

// Trailing mode: runs with last payload
await myTask.trigger(
  { count: 1 },
  { debounce: { key: "user-123", delay: "5s", mode: "trailing" } }
);
await myTask.trigger(
  { count: 2 },
  { debounce: { key: "user-123", delay: "5s", mode: "trailing" } }
);
// After 5 seconds, runs with { count: 2 }
```

Use **trailing mode** when you want to process the most recent data, such as:

* Saving the latest version of a document after edits stop
* Processing the final state after a series of rapid updates

**With `triggerAndWait`:**

When using `triggerAndWait` with debounce, the parent run blocks on the existing debounced run if one exists:

```ts theme={"theme":"css-variables"}
export const parentTask = task({
  id: "parent-task",
  run: async (payload: string) => {
    // Both will wait for the same run
    const result = await childTask.triggerAndWait(
      { data: payload },
      { debounce: { key: "shared-key", delay: "3s" } }
    );
    return result;
  },
});
```

<Note>
  Idempotency keys take precedence over debounce keys. If both are provided and an idempotency match
  is found, it wins.
</Note>

### `queue`

When you trigger a task you can override the concurrency limit. This is really useful if you sometimes have high priority runs.

The task:

```ts /trigger/override-concurrency.ts theme={"theme":"css-variables"}
const generatePullRequest = task({
  id: "generate-pull-request",
  queue: {
    //normally when triggering this task it will be limited to 1 run at a time
    concurrencyLimit: 1,
  },
  run: async (payload) => {
    //todo generate a PR using OpenAI
  },
});
```

Triggering from your backend and overriding the concurrency:

```ts app/api/push/route.ts theme={"theme":"css-variables"}
import { generatePullRequest } from "~/trigger/override-concurrency";

export async function POST(request: Request) {
  const data = await request.json();

  if (data.branch === "main") {
    //trigger the task, with a different queue
    const handle = await generatePullRequest.trigger(data, {
      queue: {
        //the "main-branch" queue will have a concurrency limit of 10
        //this triggered run will use that queue
        name: "main-branch",
        concurrencyLimit: 10,
      },
    });

    return Response.json(handle);
  } else {
    //triggered with the default (concurrency of 1)
    const handle = await generatePullRequest.trigger(data);
    return Response.json(handle);
  }
}
```

### `concurrencyKey`

If you're building an application where you want to run tasks for your users, you might want a separate queue for each of your users. (It doesn't have to be users, it can be any entity you want to separately limit the concurrency for.)

You can do this by using `concurrencyKey`. It creates a separate queue for each value of the key.

Your backend code:

```ts app/api/pr/route.ts theme={"theme":"css-variables"}
import { generatePullRequest } from "~/trigger/override-concurrency";

export async function POST(request: Request) {
  const data = await request.json();

  if (data.isFreeUser) {
    //free users can only have 1 PR generated at a time
    const handle = await generatePullRequest.trigger(data, {
      queue: {
        //every free user gets a queue with a concurrency limit of 1
        name: "free-users",
        concurrencyLimit: 1,
      },
      concurrencyKey: data.userId,
    });

    //return a success response with the handle
    return Response.json(handle);
  } else {
    //trigger the task, with a different queue
    const handle = await generatePullRequest.trigger(data, {
      queue: {
        //every paid user gets a queue with a concurrency limit of 10
        name: "paid-users",
        concurrencyLimit: 10,
      },
      concurrencyKey: data.userId,
    });

    //return a success response with the handle
    return Response.json(handle);
  }
}
```

### `maxAttempts`

You can set the maximum number of attempts for a task run. If the run fails, it will be retried up to the number of attempts you specify.

```ts theme={"theme":"css-variables"}
await myTask.trigger({ some: "data" }, { maxAttempts: 3 });
await myTask.trigger({ some: "data" }, { maxAttempts: 1 }); // no retries
```

This will override the `retry.maxAttempts` value set in the task definition.

### `tags`

View our [tags doc](/docs/tags) for more information.

### `metadata`

View our [metadata doc](/docs/runs/metadata) for more information.

### `maxDuration`

View our [maxDuration doc](/docs/runs/max-duration) for more information.

### `priority`

View our [priority doc](/docs/runs/priority) for more information.

### `region`

You can override the default region when you trigger a run:

```ts theme={"theme":"css-variables"}
await yourTask.trigger(payload, { region: "eu-central-1" });
```

If you don't specify a region it will use the default for your project. Go to the "Regions" page in the dashboard to see available regions or switch your default. Static IP addresses are also available on the Regions page for paid plans.

The region is where your runs are executed, it does not change where the run payload, output, tags, logs, or are any other data is stored.

### `machine`

You can override the default machine preset when you trigger a run:

```ts theme={"theme":"css-variables"}
await yourTask.trigger(payload, { machine: "large-1x" });
```

If you don't specify a machine it will use the machine preset for your task (or the default for your project). For more information read [the machines guide](/docs/machines).

## Streaming batch triggering

<Note>This feature is only available with SDK 4.3.1+</Note>

For large batches, you can pass an `AsyncIterable` or `ReadableStream` instead of an array. This allows you to generate items on-demand without loading them all into memory upfront.

```ts /trigger/my-task.ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { myOtherTask } from "~/trigger/my-other-task";

export const myTask = task({
  id: "my-task",
  run: async (payload: { userIds: string[] }) => {
    // Use an async generator to stream items
    async function* generateItems() {
      for (const userId of payload.userIds) {
        yield { payload: { userId } };
      }
    }

    const batchHandle = await myOtherTask.batchTrigger(generateItems());

    return { batchId: batchHandle.batchId };
  },
});
```

This works with all batch trigger methods:

* `yourTask.batchTrigger()`
* `yourTask.batchTriggerAndWait()`
* `batch.triggerByTask()`
* `batch.triggerByTaskAndWait()`
* `tasks.batchTrigger()`

Streaming is especially useful when generating batches from database queries, API pagination, or
file processing where you don't want to load all items into memory at once.

## Handling batch trigger errors

When batch triggering fails, the SDK throws a `BatchTriggerError` with properties that help you understand what went wrong and how to react:

| Property        | Type                   | Description                                     |
| :-------------- | :--------------------- | :---------------------------------------------- |
| `isRateLimited` | `boolean`              | `true` if the error was caused by rate limiting |
| `retryAfterMs`  | `number \| undefined`  | Milliseconds until the rate limit resets        |
| `phase`         | `"create" \| "stream"` | Which phase of batch creation failed            |
| `batchId`       | `string \| undefined`  | The batch ID if it was created before failure   |
| `itemCount`     | `number`               | Number of items attempted in the batch          |
| `cause`         | `unknown`              | The underlying error                            |

### Detecting and handling rate limits

When you hit [batch trigger rate limits](/docs/limits#batch-trigger-rate-limits), you can detect this and implement retry logic:

```ts Your backend theme={"theme":"css-variables"}
import { tasks, BatchTriggerError } from "@trigger.dev/sdk";
import type { myTask } from "~/trigger/myTask";

async function triggerBatchWithRetry(items: { payload: { userId: string } }[], maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await tasks.batchTrigger<typeof myTask>("my-task", items);
    } catch (error) {
      if (error instanceof BatchTriggerError && error.isRateLimited) {
        // Rate limited - wait and retry
        const waitMs = error.retryAfterMs ?? 10000;
        console.log(`Rate limited. Waiting ${waitMs}ms before retry ${attempt + 1}/${maxRetries}`);
        await new Promise((resolve) => setTimeout(resolve, waitMs));
        continue;
      }
      // Not a rate limit error - rethrow
      throw error;
    }
  }
  throw new Error("Max retries exceeded");
}
```

### Handling errors inside tasks

When calling `batchTrigger` from inside another task, you can handle errors similarly:

```ts /trigger/parent-task.ts theme={"theme":"css-variables"}
import { task, BatchTriggerError } from "@trigger.dev/sdk";
import { childTask } from "./child-task";

export const parentTask = task({
  id: "parent-task",
  run: async (payload: { userIds: string[] }) => {
    const items = payload.userIds.map((userId) => ({ payload: { userId } }));

    try {
      const batchHandle = await childTask.batchTrigger(items);
      return { batchId: batchHandle.batchId };
    } catch (error) {
      if (error instanceof BatchTriggerError) {
        // Log details about the failure
        console.error("Batch trigger failed", {
          message: error.message,
          phase: error.phase,
          itemCount: error.itemCount,
          isRateLimited: error.isRateLimited,
        });

        if (error.isRateLimited) {
          // You might want to re-throw to let the task retry naturally
          throw error;
        }
      }
      throw error;
    }
  },
});
```

<Note>
  For rate limit values and how the token bucket algorithm works, see [Batch trigger rate limits](/docs/limits#batch-trigger-rate-limits).
</Note>

## Large Payloads

We recommend keeping your task payloads as small as possible. We currently have a hard limit on task payloads above 10MB.

If your payload size is larger than 512KB, instead of saving the payload to the database, we will upload it to an S3-compatible object store and store the URL in the database.

When your task runs, we automatically download the payload from the object store and pass it to your task function. We also will return to you a `payloadPresignedUrl` from the `runs.retrieve` SDK function so you can download the payload if needed:

```ts theme={"theme":"css-variables"}
import { runs } from "@trigger.dev/sdk";

const run = await runs.retrieve(handle);

if (run.payloadPresignedUrl) {
  const response = await fetch(run.payloadPresignedUrl);
  const payload = await response.json();

  console.log("Payload", payload);
}
```

<Note>
  We also use this same system for dealing with large task outputs, and subsequently will return a
  corresponding `outputPresignedUrl`. Task outputs are limited to 100MB.
</Note>

If you need to pass larger payloads, you'll need to upload the payload to your own storage and pass a URL to the file in the payload instead. For example, uploading to S3 and then sending a presigned URL that expires in URL:

<CodeGroup>
  ```ts /yourServer.ts theme={"theme":"css-variables"}
  import { myTask } from "./trigger/myTasks";
  import { s3Client, getSignedUrl, PutObjectCommand, GetObjectCommand } from "./s3";
  import { createReadStream } from "node:fs";

  // Upload file to S3
  await s3Client.send(
    new PutObjectCommand({
      Bucket: "my-bucket",
      Key: "myfile.json",
      Body: createReadStream("large-payload.json"),
    })
  );

  // Create presigned URL
  const presignedUrl = await getSignedUrl(
    s3Client,
    new GetObjectCommand({
      Bucket: "my-bucket",
      Key: "my-file.json",
    }),
    {
      expiresIn: 3600, // expires in 1 hour
    }
  );

  // Now send the URL to the task
  const handle = await myTask.trigger({
    url: presignedUrl,
  });
  ```

  ```ts /trigger/myTasks.ts theme={"theme":"css-variables"}
  import { task } from "@trigger.dev/sdk";

  export const myTask = task({
    id: "my-task",
    run: async (payload: { url: string }) => {
      // Download the file from the URL
      const response = await fetch(payload.url);
      const data = await response.json();

      // Do something with the data
    },
  });
  ```
</CodeGroup>


# Common problems
Source: https://trigger.dev/docs/troubleshooting

Some common problems you might experience and their solutions

## Development

### `EACCES: permission denied`

If you see this error:

```
6090 verbose stack Error: EACCES: permission denied, rename '/Users/user/.npm/_cacache/tmp/f1bfea11' -> '/Users/user/.npm/_cacache/content-v2/sha512/31/d8/e094a47a0105d06fd246892ed1736c02eae323726ec6a3f34734eeb71308895dfba4f4f82a88ffe7e480c90b388c91fc3d9f851ba7b96db4dc33fbc65528'
```

First, clear the npm cache:

```sh theme={"theme":"css-variables"}
npm cache clean --force
```

Then change the permissions of the npm folder (if 1 doesn't work):

```sh theme={"theme":"css-variables"}
sudo chown -R $(whoami) ~/.npm
```

### Clear the build cache

Ensure you have stopped your local dev server then locate the hidden `.trigger` folder in your project and delete it. You can then restart your local dev server.

### Yarn Plug'n'Play conflicts

If you see errors like this when running `trigger.dev dev`:

```
Could not resolve "@trigger.dev/core"
The Yarn Plug'n'Play manifest forbids importing "@trigger.dev/core" here because it's not listed as a dependency of this package
```

And you're using Yarn v1.22 or another package manager, check if you have a `.pnp.cjs` file in your home directory. This can happen if you previously had Yarn Plug'n'Play enabled globally. Remove the `.pnp.cjs` file to resolve the issue.

### `Connection error` when logging in

If you see "Connection error" when running `trigger login` (or "Failed to create authorization code"), try these in order:

1. **Clear saved auth and retry:** `npx trigger.dev@latest logout`, then `npx trigger.dev@latest login` again. Sometimes an invalid config is cached.
2. **VPN or firewall:** Disconnect from VPN or check firewall/proxy; try `npx trigger.dev@latest login --log-level debug` for more detail.
3. **TLS / certificate store:** Node may use a different CA store than your OS (e.g. `curl` works but the CLI fails). Try `export NODE_EXTRA_CA_CERTS=/etc/ssl/cert.pem` (macOS/Linux) then login again, or reinstall Node so it gets updated certs. Behind a corporate proxy or custom CA? Set `NODE_EXTRA_CA_CERTS` to that CA file.

### Runs never dequeue in dev

If runs sit in the queue and never start while running `trigger.dev dev`, check whether you have more than one local instance running against the same project. Each project has a single dev environment, and only one `trigger.dev dev` instance can consume from it at a time, so a second instance causes runs to be split between them and some appear stuck. Keep a single `trigger.dev dev` running per project. [Upvote isolated dev sessions](https://triggerdev.featurebase.app/p/isolated-dev-sessions-for-multiple-local-trigger-dev-instances) if you need to run multiple local instances.

## Deployment

Running the \[trigger.dev deploy] command builds and deploys your code. Sometimes there can be issues building your code.

You can run the deploy command with `--log-level debug` at the end. This will spit out a lot of information about the deploy. If you can't figure out the problem from the information below please join [our Discord](https://trigger.dev/discord) and create a help forum post. Do NOT share the extended debug logs publicly as they might reveal private information about your project.

You can also review the build by supplying the `--dry-run` flag. This will build your project but not deploy it. You can then inspect the build output on your machine.

Here are some common problems and their solutions:

### `Failed to build project image: Error building image`

There should be a link below the error message to the full build logs on your machine. Take a look at these to see what went wrong. Join [our Discord](https://trigger.dev/discord) and you share it privately with us if you can't figure out what's going wrong. Do NOT share these publicly as the verbose logs might reveal private information about your project.

### `Error: failed to solve: failed to resolve source metadata for docker.io/docker/dockerfile:1`

If you see this error after uninstalling Docker Desktop:

```
Error: failed to solve: failed to resolve source metadata for docker.io/docker/dockerfile:1: error getting credentials - err: exec: "docker-credential-desktop": executable file not found in $PATH
```

This happens because Docker Desktop left behind a config file that's still trying to use its credential store. To fix this, remove or update the `~/.docker/config.json` file. You don't need Docker Desktop installed to use Trigger.dev.

### `Deployment encountered an error`

Usually there will be some useful guidance below this message. If you can't figure out what's going wrong then join [our Discord](https://trigger.dev/discord) and create a Help forum post with a link to your deployment.

### `resource_exhausted`

If you see a `resource_exhausted` error during deploy, the build may have hit resource limits on our build infrastructure. Try our [native builder](https://trigger.dev/changelog/deployments-with-native-builds).

### `No loader is configured for ".node" files`

This happens because `.node` files are native code and can't be bundled like other packages. To fix this, add your package to [`build.external`](/docs/config/config-file#external) in the `trigger.config.ts` file like this:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    external: ["your-node-package"],
  },
});
```

### `Cannot find module '/app/lib/worker.js"` when using pino

If you see this error, add pino (and any other associated packages) to your `external` build settings in your `trigger.config.ts` file. Learn more about the `external` setting in the [config docs](/docs/config/config-file#external).

### `Failed to index deployment` with `Column must be greater than or equal to 0, got -1`

This can occur when using `runtime: "bun"` during the indexing phase (we load and execute your task files to discover exports). A short-term workaround is to [pnpm patch](https://pnpm.io/cli/patch) the `source-map` package. See [this GitHub issue](https://github.com/triggerdotdev/trigger.dev/issues/3045) for the patch details.

### `reactDOMServer.renderToPipeableStream is not a function` when using react-email

If you see this error when using `@react-email/render`:

```
TypeError: reactDOMServer.renderToPipeableStream is not a function
    at __spreadValues.selectors (file:///node_modules/.pnpm/@react-email+render@1.0.6_react-dom@19.0.0_react@19.0.0/node_modules/@react-email/render/dist/node/index.mjs:162:37)
```

This happens because react-email packages have bundling conflicts with our build process. To fix this, add the react-email packages to your `external` build settings in your `trigger.config.ts` file:

```ts trigger.config.ts theme={"theme":"css-variables"}
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "<project ref>",
  // Your other config settings...
  build: {
    external: ["react", "react-dom", "@react-email/render", "@react-email/components"],
  },
});
```

### `Cannot find matching keyid`

This error occurs when using Node.js v22 with corepack, as it's not yet compatible with the latest package manager signatures. To fix this, either:

1. Downgrade to Node.js v20 (LTS), or
2. Install corepack globally: `npm i -g corepack@latest`

The corepack bug and workaround are detailed in [this issue](https://github.com/npm/cli/issues/8075).

## Project setup issues

### `The requested module 'node:events' does not provide an export named 'addAbortListener'`

If you see this error it means you're not a supported version of Node:

```
SyntaxError: The requested module 'node:events' does not provide an export named 'addAbortListener'
at ModuleJob._instantiate (node:internal/modules/esm/module_job:123:21)
at async ModuleJob.run (node:internal/modules/esm/module_job:189:5)

Node.js v19.9.0
```

You need to be on at least these minor versions:

| Version | Minimum |
| ------- | ------- |
| 18      | 18.20+  |
| 20      | 20.5+   |
| 21      | 21.0+   |
| 22      | 22.0+   |

## Runtime issues

### `Environment variable not found:`

Your code is deployed separately from the rest of your app(s) so you need to make sure that you set any environment variables you use in your tasks in the Trigger.dev dashboard. [Read the guide](/docs/deploy-environment-variables).

### `Error: @prisma/client did not initialize yet.`

Prisma uses code generation to create the client from your schema file. This means you need to add a bit of config so we can generate this file before your tasks run: [Read the guide](/docs/config/extensions/prismaExtension).

### Database connection requires IPv4

Trigger.dev currently only supports IPv4 database connections. If your database provider only provides an IPv6 connection string, you'll need to use an IPv4 address instead. [Upvote IPv6 support](https://triggerdev.featurebase.app/p/support-ipv6-database-connections).

### `Parallel waits are not supported`

In the current version, you can't perform more that one "wait" in parallel.

Waits include:

* `wait.for()`
* `wait.until()`
* `task.triggerAndWait()`
* `task.batchTriggerAndWait()`
* `retry.onThrow()`
* And any of our functions with `wait` in the name.

This restriction exists because we suspend the task server after a wait, and resume it when the wait is done. At the moment, if you do more than one wait, the run will never continue when deployed, so we throw this error instead.

The most common situation this happens is if you're using `Promise.all` around some of our wait functions. Instead of doing this use our built-in functions for [triggering tasks](/docs/triggering#triggering-from-inside-another-task). We have functions that allow you to trigger different tasks in parallel.

### When triggering subtasks the parent task finishes too soon

Make sure that you always use `await` when you call `trigger`, `triggerAndWait`, `batchTrigger`, and `batchTriggerAndWait`. If you don't then it's likely the task(s) won't be triggered because the calling function process can be terminated before the networks calls are sent.

### `COULD_NOT_FIND_EXECUTOR`

If you see a `COULD_NOT_FIND_EXECUTOR` error when triggering a task, it may be caused by dynamically importing the child task. When tasks are dynamically imported, the executor may not be properly registered.

Use a top-level import instead:

```ts theme={"theme":"css-variables"}
import { myChildTask } from "~/trigger/my-child-task";

export const myTask = task({
  id: "my-task",
  run: async (payload: string) => {
    await myChildTask.trigger({ payload: "data" });
  },
});
```

Alternatively, use `tasks.trigger()` or `batch.triggerAndWait()` without importing the task:

```ts theme={"theme":"css-variables"}
import { batch } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  run: async (payload: string) => {
    await batch.triggerAndWait([{ id: "my-child-task", payload: "data" }]);
  },
});
```

### Rate limit exceeded

The most common cause of hitting the API rate limit is if you're calling `trigger()` on a task in a loop, instead of doing this use `batchTrigger()` which will trigger multiple tasks in a single API call. You can have up to 1,000 tasks in a single batch trigger call with SDK 4.3.1+ (500 in prior versions).

View the [rate limits](/docs/limits) page for more information.

### Runs waiting in queue due to concurrency limits

If runs are staying in the `QUEUED` state for extended periods, check your concurrency usage in the dashboard. Review how many runs are `EXECUTING` or `DEQUEUED` (these count against limits) and check if any runs are stuck in `EXECUTING` state, as they may be blocking new runs.

**Solutions:**

* **Increase concurrency limits** - If you're on a paid plan, increase your environment concurrency limit via the dashboard
* **Review queue concurrency limits** - Check if individual queues have restrictive `concurrencyLimit` settings
* **Check for stuck runs** - See if stalled runs are blocking new executions

### Runs queued because a billing limit was reached

If your organization reaches a billing limit, billable environments (`production`, `staging`, and `preview`) pause: new runs still queue, but they won't start executing until you raise or remove the limit and choose whether to resume or cancel the queued runs. See [Billing limits and alerts](/docs/billing-limits) for the full flow.

### `Crypto is not defined`

This can happen in different situations, for example when using plain strings as idempotency keys. Support for `Crypto` without a special flag was added in Node `v19.0.0`. You will have to upgrade Node - we recommend even-numbered major releases, e.g. `v20` or `v22`. Alternatively, you can switch from plain strings to the `idempotencyKeys.create` SDK function. [Read the guide](/docs/idempotency).

### Task run stalled executing

If you see a `TASK_RUN_STALLED_EXECUTING` error it means that we didn't receive a heartbeat from your task before the stall timeout. We automatically heartbeat runs every 30 seconds, and the heartbeat timeout is 5 minutes.

<Note>
  If this was a dev run, then most likely the `trigger.dev dev` CLI was stopped, and it wasn't an issue with your code.
</Note>

These errors can happen when code inside your task is blocking the event loop for too long. The most likely cause would be an accidental infinite loop. It could also be a CPU-heavy operation that's blocking the event loop, like nested loops with very large arrays.

If you use **Prisma 7.x**, query compilation and caching run on the main thread and can block the event loop during heavy or repeated database work. In tasks that do a lot of Prisma calls (e.g. in loops or many sequential queries), add `await heartbeats.yield()` periodically so the event loop can run and send heartbeats.

We recommend reading the [Don't Block the Event Loop](https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop) guide from Node.js for common patterns that can cause this.

If you are doing a continuous CPU-heavy task, then we recommend you try using our `heartbeats.yield` function to automatically yield to the event loop periodically:

```ts theme={"theme":"css-variables"}
import { heartbeats } from "@trigger.dev/sdk";

// code inside your task
for (const row of bigDataset) {
  await heartbeats.yield(); // safe to call every iteration, we will only actually yield when we need to
  process(row); // this is a synchronous operation
}
```

<Note>
  You could also offload the CPU-heavy work to a Node.js worker thread, but this is more complex to setup currently. We are planning on adding support for this in the future.
</Note>

If the above doesn't work, then we recommend you try increasing the machine size of your task. See our [machines guide](/docs/machines) for more information.

### Uncaught exceptions

If you see a `TASK_RUN_UNCAUGHT_EXCEPTION` error, an exception escaped your task's `run()` function without being thrown through your `await` chain — the runtime caught it via Node's `process.on("uncaughtException")` handler. The dashboard surfaces this as a regular task failure (status `Failed`) and the run will retry according to your task's retry policy, but the exception still indicates a bug worth fixing.

The most common cause is a Node `EventEmitter` emitting an `"error"` event with no listener attached. When this happens, Node escalates the event into an `uncaughtException`. Long-lived clients like `node-redis`, `pg`, `kafkajs`, and `mongodb` all surface socket-level errors this way.

For example, a `node-redis` client with no error listener will fail your run with an `Error: read ECONNRESET` (or similar TCP error) the next time the socket is reset:

```ts theme={"theme":"css-variables"}
import { task } from "@trigger.dev/sdk";
import { createClient } from "redis";

export const myTask = task({
  id: "my-task",
  run: async () => {
    const client = createClient({ url: process.env.REDIS_URL });

    // BAD: no .on("error", ...) listener — a socket reset will crash the run
    // with an uncaught exception, even if the next .get() would have worked.
    await client.connect();
    return await client.get("foo");
  },
});
```

Fix it by attaching an `error` listener so the event has somewhere to go:

```ts theme={"theme":"css-variables"}
const client = createClient({ url: process.env.REDIS_URL });

// GOOD: the listener catches socket-level errors. The awaited command
// (e.g. .get) will still reject if the connection is broken, and that
// rejection propagates through your run() and fails the attempt cleanly.
client.on("error", (err) => {
  logger.warn("Redis client error", { err });
});

await client.connect();
return await client.get("foo");
```

The same fix applies to any library that emits `"error"` events. As a rule, attach an `.on("error", ...)` listener to every long-lived client you create inside a task.

<Note>
  Unhandled promise rejections (e.g. `Promise.reject(...)` with no `.catch`) take the same path — Node routes them through `uncaughtException` by default, and the runtime treats them as `TASK_RUN_UNCAUGHT_EXCEPTION` for the same reasons. Make sure every promise either gets `await`ed or has a `.catch(...)` handler.
</Note>

### Realtime stream error (`sendBatchNonBlocking` / `S2AppendSession`)

Errors mentioning `sendBatchNonBlocking`, `@s2-dev/streamstore`, or `S2AppendSession` (often with `code: undefined`) can occur when you close a stream and then await `waitUntilComplete()`, or when a stream runs for a long time (e.g. 20+ minutes). Wrap `waitUntilComplete()` in try/catch so Transport/closed-stream errors don't fail your task:

```ts theme={"theme":"css-variables"}
import { streams } from "@trigger.dev/sdk";

const { waitUntilComplete } = streams.pipe("my-stream", dataStream); // or streams.writer(...)
try {
  await waitUntilComplete();
} catch (err) {
  // Transport/closed-stream; log if needed or ignore
}
```

Alternatively, await `waitUntilComplete()` before closing the stream. See [Realtime Streams](/docs/tasks/streams) for more.

## Framework specific issues

### NestJS swallows all errors/exceptions

If you're using NestJS and you add code like this into your tasks you will prevent any errors from being surfaced:

```ts theme={"theme":"css-variables"}
export const simplestTask = task({
  id: "nestjs-example",
  run: async (payload) => {
    //by doing this you're swallowing any errors
    const app = await NestFactory.createApplicationContext(AppModule);
    await app.init();

    //etc...
  },
});
```

NestJS has a global exception filter that catches all errors and swallows them, so we can't receive them. Our current recommendation is to not use NestJS inside your tasks. If you're a NestJS user you can still use Trigger.dev but just don't use NestJS inside your tasks like this.

### React is not defined

If you see this error:

```
Worker failed to start ReferenceError: React is not defined
```

Either add this to your file:

```ts theme={"theme":"css-variables"}
import React from "react";
```

Or change the tsconfig jsx setting:

```json theme={"theme":"css-variables"}
{
  "compilerOptions": {
    //...
    "jsx": "react-jsx"
  }
}
```

### Next.js build failing due to missing API key in GitHub CI

This issue occurs during the Next.js app build process on GitHub CI where the Trigger.dev SDK is expecting the TRIGGER\_SECRET\_KEY environment variable to be set at build time. Next.js attempts to compile routes and creates static pages, which can cause issues with SDKs that require runtime environment variables. The solution is to mark the relevant pages as dynamic to prevent Next.js from trying to make them static. You can do this by adding the following line to the route file:

```ts theme={"theme":"css-variables"}
export const dynamic = "force-dynamic";
```

### Correctly passing event handlers to React components

An issue can sometimes arise when you try to pass a function directly to the `onClick` prop. This is because the function may require specific arguments or context that are not available when the event occurs. By wrapping the function call in an arrow function, you ensure that the handler is called with the correct context and any necessary arguments. For example:

This works:

```tsx theme={"theme":"css-variables"}
<Button onClick={() => myTask()}>Trigger my task</Button>
```

Whereas this does not work:

```tsx theme={"theme":"css-variables"}
<Button onClick={myTask}>Trigger my task</Button>
```


# Alerts
Source: https://trigger.dev/docs/troubleshooting-alerts

Get alerted when runs or deployments fail, or when deployments succeed.

We support receiving alerts for the following events:

* Run fails
* Deployment fails
* Deployment succeeds

## How to setup alerts

<Steps>
  <Step title="Create a new alert">
    Click on "Alerts" in the left hand side menu, then click on "New alert" to open the new alert modal.

    <img alt="Email alerts" />
  </Step>

  <Step title="Choose your alert method">
    Choose to be notified by email, Slack notification or webhook whenever:

    * a run fails
    * a deployment fails
    * a deployment succeeds

      <img alt="Email alerts" />
  </Step>

  <Step title="Delete or disable alerts">
    Click on the triple dot menu on the right side of the table row and select "Disable" or "Delete".

    <img alt="Disable and delete alerts" />
  </Step>
</Steps>

## Alert webhooks

For the alert webhooks you can use the SDK to parse them. Here is an example of how to parse the webhook payload in Remix:

```ts theme={"theme":"css-variables"}
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { webhooks, WebhookError } from "@trigger.dev/sdk";

export async function action({ request }: ActionFunctionArgs) {
  // Make sure this is a POST request
  if (request.method !== "POST") {
    return json({ error: "Method not allowed" }, { status: 405 });
  }

  try {
    // Construct and verify the webhook event
    // This secret can be found on your Alerts page when you create a webhook alert
    const event = await webhooks.constructEvent(request, process.env.ALERT_WEBHOOK_SECRET!);

    // Process the event based on its type
    switch (event.type) {
      case "alert.run.failed": {
        console.log("[Webhook Internal Test] Run failed alert webhook received", { event });
        break;
      }
      case "alert.deployment.success": {
        console.log("[Webhook Internal Test] Deployment success alert webhook received", { event });
        break;
      }
      case "alert.deployment.failed": {
        console.log("[Webhook Internal Test] Deployment failed alert webhook received", { event });
        break;
      }
      default: {
        console.log("[Webhook Internal Test] Unhandled webhook type", { event });
      }
    }

    // Return a success response
    return json({ received: true }, { status: 200 });
  } catch (err) {
    // Handle webhook errors
    if (err instanceof WebhookError) {
      console.error("Webhook error:", { message: err.message });
      return json({ error: err.message }, { status: 400 });
    }

    if (err instanceof Error) {
      console.error("Error processing webhook:", { message: err.message });
      return json({ error: err.message }, { status: 400 });
    }

    // Handle other errors
    console.error("Error processing webhook:", { err });
    return json({ error: "Internal server error" }, { status: 500 });
  }
}
```

### Common properties

When you create a webhook alert, you'll receive different payloads depending on the type of alert. All webhooks share some common properties:

<ParamField type="string">
  A unique identifier for this webhook event
</ParamField>

<ParamField type="datetime">
  When this webhook event was created
</ParamField>

<ParamField type="string">
  The version of the webhook payload format
</ParamField>

<ParamField type="string">
  The type of alert webhook. One of: `alert.run.failed`, `alert.deployment.success`, or `alert.deployment.failed`
</ParamField>

### Run Failed Alert

This webhook is sent when a run fails. The payload is available on the `object` property:

<ParamField type="string">
  Unique identifier for the task
</ParamField>

<ParamField type="string">
  File path where the task is defined
</ParamField>

<ParamField type="string">
  Name of the exported task function
</ParamField>

<ParamField type="string">
  Version of the task
</ParamField>

<ParamField type="string">
  Version of the SDK used
</ParamField>

<ParamField type="string">
  Version of the CLI used
</ParamField>

<ParamField type="string">
  Unique identifier for the run
</ParamField>

<ParamField type="number">
  Run number
</ParamField>

<ParamField type="string">
  Current status of the run
</ParamField>

<ParamField type="datetime">
  When the run was created
</ParamField>

<ParamField type="datetime">
  When the run started executing
</ParamField>

<ParamField type="datetime">
  When the run finished executing
</ParamField>

<ParamField type="boolean">
  Whether this is a test run
</ParamField>

<ParamField type="string">
  Idempotency key for the run
</ParamField>

<ParamField type="string[]">
  Associated tags
</ParamField>

<ParamField type="object">
  Error information
</ParamField>

<ParamField type="boolean">
  Whether the run was an out-of-memory error
</ParamField>

<ParamField type="string">
  Machine preset used for the run
</ParamField>

<ParamField type="string">
  URL to view the run in the dashboard
</ParamField>

<ParamField type="string">
  Environment ID
</ParamField>

<ParamField type="string">
  Environment type (STAGING or PRODUCTION)
</ParamField>

<ParamField type="string">
  Environment slug
</ParamField>

<ParamField type="string">
  Organization ID
</ParamField>

<ParamField type="string">
  Organization slug
</ParamField>

<ParamField type="string">
  Organization name
</ParamField>

<ParamField type="string">
  Project ID
</ParamField>

<ParamField type="string">
  Project reference
</ParamField>

<ParamField type="string">
  Project slug
</ParamField>

<ParamField type="string">
  Project name
</ParamField>

### Deployment Success Alert

This webhook is sent when a deployment succeeds. The payload is available on the `object` property:

<ParamField type="string">
  Deployment ID
</ParamField>

<ParamField type="string">
  Deployment status
</ParamField>

<ParamField type="string">
  Deployment version
</ParamField>

<ParamField type="string">
  Short code identifier
</ParamField>

<ParamField type="datetime">
  When the deployment completed
</ParamField>

<ParamField type="array">
  Array of deployed tasks with properties: id, filePath, exportName, and triggerSource
</ParamField>

<ParamField type="string">
  Environment ID
</ParamField>

<ParamField type="string">
  Environment type (STAGING or PRODUCTION)
</ParamField>

<ParamField type="string">
  Environment slug
</ParamField>

<ParamField type="string">
  Organization ID
</ParamField>

<ParamField type="string">
  Organization slug
</ParamField>

<ParamField type="string">
  Organization name
</ParamField>

<ParamField type="string">
  Project ID
</ParamField>

<ParamField type="string">
  Project reference
</ParamField>

<ParamField type="string">
  Project slug
</ParamField>

<ParamField type="string">
  Project name
</ParamField>

### Deployment Failed Alert

This webhook is sent when a deployment fails. The payload is available on the `object` property:

<ParamField type="string">
  Deployment ID
</ParamField>

<ParamField type="string">
  Deployment status
</ParamField>

<ParamField type="string">
  Deployment version
</ParamField>

<ParamField type="string">
  Short code identifier
</ParamField>

<ParamField type="datetime">
  When the deployment failed
</ParamField>

<ParamField type="string">
  Error name
</ParamField>

<ParamField type="string">
  Error message
</ParamField>

<ParamField type="string">
  Error stack trace (optional)
</ParamField>

<ParamField type="string">
  Standard error output (optional)
</ParamField>

<ParamField type="string">
  Environment ID
</ParamField>

<ParamField type="string">
  Environment type (STAGING or PRODUCTION)
</ParamField>

<ParamField type="string">
  Environment slug
</ParamField>

<ParamField type="string">
  Organization ID
</ParamField>

<ParamField type="string">
  Organization slug
</ParamField>

<ParamField type="string">
  Organization name
</ParamField>

<ParamField type="string">
  Project ID
</ParamField>

<ParamField type="string">
  Project reference
</ParamField>

<ParamField type="string">
  Project slug
</ParamField>

<ParamField type="string">
  Project name
</ParamField>


# Debugging in VS Code
Source: https://trigger.dev/docs/troubleshooting-debugging-in-vscode

Attach the VS Code debugger to your Trigger.dev tasks to set breakpoints and step through your code.

Debugging your task code in `dev` is supported via VS Code, without having to pass in any additional flags. Create a launch configuration in `.vscode/launch.json`:

```json launch.json theme={"theme":"css-variables"}
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Trigger.dev: Dev",
      "type": "node",
      "request": "launch",
      "cwd": "${workspaceFolder}",
      "runtimeExecutable": "npx",
      "runtimeArgs": ["trigger.dev@latest", "dev"],
      "skipFiles": ["<node_internals>/**"],
      "sourceMaps": true
    }
  ]
}
```

Then you can start debugging your tasks code by selecting the `Trigger.dev: Dev` configuration in the debug panel, and set breakpoints in your tasks code.


# GitHub Issues
Source: https://trigger.dev/docs/troubleshooting-github-issues



Please [join our community on Discord](https://trigger.dev/discord) to report bugs, request features, or get help from the team and other developers.


# Uptime Status
Source: https://trigger.dev/docs/troubleshooting-uptime-status

Subscribe to email notifications for Trigger.dev platform incidents and status updates.

Get email notifications when Trigger.dev creates, updates or resolves a platform incident.
[Subscribe](https://status.trigger.dev/)


# How to upgrade the Trigger.dev packages
Source: https://trigger.dev/docs/upgrading-packages

When we release fixes and new features we recommend you upgrade your Trigger.dev packages.

## Update command

Run this command in your project:

```sh theme={"theme":"css-variables"}
npx trigger.dev@latest update
```

This will update all of the Trigger.dev packages in your project to the latest version.

## Running the CLI locally

When you run the CLI locally use the latest version for the `dev` and `deploy` commands:

```sh theme={"theme":"css-variables"}
npx trigger.dev@latest dev
```

```sh theme={"theme":"css-variables"}
npx trigger.dev@latest deploy
```

These commands will also give you the option to upgrade if you are behind on versions.

## Deploying with GitHub Actions

You can deploy using [GitHub Actions](/docs/github-actions). We recommend that you lock your version in the workflow file so make sure to upgrade.

<Warning>
  The deploy step will fail if version mismatches are detected. It's important that you update the
  version using the steps below.
</Warning>

<Steps>
  <Step title="Find your workflow file">
    In your `.githubs/workflows` folder you can find your workflow yml files. You may have a prod
    and staging one.
  </Step>

  <Step title="Update the version for the run command">
    In the steps you'll see a `run` command. It will run the trigger.dev deploy CLI command. Make
    sure to update this version to the latest version (e.g. `npx trigger.dev@3.0.0 deploy`).
  </Step>
</Steps>

## package.json dev dependency

Instead of using `npx`, `pnpm dlx` or `yarn dlx` you can add the Trigger.dev CLI as a dev dependency to your package.json file.

For example:

```json theme={"theme":"css-variables"}
{
  "devDependencies": {
    "trigger.dev": "3.0.0"
  }
}
```

If you've done this make sure to update the version to match the `@trigger.dev/sdk` package.

Once you have added the `trigger.dev` package to your `devDependencies`, you can use `npm exec trigger.dev`, `pnpm exec trigger.dev`, or `yarn exec trigger.dev` to run the CLI.

But we recommend adding your dev and deploy commands to the `scripts` section of your `package.json` file:

```json theme={"theme":"css-variables"}
{
  "scripts": {
    "dev:trigger": "trigger dev",
    "deploy:trigger": "trigger deploy"
  }
}
```

Then you can run `npm run dev:trigger` and `npm run deploy:trigger` to run the CLI.


# Vercel integration
Source: https://trigger.dev/docs/vercel-integration

Automatically deploy your tasks whenever you deploy to Vercel.

## How it works

The Vercel integration connects your Vercel project to your Trigger.dev project so that every Vercel deployment automatically triggers a Trigger.dev deployment. It also syncs environment variables from Vercel into Trigger.dev and supports atomic deployments to keep your app and tasks in sync.

This eliminates the need to manually run the `trigger.dev deploy` command or maintain custom CI/CD workflows for Vercel-based projects.

<Note>
  The Vercel integration requires the [GitHub integration](/docs/github-integration) to be connected as
  well, since Trigger.dev builds your tasks from your GitHub repository.
</Note>

## Installation

You can connect Vercel from two entry points:

### From the Trigger.dev dashboard

<Steps>
  <Step title="Connect Vercel">
    Go to your project's **Settings** page and click **Connect Vercel**. This will redirect you to
    Vercel to authorize the Trigger.dev app.
  </Step>

  <Step title="Select a Vercel project">
    Choose which Vercel project to connect to your Trigger.dev project.
  </Step>

  <Step title="Map environments">
    If your Vercel project has custom environments, choose which one maps to your Trigger.dev staging
    environment.
  </Step>

  <Step title="Sync environment variables">
    Review the environment variables that will be pulled from Vercel into Trigger.dev. You can
    deselect any variables you don't want to sync.
  </Step>

  <Step title="Configure build options">
    Optionally adjust [build options](#build-options) for atomic deployments, env var pulling, and new
    env var discovery.
  </Step>

  <Step title="Connect GitHub">
    If your GitHub repository isn't already connected, you'll be prompted to connect it.
  </Step>
</Steps>

### From the Vercel Marketplace

<Steps>
  <Step title="Install the integration">
    Install the [Trigger.dev integration from the Vercel
    Marketplace](https://vercel.com/marketplace/trigger). This will redirect you to Trigger.dev to
    complete setup.
  </Step>

  <Step title="Select your Trigger.dev organization and project">
    Choose which Trigger.dev organization and project to connect. If you're new to Trigger.dev, you'll
    be guided through creating an organization and project.
  </Step>

  <Step title="Connect GitHub">
    If your GitHub repository isn't already connected, you'll be prompted to connect it.
  </Step>
</Steps>

<Note>
  When installing from the Vercel Marketplace, default Build options are applied automatically. You
  can adjust them later in your project settings.
</Note>

<Warning>
  **Vercel Root Directory:** If your Vercel project uses a **Root Directory** (e.g. you deploy a
  single subfolder such as `app` or `web`), you may see "The specified Root Directory does not
  exist" after connecting the integration. If you see this error, try using the **repository root**
  (leave Root Directory empty) in your Vercel project settings. If your Vercel frontend build
  requires a Root Directory (e.g. in a monorepo), keep that setting in Vercel and instead point
  Trigger.dev to the subfolder by setting the **Trigger config file** path (and other [Build
  options](#build-options)) in your Trigger.dev project configuration. Trigger.dev always builds
  from the repo root.
</Warning>

## Environment variable sync

The integration syncs environment variables in both directions:

**Vercel → Trigger.dev**: Environment variables from your Vercel project are pulled into Trigger.dev. This happens during the initial setup and optionally before each build. Variables are synced per-environment (production, staging, preview).

**Trigger.dev → Vercel**: Trigger.dev syncs API keys (like `TRIGGER_SECRET_KEY`) to your Vercel project so your app can communicate with Trigger.dev.

The following variables are excluded from the Vercel → Trigger.dev sync:

* `TRIGGER_SECRET_KEY`, `TRIGGER_VERSION`, `TRIGGER_PREVIEW_BRANCH` (managed by Trigger.dev)
* Sensitive/secret-type variables (Vercel API limitation)

You can control sync behavior per-variable from your project's Vercel settings. Deselecting a variable prevents its value from being updated during future syncs.

<Note>
  Environment variables are pulled from Vercel before each build. To sync updated values into
  Trigger.dev, trigger a new Vercel deployment — either by pushing a commit to your connected branch
  or by redeploying from the Vercel dashboard.
</Note>

<Warning>
  If you are experiencing incorrectly populated environment variables, check that you are not using
  the `syncVercelEnvVars` build extension in your `trigger.config.ts`. This extension is deprecated
  and conflicts with the Vercel integration's built-in env var syncing. Remove it if present.
</Warning>

### Supabase and Neon database branching

If you use [Supabase Branching](https://supabase.com/docs/guides/deployment/branching) or [Neon Database Branching](https://neon.tech/docs/guides/branching-intro) for preview environments, disable syncing for database env vars on the Environment Variables page and use the [syncSupabaseEnvVars](/docs/config/extensions/syncEnvVars#syncsupabaseenvvars) or [syncNeonEnvVars](/docs/config/extensions/syncEnvVars#syncneonenvvars) build extensions instead. These extensions automatically resolve the correct branch-specific credentials at build time.

## Atomic deployments

Atomic deployments ensure your Vercel app and Trigger.dev tasks are deployed in sync. When enabled, Trigger.dev gates your Vercel deployment until the task build completes, then triggers a Vercel redeployment with the correct `TRIGGER_VERSION` set. This guarantees your app always uses the matching version of your tasks.

```mermaid theme={"theme":"css-variables"}
sequenceDiagram
  participant Dev as Developer
  participant GH as GitHub
  participant V as Vercel
  participant TD as Trigger.dev

  Dev->>GH: Push code
  GH->>V: Webhook: new commit
  V->>V: Start deployment
  V->>TD: Deployment created
  Dev->>GH: Create pull request
  GH->>TD: Pull request created
  TD->>TD: Start task build

  V->>TD: Deployment check
  TD-->>V: Check pending (gate deployment)
  TD->>TD: Build completes
  TD->>V: Set TRIGGER_VERSION env var
  TD->>V: Trigger redeployment
  V->>V: Redeploy with correct TRIGGER_VERSION
  V->>TD: Deployment check (redeployment)
  TD-->>V: Check passed
  V->>V: Promote deployment
  V->>TD: Deployment promoted
  TD->>TD: Promote build
```

Atomic deployments are enabled for the production environment by default.

<Note>
  When atomic deployments are enabled, the integration automatically disables `Auto-assign Custom
      Production Domains` on your Vercel project. This is required so that Vercel doesn't promote a
  deployment before the Trigger.dev build is ready.
</Note>

Previously, setting up atomic deployments with Vercel required custom GitHub Actions workflows. The Vercel integration automates this entirely. For more details on how atomic deployments work, see [Atomic deploys](/docs/deployment/atomic-deployment).

## Environment mapping

The integration maps Vercel environments to Trigger.dev environments:

| Vercel environment | Trigger.dev environment        |
| ------------------ | ------------------------------ |
| Production         | Production                     |
| Custom environment | Staging (you choose which one) |
| Preview            | Preview                        |
| Development        | Development                    |

If your Vercel project has a custom environment, you can select which one maps to your Trigger.dev staging environment during setup or in your project settings.

<Note>
  Preview deployments require the preview environment to be enabled on your project. Learn more
  about [preview branches](/docs/deployment/preview-branches).
</Note>

## Build options

You can configure the following settings per-environment from your project's Vercel settings:

* **Atomic deployments**: Controls whether Trigger.dev and Vercel deployments are synchronized. Enabled for production by default.
* **Pull env vars before build**: When enabled, Trigger.dev pulls the latest environment variables from Vercel before each build. Enabled for production, staging, and preview by default.
* **Discover new env vars**: When enabled, new environment variables found in Vercel that don't yet exist in Trigger.dev are created automatically during builds. Only available for environments that also have env var pulling enabled. Enabled for production, staging, and preview by default.

To change build options that would normally go in `trigger.config.ts` (such as [extensions](/docs/config/config-file#extensions) or other build configuration), use **Build options** on your project's configuration page in the Trigger.dev dashboard.

## Disconnecting

You can disconnect the Vercel integration from either side:

* **From Trigger.dev**: Go to your project **Settings** and disconnect Vercel.
* **From Vercel**: Uninstall the Trigger.dev integration from your Vercel dashboard. This is automatically detected and the connection is removed on the Trigger.dev side.

Disconnecting stops automatic deployments, environment variable syncing, and deployment checks. Your existing deployments and environment variables are not affected.

## Related

* [GitHub integration](/docs/github-integration)
* [Atomic deploys](/docs/deployment/atomic-deployment)
* [Environment variables](/docs/deploy-environment-variables)
* [Preview branches](/docs/deployment/preview-branches)


# Versioning
Source: https://trigger.dev/docs/versioning

We use atomic versioning to ensure that started tasks are not affected by changes to the task code.

A version is a bundle of tasks at a certain point in time.

## Version identifiers

Version identifiers look like this:

* `20240313.1` - March 13th, 2024, version 1
* `20240313.2` - March 13th, 2024, version 2
* `20240314.1` - March 14th, 2024, version 1

You can see there are two parts to the version identifier:

* The date (in reverse format)
* The version number

Versions numbers are incremented each time a new version is created for that date and environment. So it's possible to have `20240313.1` in both the `dev` and `prod` environments.

## Version locking

When a task run starts it is locked to the latest version of the code (for that environment). Once locked it won't change versions, even if you deploy new versions. This is to ensure that a task run is not affected by changes to the code.

Delayed runs are locked to the version that's active when they begin executing, not when they're enqueued.

### Child tasks and version locking

Trigger and wait functions version lock child task runs to the parent task run version. This ensures the results from child runs match what the parent task is expecting. If you don't wait then version locking doesn't apply.

| Trigger function        | Parent task version | Child task version | isLocked |
| ----------------------- | ------------------- | ------------------ | -------- |
| `trigger()`             | `20240313.2`        | Latest             | No       |
| `batchTrigger()`        | `20240313.2`        | Latest             | No       |
| `triggerAndWait()`      | `20240313.2`        | `20240313.2`       | Yes      |
| `batchTriggerAndWait()` | `20240313.2`        | `20240313.2`       | Yes      |

## Local development

When running the local server (using `npx trigger.dev dev`), every relevant code change automatically creates a new version of all tasks.

So a task run will continue running on the version it was locked to. We do this by spawning a new process for each task run. This ensures that the task run is not affected by changes to the code.

## Deployment

Every deployment creates a new version of all tasks for that environment.

## Retries and reattempts

When a task has an uncaught error it will [retry](/docs/errors-retrying), assuming you have not set `maxAttempts` to 0. Retries are locked to the original version of the run.

## Replays

A "replay" is a new run of a task that uses the same inputs but will use the latest version of the code. This is useful when you fix a bug and want to re-run a task with the same inputs. See [replaying](/docs/replaying) for more information.


# Video walkthrough
Source: https://trigger.dev/docs/video-walkthrough

Go from zero to a working task in your Next.js app in 10 minutes.

<iframe title="Trigger.dev walkthrough" />

### In this video we cover the following topics:

* [0:00](https://youtu.be/YH_4c0K7fGM?si=J8svVzotZtyTXDap\&t=0) – [Install Trigger.dev](/docs/quick-start) in an existing Next.js project
* [1:44](https://youtu.be/YH_4c0K7fGM?si=J8svVzotZtyTXDap\&t=104) – [Run and test](/docs/run-tests) the "Hello, world!" example project
* [2:09](https://youtu.be/YH_4c0K7fGM?si=FMTP8ep_cDBCU0_x\&t=128) – Create and run an AI image generation task that uses [Fal.ai](https://fal.ai) – ([View the code](/docs/guides/examples/fal-ai-image-to-cartoon))
* [6:25](https://youtu.be/YH_4c0K7fGM?si=pPc8iLI2Y9FGD3yo\&t=385) – Create and run a [Realtime](/docs/realtime/overview) example using [React hooks](/docs/realtime/react-hooks) – ([View the code](/docs/guides/examples/fal-ai-realtime))
* [11:10](https://youtu.be/YH_4c0K7fGM?si=Mjd0EvvNsNlVouvY\&t=670) – [Deploy your task](/docs/cli-deploy-commands) to the Trigger.dev Cloud


# Wait: Overview
Source: https://trigger.dev/docs/wait

During your run you can wait for a period of time or for something to happen.

Waiting allows you to write complex tasks as a set of async code, without having to schedule another task or poll for changes.

In the Trigger.dev Cloud we automatically pause execution of tasks when they are waiting for
longer than a few seconds.

When triggering and waiting for subtasks, the parent is checkpointed and while waiting does not count towards compute usage. When waiting for a time period (`wait.for` or `wait.until`), a wait longer than 5 seconds does not count towards compute usage.

Free compute isn't the same as freed concurrency: the concurrency slot is only released once we've snapshotted the machine and shut it down. For `wait.for` and `wait.until` that happens 60 seconds into the wait, so anything shorter stays `EXECUTING` and holds its slot for the whole wait.

| Function                                                              | What it does                                       |
| :-------------------------------------------------------------------- | :------------------------------------------------- |
| [wait.for()](/docs/wait-for)                                               | Waits for a specific period of time, e.g. 1 day.   |
| [wait.until()](/docs/wait-until)                                           | Waits until the provided `Date`.                   |
| [wait.forToken()](/docs/wait-for-token)                                    | Pauses runs until a token is completed.            |
| [inputStream.wait()](/docs/tasks/streams#wait--suspend-until-data-arrives) | Pauses runs until data arrives on an input stream. |


# Wait for
Source: https://trigger.dev/docs/wait-for

Wait for a period of time, then continue execution.

Inside your tasks you can wait for a period of time before you want execution to continue.

```ts /trigger/long-task.ts theme={"theme":"css-variables"}
export const veryLongTask = task({
  id: "very-long-task",
  run: async (payload) => {
    await wait.for({ seconds: 5 });

    await wait.for({ minutes: 10 });

    await wait.for({ hours: 1 });

    await wait.for({ days: 1 });

    await wait.for({ weeks: 1 });

    await wait.for({ months: 1 });

    await wait.for({ years: 1 });
  },
});
```

This allows you to write linear code without having to worry about the complexity of scheduling or managing cron jobs.

In the Trigger.dev Cloud we automatically pause execution of tasks when they are waiting for
longer than a few seconds.

When triggering and waiting for subtasks, the parent is checkpointed and while waiting does not count towards compute usage. When waiting for a time period (`wait.for` or `wait.until`), a wait longer than 5 seconds does not count towards compute usage.

Free compute isn't the same as freed concurrency: the concurrency slot is only released once we've snapshotted the machine and shut it down. For `wait.for` and `wait.until` that happens 60 seconds into the wait, so anything shorter stays `EXECUTING` and holds its slot for the whole wait.

## Wait idempotency

You can pass an idempotency key to any wait function, allowing you to skip waits if the same idempotency key is used again. This can be useful if you want to skip waits when retrying a task, for example:

```ts theme={"theme":"css-variables"}
// Specify the idempotency key and TTL when waiting for a duration:
await wait.for({ seconds: 10, idempotencyKey: "my-idempotency-key", idempotencyKeyTTL: "1h" });
```


# Wait for token
Source: https://trigger.dev/docs/wait-for-token

Wait until a token is completed using waitpoint tokens.

Waitpoint tokens pause task runs until you complete the token. They're commonly used for approval workflows and other scenarios where you need to wait for external confirmation, such as human-in-the-loop processes.

You can complete a token using the SDK or by making a POST request to the token's URL.

<Note>
  If you're waiting for data from an [input stream](/docs/tasks/streams#input-streams), use
  [`inputStream.wait()`](/docs/tasks/streams#wait--suspend-until-data-arrives) instead — it uses
  waitpoint tokens internally but provides a simpler API with full type safety from your stream
  definition.
</Note>

## Usage

To get started using wait tokens, you need to first create a token using the `wait.createToken` function:

```ts theme={"theme":"css-variables"}
import { wait } from "@trigger.dev/sdk";

// This can be called anywhere in your codebase, either in a task or in your backend code
const token = await wait.createToken({
  timeout: "10m", // you can optionally specify a timeout for the token
});
```

Once you have a token, you can wait for it to be completed using the `wait.forToken` function:

```ts theme={"theme":"css-variables"}
import { wait } from "@trigger.dev/sdk";

type ApprovalToken = {
  status: "approved" | "rejected";
};

// This must be called inside a task run function
const result = await wait.forToken<ApprovalToken>(tokenId);

if (result.ok) {
  console.log("Token completed", result.output.status); // "approved" or "rejected"
} else {
  console.log("Token timed out", result.error);
}
```

To complete a token, you can use the `wait.completeToken` function:

```ts theme={"theme":"css-variables"}
import { wait } from "@trigger.dev/sdk";
// This can be called anywhere in your codebase, or from an external service,
// passing in the token ID and the output of the token
await wait.completeToken<ApprovalToken>(tokenId, {
  status: "approved",
});
```

## Completing from the browser

The `publicAccessToken` returned by `wait.createToken()` is scoped to that specific waitpoint and intended for client-side completion. The completion endpoint has CORS enabled, so you can call it directly from client-side code without proxying through your backend.

<Steps>
  <Step title="Create the token in your backend">
    ```typescript theme={"theme":"css-variables"}
    import { wait } from "@trigger.dev/sdk";

    const token = await wait.createToken({ timeout: "10m" });
    // Pass token.id and token.publicAccessToken to your frontend
    ```
  </Step>

  <Step title="Complete the token from the browser">
    ```typescript theme={"theme":"css-variables"}
    // tokenId and publicAccessToken passed from your backend
    const tokenId = token.id;
    const publicAccessToken = token.publicAccessToken;

    const response = await fetch(
      `https://api.trigger.dev/api/v1/waitpoints/tokens/${tokenId}/complete`,
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${publicAccessToken}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ data: { status: "approved" } }),
      }
    );

    if (!response.ok) {
      throw new Error(`Failed to complete token: ${response.statusText}`);
    }
    ```
  </Step>
</Steps>

## Completing via webhook callback

<Warning>
  The `token.url` webhook callback URL is designed for server-to-server use and does **not** have
  CORS headers. Don't call it from the browser, use the [Completing from the
  browser](#completing-from-the-browser) pattern instead.
</Warning>

Or you can make an HTTP POST request to the `url` it returns. This is an HTTP callback:

```ts theme={"theme":"css-variables"}
import { wait } from "@trigger.dev/sdk";

const token = await wait.createToken({
  timeout: "10m",
});

const call = await replicate.predictions.create({
  version: "27b93a2413e7f36cd83da926f3656280b2931564ff050bf9575f1fdf9bcd7478",
  input: {
    prompt: "A painting of a cat by Andy Warhol",
  },
  // pass the provided URL to Replicate's webhook, so they can "callback"
  webhook: token.url,
  webhook_events_filter: ["completed"],
});

const prediction = await wait.forToken<Prediction>(token).unwrap();
// unwrap() throws a timeout error or returns the result   👆
```

## wait.createToken

Create a waitpoint token.

### options

The `createToken` function accepts an object with the following properties:

<ParamField type="string">
  The maximum amount of time to wait for the token to be completed. Defaults to "10m".
</ParamField>

<ParamField type="string">
  An idempotency key for the token. If provided, the token will be completed with the same payload
  if the same idempotency key is used again.
</ParamField>

<ParamField type="string">
  The time to live for the idempotency key. Defaults to "1h".
</ParamField>

<ParamField type="string[]">
  Tags to attach to the token. Tags can be used to filter waitpoints in the dashboard.
</ParamField>

### returns

The `createToken` function returns a token object with the following properties:

<ParamField type="string">
  The ID of the token. Starts with `waitpoint_`.
</ParamField>

<ParamField type="string">
  The URL of the token. This is the URL you can make a POST request to in order to complete the token.

  The JSON body of the POST request will be used as the output of the token. If there's no body the output will be an empty object `{}`.
</ParamField>

<ParamField type="boolean">
  Whether the token is cached. Will return true if the token was created with an idempotency key and
  the same idempotency key was used again.
</ParamField>

<ParamField type="string">
  A Public Access Token that can be used to complete the token from a client-side application (or
  another backend). See our [Realtime docs](/docs/realtime/auth) for more details.
</ParamField>

### Example

```ts theme={"theme":"css-variables"}
import { wait } from "@trigger.dev/sdk";

const token = await wait.createToken({
  timeout: "10m",
  idempotencyKey: "my-idempotency-key",
  tags: ["my-tag"],
});
```

## wait.completeToken

Complete a waitpoint token.

### parameters

<ParamField type="string">
  The ID of the token to complete.
</ParamField>

<ParamField type="any">
  The data to complete the token with.
</ParamField>

### returns

The `completeToken` function returns an object with the following properties:

<ParamField type="boolean">
  Whether the token was completed successfully.
</ParamField>

### Example

```ts theme={"theme":"css-variables"}
import { wait } from "@trigger.dev/sdk";

await wait.completeToken<ApprovalToken>(tokenId, {
  status: "approved",
});
```

### From another language

You can complete a token using a raw HTTP request or from another language.

<CodeGroup>
  ```bash curl theme={"theme":"css-variables"}
  curl -X POST "https://api.trigger.dev/api/v1/waitpoints/tokens/{tokenId}/complete" \
    -H "Authorization: Bearer {token}" \
    -H "Content-Type: application/json" \
    -d '{"data": { "status": "approved"}}'
  ```

  ```python python theme={"theme":"css-variables"}
  import requests

  response = requests.post(
    "https://api.trigger.dev/api/v1/waitpoints/tokens/{tokenId}/complete",
    headers={"Authorization": f"Bearer {token}"},
    json={"data": { "status": "approved"}}
  )
  ```

  ```ruby ruby theme={"theme":"css-variables"}
  require "net/http"

  uri = URI("https://api.trigger.dev/api/v1/waitpoints/tokens/{tokenId}/complete")

  http = Net::HTTP.new(uri.host, uri.port)
  request = Net::HTTP::Post.new(uri)
  request["Authorization"] = "Bearer {token}"
  request["Content-Type"] = "application/json"
  request.body = JSON.generate({ data: { status: "approved" } })

  response = http.request(request)
  ```

  ```go go theme={"theme":"css-variables"}
  package main

  import (
  	"bytes"
  	"encoding/json"
  	"fmt"
  	"net/http"
  )

  func main() {
  	url := "https://api.trigger.dev/api/v1/waitpoints/tokens/{tokenId}/complete"

  	payload := map[string]interface{}{
  		"data": map[string]interface{}{
  			"status": "approved",
  		},
  	}

  	jsonData, err := json.Marshal(payload)
  	if err != nil {
  		fmt.Println("Error marshalling payload:", err)
  		return
  	}

  	req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
  	if err != nil {
  		fmt.Println("Error creating request:", err)
  		return
  	}

  	req.Header.Set("Authorization", "Bearer {token}")
  	req.Header.Set("Content-Type", "application/json")

  	client := &http.Client{}
  	resp, err := client.Do(req)
  	if err != nil {
  		fmt.Println("Error sending request:", err)
  		return
  	}

  	defer resp.Body.Close()

  	fmt.Println("Response status:", resp.Status)
  }
  ```
</CodeGroup>

## wait.forToken

Wait for a token to be completed.

### parameters

<ParamField type="string | { id: string }">
  The token to wait for.
</ParamField>

### returns

The `forToken` function returns a result object with the following properties:

<ParamField type="boolean">
  Whether the token was completed successfully.
</ParamField>

<ParamField type="any">
  If `ok` is `true`, this will be the output of the token.
</ParamField>

<ParamField type="Error">
  If `ok` is `false`, this will be the error that occurred. The only error that can occur is a
  timeout error.
</ParamField>

### unwrap()

We provide a handy `.unwrap()` method that will throw an error if the result is not ok. This means your happy path is a lot cleaner.

```ts theme={"theme":"css-variables"}
const approval = await wait.forToken<ApprovalToken>(tokenId).unwrap();
// unwrap means an error will throw if the waitpoint times out 👆

// This is the actual data you sent to the token now, not a result object
console.log("Approval", approval);
```

### Example

```ts theme={"theme":"css-variables"}
import { wait } from "@trigger.dev/sdk";

const result = await wait.forToken<ApprovalToken>(tokenId);

if (result.ok) {
  console.log("Token completed", result.output.status); // "approved" or "rejected"
} else {
  console.log("Token timed out", result.error);
}
```

## wait.listTokens

List all tokens for an environment.

### parameters

The `listTokens` function accepts an object with the following properties:

<ParamField type="string | string[]">
  Statuses to filter by. Can be one or more of: `WAITING`, `COMPLETED`, `TIMED_OUT`.
</ParamField>

<ParamField type="string">
  The idempotency key to filter by.
</ParamField>

<ParamField type="string | string[]">
  Tags to filter by.
</ParamField>

<ParamField type="string">
  The period to filter by. Can be one of: `1h`, `1d`, `7d`, `30d`.
</ParamField>

<ParamField type="Date | number">
  The start date to filter by.
</ParamField>

<ParamField type="Date | number">
  The end date to filter by.
</ParamField>

### returns

The `listTokens` function returns a list of tokens that can be iterated over using a for-await-of loop.

Each token is an object with the following properties:

<ParamField type="string">
  The ID of the token.
</ParamField>

<ParamField type="string">
  The URL of the token. This is the URL you can make a POST request to in order to complete the token.

  The JSON body of the POST request will be used as the output of the token. If there's no body the output will be an empty object `{}`.
</ParamField>

<ParamField type="string">
  The status of the token.
</ParamField>

<ParamField type="Date">
  The date and time the token was completed.
</ParamField>

<ParamField type="Date">
  The date and time the token will timeout.
</ParamField>

<ParamField type="string">
  The idempotency key of the token.
</ParamField>

<ParamField type="Date">
  The date and time the idempotency key will expire.
</ParamField>

<ParamField type="string[]">
  The tags of the token.
</ParamField>

<ParamField type="Date">
  The date and time the token was created.
</ParamField>

<Note>
  The output of the token is not included in the list. To get the output, you need to retrieve the
  token using the `wait.retrieveToken` function.
</Note>

### Example

```ts theme={"theme":"css-variables"}
import { wait } from "@trigger.dev/sdk";

const tokens = await wait.listTokens({
  status: "COMPLETED",
  tags: ["user:123"],
});

for await (const token of tokens) {
  console.log(token);
}
```

## wait.retrieveToken

Retrieve a token by ID.

### parameters

<ParamField type="string">
  The ID of the token to retrieve.
</ParamField>

### returns

The `retrieveToken` function returns a token object with the following properties:

<ParamField type="string">
  The ID of the token.
</ParamField>

<ParamField type="string">
  The URL of the token. This is the URL you can make a POST request to in order to complete the token.

  The JSON body of the POST request will be used as the output of the token. If there's no body the output will be an empty object `{}`.
</ParamField>

<ParamField type="string">
  The status of the token.
</ParamField>

<ParamField type="Date">
  The date and time the token was completed.
</ParamField>

<ParamField type="Date">
  The date and time the token will timeout.
</ParamField>

<ParamField type="string">
  The idempotency key of the token.
</ParamField>

<ParamField type="Date">
  The date and time the idempotency key will expire.
</ParamField>

<ParamField type="string[]">
  The tags of the token.
</ParamField>

<ParamField type="Date">
  The date and time the token was created.
</ParamField>

<ParamField type="any">
  The output of the token.
</ParamField>

<ParamField type="Error">
  The error that occurred.
</ParamField>

### Example

```ts theme={"theme":"css-variables"}
import { wait } from "@trigger.dev/sdk";

const token = await wait.retrieveToken(tokenId);

console.log(token);
```

## Wait idempotency

You can pass an idempotency key to any wait function, allowing you to skip waits if the same idempotency key is used again. This can be useful if you want to skip waits when retrying a task, for example:

```ts theme={"theme":"css-variables"}
// Specify the idempotency key and TTL when creating a wait token
const token = await wait.createToken({
  idempotencyKey: "my-idempotency-key",
  idempotencyKeyTTL: "1h",
});
```


# Wait until
Source: https://trigger.dev/docs/wait-until

Wait until a date, then continue execution.

This example sends a reminder email to a user at the specified datetime.

```ts /trigger/reminder-email.ts theme={"theme":"css-variables"}
export const sendReminderEmail = task({
  id: "send-reminder-email",
  run: async (payload: { to: string; name: string; date: string }) => {
    //wait until the date
    await wait.until({ date: new Date(payload.date) });

    //todo send email
    const { data, error } = await resend.emails.send({
      from: "hello@trigger.dev",
      to: payload.to,
      subject: "Don't forget…",
      html: `<p>Hello ${payload.name},</p><p>...</p>`,
    });
  },
});
```

This allows you to write linear code without having to worry about the complexity of scheduling or managing cron jobs.

In the Trigger.dev Cloud we automatically pause execution of tasks when they are waiting for
longer than a few seconds.

When triggering and waiting for subtasks, the parent is checkpointed and while waiting does not count towards compute usage. When waiting for a time period (`wait.for` or `wait.until`), a wait longer than 5 seconds does not count towards compute usage.

Free compute isn't the same as freed concurrency: the concurrency slot is only released once we've snapshotted the machine and shut it down. For `wait.for` and `wait.until` that happens 60 seconds into the wait, so anything shorter stays `EXECUTING` and holds its slot for the whole wait.

## `throwIfInThePast`

You can optionally throw an error if the date is already in the past when the function is called:

```ts theme={"theme":"css-variables"}
await wait.until({ date: new Date(date), throwIfInThePast: true });
```

You can of course use try/catch if you want to do something special in this case.

## Wait idempotency

You can pass an idempotency key to any wait function, allowing you to skip waits if the same idempotency key is used again. This can be useful if you want to skip waits when retrying a task, for example:

```ts theme={"theme":"css-variables"}
// Specify the idempotency key and TTL when waiting until a date:
await wait.until({
  date: futureDate,
  idempotencyKey: "my-idempotency-key",
  idempotencyKeyTTL: "1h",
});
```


# Writing tasks: Overview
Source: https://trigger.dev/docs/writing-tasks-introduction

Tasks are the core of Trigger.dev. They are long-running processes that are triggered by events.

Before digging deeper into the details of writing tasks, you should read the [fundamentals of tasks](/docs/tasks/overview) to understand what tasks are and how they work.

## Writing tasks

| Topic                                        | Description                                                                                         |
| :------------------------------------------- | :-------------------------------------------------------------------------------------------------- |
| [Logging](/docs/logging)                          | View and send logs and traces from your tasks.                                                      |
| [Errors & retrying](/docs/errors-retrying)        | How to deal with errors and write reliable tasks.                                                   |
| [Wait](/docs/wait)                                | Wait for periods of time or for external events to occur before continuing.                         |
| [Concurrency & Queues](/docs/queue-concurrency)   | Configure what you want to happen when there is more than one run at a time.                        |
| [Realtime notifications](/docs/realtime/overview) | Send realtime notifications from your task that you can subscribe to from your backend or frontend. |
| [Versioning](/docs/versioning)                    | How versioning works.                                                                               |
| [Machines](/docs/machines)                        | Configure the CPU and RAM of the machine your task runs on                                          |
| [Idempotency](/docs/idempotency)                  | Protect against mutations happening twice.                                                          |
| [Replaying](/docs/replaying)                      | You can replay a single task or many at once with a new version of your code.                       |
| [Max duration](/docs/runs/max-duration)           | Set a maximum duration for your task to run.                                                        |
| [Tags](/docs/tags)                                | Tags allow you to easily filter runs in the dashboard and when using the SDK.                       |
| [Metadata](/docs/runs/metadata)                   | Attach a small amount of data to a run and update it as the run progresses.                         |
| [Usage](/docs/run-usage)                          | Get compute duration and cost from inside a run, or for a specific block of code.                   |
| [Context](/docs/context)                          | Access the context of the task run.                                                                 |
| [Bulk actions](/docs/bulk-actions)                | Run actions on many task runs at once.                                                              |
| [Priority](/docs/runs/priority)                   | Specify a priority when triggering a task.                                                          |
| [Hidden tasks](/docs/hidden-tasks)                | Create tasks that are not exported from your trigger files but can still be executed.               |

## Our library of examples, guides and projects

<CardGroup>
  <Card title="Walkthrough guides" icon="book" href="/guides/introduction">
    Detailed guides for setting up Trigger.dev with popular frameworks and services, including
    Next.js, Remix, Supabase, Stripe and more.
  </Card>

  <Card title="Example tasks" icon="code" href="/guides/introduction#example-tasks">
    Task code you can copy and paste to use in your own projects, including OpenAI, Vercel AI SDK,
    Deepgram, FFmpeg, Puppeteer, Stripe, Supabase and more.
  </Card>

  <Card title="Webhook guides" icon="code" href="/guides/frameworks/webhooks-guides-overview">
    Learn how to trigger tasks from webhooks, including Next.js, Remix, Supabase and Stripe and
    more.
  </Card>

  <Card title="Example projects" icon="GitHub" href="/guides/introduction#example-projects">
    Full-stack projects demonstrating how to use Trigger.dev. Fork them in GitHub as a starting
    point for your own projects.
  </Card>
</CardGroup>