Appearance
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
$eventsuniformly.
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>.$eventsExample 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
timeoutMson 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;mode | Behavior |
|---|---|
'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. |
null | No 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 streamsJetStream 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
- Sessions — conceptual overview.
- Events —
$eventsmechanics. - Request/reply — the initiating idiom.
- Subjects — wire form.
- Reserved subjects — facet list.