Skip to content

Streaming / sessions

A streaming session is a multi-message exchange built on top of request/reply and pub/sub. The protocol does not have a "streaming" primitive of its own — sessions are convention. This page documents the two wire patterns used in production.

Pattern A — events-tagged-by-sessionId

The most common pattern. The caller starts a session through a normal request/reply, gets a sessionId in the response, and subscribes to the actor's $events filtering by that session ID.

Wire flow (LLM token stream against system.inference)

1. caller → system.inference/$inbox
   {
     "op": "inference.start",
     "payload": {
       "model": "claude-opus-4-7",
       "prompt": "...",
       "stream": true
     },
     "correlationId": "f12d-7af0",
     "replyTo": "$replies.f12d-7af0"
   }

2. caller ← system.inference/$reply.f12d-7af0
   {
     "op": "$reply",
     "correlationId": "f12d-7af0",
     "payload": { "ok": true, "sessionId": "sess_abc123", "model": "claude-opus-4-7" }
   }

3. caller subscribes: system.inference/$events
   (caller's handler filters payloads by sessionId === 'sess_abc123')

4. caller ← system.inference/$events
   { "op": "inference.token", "payload": { "sessionId": "sess_abc123", "type": "token", "text": "Hello" } }
   { "op": "inference.token", "payload": { "sessionId": "sess_abc123", "type": "token", "text": "," } }
   { "op": "inference.token", "payload": { "sessionId": "sess_abc123", "type": "token", "text": " world" } }

5. caller ← system.inference/$events
   { "op": "inference.done", "payload": { "sessionId": "sess_abc123", "stopReason": "end_turn" } }

6. (optional) caller → system.inference/$inbox
   { "op": "inference.cancel", "payload": { "sessionId": "sess_abc123" }, ... }

Why events, not a per-session reply subject

Using $events for session output means:

  • The actor publishes once; multiple subscribers can observe (debugging, Director, the caller).
  • The actor doesn't need per-session reply subscriptions.
  • Tracing and pub/sub auditing all flow through $events uniformly.

The cost is that the consumer must filter by sessionId. Some actors offer a per-session subject as a convenience; that is an implementation choice, not a protocol requirement.

Pattern B — per-session subject

When the session has high volume or strong isolation needs, the actor may allocate a dedicated subject for each session:

{root}.<mount>.<sessionId>.$events

Example with the terminal-mux actor:

1. caller → terminal-mux/$inbox
   { "op": "terminal.attach", "payload": { ... }, ... }

2. caller ← reply
   { "ok": true, "sessionId": "term-abc", "stream": "terminal-mux.sess.term-abc/$events" }

3. caller subscribes: terminal-mux.sess.term-abc/$events
   (only this session's traffic; no need to filter)

This requires a stable mount/subject grammar for sessions. The actor typically composes it as <actor-mount>.sess.<sessionId> so the registry and gateway see a deterministic shape.

Cancellation

Both patterns rely on an explicit cancel op:

op:    <domain>.cancel
payload: { sessionId }

The actor stops producing further events and emits a terminal event (typically <domain>.cancelled). If the caller drops without canceling, the actor must time out the session itself — there is no transport-level "caller went away" signal.

Session timeouts

Sessions are NOT bounded by the request timeout. The initiating request/reply respects timeoutMs (default 5000 ms) for the initial ack, but the session itself can run for minutes or hours. The actor manages session lifetime through its own heartbeats or idle timers.

Note: if the initial reply is slow (e.g., model warm-up), pass a larger timeoutMs on the initiating request. 30_000 ms is conservative for inference; terminal/REPL sessions return immediately so 5_000 ms is fine.

static interactive declaration

MatrixActor exposes a static interactive field that gives the universal CLI a default session pattern when invoked without an op:

typescript
// projects/matrix-3/packages/core/src/core/MatrixActor.ts:238
static interactive?: {
  mode: 'repl' | 'stream' | null;
  prompt?: string;
  sessionOp?: string;
  sessionIdField?: string;
  codeField?: string;
  topic?: string;
} | null;
modeBehavior
'repl'CLI starts a readline loop, sending each line via sessionOp carrying the sessionIdField.
'stream'CLI subscribes to topic (default $events) and prints incoming envelopes.
nullNo interactive mode; CLI falls back to $introspect.

This is how matrix <address> becomes useful without an op argument.

Trace context

Every event in a session SHOULD carry the parent trace context from the initiating request. Concretely: copy traceId/parentSpanId from the request envelope, generate a fresh spanId per emitted event. This preserves distributed tracing across the session.

What sessions do NOT provide

  • Ordered delivery across publishers. Two actors writing into the same session subject are ordered per-publisher only.
  • Replay. Late subscribers see only events published after they subscribed. For replayable history, the actor must declare a static streams JetStream stream — see Actors.
  • Backpressure. The transport drops to NATS backpressure rules. A slow consumer may miss events; the actor should not assume delivery.
  • Authorization per session. All session events are published on the actor's $events (or a per-session sub-subject) under the actor's authority root. NATS ACL governs access; there is no per-session ACL.

See also