Appearance
Define an actor
A MatrixActor subclass declares what it accepts and emits, supplies handler methods, and the framework wires it onto the bus. This page covers everything from the bare minimum to a complete contract.
The skeleton
ts
import { MatrixActor } from '@open-matrix/core';
export class StockFeedActor extends MatrixActor {
static accepts = {
'market.quote': {
description: 'Get the current price for a symbol',
schema: { symbol: { type: 'string', description: 'Ticker, e.g. "TSLA"' } },
returns: {
price: { type: 'number', description: 'Last trade price in USD' },
timestamp: { type: 'number', description: 'Unix ms of last trade' },
},
},
};
static emits = {
'market.tick': {
description: 'Real-time trade event for any tracked symbol',
schema: {
symbol: { type: 'string', description: 'Ticker' },
price: { type: 'number', description: 'Trade price' },
},
frequency: 'high',
},
};
async onMarketQuote(payload: { symbol: string }) {
const price = await this.fetchQuote(payload.symbol);
return { price, timestamp: Date.now() };
}
private async fetchQuote(symbol: string): Promise<number> {
return 245.50; // placeholder
}
}That is a complete actor. Mounted onto a MatrixRuntime, it answers market.quote requests and is callable from any other actor or any client connected to the bus.
The static surface
Every actor declares some subset of these static fields. They are read by the framework when the actor is constructed and surfaced through $introspect.
| Static field | Purpose |
|---|---|
static accepts | Request/Reply ops this actor handles. The keys are op names, the values describe input/output. |
static emits | Pub/Sub events this actor fires. The keys are op names, the values describe payload + frequency. |
static streams | JetStream durable streams this actor produces (replayable history). |
static state | JetStream KV state this actor publishes (last-value-wins). |
static subscribes | Inbound subscriptions this actor declares (for documentation + auto-wiring). |
static blackboard | Shared facts read/written through the blackboard plane. |
static description | Human-readable self-description. Surfaced in $introspect and oracle snapshots. |
static contract | Optional unified service contract. When set, replaces ad-hoc accepts/emits in $introspect. |
static tokens | Theme tokens this actor declares support for. |
The full type signatures live in projects/matrix-3/packages/core/src/core/MatrixActor.ts:180-300. The grammar of accepts/emits/streams/state/subscribes/blackboard is the Actor Communication Contract: every op and every field has a description. See WORKSTREAMS/core-and-packaging/ACTOR-COMMUNICATION-CONTRACT.md.
Tip: If a field does not have a
description, that is a bug. LLM agents read the descriptions verbatim to decide whether and how to call your actor.
Op-name → handler-name convention
The framework dispatches incoming ops to a method computed from the op name. The mapping is performed by toHandlerCase in engine/utils/NameUtils.ts:
| Op name (incoming) | Handler method (your code) |
|---|---|
market.quote | onMarketQuote |
chat.send-message | onChatSendMessage |
system.factotum.put-credential | onSystemFactotumPutCredential |
$introspect | on$introspect (system op, base class supplies it) |
$ping | on$ping (system op) |
Dots, hyphens, and underscores are collapsed and the next character is uppercased. The leading verb is on. The system ops keep the literal $ prefix because the base class names handlers on$ping, on$introspect, etc.
If an envelope arrives with an op for which no handler exists and the op is not a declared system op, dispatch is silent — there is no exception. Use $introspect to verify what an actor actually handles at runtime.
The two return idioms
A handler can either return a value or void:
- Return a value when the caller used Request/Reply (
accepts). The framework correlates it back to the caller's$reply.{correlationId}topic. - Return
voidwhen the op is fire-and-forget. Subscribers to your$eventstopic will see whatever youemit().
Both styles compose in the same actor:
ts
async onMarketQuote(payload: { symbol: string }) {
return { price: 245.50 }; // request/reply
}
onMarketSubscribe(payload: { symbol: string }): void {
this.subscribers.add(payload.symbol); // fire-and-forget
}Emitting events
this.emit(event, payload) publishes to your $events topic (projects/matrix-3/packages/core/src/core/MatrixActor.ts:1756-1804). The framework wraps your payload in an MxEnvelope with op, from, and a Lamport clock value:
ts
this.emit('market.tick', { symbol: 'TSLA', price: 245.50 });
// publishes envelope { op: 'market.tick', payload: {...}, from: this.mount, lamport: <n> }
// to topic: <root>.<mount>.$eventsYou only emit() ops that you declared in static emits. The framework does not enforce this at runtime, but every gateway, introspector, and consumer reads static emits to decide what to subscribe to. Emitting an undeclared op makes your actor look broken.
Sending to other actors
sendTo(targetMount, op, payload) publishes an envelope to the other actor's $inbox (MatrixActor.ts:1815-1832). It is fire-and-forget — the response, if any, comes back via $events or via a request/reply correlation set up by the helper layer (RequestReply / requestReply).
ts
this.sendTo('chat.conversation', 'chat.append', { text: 'hi' });For request/reply with a typed result, use RequestReply:
ts
import { RequestReply } from '@open-matrix/core';
const result = await RequestReply.request<MyResult>(
this._context!.transport,
'chat.conversation',
'chat.list',
{ limit: 10 },
);For cross-root calls (federation), use invoke():
ts
const result = await this.invoke(
'COM.NIMBLETEC.RICHARD-SANTOMAURO',
'system.inference.request',
{ prompt: 'hi' },
);invoke() requires a transport that implements the invoke extension (see MatrixActor.ts:1899-1909). FederationTransportAdapter does.
A real example: Greeter running on InMemoryTransport
ts
// greeter.ts
import {
MatrixActor,
MatrixRuntime,
InMemoryTransport,
InMemoryBroker,
RequestReply,
} from '@open-matrix/core';
class Greeter extends MatrixActor {
static accepts = {
'greeter.hello': {
description: 'Reply with a greeting addressed to the supplied name.',
schema: { name: { type: 'string', description: 'Person to greet' } },
returns: { greeting: { type: 'string', description: 'Polite hello' } },
},
};
async onGreeterHello(payload: { name: string }) {
return { greeting: `Hello, ${payload.name}!` };
}
}
const broker = new InMemoryBroker();
const transport = new InMemoryTransport(broker, { name: 'demo' });
const runtime = new MatrixRuntime({ transport });
await runtime.create(Greeter, 'greeter');
const result = await RequestReply.request<{ greeting: string }>(
transport,
'greeter',
'greeter.hello',
{ name: 'world' },
);
console.log(result.greeting); // "Hello, world!"
await runtime.shutdown();This is a complete program. It runs in Node, in a browser tab (with the in-memory transport), or as a unit test fixture.
What you usually subclass
For most packages, you do not extend MatrixActor directly. You extend a package-local convenience class that already wires things like security realm, fact-domain hydration, or shell discovery. Examples in the tree:
ChatActorElementextendsMatrixActorHtmlElement— used by every component in@open-matrix/chat.WorkstreamActorextendsMatrixActor— the base for@open-matrix/cognitive-workstreamactors.RootActorextendsMatrixActor— the runtime's mount-""self-actor.
When in doubt, follow the convention of the package you are working in.
See also
- Actor lifecycle —
initialize(), dispose, supervision. - Actor ops — system vs domain,
$introspect, dispatch details. - Actor state —
setState()and$stateChanged. - Actor Communication Contract — the six static declarations.
Source:
projects/matrix-3/packages/core/src/core/MatrixActor.ts:168-340(class + static fields),MatrixActor.ts:1756-1832(emit,sendTo),engine/utils/NameUtils.ts(toHandlerCase).