Files
n8n/packages/@n8n/instance-ai/docs/streaming-protocol.md
T

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-ID header (auto-reconnect) or ?lastEventId query 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-typesinstanceAiEventSchema 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-finish with payload.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 rich InstanceAiMessage[] with full agent trees, tool calls, and reasoning. Includes a nextEventId field 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

  1. Persisted messages@n8n/agents persists tool invocations, reasoning, and text in its message format. The backend parses these into rich InstanceAiMessage[] objects with tool calls and flat agent trees.

  2. 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.

  3. SSE cursor — the messages response includes nextEventId. The frontend sets its SSE cursor to nextEventId - 1 so 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.