This page documents the protocol that chat clients use to communicate withDocumentation Index
Fetch the complete documentation index at: https://trigger-docs-tri-7532-ai-sdk-chat-transport-and-chat-task-s.mintlify.app/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 four parts:
- Create the session — idempotent on your chat ID
- Trigger a run — start an agent run bound to the session
- Subscribe to
.out— receiveUIMessageChunkevents via SSE - Append to
.in— send messages, stops, or actions
Step 1: Create the session
Before triggering a run, create a Session. Use your stable chat ID asexternalId — this makes creation idempotent, so two concurrent clients for the same chat converge on the same session.
id is the session_* friendly ID — persist it alongside your chat state. isCached: true means the server returned an existing session for this externalId (safe to ignore).
POST /api/v1/sessions is documented inline in the wire-protocol section below.
Step 2: Trigger a run
Start an agent run bound to the session. The payload follows theChatTaskWirePayload shape plus a sessionId field:
x-trigger-jwt — a JWT with the scopes the transport needs to operate against the session:
read:runs:{runId}— read the runread:sessions:{sessionId}— subscribe to.outwrite:sessions:{sessionId}— append to.in, close the session
runId + publicAccessToken + sessionId + lastEventId as your client-side chat state.
The built-in SDK clients (
TriggerChatTransport, AgentChat) mint this token with the right scopes automatically. If you’re using the ApiClient from @trigger.dev/core/v3, triggerTask() returns { id, publicAccessToken } with the header already extracted.Preloading (optional)
To preload an agent before the first message, trigger with"trigger": "preload" and an empty messages array:
onPreload, opens the session handle, and waits for the first real message on .in.
Step 3: Subscribe to .out
Subscribe to the agent’s response via SSE on the session’s .out channel:
sessionId — not runId. 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.
Stream format (S2)
The output stream uses S2 under the hood. SSE events arrive as batches — each event hasevent: batch and a data field containing an array of records:
body is a JSON string containing { data, id }. data is the actual UIMessageChunk object (not a stringified payload). seq_num is the resume cursor.
Recommended: use SSEStreamSubscription from @trigger.dev/core/v3 to handle parsing automatically — it takes care of batch decoding, deduplication, and Last-Event-ID tracking:
Chunk types
Each chunk’sdata field is a UIMessageChunk from the AI SDK plus two Trigger.dev-specific control chunks (trigger:turn-complete, trigger:upgrade-required) covered below.
trigger:turn-complete
Signals that the agent’s turn is finished — stop reading and wait for user input.
| Field | Type | Description |
|---|---|---|
type | "trigger:turn-complete" | Always this string |
publicAccessToken | string (optional) | A refreshed JWT with the same session + run scopes. If present, replace your stored token. |
- Update
publicAccessTokenif one is included. - 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.
trigger:upgrade-required
Signals that the agent cannot handle this message on its current version and the client should re-trigger on a new run. Emitted when the agent calls chat.requestUpgrade() before processing the turn.
- Close the stream reader.
- Immediately trigger a new run on the same session — keep
sessionId, refreshrunId+publicAccessToken. Includecontinuation: truein the payload. - Resubscribe to
/realtime/v1/sessions/{sessionId}/out.
Resuming a stream
If the SSE connection drops, reconnect with theLast-Event-ID header set to the last seq_num you received:
SSEStreamSubscription tracks this automatically via its lastEventId option.
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 record of .out; if it’s trigger:turn-complete (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 trigger: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 4: Send messages, stops, and actions
All client-to-agent signals are appended to the session’s.in channel:
ChatInputChunk — a tagged union covering messages, stops, and actions. Send them as raw JSON strings (not wrapped in a data field).
ChatInputChunk
kind drives the agent’s dispatch — "message" goes to the turn loop, "stop" fires the abort controller.
Sending a message
.out (if you closed the stream after trigger:turn-complete) to receive the response.
Sending a stop
streamText aborts, the agent emits trigger:turn-complete, 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:
onAction hook, then trigger a normal run() turn. The action payload is validated against the agent’s actionSchema. See Actions.
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:
id against the accumulated conversation. 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.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 message — the samekind: "message" on .in. 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 append POST to .in will deliver the record to the session — but with no live run consuming .in, nothing will happen until the next run starts.
The transport’s job is to detect “no live run” and trigger a new one on the same session. Trigger with continuation: true so the agent’s onChatStart hook can distinguish from a brand-new conversation:
sessionId is reused. Only runId and publicAccessToken change.
Closing the conversation
When the user is done with the conversation, close the session: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. |
publicAccessToken | JWT for session + run access. Refreshed via trigger:turn-complete chunks. |
lastEventId | Last SSE event ID received on .out. Use to resume mid-stream. |
sessionId and chatId are durable. runId and publicAccessToken are live-run state that refreshes on each new run. On reload, you only need sessionId + publicAccessToken + lastEventId to resume — runId is a live-run 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 |
Close session (POST /api/v1/sessions/{id}/close) | Secret API key or JWT with admin:sessions:{id} |
| Trigger task | Secret API key or JWT with write:tasks |
.in append | JWT with write:sessions:{id} |
.out subscribe | JWT with read:sessions:{id} |
publicAccessToken returned from POST /api/v1/sessions carries both read:sessions:{id} and write:sessions:{id} for the session — use it for all session operations. A token minted for either the externalId form or the friendlyId form authorizes both URL forms on every read and write route.
See also
TriggerChatTransport— Built-in browser transport (implements this protocol)AgentChat— Built-in server-side client- Backend lifecycle — What the agent does on each event
- Version upgrades — How
chat.requestUpgrade()uses continuations

