Concepts

Channels

Named streams of events that agents subscribe to and publish into: what they carry, who receives what, and how events leave the network.

A channel is a named stream of events inside a network: agents subscribe to channels to receive events and publish to channels to send them.

Mental model

Each lane is a channel, main first. An agent sits where the channel it subscribes to meets the channel it publishes to, and a proxy is a port on the network's border where a channel's events leave it (here sse on client).

Wiring

This setup adds a work and a client channel next to main and wires three agents to them:

channels.ts
export const const network: AgentNetwork<never>network = class AgentNetwork<TDeps extends DependencyLayerDef<string, unknown, S.Schema.Any> = never>AgentNetwork.AgentNetwork<TDeps extends DependencyLayerDef<string, unknown, S.Schema.Any> = never>.setup(callback: (ctx: AgentNetworkSetupContext) => void, options?: AgentNetworkSetupOptions): AgentNetwork<never>setup(
  ({ mainChannel: ConfiguredChannelmainChannel, createChannel: (name: string) => ConfiguredChannelcreateChannel, 
proxy: {
    sse(): ProxyDef;
    custom(kind: string, config?: unknown, direction?: ProxyDirection): ProxyDef;
}
proxy
, registerAgent: (agent: AnyAgent) => AgentBindingregisterAgent, endsOn: <E extends EventDef>(event: E, when?: (envelope: EventEnvelope<E>) => boolean) => void
Declares a terminal event: when it is published in a run (and `when` accepts it, if given), the run completes with `m4trix:run.completed`, its in-flight agents are aborted and its stream closes. Call it once per terminal event.
endsOn
}) => {
const const work: ConfiguredChannelwork = createChannel: (name: string) => ConfiguredChannelcreateChannel('work').ConfiguredChannel.events(events: ReadonlyArray<EventDef>): ConfiguredChannel
Declares the events this channel carries. Publishing any other event to it is refused: an agent's emit throws `EmitValidationError` (reason `channel`), an external publish resolves `false`. Runtime `m4trix:*` events always pass. Without a declaration a channel carries every event.
events
([
const Task: AgentNetworkEventDef<"task", S.Struct<{
    topic: typeof S.String;
}>>
Task
]);
const const client: ConfiguredChannelclient = createChannel: (name: string) => ConfiguredChannelcreateChannel('client').ConfiguredChannel.events(events: ReadonlyArray<EventDef>): ConfiguredChannel
Declares the events this channel carries. Publishing any other event to it is refused: an agent's emit throws `EmitValidationError` (reason `channel`), an external publish resolves `false`. Runtime `m4trix:*` events always pass. Without a declaration a channel carries every event.
events
([
const Answer: AgentNetworkEventDef<"answer", S.Struct<{
    text: typeof S.String;
}>>
Answer
]).ConfiguredChannel.proxy(...proxies: ProxyDef[]): ConfiguredChannelproxy(
proxy: {
    sse(): ProxyDef;
    custom(kind: string, config?: unknown, direction?: ProxyDirection): ProxyDef;
}
proxy
.function sse(): ProxyDefsse());
registerAgent: (agent: AnyAgent) => AgentBindingregisterAgent(
const router: Agent<unknown, {
    name: "user-message";
    meta: EventMeta;
    payload: {
        readonly text: string;
    };
}, {
    name: "task";
    payload: {
        readonly topic: string;
    };
}, never, never, never>
router
).function subscribe(channel: ConfiguredChannel): AgentBindingsubscribe(mainChannel: ConfiguredChannelmainChannel).function publishTo(channel: ConfiguredChannel): AgentBindingpublishTo(const work: ConfiguredChannelwork);
registerAgent: (agent: AnyAgent) => AgentBindingregisterAgent(
const worker: Agent<unknown, {
    name: "task";
    meta: EventMeta;
    payload: {
        readonly topic: string;
    };
}, {
    name: "answer";
    payload: {
        readonly text: string;
    };
}, never, never, never>
worker
).function subscribe(channel: ConfiguredChannel): AgentBindingsubscribe(const work: ConfiguredChannelwork).function publishTo(channel: ConfiguredChannel): AgentBindingpublishTo(const client: ConfiguredChannelclient);
registerAgent: (agent: AnyAgent) => AgentBindingregisterAgent(const auditLog: Agent<unknown, never, never, never, never, never>auditLog).function subscribe(channel: ConfiguredChannel): AgentBindingsubscribe(mainChannel: ConfiguredChannelmainChannel).function subscribe(channel: ConfiguredChannel): AgentBindingsubscribe(const work: ConfiguredChannelwork);
endsOn: <AgentNetworkEventDef<"answer", S.Struct<{
    text: typeof S.String;
}>>>(event: AgentNetworkEventDef<"answer", S.Struct<{
    text: typeof S.String;
}>>, when?: ((envelope: {
    ...;
}) => boolean) | undefined) => void
Declares a terminal event: when it is published in a run (and `when` accepts it, if given), the run completes with `m4trix:run.completed`, its in-flight agents are aborted and its stream closes. Call it once per terminal event.
endsOn
(
const Answer: AgentNetworkEventDef<"answer", S.Struct<{
    text: typeof S.String;
}>>
Answer
);
}, );

The events and agents it wires live in the same file; the player below shows them next to this code.

How a run flows

One run of this network, recorded from the real runtime, starting with a user-message published on main:

1 of 4

1. user-message is published on main from outside the network; router picks it up, and auditLog, which has no listensTo and so takes every event on main, gets a copy too.

Step 1 of 4: user-message is published on main from outside the network; router picks it up, and auditLog, which has no listensTo and so takes every event on main, gets a copy too.

Payload user-message
{
  "text": "Why did checkout get slower?"
}
channels.tsline 52
export const router = AgentFactory.run()
  .listensTo([UserMessage])
  .emits([Task])
  .logic(async ({ triggerEvent, emit }) => {
    emit({ name: 'task', payload: { topic: triggerEvent.payload.text } });
  })
  .produce({});

export const worker = AgentFactory.run()
  .listensTo([Task])
  .emits([Answer])
  .logic(async ({ triggerEvent, emit }) => {
    emit({ name: 'answer', payload: { text: `Looked into: ${triggerEvent.payload.topic}` } });
  })
  .produce({});

// No listensTo: it receives every event on the channels it subscribes to.
export const auditLog = AgentFactory.run()
  .logic(async ({ triggerEvent }) => {
    console.log('[audit]', triggerEvent);
  })
  .produce({});

export const network = AgentNetwork.setup(
  ({ mainChannel, createChannel, proxy, registerAgent, endsOn }) => {
    const work = createChannel('work').events([Task]);
    const client = createChannel('client').events([Answer]).proxy(proxy.sse());

    registerAgent(router).subscribe(mainChannel).publishTo(work);
    registerAgent(worker).subscribe(work).publishTo(client);
    registerAgent(auditLog).subscribe(mainChannel).subscribe(work);

    endsOn(Answer);
  },
);

/** What the docs recorder publishes to start a run of this snippet. */
export const scenario = {
  start: UserMessage.make({ text: 'Why did checkout get slower?' }),
};
  1. user-message is published on main from outside the network; router picks it up, and auditLog, which has no listensTo and so takes every event on main, gets a copy too.
  2. router receives it and emits task on work, a channel that only carries task; worker takes it from there, and auditLog gets a copy too because it also subscribes to work.
  3. worker receives it and emits answer on client, a channel that only carries answer; no agent subscribes to client, so answer leaves the network through its sse proxy and, as a terminal event, ends the run.
  4. The runtime then publishes m4trix:run.completed on every channel (main, work and client) to close the run; runtime events never invoke agents, so not even auditLog receives it.

Rules and guarantees

Defining channels

  • Names are kebab-case: lowercase letters and digits in hyphen-separated segments, such as work or client-output. Any other name makes AgentNetwork.setup() throw. Names are typed as the branded ChannelName.
  • main always exists. The network creates it, and mainChannel in the setup context is that channel, not a function: pass it straight to subscribe or publishTo. expose() publishes start events to it.
  • One channel per name. createChannel(name) returns the existing channel when the name is taken, so createChannel('main') is mainChannel.
  • Agents choose their channels. Each subscribe(channel) or publishTo(channel) call on the binding from registerAgent adds one channel; an agent can have several of each.

What a channel carries

  • Everything, unless declared. .events([...]) limits a channel to those events. Calling it again replaces the list, and an empty list carries everything again. See .events().
  • An agent's emit goes where it is accepted. The event is published to each of the agent's publishTo channels that accepts it and skips the others. If the agent has publishTo channels and none of them accepts it, emit throws EmitValidationError (reason: 'channel'): the invocation fails even if the agent catches the error, and the network's onAgentError policy applies (run failure kind invalid-emit). An agent without any publishTo channel publishes nowhere instead (see Common mistakes).
  • Outside publishes are dropped, not thrown. plane.publish, or a proxy's publish / publishInbound, of an event the channel does not accept resolves false, delivers nothing and logs a warning through the network's logger. The run keeps going.
  • Start events must fit main. expose() throws at setup when main declares events that exclude one of its triggerEvents.

Delivery

  • Broadcast, not a work queue. Every agent subscribed to the channel whose listensTo names the event gets it, and so does every reader of the channel (plane.subscribe, SSE streams).
  • Catch-all agents. An agent without .listensTo(), like auditLog, receives every event on the channels it subscribes to, except the runtime's m4trix:* events.
  • Once per agent. An event published to several channels at once reaches each agent once, even when the agent subscribes to more than one of them.
  • One at a time, in order, per run. Each agent has one inbox per run, merged across its channels, and handles that run's events one at a time in publish order. Different agents handle the same event concurrently, runs proceed in parallel, and nothing orders one agent's work against another's. See Concurrency model.
  • Emits keep their order. Everything one invocation emits is published in the order it called emit.

Runtime events

  • Every channel gets them. When a run ends, the runtime publishes m4trix:run.completed, m4trix:run.failed or m4trix:run.cancelled to every channel of the network (and m4trix:agent.error under onAgentError: 'continue'). They pass any .events() declaration and never trigger agents. The m4trix: prefix is reserved: AgentNetworkEvent.of() rejects it. See Run Lifecycle.

Proxies

  • A proxy is a declaration. .proxy(...) marks a channel whose events cross the network's border; network.expose(...) creates the stream that reads it. A channel can declare several proxies.
  • proxy.sse() is built in. registerSSEStream() streams one channel: the one passed as channel (it must declare sse, or expose() throws), else the only channel with an sse proxy, else client, else main. With several sse channels and no channel, expose() throws. See registerSSEStream() options.
  • Other kinds are your own. Declare one with proxy.custom(kind, config?, direction?) or defineProxyKind(kind) and expose it with registerCustomProxy or the kind's .register(). The direction defaults to 'outbound'. Kafka and Socket.IO bridges are not built in. See Custom Proxies.

Setup-time check

  • Contradictions throw. When the setup callback returns, AgentNetwork.setup() compares what each agent (and its tools) emits and listens to with what its channels accept. It throws a NetworkWiringError listing every emit-not-accepted (no publishTo channel accepts an event from .emits()) and listen-not-accepted (no subscribed channel accepts an event from .listensTo()).
  • Smells are logged. unconsumed-event, agent-publishes-nowhere, agent-not-subscribed, agent-unreachable and terminal-event-not-emitted go to the network's logger, and the network runs.
  • What counts as the border. The check treats main and channels with an 'inbound' or 'bidirectional' proxy as fed from outside, and channels with a proxy that is not 'inbound', or named client, as streamed out.
  • network.validate() runs the same check without throwing. See Wiring check.

Common mistakes

  • Calling mainChannel('main'). mainChannel is already the main channel: write subscribe(mainChannel).
  • Wiring an event to a channel that does not declare it. With work declared as .events([Task]), an agent that emits answer and publishes only to work makes setup throw emit-not-accepted; one that listens to answer and subscribes only to work gets listen-not-accepted. Add the event to .events([...]) or wire the agent to a channel that carries it.
  • Forgetting publishTo. registerAgent(worker).subscribe(work) only logs agent-publishes-nowhere. The run still ends on answer (endsOn), but answer lands on no channel, so the stream on client only receives m4trix:run.completed.
  • Reading a quiet check as a correct one. Subscribe worker to mainChannel instead of work and it never runs, yet setup reports nothing: auditLog listens to everything on work, so task counts as consumed, and main counts as fed from outside. Without auditLog, setup warns unconsumed-event.

What next

GoalPage
Define the events channels carryEvents
Declare what agents listen to and emitAgents
Wire agents and channels into a networkNetworks
Fan out, chain agents, wait for repliesPatterns
Stream a channel to the browserStreaming
End runs and handle m4trix:* eventsRun Lifecycle
Look up .events() and .proxy()Channel API
Read every wiring-check codeWiring check