FabricFabricHarness
Reference

Streaming protocol

The HTTP wire protocol for delivering messages to an agent conversation and reading it back by offset, over JSON or SSE.

Every persistent agent conversation is an append-only, offset-addressed record stream. Delivery and reading are separate operations: a POST durably admits a message and returns immediately, and the reply arrives in the conversation, which you read from an offset.

@fabric-harness/client and @fabric-harness/react wrap this surface, and most applications should use them. Read this page when you are writing a client in another language, debugging a transport, or building your own UI layer.

Routes

Relative to a persistent agent instance at /agents/:name/:instanceId:

RoutePurpose
POST /agents/:name/:instanceIdDeliver one message. Returns 202 on durable admission.
GET /agents/:name/:instanceId/conversationOffset-based catch-up read (JSON).
GET /agents/:name/:instanceId/streamCatch-up plus live tail (SSE).
GET /agents/:name/:instanceId/submissions/:submissionIdOne submission's status.
POST /agents/:name/:instanceId/abortAbort in-flight and queued work.

conversation and stream both accept session (default default) and offset. conversation also accepts limit.

Delivering a message

POST /agents/support/ticket-8472
Content-Type: application/json

{ "message": { "kind": "user", "body": "Summarize the open issues on my case." } }

message accepts a full DeliveredMessage as above, or a bare string as shorthand for { "kind": "user", "body": "..." }. The body also accepts session (default default), initialData for instance creation, and uid to pin a generation.

The response is 202 as soon as the message is durably admitted — before the model runs:

{
  "submissionId": "sub_01HZX...",
  "streamUrl": "/agents/support/ticket-8472/conversation?session=default&offset=42",
  "conversationUrl": "/agents/support/ticket-8472/conversation?session=default&offset=42",
  "updatesUrl": "/agents/support/ticket-8472/stream?session=default&offset=42",
  "offset": "42"
}

offset is the stream position before this delivery, so reading from it returns this message and everything the agent produces in response — with no race against the agent starting. uid is present when the instance has a resolved generation.

There is no "wait for the reply" mode on this route in the durable path; read the outcome from the conversation, or poll the submission.

Reading by offset

GET /agents/support/ticket-8472/conversation?offset=42&limit=100
{
  "incarnation": "inc_01HZ...",
  "batches": [{ "offset": "42", "records": [] }],
  "nextOffset": "57",
  "upToDate": true
}

Pass nextOffset as the next request's offset to continue. upToDate is true when the read reached the tail. A conversation that does not exist yet returns incarnation: null with an empty batches array rather than a 404, so a reader can start before the first message lands.

Offsets are opaque strings. Compare them for equality, never order them numerically or arithmetically.

Incarnation

incarnation identifies the stream's producer generation. If it changes between reads, the stream was rebuilt and offsets from the previous incarnation are meaningless — discard projected state and re-read from "0".

Live tail over SSE

GET /agents/support/ticket-8472/stream?offset=42
Accept: text/event-stream

The server replays from offset, then tails. Every frame is a JSON object in data:; : keepalive comments are sent periodically and carry no data.

Frame typePayloadMeaning
stream_checkpointincarnation, offsetProducer generation. A change means re-read from "0".
conversationoffset, recordsDurable records. offset is the next offset to resume from.
deltaeventId, kind (text | reasoning), delta, submissionId?, turnId?Token-level text or reasoning fragment.
toolcall_deltaeventId, toolCallId, toolName, argumentTextDelta, submissionId?, turnId?Streaming tool-call arguments.

Only conversation frames are durable. delta and toolcall_delta are presentation-only: they let a UI render tokens as they arrive, are not replayed on reconnect, and are always superseded by the conversation records covering the same turn. A client that ignores them loses nothing but interactivity; a client that persists them will double-count.

Reconnect by re-requesting stream with the last offset you received in a conversation frame. @fabric-harness/client reconnects after 90 seconds of transport silence by default (idleTimeoutMs), and falls back to JSON polling when SSE is unavailable (live: 'auto' | 'sse' | 'poll').

Records

Each record in a records array is a ConversationStreamRecord:

type ConversationStreamRecord =
  | { kind: 'entry'; entry: SessionEntry }
  | { kind: 'truncated'; rewoundTo: string; newLeafId: string; reason: string };

A truncated record means the conversation path branched — a replay, a checkpoint restore, or a fork. Drop every projected record after rewoundTo and continue from the records that follow. Ignoring truncated leaves a client showing messages that are no longer on the active path.

Projecting records for display

Raw entries are the canonical log, not a render-ready view. projectConversationRecords from @fabric-harness/sdk/conversation reduces a record sequence into a stable UI protocol:

import { projectConversationRecords } from '@fabric-harness/sdk/conversation';

const { messages, settlements } = projectConversationRecords(records);

A ConversationMessage carries id, role, purpose, display, optional submissionId/turnId/metadata, and an ordered parts array:

Part typeShape
texttext, state: 'streaming' | 'done'
reasoningtext, state: 'streaming' | 'done'
filemediaType, optional id, size, filename
dynamic-tooltoolName, toolCallId, and a state-specific payload (input-streaming, input-available, output-available, output-error)
data-${name}data — agent-authored structured parts written with useDataWriter()

Honor display: hidden messages are not for users, and diagnostic ones belong behind a debug affordance. purpose distinguishes a user turn from a dispatched signal or an advisory injection.

settlements reports terminal submission outcomes — completed, failed, or aborted, with error and answeredBySubmissionId where they apply. A submission that joined a busy conversation is answered by another submission's assistant turn, which answeredBySubmissionId names.

Implementation checklist

For a client in another language:

  1. POST the message; keep submissionId and offset from the 202.
  2. Read from that offset — SSE if you want live tokens, JSON polling otherwise.
  3. Treat offsets as opaque; resume from nextOffset or a conversation frame's offset.
  4. Re-read from "0" whenever incarnation changes.
  5. Apply truncated records by discarding everything after rewoundTo.
  6. Render from delta frames if you want streaming, but persist only conversation records.
  7. Stop when a settlement for your submissionId appears, or when the submission status is terminal.

See also

  • Events — the session entry types that appear inside records.
  • Agent behavior — built-in tool semantics reflected in tool parts.
  • React — the hook layer built on this protocol.