Sessions

Live Subscriptions

Consume the unified SessionMutation stream for streaming text, tools, Turns, and Session changes

Live Subscriptions

session.subscribe() is the only live Session entry point. It broadcasts only changes after subscription, with no cache, replay, or backpressure management.

const unsubscribe = session.subscribe((mutation) => {
  apply_mutation(mutation);
});

// Always release it when it is no longer needed.
unsubscribe();

The callback may be async. A thrown error or rejection from one subscriber does not roll back persisted state or block other subscribers.

Eight Mutation variants

Every Mutation has mutation_id, session_id, and created_at.

varianttypePurposeHas Message revision
message`useragent`Complete top-level Message snapshot
part`textreasoningtool
delta`textreasoningtool_input`
turn`startfinish`Turn execution lifecycle
file_diff—Live file-change summary for the current TurnNo
session`titleconfig`Session property change
warningmodel_requestModel request failure and retry progressNo

message, part, and delta are published only after the corresponding complete Message is persisted. The Mutation envelope itself is never written to canonical history.

Render text with Deltas

session.subscribe((mutation) => {
  if (mutation.variant !== "delta" || mutation.type !== "text") return;

  append_text({
    message_id: mutation.message_id,
    part_id: mutation.part_id,
    delta: mutation.delta,
  });
});

A Delta is an appended fragment, not cumulative text. Do not use it as a complete Part snapshot and do not infer order from text content; use message_id, part_id, and revision. A tool_input Delta also carries the matching tool_call_id.

Correct state with complete snapshots

Network behavior, batched rendering, and UI transitions can leave client-maintained intermediate state incomplete. message and part are complete corrective snapshots:

function apply_mutation(mutation: SessionMutation): void {
  if (mutation.variant === "message") {
    upsert_message(mutation.message, mutation.revision);
    return;
  }

  if (mutation.variant === "part") {
    upsert_part(mutation.message_id, mutation.part, mutation.revision);
    return;
  }

  if (mutation.variant === "delta") {
    if (mutation.type === "tool_input") {
      append_tool_input_delta(
        mutation.message_id,
        mutation.part_id,
        mutation.tool_call_id,
        mutation.delta,
      );
    } else {
      append_text_delta(mutation.message_id, mutation.part_id, mutation.delta);
    }
  }
}

Track the greatest applied revision for each Message. A message or part snapshot at or below that revision can be ignored so an old event cannot overwrite newer state.

Turns and title updates

session.subscribe((mutation) => {
  if (mutation.variant === "turn" && mutation.type === "start") {
    set_turn_running(mutation.turn_id);
  }

  if (mutation.variant === "turn" && mutation.type === "finish") {
    set_turn_finished(mutation.turn_id, mutation.status, mutation.error);
  }

  if (mutation.variant === "session" && mutation.type === "title") {
    set_session_title(mutation.title);
  }
});

turn.finish has completed, failed, or stopped status. It and turn.finished describe the same final execution: the former is for subscription-driven UI, the latter for control flow.

Context compaction is not a separate Mutation and has no public Handle. It is a SessionComposer.advance_context() result recorded as an Action Part: completed on success, failed on failure, with action_type set to context-compaction. It never rewrites canonical history; it only advances the Composer's derived tables (see Metadata and storage).

On a failed model call, warning / model_request carries request_kind, code, message, retryable, attempt, max_attempts, and will_retry. Main Turn requests also include turn_id; background title and history-compaction requests may omit it. It is a live diagnostic and is not persisted as Message history. retryable reflects the Provider error classification; will_retry: false means no further automatic retry follows this failure.

Tool Part state machine

The same Tool Part is updated under the same part_id:

input-streaming -> ready -> waiting-user -> running -> completed
                                                   \-> failed
ready ----------------------------------------------> running

input-streaming means the model is still producing tool arguments. During this phase, append tool_input Deltas to the Tool Part's input_text; the ready Part is the corrective snapshot after input completes and carries the parsed input. Key UI elements by tool_call_id or part_id; do not append a new item for every Delta.

If the completed argument stream is not valid JSON, the same Part moves directly from input-streaming to failed, with error describing the parse failure; the Tool implementation is not executed. A structured { success: false } Tool return also produces failed. Clients should render state and error instead of inferring success from output text.

See Tool approvals for approval handling.

Lifecycle boundary

unsubscribe() stops only future events. It does not stop a Session, Turn, or tool. Call it on route, component, or Session changes; when returning to the Session, reload snapshots and subscribe again.

Reconnecting requires more than subscribing again. See Reconnect and sync.