Skip to content

Actor ops

An "op" in Matrix is the verb on an envelope. Ops arrive on {root}.{mount}.$inbox, and the framework dispatches them to a method on your actor. This page covers the full dispatch path, the system ops every actor handles, the sealed ops you cannot override, and the request/reply contract.

The single inbox subscription

When an actor is initialized, the framework creates exactly one transport subscription, on {mount}.$inbox (MatrixActor.ts:2820-2900). All inbound dispatch flows through it. There is no per-op subscription. Adding an op to static accepts adds a row to the dispatch table, not a subscription.

The subscription handler is called with (payload, meta) where payload is an MxEnvelope:

ts
interface MxEnvelope {
  op: string;
  payload: unknown;
  from?: string;       // sender mount (set by transport, NOT by caller)
  lamport?: number;    // logical clock value
  correlationId?: string;
  replyTo?: string;    // request/reply: where the response goes
  // ...
}

The framework does five things, in order:

  1. Lamport update. If the envelope has lamport, advance our clock past it.
  2. Op admittance. If op is not in the union of system ops + declared accepts + framework ops, drop silently.
  3. History push. Bounded ring buffer of the last 100 commands (_commandHistory).
  4. Policy / membrane evaluation. Skip for system-exempt ops ($ping, $introspect, $join, $join_ack, $leave, $membrane, $membrane-inherit). Otherwise run the security realm's policy engine and/or membrane Lisp program — they can allow, deny, or mutate the envelope.
  5. Dispatch. If the actor has a mailbox, enqueue. Otherwise call dispatchTable[op] directly.

The dispatch table is built once at _subscribeToInbox() time. For each admitted op, the framework looks up on{toHandlerCase(op)} on this and binds it. If the method does not exist, the entry falls back to onMessage if present (catch-all), otherwise the op silently no-ops.

Op categories

The framework recognizes three kinds of ops:

System ops — auto-subscribed, NOT in static accepts

The SYSTEM_COMMANDS array in MatrixActor.ts:111-139 lists what every actor handles for free:

$join, $join_ack, $leave,
introspect, $introspect, $ping,
$reply, $getState, $getHistory, $createControl, $activity,
$preferences, $config,
$membrane-inherit, $membrane,
$skill, $cognitive.set, $cognitive.get,
$triggers.add, $triggers.remove, $triggers.list,
$skills.add, $skills.remove

You can override these by defining on$ping, on$introspect, etc., in your subclass — except for the sealed list (see below).

Domain ops — declared in static accepts

Anything you put into static accepts is a domain op. The framework routes envelopes whose op matches a key in that record. The handler lookup is on{toHandlerCase(op)}.

ts
class MyActor extends MatrixActor {
  static accepts = {
    'foo.bar': { description: 'Bar-ify a foo', schema: {}, returns: {} },
  };
  async onFooBar(payload: unknown) { ... }
}

Tip: _subscribeToInbox() warns at startup if an accepts entry has only a description and no parameter schema. Agents need both.

Framework ops — environment-dependent

Two ops are dispatched only on headless actors (not when wrapped by a MatrixActorHtmlElement shell): $prompt and $source-code. Plus factory.create-from-code, factory.load, component.save. The shell handles its own copies (and would race the inner actor if both fired). See MatrixActor.ts:2851-2854.

Sealed ops

$prompt is sealed (MatrixActor.ts:2870-2887). Subclasses cannot override it; if they try, the framework logs an error and uses the base implementation anyway. The base on$prompt handler is the framework's prompt trampoline that builds context and routes to system.agents. Domain-specific prompt handling belongs in named ops (session.start, codex.prompt, chat.send-message, etc.), not in $prompt.

The single exception: an actor with static isPromptRouter = true (e.g., AgentsRoot, AgentActor) is the prompt router itself and is allowed to handle $prompt directly.

The $introspect contract

$introspect is the cornerstone of agent discoverability. Every actor implements it; the base class supplies a default (MatrixActor.ts:1530-1704 for onIntrospect, :840-842 for the $ alias). It returns:

ts
{
  mount: string,
  description?: string,
  accepts: Record<string, ...>,
  emits: Record<string, ...>,
  state: Record<string, ...>,
  streams?: Record<string, ...>,
  subscribes?: Record<string, ...>,
  blackboard?: Record<string, ...>,
  contract?: ActorContract,
  shell?: { tag, moduleUrl, description },
  children?: Array<{ id, mount, ... }>,
}

$introspect accepts an optional { depth: 'basic' | 'full' } payload. 'full' includes outputSchemas, errorSpecs, and examples — useful for agent prompt construction.

To call introspect over the bus:

ts
import { RequestReply } from '@open-matrix/core';

const intro = await RequestReply.request(
  transport,
  'system.factotum',
  '$introspect',
  { depth: 'full' },
);

Note: Both introspect (no $) and $introspect are accepted as an alias. New code should prefer $introspect to make it explicit it's a system op.

Request/reply

For a "call and wait for the response" pattern, use RequestReply (or its lower-level requestReply function from @open-matrix/core). It allocates a correlation id and a $reply.{correlationId} topic, attaches them to the envelope as replyTo + correlationId, then awaits the matching $reply envelope.

ts
import { RequestReply } from '@open-matrix/core';

const result = await RequestReply.request<{ greeting: string }>(
  this._context!.transport,
  'greeter',
  'greeter.hello',
  { name: 'world' },
  { timeoutMs: 5000 },
);

On the receiving side, returning a value from your handler is enough. If the incoming envelope has replyTo, the framework routes the return value to the right reply topic for you (MatrixActor.ts:3000-3015).

Fire-and-forget

sendTo(mount, op, payload) publishes to the target's $inbox without a replyTo (MatrixActor.ts:1815-1832). The receiver's handler runs; its return value is discarded. Use this when:

  • The receiver is going to emit on $events and you'll subscribe to those.
  • You explicitly do not want a response (logs, metrics, fan-out).
  • You are starting a long-running task and will track it via $activity frames.

Error handling

If a handler throws and the envelope had a replyTo, the framework sends an error reply back to the caller via RequestReplyHelper.sendErrorReply. The error is wrapped: caller gets { error: { message: '...' } }.

If the handler throws on a fire-and-forget op, the error is logged to the kernel (console.error) and dispatch moves on. There is no upstream propagation.

Op-name conventions

Conventions used across the tree (these are conventions, not enforcement):

  • Namespaced. Use {package}.{verb} or {package}.{noun}.{verb}. Examples: chat.send-message, system.factotum.put-credential, system.runtimes.list.
  • Lowercase, hyphen-separated verb. list, get, create, put-credential, set-active-model. Avoid camelCase in op names; the handler-name conversion will produce sensible TypeScript.
  • $-prefixed for system ops only. Don't mint your own $myOp; use a domain-namespaced name.
  • Pagination. List ops return cursor-based pagination (root CLAUDE.md § "Coding standards" #7).
  • Idempotency. Write ops accept an optional idempotencyKey (CLAUDE.md #8).

See also

Source: projects/matrix-3/packages/core/src/core/MatrixActor.ts:111-139 (system commands list), :2820-3030 (_subscribeToInbox and dispatch), :840-842 ($introspect alias), :1530-1704 (onIntrospect).