This page documents the protocol that chat clients use to communicate withDocumentation Index
Fetch the complete documentation index at: https://trigger.dev/docs/llms.txt
Use this file to discover all available pages before exploring further.
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.
Most users don’t need this. Use
TriggerChatTransport for browser apps or AgentChat for server-side code. This page is for building your own from scratch.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:
- Create the session — idempotent on your chat ID. Creates the row and triggers the first run in one call. Returns the
publicAccessTokenyou’ll use for everything else. - Subscribe to
.out— receiveUIMessageChunkevents via SSE. - Append to
.in— send subsequent user messages, stops, or actions.
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 and How history is rebuilt.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.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.End-to-end curl recipe
A single-shell walk-through of the whole protocol — copy, fill inBASE_URL / SECRET_KEY / TASK_ID, and run. Drives a two-turn conversation (pong → echo) using only curl and jq.
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:
"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 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 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 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 yourexternalId. The agent uses this as its conversation identity (e.g. as a DB key inhydrateMessages); theexternalIdis 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/appendcalls.
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
| 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). |
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. |
isCached | true if the session existed already (idempotent re-create). HTTP status is 200 in that case, 201 on a fresh create. |
Idempotency
Re-callingPOST /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,runIdunchanged, and a fresh 60-minutepublicAccessToken. No duplicate run is triggered. (Idle/exited runs are different — see Continuations.) - If the session has been closed (
POST /api/v1/sessions/{id}/close): returns HTTP 409. Closed is one-way; reuse a differentexternalIdto 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.metadatamid-conversation. The new fields apply to future runs (continuations); the currently-live run keeps its original config.
Refreshing the token
ThepublicAccessToken returned by POST /api/v1/sessions is valid for 60 minutes. Two ways to keep going past that:
- Take refreshed tokens from the stream. Every
turn-completecontrol record on.outcarries apublic-access-tokenheader with a refreshed JWT (seeturn-completecontrol record). For active conversations this just rolls — replace your stored token whenever the header is present. - Re-call
POST /api/v1/sessions. Idempotent, returnsisCached: trueand 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.
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.Step 2: Subscribe to .out
Subscribe to the agent’s response via SSE on the session’s .out channel:
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 theTimeout-Seconds request header (1–600):
data: [DONE] and closes. Reconnect with Last-Event-ID to continue (see Resuming a stream).
Stream format (S2)
The output stream uses S2 under the hood and follows the standard SSE wire format (WHATWG spec). 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>}. Ignore it. |
(no event:, just data: [DONE]) | Stream is closing — server sends this once before EOF. |
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: 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).
Decoded data payload:
| 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[].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. 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 |
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.
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.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:
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-separatedid: line, batch unwrapping, the inner body string, and ping / [DONE] events:
Chunk types
Data records on the stream carry aUIMessageChunk from the AI SDK. Two Trigger.dev-specific control events ride alongside as header-form control records (see 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). |
start-step | { type: "start-step" } | New prepareStep boundary. |
text-start / text-delta / text-end | { type: ..., id: string, delta?: string } | Streaming text. Concatenate deltas 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. |
turn-complete control record
Signals that the agent’s turn is finished — stop reading and wait for user input.
| 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) | Internal cursor used by the agent to resume .in across worker boots without replaying already-processed user messages. Custom transports should ignore this header — it carries no client-side meaning. |
- Update
publicAccessTokenif one is included on the headers. - Close the stream reader (unless you want to keep it open across turns — see Resuming a stream).
- Wait for the next user message before sending on
.in.
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().
- Treat it as informational — no client action required. The same SSE keeps streaming the new run’s chunks on the same session.
- Optionally surface a “switched to vN.N+1” indicator in your UI.
Resuming a stream
If the SSE connection drops, reconnect with theLast-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):
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.
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-completerecord, 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.
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=0internally — drains any residual records and closes in ~1s instead of long-polling for 60s. - Sets the
X-Session-Settled: trueresponse header so the client can tell the close is terminal rather than a mid-stream drop.
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.
Step 3: Send messages, stops, and actions
All client-to-agent signals are appended to the session’s.in channel:
{sessionId} accepts the same friendly-or-external forms as .out. The publicAccessToken from session-create authorizes both.
The body is a JSON-serialized 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 }; 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 512 KiB. A normal kind: "message" payload is a few KB; if you hit this you’re shipping more than one message per record. |
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). |
ChatInputChunk
kind drives the agent’s dispatch — "message" goes to the turn loop, "stop" fires the abort controller.
ChatTaskWirePayload
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.Sending a message
.out (if you closed the stream after the previous turn’s turn-complete) to receive the response.
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.Sending a stop
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:
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:
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 for the agent-side schema setup.
Regenerating the last response
To regenerate the assistant’s last response, sendtrigger: "regenerate-message" with no message:
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 (with approval-responded tool parts) back as a kind: "message" chunk — singular, not the full chain:
id against the rebuilt accumulator. If a match is found, it replaces the existing message instead of appending.
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.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 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)
WhenhydrateMessages is not registered, the runtime reconstructs history from durable infrastructure on every run boot:
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.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.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.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.
Head-start protocol caveat
Thechat.headStart 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:
- The route handler runs against the customer’s own HTTP endpoint, not
/realtime/v1/sessions/{id}/in/append. The 512 KiB body cap on the realtime route doesn’t apply. headStartMessagesis only honored ontrigger: "handover-prepare". The runtime ignores the field on every other trigger — the one-message-per-record rule still holds for normal turns.
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 normalkind: "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 nextprepareStepboundary. The agent sees it and can adjust its behavior mid-response. - Without
pendingMessagesconfig: the message queues for the next turn.
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.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:
/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.
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.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.Closing the conversation
When the user is done with the conversation, close the session:{} (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}) |
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.
FAQ
See also
TriggerChatTransport— Built-in browser transport (implements this protocol)AgentChat— Built-in server-side client- Persistence and replay — How the snapshot + replay model works end-to-end
- Lifecycle hooks — What the agent does on each event
- Version upgrades — How
chat.requestUpgrade()uses continuations

