The AI agent landscape has a gap. We have incredible LLMs, solid frameworks for building individual agents (LangChain, CrewAI, AutoGen), and growing demand for multi-agent systems that can tackle complex workflows. What we don't have is good coordination infrastructure.
Most teams building multi-agent systems end up reinventing the same coordination plumbing: task queues on Redis, state management on a database, custom WebSocket servers for real-time updates, and bespoke logging for observability. This is undifferentiated heavy lifting that distracts from the actual agent logic.
The Problem with Direct Orchestration
The simplest multi-agent pattern is direct orchestration: a coordinator calls agents in sequence, passing outputs from one to the next. It looks clean in a demo.
// The naive approach: direct function calls
const research = await researchAgent.run(query);
const draft = await draftAgent.run(research);
const review = await reviewAgent.run(draft);
const final = await publishAgent.run(review);
// Problems:
// - Sequential, no parallelism
// - No visibility into what's happening
// - No way to retry a single step
// - No shared state between agents
// - No human approval before publish
// - If a worker crashes mid-step, the step is lostThis approach breaks down as soon as you need any of the following: parallel execution across multiple workers, visibility into what each agent is doing, the ability to retry a single failed step, shared state that all agents can read and write, human approval gates before high-stakes actions, or resilience when a worker process crashes.
These aren't edge cases. They're table stakes for production multi-agent systems.
Why Pub/Sub Is the Right Primitive
Publish/subscribe messaging solves the coordination problem at the right level of abstraction. Here's why:
Decoupling. Publishers don't know about subscribers. An orchestrator dispatches a task to a topic. Any worker subscribed to that topic with matching capabilities picks it up. You can add, remove, or restart workers without changing the orchestrator.
Fan-out. A single event can reach multiple interested parties: the worker that processes it, the dashboard that displays it, the logger that records it, and the human who might need to approve the result.
Durable delivery for workers. Put workers in a load-balanced group, connected as agent actors (the actor type whose broker session persists), and the broker hands each task to one member of the group and holds the tasks dispatched while the group was scaled to zero until a member comes back. Tasks dispatched while no worker was listening are not lost; see Replay and Durable Delivery for the precise contract, including what it does not cover. Shared state is published retained, so an agent that joins late reads the current plan instead of waiting for the next change. None of this is exactly-once delivery: a task can, in principle, be seen twice, so keep task handlers idempotent.
Presence. Built-in presence tracking means you always know which agents are online and what each one can do, because every agent advertises its capabilities as presence data. With persistent presence, a scaled-to-zero agent stays discoverable with a status of offline or waking and is woken when work arrives. No custom health check infrastructure needed. (Presence does not report load: "idle" or "overloaded" is something an agent would have to publish itself.)
Six Patterns as Coordination Primitives
We identified six patterns that cover the coordination needs of multi-agent systems:
- Handoff - Task dispatch and result collection with capability-based routing
- Inbox - Direct agent-to-agent messaging for point-to-point communication
- Blackboard - Shared state with versioning (last writer wins) so all agents see the same world
- Observe - Real-time event stream for monitoring, logging, and debugging
- Approve - Human-in-the-loop gates for high-stakes decisions
- Tools - Remote tool invocation so agents can share capabilities
Each pattern maps to specific topics in the room (tasks, results, state, events, inbox, tools, approval) and a typed message envelope. But you interact with them through a high-level SDK that hides the pub/sub mechanics.
What Coordination Actually Looks Like
Here's the same content pipeline from earlier, but with proper coordination infrastructure. The orchestrator and each worker connect with their own actor tokens (the broker never delivers a message back to the actor that published it), attach @nolag/agents to the connection, and meet in a room.
import { NoLag } from "@nolag/js-sdk";
import { NoLagAgents, Handoff, Blackboard, Approve, Observe } from "@nolag/agents";
// APP_SLUG is the slug returned when you created the app from the
// nolag-agents-sdk blueprint (it has a random suffix). Create the
// content-pipeline room in the control plane first: rooms never exist implicitly.
const client = NoLag(ORCHESTRATOR_TOKEN);
const agents = new NoLagAgents({
client,
appName: APP_SLUG,
agentId: "orchestrator",
presence: { name: "orchestrator", role: "orchestrator" },
});
await client.connect();
await agents.ready();
const room = agents.room("content-pipeline");
// Shared state visible to all agents (retained, so late joiners see it)
const blackboard = new Blackboard(room, agents.agentId);
blackboard.set("plan", {
steps: ["research", "draft", "review", "publish"],
current: 0,
});
// Dispatch to any worker advertising the capability, and wait for its result.
// dispatch() throws if no connected agent advertises the capability.
const handoff = new Handoff(room);
async function run(capability: string, payload: Record<string, unknown>) {
const result = await handoff.dispatch(capability, payload, { waitForResult: true });
if (!result || result.status !== "success") throw new Error(`${capability} failed`);
return result.payload;
}
const research = await run("research", { query });
const draft = await run("draft", { research });
const review = await run("review", { draft });
// Human gate before publish
const approve = new Approve(room, agents.agentId);
const { decision, reason } = await approve.request("publish", review, { urgency: "high" });
if (decision === "approved") {
await run("publish", { review });
} else {
log("publish", decision, reason);
}
// Full observability: everything agents emit on the room's events topic
new Observe(room, agents.agentId).on((event) => log(event.category, event.severity, event.payload));The difference: workers can be scaled independently (every worker in a load-balanced group receives a share of the tasks rather than a copy), every event a worker emits is observable, shared state is accessible to all agents, human approval gates are built in, and a worker that restarts drains the tasks its group missed while it was away.
Two honest details. Handoff does not narrate itself: nothing appears on the events topic unless an agent calls observe.emit, which is why the worker above emits at each step. And approve.request resolves with a decision of approved, rejected or deferred, so the orchestrator has to handle all three rather than a boolean.
NoLag as the Substrate
NoLag was built as real-time messaging infrastructure for chat, notifications, and IoT. It turns out that the same primitives - topics, rooms, presence, QoS, and access control - map directly to multi-agent coordination needs.
@nolag/agents is a high-level SDK that wraps these primitives into the six patterns above. Under the hood, it uses the same WebSocket infrastructure that carries NoLag's chat, notification and IoT traffic. You get load-balanced dispatch, durable catch-up for worker groups, presence-based discovery, retained shared state and per-topic ACL without building any of it yourself.
The key insight: agent coordination is a real-time messaging problem. And real-time messaging is a solved problem - if you use the right infrastructure.