22 KiB
Streaming Protocol
Overview
Instance AI uses a pub/sub event bus to deliver agent events to the frontend in real-time. All agents — the orchestrator and eval-setup background agent — publish events to a per-thread channel. The frontend subscribes independently via SSE.
The protocol is designed for minimal time-to-first-token, progressive rendering of multi-agent activity, and resilient reconnection.
Transport
Sending Messages
- Endpoint:
POST /instance-ai/chat/:threadId - Request body:
{ "message": "user's message" } - Response:
{ "runId": "run_abc123" } - Concurrency: One active run per thread. A second POST for the same thread
while a run is active is rejected (
409 Conflict).
The POST kicks off the orchestrator. Events are delivered via the SSE endpoint, not the POST response.
Receiving Events
- Endpoint:
GET /instance-ai/events/:threadId - Format: Server-Sent Events (SSE)
- Reconnect:
Last-Event-IDheader (auto-reconnect) or?lastEventIdquery parameter (manual reconnect) replays missed events from storage
SSE Headers
Content-Type: text/event-stream; charset=UTF-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no
X-Accel-Buffering: no disables nginx/reverse proxy buffering so events are
delivered immediately.
SSE Event IDs
Replayable SSE frames include an id: field generated by the server:
id: 42
data: {"type":"tool-call","runId":"run_abc","agentId":"agent-001","payload":{"toolCallId":"tc_abc","toolName":"workflows","args":{"action":"list"}}}
Event IDs are monotonically increasing integers per thread channel. They are
unique within that thread. With the durable log enabled, the shared database
assigns the per-thread sequence. With the durable log disabled, multi-main
deployments use a shared Redis sequence. In both modes, IDs and replay cursors
are valid against any main. Live-only ephemeral frames do not include an id:
field when the durable log is enabled.
Event Schema
Every event follows this schema:
{
type: string; // event type
runId: string; // correlates all events in a single message → response cycle
agentId: string; // agent this event is attributed to in the UI
userId?: string; // user attribution when supplied by the publisher
responseId?: string; // groups events from one streamed response segment
ts?: number; // epoch ms stamped at publish — replays reconstruct real timing from it
payload: object; // event-specific data
}
The runId correlates all events started by one user message. This includes
events from detached work that continues after the orchestrator responds. The
POST endpoint returns the runId, and every event carries it.
The agentId identifies which agent branch (orchestrator or background agent) the
event belongs to. The frontend uses this to render an agent activity tree.
For the full TypeScript type definitions, see
@n8n/api-types — instanceAiEventSchema in schemas/instance-ai.schema.ts.
Event Types
run-start
The orchestrator has started processing a user message. Always the first event in a run.
{
"type": "run-start",
"runId": "run_abc123",
"agentId": "agent-001",
"payload": {
"messageId": "msg_xyz"
}
}
The agentId on this event identifies the orchestrator — the frontend uses
this as the root of the agent activity tree.
text-delta
Incremental text from an agent's response.
{"type":"text-delta","runId":"run_abc123","agentId":"agent-001","payload":{"text":"You have 3 active workflows."}}
The frontend appends payload.text to the agent's current message content.
reasoning-delta
Incremental reasoning/thinking from an agent. Always streamed to the frontend when the model produces it — this gives users visibility into the agent's decision-making and supports faster iteration.
{"type":"reasoning-delta","runId":"run_abc123","agentId":"agent-001","payload":{"text":"Let me check the workflow list..."}}
Policy: Reasoning is always shown to the user. Not all models emit
reasoning tokens; when a model doesn't support it, no reasoning-delta events
are sent. The frontend should handle the absence gracefully.
tool-input-start
A tool call's arguments have started streaming from the model. Sent before
tool-call — for tools with large arguments (e.g. build-workflow streaming
generated workflow code) this can precede the full tool-call event by a
long time, so the frontend can surface the pending call immediately.
{
"type": "tool-input-start",
"runId": "run_abc123",
"agentId": "agent-001",
"payload": {
"toolCallId": "tc_abc123",
"toolName": "build-workflow"
}
}
The frontend adds a pending entry to the agent's toolCalls with empty args
and isLoading: true; the subsequent tool-call event fills in the args.
tool-call
An agent is invoking a tool. Sent when the tool's arguments are complete, before the tool executes.
{
"type": "tool-call",
"runId": "run_abc123",
"agentId": "agent-001",
"payload": {
"toolCallId": "tc_abc123",
"toolName": "workflows",
"args": {"action": "list", "limit": 10}
}
}
The frontend adds a new entry to the agent's toolCalls with isLoading: true.
tool-result
A tool has completed successfully.
{
"type": "tool-result",
"runId": "run_abc123",
"agentId": "agent-001",
"payload": {
"toolCallId": "tc_abc123",
"result": {"workflows": [{"id": "1", "name": "My Workflow", "active": true}]}
}
}
The frontend updates the matching toolCall entry: sets result and
isLoading: false.
tool-error
A tool has failed.
{
"type": "tool-error",
"runId": "run_abc123",
"agentId": "agent-001",
"payload": {
"toolCallId": "tc_abc123",
"error": "Workflow not found"
}
}
agent-spawned
The orchestrator has started a detached background agent (for example via
eval-setup-with-agent).
{
"type": "agent-spawned",
"runId": "run_abc123",
"agentId": "agent-002",
"payload": {
"parentId": "agent-001",
"role": "eval-setup",
"tools": ["workflows", "nodes"]
}
}
The frontend adds a new node to the agent activity tree under the parent.
For this event type, agentId is the spawned background agent ID; payload.parentId
links it to the orchestrator.
agent-completed
A background agent has finished its work.
{
"type": "agent-completed",
"runId": "run_abc123",
"agentId": "agent-002",
"payload": {
"role": "eval-setup",
"result": "Added evaluation nodes to workflow wf-123"
}
}
The frontend marks the background agent node as completed.
confirmation-request
A tool requires user approval before execution (HITL confirmation protocol). The tool's execution is paused until the user responds.
{
"type": "confirmation-request",
"runId": "run_abc123",
"agentId": "agent-001",
"payload": {
"requestId": "cr_xyz",
"toolCallId": "tc_abc123",
"toolName": "workflows",
"args": {"action": "delete", "workflowId": "wf-123"},
"severity": "warning",
"message": "Archive workflow 'My Workflow'?"
}
}
The frontend renders an approval card on the matching tool call (matched by
toolCallId). The user responds via POST /instance-ai/confirm/:requestId
with { approved: boolean }. On approval, normal tool-result follows. On
denial, the resumed tool usually returns a structured denied result. A tool can
also emit tool-error if its resume path throws.
Rich payload fields (all optional, extend the base confirmation):
| Field | Type | When used |
|---|---|---|
inputType |
'approval' | 'text' | 'questions' | 'plan-review' |
Controls which UI component renders. Default: approval |
questions |
[{id, question, type, options?}] |
Structured Q&A wizard (inputType=questions) |
tasks |
TaskList |
Plan approval checklist (inputType=plan-review) |
introMessage |
string | Intro text shown above questions or plan review |
credentialRequests |
array | Credential setup requests |
credentialFlow |
{stage: 'generic' | 'finalize'} |
Controls credential picker UX |
setupRequests |
WorkflowSetupNode[] |
Per-node setup cards for workflow credential/parameter config |
workflowId |
string | Workflow being set up (for workflows(action="setup")) |
projectId |
string | Scopes actions to a project (e.g., credential creation) |
domainAccess |
{url, host} |
Renders domain-access approval UI instead of generic confirm |
tasks-update
A task checklist has been created or updated. The frontend renders a live progress indicator from this data.
{
"type": "tasks-update",
"runId": "run_abc123",
"agentId": "agent-001",
"payload": {
"tasks": [
{"id": "t1", "description": "Build weather workflow", "status": "completed"},
{"id": "t2", "description": "Set up Slack credential", "status": "in_progress"},
{"id": "t3", "description": "Test end-to-end", "status": "pending"}
]
}
}
status
A transient status message. Empty string clears the indicator.
{"type":"status","runId":"run_abc123","agentId":"agent-001","payload":{"message":"Searching nodes..."}}
thread-title-updated
The thread title has been updated (e.g., auto-generated from conversation).
{"type":"thread-title-updated","runId":"run_abc123","agentId":"agent-001","payload":{"title":"Weather to Slack workflow"}}
error
A system-level error occurred.
{"type":"error","runId":"run_abc123","agentId":"agent-001","payload":{"content":"An error occurred"}}
tool-interrupted
A tool call was still in flight when its process died. Appended by the
interrupted-run sweep on startup, which converts orphaned tool-call entries
into a terminal fact so the UI does not render a call that will never resolve.
{"type":"tool-interrupted","runId":"run_abc123","agentId":"agent-001","payload":{"toolCallId":"tc_abc123","error":"Interrupted by a process restart — effect unverified; verify before retrying."}}
The frontend settles the matching tool call as terminated. The interrupted-run
sweep follows it with a run-finish event that has status: "interrupted".
run-finish
The orchestrator has finished processing the user's message. This event ends
orchestrator streaming for the message. Detached background-agent events that
share the runId can arrive after it.
{"type":"run-finish","runId":"run_abc123","agentId":"agent-001","payload":{"status":"completed"}}
The frontend sets isStreaming: false and re-enables input.
When a run is cancelled:
{"type":"run-finish","runId":"run_abc123","agentId":"agent-001","payload":{"status":"cancelled","reason":"user_cancelled"}}
When a run errors:
{"type":"run-finish","runId":"run_abc123","agentId":"agent-001","payload":{"status":"error","reason":"LLM provider unavailable"}}
When a run's process died mid-flight, the startup sweep appends:
{"type":"run-finish","runId":"run_abc123","agentId":"agent-001","payload":{"status":"interrupted","reason":"crash_interrupted"}}
The four statuses are completed, cancelled, error and interrupted.
Typical Event Sequence
Simple Query (No Sub-Agents)
← run-start {runId: "r1", agentId: "a1", payload: {messageId: "m1"}}
← reasoning-delta {runId: "r1", agentId: "a1", payload: {text: "Let me look up..."}}
← tool-call {runId: "r1", agentId: "a1", payload: {toolCallId: "tc1", toolName: "workflows", args: {action: "list"}}}
← tool-result {runId: "r1", agentId: "a1", payload: {toolCallId: "tc1", result: [...]}}
← text-delta {runId: "r1", agentId: "a1", payload: {text: "You have 3 workflows:\n"}}
← run-finish {runId: "r1", agentId: "a1", payload: {status: "completed"}}
Eval Setup Background Agent
← run-start {runId: "r1", agentId: "a1", payload: {messageId: "m1"}}
← tool-call {runId: "r1", agentId: "a1", payload: {toolCallId: "tc1", toolName: "eval-setup-with-agent", args: {workflowId: "wf-123", task: "Add evaluation nodes"}}}
← agent-spawned {runId: "r1", agentId: "a2", payload: {parentId: "a1", role: "eval-setup", tools: ["workflows", "nodes"], taskId: "task-1"}}
← tool-result {runId: "r1", agentId: "a1", payload: {toolCallId: "tc1", result: {result: "Eval setup started (task: task-1).", taskId: "task-1"}}}
← text-delta {runId: "r1", agentId: "a1", payload: {text: "Evaluation setup has started."}}
← run-finish {runId: "r1", agentId: "a1", payload: {status: "completed"}}
← tool-call {runId: "r1", agentId: "a2", payload: {toolCallId: "tc2", toolName: "workflows", args: {action: "get-json", workflowId: "wf-123"}}}
← tool-result {runId: "r1", agentId: "a2", payload: {toolCallId: "tc2", result: {...}}}
← tool-call {runId: "r1", agentId: "a2", payload: {toolCallId: "tc3", toolName: "workflows", args: {action: "update", workflowId: "wf-123", workflow: {...}}}}
← tool-result {runId: "r1", agentId: "a2", payload: {toolCallId: "tc3", result: {...}}}
← agent-completed {runId: "r1", agentId: "a2", payload: {role: "eval-setup", result: "Added evaluation nodes"}}
Because eval setup is detached, its events can interleave with orchestrator
events or continue after the orchestrator's run-finish event.
Event Bus
Architecture
graph LR
subgraph Agents
O[Orchestrator] -->|publish| Bus[Event Bus]
S1[Sub-Agent A] -->|publish| Bus
S2[Sub-Agent B] -->|publish| Bus
end
Bus --> Store[Replay Storage]
Bus --> SSE[SSE Endpoint]
SSE --> FE[Frontend]
All events are published to a per-thread channel on the event bus and delivered to connected SSE clients. The durable log persists replayable facts. Ephemeral transport events remain live-only.
Implementations
| Deployment | Transport | Why |
|---|---|---|
| Single instance | In-process EventEmitter |
Zero infrastructure |
| Queue mode | Redis Pub/Sub | n8n already uses Redis |
Replay storage depends on N8N_INSTANCE_AI_DURABLE_LOG. On (the default),
the durable event log (instance_ai_events) is the replay source: coalesced
step-level facts are appended with a per-thread seq assigned by the
writer's drain, so cursors stay valid across restarts and across mains
sharing one database. Off (the rollback switch until Gate B), replay serves
from a bounded in-memory buffer per thread (500 events / 2 MB, FIFO-evicted;
ids reset on restart).
Reconnection & Replay (Canonical Rule)
The SSE endpoint supports replay via event.id > cursor. The cursor is
provided by the client through one of two mechanisms. The server behavior
is identical for both — only the source of the cursor differs.
Three scenarios:
| Scenario | Cursor source | Server behavior |
|---|---|---|
| Auto-reconnect (connection drop) | Last-Event-ID header, set by the browser automatically |
Replay events after cursor, then switch to live |
| Page reload (same thread) | ?lastEventId=N query parameter, from the frontend's per-thread stored cursor |
Replay events after cursor, then switch to live |
| Thread switch (or first open) | No cursor (neither header nor query param) | Replay full event history from the beginning |
The backend must accept the cursor from both Last-Event-ID header and
?lastEventId query parameter. If neither is present, replay starts from
event ID 0 (full history).
IDs are monotonically increasing integers per thread, assigned from a shared per-thread sequence in multi-main. Assignment order is monotonic, but delivery order is not guaranteed to be: concurrent producers on different mains (e.g. a background task while the orchestrator runs elsewhere) can interleave, so a connection may occasionally deliver a lower id after a higher one. The frontend therefore tracks its reconnect cursor as the max id seen and drops already-seen ids on replay overlap.
With the durable log enabled (N8N_INSTANCE_AI_DURABLE_LOG), ids are
database-assigned sequence numbers and only DURABLE facts carry an id:
line. Ephemeral frames (text-delta, reasoning-delta, status,
filesystem-request) are live-only: their SSE frames have no id: line, so
the browser's replay cursor never points at them (the same mechanism as the
run-sync control frames). On replay, the deltas a client missed are covered
by coalesced text-block / reasoning-block facts, which the shared run
reducer applies with REPLACE semantics keyed on the segment's responseId —
a client that reconnects mid-block never renders partial text twice. The
writer persists each successful batch before it emits the live frames. The
endpoint's replay-and-subscribe handoff deduplicates by seq across the
asynchronous replay bootstrap.
Abort Support
The frontend can abort a running agent by sending:
- Endpoint:
POST /instance-ai/chat/:threadId/cancel - Semantics: Idempotent. Cancels the active run for the thread (if any).
- Behavior: Stops orchestrator and active background agents, then emits final
run-finishwithpayload.status = "cancelled". - Race behavior: If the run already completed, cancel is a no-op.
In-flight tool calls
Cancel aborts the run AbortSignal that is passed to every tool as
ctx.abortSignal. Instance AI wraps tool handlers so Stop unblocks the
executor promptly (handlers race the signal). Long-running I/O tools should
also forward ctx.abortSignal into fetches and child work so the underlying
request stops, not only the handler promise. Aborted tool calls are settled as
cancelled tool results (no dangling tool_call entries).
Frontend Rendering
Agent Activity Tree
The frontend renders events as a collapsible tree grouped by agentId:
🤖 Orchestrator
├── 💭 "Let me check what credentials are available..."
├── 🔧 credentials → [slack-bot, weather-api]
├── 📋 create-tasks: build → configure evaluation
├── 🔧 build-workflow → wf-123
├── 🔧 executions(run) wf-123
│
├── 🤖 Eval setup
│ ├── 🔧 workflows(get-json) → wf-123
│ ├── 🔧 nodes(type-definition) → evaluation nodes
│ ├── 🔧 workflows(update) → wf-123
│ └── ✅ "Added evaluation nodes"
│
└── 💬 "Done! Your workflow runs daily at 8am..."
The eval-setup section is collapsible. Users can inspect its tool activity or view only the summary.
Session Restore
When the user refreshes the page or navigates back to a thread, the frontend restores the full session state (messages, tool calls, agent trees) without replaying all SSE events.
Endpoints
-
GET /instance-ai/threads/:threadId/messages— returns richInstanceAiMessage[]with full agent trees, tool calls, and reasoning. Includes anextEventIdfield indicating the SSE cursor position at the time of response. -
GET /instance-ai/threads/:threadId/status— returns the thread's current activity state:{ "hasActiveRun": false, "isSuspended": false, "backgroundTasks": [ { "taskId": "t1", "role": "eval-setup", "agentId": "agent-002", "status": "running", "startedAt": 1709300000 } ] }
How It Works
-
Persisted messages —
@n8n/agentspersists tool invocations, reasoning, and text in its message format. The backend parses these into richInstanceAiMessage[]objects with tool calls and flat agent trees. -
Agent trees — with the durable log enabled, history folds event-log rows through
buildAgentTreeFromEvents()when it reads a page. Stored snapshots remain as the non-durable path and as a fallback for older history. The backend updates snapshots when runs and background tasks settle. -
SSE cursor — the messages response includes
nextEventId. The frontend sets its SSE cursor tonextEventId - 1so the SSE connection only receives events that arrived after the historical snapshot. This prevents duplicate messages on refresh.
Frontend Flow
1. Load historical messages (GET /threads/:threadId/messages)
└── Sets messages[], sets SSE cursor to nextEventId - 1
2. Load thread status (GET /threads/:threadId/status)
└── Sets activeRunId if run is active, injects background tasks
3. Connect SSE (GET /events/:threadId?lastEventId=<cursor>)
└── Only receives live events going forward
The order is sequential: historical messages load first, then SSE connects. This eliminates the race condition where SSE and HTTP responses would compete, creating duplicate messages.
Complete Event Type Reference
| Event Type | Payload Key Fields | Purpose |
|---|---|---|
run-start |
messageId |
First event in a run |
run-finish |
status, reason? |
Ends orchestrator streaming; detached events can follow |
text-delta |
text |
Incremental agent text |
reasoning-delta |
text |
Incremental agent reasoning |
tool-call |
toolCallId, toolName, args |
Tool invocation (before execution) |
tool-result |
toolCallId, result |
Successful tool completion |
tool-error |
toolCallId, error |
Failed tool execution |
agent-spawned |
parentId, role, tools |
Sub-agent created |
agent-completed |
role, result |
Sub-agent finished |
confirmation-request |
requestId, toolCallId, severity, message, ... |
HITL approval gate |
tasks-update |
tasks |
Task checklist created/updated |
status |
message |
Transient status indicator |
error |
content, statusCode?, provider? |
System-level error |
thread-title-updated |
title |
Thread title changed |
filesystem-request |
requestId, toolCall |
Local gateway MCP tool request (internal) |
tool-input-start |
toolCallId, toolName |
Tool arguments began streaming |
text-block |
text (responseId is on the event) |
Completed text segment, coalesced |
reasoning-block |
text (responseId is on the event) |
Completed reasoning segment, coalesced |
tool-interrupted |
toolCallId, error |
Tool call was in flight when its process died |
All event types are defined as a Zod discriminated union in
@n8n/api-types/src/schemas/instance-ai.schema.ts.