Reference

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:

ParameterTypeMeaning
protocolintegerRequired. The protocol version you speak. The server speaks version 3.
after_seqintegerOptional 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:

ClassFramesHow to treat it
Cursored append-onlyrecords_appendOrdered by feed_seq. The only frame that advances your resume cursor.
Last-writer-wins snapshotsessionThe whole object, resent on any change. No cursor: the latest one wins.
Fire-and-forgetdelta, heartbeatEphemeral. Missing one is harmless.
Terminaldone, errorEnd 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:

  1. Type names are {subject}_{event}, with customer-facing subjects (session_*, sandbox_*). New subjects may join the feed without a protocol bump.
  2. Payload shapes are published as JSON Schema (@ellipsis-dev/sdk ships the generated types as LifecyclePayloads). Payload changes are additive only: fields are never renamed or removed, and new fields carry defaults, so older records stay parseable.
  3. Clients MUST ignore unknown record types and unknown payload fields, and MUST render unknown phase, step, and status values 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_typeEmitted whenPayload
session_scheduledthe session is created and queuedsource, config_name
session_startinga worker claims it (every claim; wake_index > 0 means a wake)attempt, wake_index
session_retryinga transient infra failure hands it back for another attemptreason, attempt
sandbox_startingprovisioning begins for an executionrepositories
sandbox_phaseone provisioning state-machine transition (image, clone, setup, snapshot, hooks, restore; statuses started, completed, failed)phase, status, duration_ms, detail
sandbox_outputa 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_readythe sandbox is up and the agent is about to startrepositories, cache_tier, phase_timings
session_resumeda wake restored the prior conversation (its absence after a wake means the transcript could not be restored)wake_index
session_idlethe conversation parked; the next message wakes it
session_closedthe conversation ended
session_cancelleda preflight gate cancelled the session before any sandboxreason
message_receiveda message landed in the session inboxmessage_id, body, author, sender_attribution_type, sender_attribution_id, closes_session
message_delivereda turn consumed the messagemessage_id, turn_id
message_requeuedan errored turn returned the message for redeliverymessage_id, turn_id
turn_starteda message-exchange beganturn_id, turn_index
turn_completedthe turn closed cleanlyturn_id, turn_index, duration_ms
turn_failedthe turn terminalized without completingturn_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, where N is the highest feed_seq you received in a records_append frame. The server replays records with feed_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_append advances the cursor. Never advance it from a session frame or the snapshot's message projection: a message's own feed_seq is 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_seq regardless. 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_seq is below the snapshot's earliest_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:

CodeMeaning
1000Normal close, after a done frame.
1001Going away (a deploy or rebalance). Reconnect with after_seq.
1002Unsupported protocol version requested. The close reason lists the supported versions.
1003No protocol parameter was sent.
1008Authentication failed, or the session is not visible to you.
1011Server error, sent after an error frame.
1013Over 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 payload is the harness's native line, republished verbatim and versioned by record_format (for example claude_stream_json@2.0). You parse it at your own risk. A new harness ships new record_format values, 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.

FieldTypeDescription
messagestringThe message body.
idempotency_keystringOptional, 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/sdk

It has three dependency-free entry points:

  • @ellipsis-dev/sdk is the typed /v1 REST client.
  • @ellipsis-dev/sdk/stream is streamSession: protocol negotiation, heartbeat liveness, reconnect-with-backoff, and lossless after_seq resume, over a WebSocket you inject.
  • @ellipsis-dev/sdk/store is SessionTranscriptStore, which turns the raw frames into renderable transcript items and chat turns, with subscribe/getSnapshot for React's useSyncExternalStore.

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.