Session stream
The WebSocket protocol that streams a live agent session, with its frame taxonomy, resume cursor, close codes, and the REST companions for sending messages and reading records.
The session stream is a server-to-client WebSocket that pushes a session's live transcript, inbox, and status as it changes. It carries raw data: each transcript record ships the harness's native line verbatim inside a stable envelope, and the session ships its full public shape. Rendering is the client's job.
The stream is read-only. Every write (send a message, stop, replay) goes through the REST API. The @ellipsis-dev/sdk package implements this protocol end to end (connect, resume, frame parsing, transcript assembly), and the agent CLI's --watch and connect are built on it. Reach for the raw protocol below when you are integrating from a language the SDK does not cover.
Connect
Open a WebSocket to the session's stream endpoint, authenticated with the same bearer token as the rest of /v1:
websocat "wss://api.ellipsis.dev/v1/sessions/session_7Hq2mX4p/stream?protocol=3" \
-H "Authorization: Bearer $ELLIPSIS_API_TOKEN"Send the token in the Authorization header. Where a client cannot set handshake headers (a browser WebSocket), pass it as a ?token= query parameter instead. A ?token= value lands in ingress logs, so prefer the header wherever you can.
Visibility is ownership only: the session must belong to your account. A missing session and a session that is not yours are indistinguishable, both closing with code 1008. Whether the agent is interactive has no effect on watching. Any owner can stream any of their sessions.
Handshake
Two query parameters shape the connection:
| Parameter | Type | Meaning |
|---|---|---|
protocol | integer | Required. The protocol version you speak. The server speaks version 3. |
after_seq | integer | Optional resume cursor. Replay records after this feed_seq. Absent or 0 replays all retained history. |
protocol is mandatory. Omit it and the server closes with 1003; request a version the server does not speak and it closes with 1002, naming the supported versions in the close reason. The snapshot frame echoes the served version.
Frames
Every message is a JSON object with a type. Each frame belongs to exactly one delivery class, which tells you how to treat it:
| Class | Frames | How to treat it |
|---|---|---|
| Cursored append-only | records_append | Ordered by feed_seq. The only frame that advances your resume cursor. |
| Last-writer-wins snapshot | session | The whole object, resent on any change. No cursor: the latest one wins. |
| Fire-and-forget | delta, heartbeat | Ephemeral. Missing one is harmless. |
| Terminal | done, error | End of stream, followed by a close. |
snapshot
The first frame after the socket opens. It is lean: the session plus the open inbox only. Records always arrive separately, as records_append.
earliest_feed_seq is the retention head, the lowest feed_seq still stored for this session (or null when it has no stored records). Compare it against your after_seq: replaying from below earliest_feed_seq - 1 means older records have expired under your log retention setting, so render the gap rather than treating retained history as complete.
{
"type": "snapshot",
"protocol": 3,
"earliest_feed_seq": 214,
"session": { "id": "session_7Hq2mX4p", "status": "running", "...": "the same shape the session frame carries" },
"messages": []
}records_append
One or more raw session records, ordered by feed_seq, from every source (claude_code transcript lines and lifecycle platform notifications alike). During resume or backfill the server batches records, flushing at 200 records or roughly 1 MiB of serialized payload, whichever comes first; a single oversized record ships alone in its own frame.
Every record advances your resume cursor, including records you render as nothing (an unknown record_type, a filtered lifecycle row).
{
"type": "records_append",
"records": [
{
"id": "rec_5f2a9c",
"agent_session_id": "session_7Hq2mX4p",
"agent_turn_id": "turn_b81c04",
"feed_seq": 41,
"stream_seq": 12,
"source": "claude_code",
"record_type": "assistant",
"record_format": "claude_stream_json@2.0",
"session_message_id": null,
"payload": { "type": "assistant", "message": { "role": "assistant", "content": [] } },
"tools": ["Bash"],
"tokens_info": null,
"cost": null,
"duration": null,
"model": "claude-opus-4-8",
"created_at": "2026-07-21T17:04:12.481Z"
}
]
}The envelope fields are the stable contract: feed_seq, stream_seq, source, record_type, record_format, session_message_id, tools, tokens_info, cost, duration, model, and created_at. payload is the harness's native line, stored verbatim and versioned by record_format. Parse it against the format it declares (claude_stream_json@2.0 for Claude Code lines, ellipsis_lifecycle@1 for platform notifications), and expect its internal shape to be owned by the harness, not by this protocol.
A lifecycle record carries a small platform payload instead. For example, sandbox_output streams a chunk of provisioning output so a connected client can watch a slow dependency install:
{
"id": "rec_9b1e7f",
"agent_session_id": "session_7Hq2mX4p",
"agent_turn_id": null,
"feed_seq": 3,
"stream_seq": -3,
"source": "lifecycle",
"record_type": "sandbox_output",
"record_format": "ellipsis_lifecycle@1",
"session_message_id": null,
"payload": { "phase": "setup", "step": null, "chunk": 0, "lines": ["Collecting fastapi==0.115.6"] },
"tools": null,
"tokens_info": null,
"cost": null,
"duration": null,
"model": null,
"created_at": "2026-07-21T17:02:58.113Z"
}Lifecycle events
Lifecycle records narrate the session outside the transcript: scheduling, worker claims, sandbox provisioning, idling, and closure. The vocabulary and its evolution rules are contract:
- Type names are
{subject}_{event}, with customer-facing subjects (session_*,sandbox_*). New subjects may join the feed without a protocol bump. - Payload shapes are published as JSON Schema (
@ellipsis-dev/sdkships the generated types asLifecyclePayloads). Payload changes are additive only: fields are never renamed or removed, and new fields carry defaults, so older records stay parseable. - Clients MUST ignore unknown record types and unknown payload fields, and MUST render unknown
phase,step, andstatusvalues generically. Shipping a new event or phase is never a breaking change; it is invisible until a client learns to render it.
The types today, in feed order for a typical start:
| record_type | Emitted when | Payload |
|---|---|---|
session_scheduled | the session is created and queued | source, config_name |
session_starting | a worker claims it (every claim; wake_index > 0 means a wake) | attempt, wake_index |
session_retrying | a transient infra failure hands it back for another attempt | reason, attempt |
sandbox_starting | provisioning begins for an execution | repositories |
sandbox_phase | one provisioning state-machine transition (image, clone, setup, snapshot, hooks, restore; statuses started, completed, failed) | phase, status, duration_ms, detail |
sandbox_output | a chunk of provisioning or lifecycle-script output (many per execution, capped with a truncation marker; archived into the session log) | phase, step, stream, chunk, lines |
sandbox_ready | the sandbox is up and the agent is about to start | repositories, cache_tier, phase_timings |
session_resumed | a wake restored the prior conversation (its absence after a wake means the transcript could not be restored) | wake_index |
session_idle | the conversation parked; the next message wakes it | — |
session_closed | the conversation ended | — |
session_cancelled | a preflight gate cancelled the session before any sandbox | reason |
message_received | a message landed in the session inbox | message_id, body, author, sender_attribution_type, sender_attribution_id, closes_session |
message_delivered | a turn consumed the message | message_id, turn_id |
message_requeued | an errored turn returned the message for redelivery | message_id, turn_id |
turn_started | a message-exchange began | turn_id, turn_index |
turn_completed | the turn closed cleanly | turn_id, turn_index, duration_ms |
turn_failed | the turn terminalized without completing | turn_id, turn_index |
session_scheduled and a preflight session_cancelled are session-scoped: they carry no session_execution_id (the field is null), because they happen before any execution exists.
Inbox state rides the feed
There is no mutable messages frame in v3. The snapshot carries the pending-message projection at connect (see above); every state change after that is an ordinary record on the feed — message_received when a message lands in the inbox, message_delivered when a turn consumes it, message_requeued when an errored turn returns it for redelivery. Clients rebuild the pending set from those records, and everything about the inbox is therefore part of the durable, downloadable session log.
session
The enriched public session, resent whole whenever any field changes: status, session_state, surface, live cost. This is the only way status and cost updates arrive. It is the same shape GET /v1/sessions/{id} returns, so the stream and REST never disagree about what a session looks like. attributed_user and stopped_by_user are resolved from GitHub account ids at read time.
{
"type": "session",
"session": {
"id": "session_7Hq2mX4p",
"customer_id": "cust_3aF9",
"source": "cli",
"status": "running",
"session_state": "running",
"surface": { "session": "alive", "run": "working", "status": "working" },
"attributed_user": { "id": 5201153, "login": "priya-shah", "type": "User", "avatar_url": "https://avatars.githubusercontent.com/u/5201153" },
"stopped_by_user": null,
"cost_tokens": 8412,
"cost_sandbox_cpu": 190,
"cost_sandbox_memory": 55,
"cost_fee": 0,
"tokens_total": 40213,
"tokens_model": "claude-opus-4-8",
"created_at": "2026-07-21T17:02:41.000Z",
"updated_at": "2026-07-21T17:04:12.000Z"
}
}surface describes a durable conversation's live state and is null for a laptop session Ellipsis does not manage. The heavy near-static blobs (the frozen config snapshot, per-execution input and output) are not on the stream; fetch them from the REST detail surfaces when a view needs them.
delta
Ephemeral partial output for the response in flight: a coalesced text fragment and the running output-token count. It has no sequence number and is never resumable; the committed records_append supersedes it. After a reconnect you miss any in-flight partial text until the next delta, and the committed record catches you up.
{ "type": "delta", "agent_turn_id": "turn_b81c04", "kind": "text", "text": "Looking at the trade engine now", "output_tokens": 512 }kind is "text" today. Treat it as an open vocabulary and ignore deltas whose kind you do not recognize.
heartbeat
Sent after 20 seconds of silence. It doubles as the dead-socket probe.
{ "type": "heartbeat", "ts": "2026-07-21T17:04:32.006Z" }done
The terminal marker: the conversation is over. It follows the final session frame, which already carried the end state. The server then closes with 1000.
{ "type": "done" }error
A curated server-side error, sent before the server closes with 1011. The message is safe to show a user; it never carries internal details.
{ "type": "error", "message": "Something went wrong streaming this session." }Resume and truncation
Records carry feed_seq, a per-session total order. It is the one cursor, shared with the REST records endpoint.
- To resume after a disconnect, reconnect with
?after_seq=N, whereNis the highestfeed_seqyou received in arecords_appendframe. The server replays records withfeed_seq > N, then streams live. No gap, no overlap. after_seq=0(or omitting it) replays all retained history, so a pure-WebSocket integration needs no REST calls to bootstrap.- Only
records_appendadvances the cursor. Never advance it from asessionframe or the snapshot's message projection: a message's ownfeed_seqis placement metadata, and advancing past it could skip unseen records. - The server may close any socket at any time (a deploy or rebalance closes with
1001). Every client must implement reconnect-with-after_seqregardless. There is no idle hangup: a quiet conversation heartbeats through days of silence and wakes on the next turn. - Retention truncation is visible, never silent. If
after_seqis below the snapshot'searliest_feed_seq - 1, records have expired under your log retention setting, and the client should surface that instead of presenting a partial transcript as complete.
To cold-load over REST and then attach live with no double-fetch, page GET /v1/sessions/{id}/records to the head, then connect with after_seq set to the last feed_seq you read.
Close codes
The close code is part of the contract:
| Code | Meaning |
|---|---|
1000 | Normal close, after a done frame. |
1001 | Going away (a deploy or rebalance). Reconnect with after_seq. |
1002 | Unsupported protocol version requested. The close reason lists the supported versions. |
1003 | No protocol parameter was sent. |
1008 | Authentication failed, or the session is not visible to you. |
1011 | Server error, sent after an error frame. |
1013 | Over capacity: the per-account concurrent-socket cap was exceeded. Retry later. |
Liveness
The server heartbeats every 20 seconds while the conversation is quiet. Treat roughly twice that span of silence, about 40 seconds with no frame of any type, as a dead connection: close and reconnect with your after_seq.
Compatibility
The frame schema is a published, versioned contract. It grows without breaking existing clients as long as clients follow one rule.
Ignore what you do not recognize. Ignore unknown frame types, and ignore unknown source, record_type, record_format, and kind values. This is what lets Ellipsis add a new harness, a new lifecycle record, or a new delta kind without a version bump. A field or frame is only removed or renamed in a new protocol version, which you would opt into through ?protocol=.
Two tiers move at different speeds:
- The envelope (
feed_seq,stream_seq,source,record_type,record_format,tools,tokens_info,cost,duration,model, timestamps, and all frame-level fields) is stable and versioned. - The record
payloadis the harness's native line, republished verbatim and versioned byrecord_format(for exampleclaude_stream_json@2.0). You parse it at your own risk. A new harness ships newrecord_formatvalues, not a new protocol version.
The kind values on delta other than text are reserved; ignore them until a version documents them.
REST companions
The stream carries live output; four REST endpoints on the same /v1 surface do the writes and the cold reads. All use the same bearer auth and the same feed_seq cursor.
Read a session
GET /v1/sessions/{session_id} returns the enriched session, the exact shape the session and snapshot frames carry.
curl https://api.ellipsis.dev/v1/sessions/session_7Hq2mX4p \
-H "Authorization: Bearer $ELLIPSIS_API_TOKEN"Read a session's records
GET /v1/sessions/{session_id}/records returns the stored transcript as records in the same wire shape the stream sends, ordered by feed_seq.
Pagination is opt-in. A bare call returns the full retained transcript. Pass limit (max 1000) to page, and after_seq to start after a cursor.
curl "https://api.ellipsis.dev/v1/sessions/session_7Hq2mX4p/records?after_seq=40&limit=200" \
-H "Authorization: Bearer $ELLIPSIS_API_TOKEN"The response is { "records": [...], "messages": [...], "has_more": ..., "earliest_feed_seq": ... }. messages is the pending inbox slice, has_more says whether records past this page remain (only meaningful with limit), and earliest_feed_seq is the retention head, the same value the snapshot frame reports.
Read a conversation's turns
GET /v1/sessions/{session_id}/turns returns a durable conversation's structure: its turns and the full inbox of messages, joined by each message's delivered_turn_id (null while a message is still pending). Both lists are empty for a single-shot session. This is where delivered message history lives, since the messages frame carries only the open slice.
curl https://api.ellipsis.dev/v1/sessions/session_7Hq2mX4p/turns \
-H "Authorization: Bearer $ELLIPSIS_API_TOKEN"Send a message
POST /v1/sessions/{session_id}/messages posts a human message into a durable conversation. It is delivered at the next turn boundary, or wakes the conversation when it is idle.
curl https://api.ellipsis.dev/v1/sessions/session_7Hq2mX4p/messages \
-H "Authorization: Bearer $ELLIPSIS_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"message": "Also handle the timezone edge case on overnight shift trades",
"idempotency_key": "a3f1c8e0-2b47-4d9a-9f10-6c2e5b8d1a44"
}'The response is the created message, in the same shape the messages frame sends: stamp your optimistic UI chip with its id, then retire the chip when a messages frame or a user-echo record carries that id.
| Field | Type | Description |
|---|---|---|
message | string | The message body. |
idempotency_key | string | Optional, up to 128 characters, unique per session. A retry with the same key returns the original message: no duplicate inbox row, no second turn, no double spend. |
Not every session accepts a message. The send is refused with 409 when:
- the conversation is closed (for example, its pull request merged);
- the agent is configured as non-interactive (
interactive: false); - the session is a react or cron session, which are single-shot and never accept follow-up input. Steer those by replying on their trigger surface instead.
Watching is never gated this way. You can stream any of these sessions; only the write is refused.
Use the SDK
The @ellipsis-dev/sdk package implements this protocol, so you rarely handle frames by hand in a JavaScript or TypeScript client.
npm install @ellipsis-dev/sdkIt has three dependency-free entry points:
@ellipsis-dev/sdkis the typed/v1REST client.@ellipsis-dev/sdk/streamisstreamSession: protocol negotiation, heartbeat liveness, reconnect-with-backoff, and losslessafter_seqresume, over a WebSocket you inject.@ellipsis-dev/sdk/storeisSessionTranscriptStore, which turns the raw frames into renderable transcript items and chat turns, withsubscribe/getSnapshotfor React'suseSyncExternalStore.
You inject the transport through openSocket, so the same client runs in a browser, a terminal, or on a server. openSocket receives the prebuilt handshake query (protocol=3&after_seq=N, with the resume cursor filled in on every reconnect) and returns a small socket adapter. streamSession runs to the terminal frame, reconnecting with backoff and resuming from the last record's feed_seq, then resolves with the outcome. Cancel it with an AbortSignal.
import { streamSession, type StreamSocket } from "@ellipsis-dev/sdk/stream";
const controller = new AbortController();
const outcome = await streamSession({
sessionId: "session_7Hq2mX4p",
signal: controller.signal,
// Adapt any WebSocket to the StreamSocket interface. `query` is the
// prebuilt handshake string; append your bearer token here.
openSocket: ({ sessionId, query }): StreamSocket => {
const ws = new WebSocket(
`wss://api.ellipsis.dev/v1/sessions/${sessionId}/stream?${query}` +
`&token=${process.env.ELLIPSIS_API_TOKEN}`,
);
return {
onOpen: (cb) => ws.addEventListener("open", () => cb()),
onMessage: (cb) =>
ws.addEventListener("message", (e) => cb(String(e.data))),
onClose: (cb) => ws.addEventListener("close", (e) => cb(e.code)),
onError: (cb) =>
ws.addEventListener("error", () => cb(new Error("socket error"))),
close: () => ws.close(),
};
},
onFrame: (frame) => {
if (frame.type === "records_append") {
for (const record of frame.records) {
console.log(record.source, record.record_type, record.feed_seq);
}
}
},
});
console.log(outcome.type); // "done" | "error" | "aborted"onFrame fires for every frame; switch on frame.type and no-op on any type you do not recognize. The frame and REST types are generated from the server's own schema, so they cannot drift from the deployed API. To assemble the frames into rendered transcript items and chat turns instead of handling them yourself, feed them to SessionTranscriptStore from @ellipsis-dev/sdk/store.