Skip to content

Sessions

A session in Matrix is a multi-message conversation between a caller and an actor that share a session ID. Sessions are not a primitive of the transport — NATS only knows subjects and messages. They are a convention built on top of the request/reply and pub/sub primitives, used for:

  • streaming token-by-token LLM responses
  • terminal multiplexer attachments
  • long-running background tasks with progress events
  • paginated list iteration

Why sessions exist

The base request/reply pattern (one envelope in, one envelope out) is sufficient for most ops, but not for any of these cases:

  • An LLM produces tokens over seconds; the caller wants each token as it arrives, not a single blob at the end.
  • A terminal session lives for minutes or hours; commands and output flow in both directions.
  • A background reconciliation job emits progress events the caller wants to follow without polling.

For each, the caller initiates the session with a request, then both sides exchange follow-up messages tagged with a shared sessionId.

Session shape

A session has:

ElementWhere it lives
Session IDfirst appears in the initiating op's response, then carried in subsequent messages
Outbound oprequest/reply against the actor's $inbox
Inbound streamevents on the actor's $events filtered by session ID, OR a dedicated subject
End-of-sessionexplicit session.end op or a terminal event payload

Concretely, an LLM session against system.inference looks like:

1. caller → system.inference/$inbox          { op: 'inference.start', payload: { prompt, model } }
2. caller ← system.inference/$reply.{cid}    { sessionId: 'sess_abc', ok: true }
3. caller subscribes: system.inference/$events  filter: { sessionId: 'sess_abc' }
4. caller ← chunk events ...                 { sessionId, type: 'token', text: 'Hello' }
5. caller ← chunk events ...                 { sessionId, type: 'token', text: ', world' }
6. caller ← end event                        { sessionId, type: 'done' }
7. (optional) caller → system.inference/$inbox { op: 'inference.cancel', sessionId }

The MxEnvelope and sessions

The canonical envelope (projects/matrix-3/packages/core/src/engine/messaging/MxEnvelope.ts) does NOT carry a sessionId field — it carries op, payload, correlationId, replyTo, iface, lamport, from, traceId, spanId, parentSpanId. The session ID is convention: it lives in the payload of session-aware ops, not in the envelope.

Note: because sessionId is in the payload, the actor that introduced the session is responsible for echoing it on every event the session produces. There is no transport-level session demultiplexing today.

Static interactive declaration

MatrixActor exposes a static interactive field for actors that want a default session pattern when invoked via the universal CLI:

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;

When a user runs matrix <address> without an op, the CLI checks this:

modeBehavior
'repl'Enter a readline → eval → print loop, sending each line via sessionOp carrying the sessionIdField.
'stream'Subscribe to the actor's $events (or topic) and print payloads.
nullNo interactive mode; fall back to $introspect.

Cancellation

The session-owning actor declares a cancellation op (typically <domain>.cancel taking a sessionId). The framework does not enforce cancellation; the actor must implement it. If the caller drops without sending cancel, the actor relies on heartbeat-style timeouts to clean up (the actor is responsible for the policy — there is no built-in idle-session sweeper).

Session vs subscription

A subscription is a long-lived consumer of an actor's $events with no state in the actor. A session is a multi-message exchange where the actor maintains per-session state (token cursor, terminal scroll buffer, plan progress). Use subscriptions when the consumer just wants to observe; use sessions when the consumer is interacting.

Per-actor session limits

Some actors enforce single-flight semantics — only one session per caller — to avoid resource exhaustion. The chat/inference path uses an _activeSessions: Set<sessionId> guard in SystemLlm so a runaway caller cannot start a thousand parallel inference jobs. Each session-aware actor owns its own concurrency policy.

What is NOT a session

  • A single request/reply call is not a session; it has a correlationId, no sessionId.
  • Subscribing to $events without an initiating request is not a session; it's a subscription.
  • $state watches are not sessions; they are last-value observations.

See also