Appearance
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:
- Lamport update. If the envelope has
lamport, advance our clock past it. - Op admittance. If
opis not in the union of system ops + declaredaccepts+ framework ops, drop silently. - History push. Bounded ring buffer of the last 100 commands (
_commandHistory). - 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 canallow,deny, ormutatethe envelope. - 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.removeYou 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 anacceptsentry has only adescriptionand 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$introspectare accepted as an alias. New code should prefer$introspectto 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
$eventsand 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
$activityframes.
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
- Define an actor — the static surface, op declarations.
- Actor lifecycle — when
_subscribeToInbox()runs. - Actor sessions — the
$prompt/$activityprotocol. - Actor Communication Contract — the six declarations and
$introspect.
Source:
projects/matrix-3/packages/core/src/core/MatrixActor.ts:111-139(system commands list),:2820-3030(_subscribeToInboxand dispatch),:840-842($introspectalias),:1530-1704(onIntrospect).