Skip to content

Actor sessions

A "session" in Matrix is a conversation thread — a sequence of $prompt invocations sharing context, history, and an audit trail. Sessions are how an actor stays coherent across multiple turns of an LLM agent loop. This page covers how $prompt works, what ActivityFrames are, and how to participate from a custom actor.

Sessions as a concept

A session has three identifiers:

FieldMeaningWhere it lives
sessionIdConversation/session id used for transcript threading.ActivityFrame.sessionId
traceIdOne per user interaction; never changes.ActivityFrame.traceId
spanIdOne per agent invocation; generated by system.agents.ActivityFrame.spanId

Sessions are not stored on the actor itself by default. They are stored by the agents/conversation layer (@open-matrix/agents, system-conversation) and threaded through $prompt payloads. A custom actor receives the sessionId in incoming $prompt/$activity envelopes and forwards it on outgoing ones.

The $prompt op

on$prompt is sealed in MatrixActor (MatrixActor.ts:1173-1276). The base implementation is a trampoline that:

  1. Validates the incoming payload has a prompt: string.
  2. Mints a requestId if one was not supplied.
  3. Builds a snapshot of this actor (mount, accepts, emits, skills, cognitive state).
  4. Mints a traceId if one was not supplied.
  5. Resolves the LLM endpoint from context.getService('llm-endpoint'), falling back to 'system.agents'.
  6. Forwards activityTo, sessionId, and principalId on the outgoing payload.
  7. Either:
    • Blocking (payload.blocking === true) — uses RequestReply.execute and returns the agent's result.
    • Non-blockingRequestReply.sendToInbox(...) and returns { ok: true, accepted: true, requestId }.

Warning: Subclasses cannot override on$prompt. The framework detects override attempts and logs an error, then runs the base trampoline anyway. If you need a domain prompt verb, declare a named op (session.start, chat.send-message, mypackage.summarize).

The exception: an actor with static isPromptRouter = true (typically only AgentsRoot/AgentActor in @open-matrix/agents) is the prompt router and does handle $prompt itself.

ActivityFrame — the streaming protocol

ActivityFrame is the structured event pushed back to interested observers while a $prompt is being processed. It is defined in projects/matrix-3/packages/core/src/framework/ActivityFrame.ts:11-47:

ts
export interface ActivityFrame {
  requestId: string;
  seq: number;
  ts: number;
  source: string;            // mount that emitted the frame

  traceId?: string;
  spanId?: string;
  parentSpanId?: string;
  sessionId?: string;
  resumeId?: string;
  principalId?: string;

  kind: 'prompt' | 'llm' | 'tool' | 'system';
  phase: ActivityPhase;

  summary?: string;          // human-readable, e.g. "Querying flowpad-page.db"
  target?: string;           // for tool calls
  op?: string;               // for tool calls
  data?: unknown;
  durationMs?: number;
}

export type ActivityPhase =
  | 'accepted' | 'started' | 'thinking' | 'token'
  | 'tool-start' | 'tool-result' | 'output'
  | 'done' | 'error' | 'cancelled';

The terminal phases (done, error, cancelled) close the frame stream for that requestId. The isTerminalPhase() helper exported from @open-matrix/core is what UI components use to know when to stop accumulating frames.

How frames reach the caller

The caller passes activityTo: [mount1, mount2, ...] on the outgoing $prompt. The agents layer (system.agents) reads that field and emits $activity envelopes carrying ActivityFrame payloads to each of those mounts via sendToInbox (which crosses root boundaries).

ts
// Caller
this.sendTo('mypackage.helper', '$prompt', {
  prompt: 'do the thing',
  activityTo: [this.mount],   // me
  sessionId: 'sess-abc',
});

// Caller's on$activity handler
on$activity(payload: ActivityFrame) {
  if (payload.phase === 'token') {
    this.appendToken(payload.data);
  } else if (isTerminalPhase(payload.phase)) {
    this.finalize();
  }
}

MatrixActor.on$activity is a no-op by default (MatrixActor.ts:893-895). MatrixActorHtmlElement overrides it to drive the reactive UI: accumulate frames in this._activityFrames, schedule a render, and call _onActivity for subclass hooks.

Note on dispatch table casing: toHandlerCase('$activity') produces $activity (lowercase a after $). The dispatch table looks up on$activity, NOT on$Activity. The base class comments this explicitly at MatrixActor.ts:890-892. Watch the exact casing when overriding.

Continuing a session

To continue a session across turns, pass the same sessionId on each $prompt:

ts
const sessionId = 'sess-' + crypto.randomUUID();

await this.invokeAgent(prompt1, { sessionId, blocking: true });
await this.invokeAgent(prompt2, { sessionId, blocking: true });   // continues

The agents layer threads the conversation history into the LLM call automatically when sessionId is supplied. Without it, every $prompt starts a fresh session.

For browser components, the framework keeps a _agentSessionId per MatrixActorHtmlElement instance (MatrixActorHtmlElement.ts:333-334) so successive $prompt calls from the same component element thread by default.

Identifiers as causal chain

When a session's prompt invokes a tool (a sub-actor), the framework propagates trace IDs:

trace-A (top-level user interaction)
├── span-A1 (initial $prompt to system.agents)
│    └── tool-start frame: target=system.factotum, op=put-credential
│        └── span-A1-T1 (factotum operation, parentSpanId=A1)
└── span-A2 (follow-up $prompt with sessionId)

This is what makes the activity stream debuggable. Aggregating frames by traceId reconstructs a complete user-level interaction.

Persistence

The session itself (the conversation history) is the responsibility of @open-matrix/agents and @open-matrix/system-conversation, not @open-matrix/core. The SDK provides:

  • The ActivityFrame shape and the $prompt trampoline.
  • static stateScope and static persistState on MatrixActor (see Actor state).
  • IRecordStore (the per-package observation store) — used by the dispatch path to record op begin/end (MatrixActor.ts:2900-2910, :2980-2997).

How records are stored, how they're queried, and how transcripts get rebuilt from them is documented in @open-matrix/system-conversation and is out of scope for the SDK reference.

MatrixActor carries a small set of static fields that participate in sessions. Each is documented inline in MatrixActor.ts:267-355:

FieldWhat it controls
static promptableDefault false. When true, the actor runs an oracle loop, has prompt triggers, memory queries, plan tracking, and budget tracking.
static purposeShort description used in the LLM's system prompt.
static systemPromptCustom fragment appended after the standard system prompt.
static memoryScope'session' | 'actor' | 'subtree'. How far back the actor can query its own records.
static regime'explore' | 'focused' | 'strict'. Cascades to children if more restrictive.
static promptTriggersDeclarative event-to-prompt bindings (Phase 2).
static skillsHigher-level skill bundles that compose multiple ops into named actions.

These belong to the cognitive layer; most package authors do not touch them. They are documented here only because they alter session behaviour.

See also

Source: projects/matrix-3/packages/core/src/core/MatrixActor.ts:1173-1276 (on$prompt), :893-895 (on$activity); projects/matrix-3/packages/core/src/framework/ActivityFrame.ts (full file).