NoLag was built to power human real-time experiences: chat rooms where people exchange messages, dashboards where operators monitor live data, and tracking systems where dispatchers watch vehicles move on a map. When we started exploring multi-agent AI systems, we realized the infrastructure we built for humans maps almost perfectly to agent coordination.
This post explains how NoLag's real-time primitives translate to agent coordination patterns, the design decisions we made for each, and the performance characteristics that matter for agent workloads.
The Mapping
Real-time messaging has three core primitives: topics (named channels for routing), rooms (groups of related topics), and presence (who is connected). Agent coordination has the same three needs: task routing, workflow grouping, and agent health tracking.
# Human chat (@nolag/chat): the topics inside a room
messages chat messages
_typing typing indicators, ephemeral
_stream live token chunks of a streamed message, ephemeral
# Agent coordination (@nolag/agents): the topics inside a room
tasks task envelopes (Handoff)
results result envelopes, routed to the dispatcher by filter
state shared key-value state (Blackboard), published retained
events observability stream (Observe)
inbox direct agent-to-agent messages (Inbox)
tools tool requests (Tools)
approval human-in-the-loop requests and decisions (Approve), published retainedThe topic names change. The semantics are different. But the underlying infrastructure - message routing, delivery guarantees, retained state, and fan-out - is identical. Both wrappers publish at the client's default QoS (1 on the broker hop); the two agent topics that hold state rather than a stream, state and approval, are published retained so a late joiner receives the latest value.
Topics as Task Queues
In a chat app, a topic is a channel where messages flow from publishers to subscribers. In an agent system, a topic is a task queue. The Handoff pattern uses tasks as a work queue: the orchestrator publishes task envelopes, workers subscribe and act on the ones whose capability they registered for.
The property that turns a topic into a queue is not a QoS level, it is load balancing. By default every subscriber to tasks receives every task, which is what you want for a single worker per capability and wrong for a pool of identical workers. Connect the pool with loadBalance: true and a shared loadBalanceGroup and the broker delivers each task to exactly one member of the group. The agents SDK inherits that setting from the core client for tasks and tools only, and forces it off for the broadcast topics.
Retained messages serve a different purpose in each context. For chat, retention on the broker is not used: history lives in your own store. For agents, retention is how state and approval behave like a whiteboard rather than a stream: whoever joins sees the current value without waiting for the next write. Holding a task until a worker is available is a separate feature, replay, and it applies only to load-balanced worker groups with persistent sessions on the hosted platform (see replay); a lone worker that was offline when a task was dispatched does not get it later.
Presence as Health Monitoring
Human presence is simple: online, away, or offline. Agent presence needs more metadata: capabilities, model, provider, whatever routing needs. We extended the presence payload to carry this information without changing the underlying protocol.
{
"actorTokenId": "at_live_7f3a...",
"presence": {
"userId": "u_8x2k9",
"username": "Alice",
"status": "online"
},
"joinedAt": 1745827200000
}Both are the same ActorPresence shape: an actorTokenId, the presence data the actor set, and (in fetched snapshots) joinedAt. The agents SDK fills presence with AgentPresenceData (name, role, optional capabilities and metadata); the outer status is online, offline, or waking and only appears for persistent agents that stay discoverable while disconnected.
The Handoff pattern uses presence as a gate. Before publishing a task, dispatch() looks at who is in the room and throws if no connected agent lists the required capability. It does not look at load or capacity, because presence does not carry them: the worker's own onTask filter and, for pools, the broker's load-balance group decide who acts on a task.
QoS Levels for Different Patterns
Not every agent message needs the same delivery guarantee. Just like in human real-time, the right QoS level depends on the consequence of a lost message, and it is worth being precise about what QoS covers on NoLag. The level you pick (0, 1 or 2, default 1) is applied to the broker's internal MQTT hop. The WebSocket leg between your process and the broker has an optional published acknowledgement, surfaced as the emit callback, and no resend. There is no exactly-once guarantee end to end, so the envelopes carry the ids you need to tolerate a duplicate.
import { NoLag } from "@nolag/js-sdk";
import { NoLagAgents, Handoff, createStateEnvelope } from "@nolag/agents";
const client = NoLag(TOKEN);
await client.connect();
// QoS 0 (fire and forget): typing indicators, cursor positions.
// Lost messages are harmless: the next update overwrites anyway.
const doc = client.setApp(COLLAB_APP_SLUG).setRoom("my-doc");
doc.emit("cursors", { x: 120, y: 48 }, { qos: 0 });
// QoS 1 (at least once): the default for everything the wrappers publish.
// Results carry a correlationId, so a pending dispatch resolves once and a
// duplicate result is dropped. Tasks carry a taskId for your worker to do
// the same on its side.
const agents = new NoLagAgents({ client, appName: AGENTS_APP_SLUG, agentId: "orchestrator" });
await agents.ready();
const room = agents.room("research-pipeline");
const handoff = new Handoff(room);
const result = await handoff.dispatch("research", { query }, { waitForResult: true });
// QoS 2 on the broker hop: for a raw publish where you also want the
// broker-side dedup. It adds nothing on the WebSocket leg.
const envelope = createStateEnvelope("run:42", "started", 1, agents.agentId);
room.context.emit("state", envelope, { qos: 2, retain: true });This is one of the advantages of building on real messaging infrastructure rather than a custom agent framework. Delivery levels are a solved problem in messaging. We don't need to reinvent them; we map each pattern to the level it needs and put the idempotency keys (task ids, correlation ids, state versions) in the envelopes so your handlers can be idempotent.
Rooms as Workflow Boundaries
In chat, rooms group related conversations. In agent coordination, rooms group related workflows. A room called research-pipeline contains all the topics for that pipeline: task dispatch, results, shared state, and events. A separate room called content-review contains its own isolated set of topics.
This isolation is important. Just as you don't want messages from #general leaking into #engineering, you don't want tasks from one workflow accidentally being picked up by workers in another. Rooms provide that boundary for free. They are created on the control plane, never implicitly: subscribing to a room that does not exist returns unknown_topic.
Performance Characteristics
Agent workloads have different performance profiles than human real-time:
| Metric | Human Realtime | Agent Realtime |
|---|---|---|
| Message size | Small (chat text, GPS coords) | Medium-large (task payloads, LLM outputs) |
| Message rate | Bursty (typing, idle cycles) | Steady (continuous task dispatch) |
| Latency sensitivity | High (users notice 200ms+) | Moderate (agents tolerate seconds) |
| Delivery guarantee | Mixed (QoS 0-1) | QoS 1 with idempotent envelopes |
| Connection count | Many (one per user/tab) | Few (one per agent process) |
| Fan-out ratio | High (1:1000 in large rooms) | Low (1:1 task dispatch, 1:N observe) |
Agent workloads typically involve fewer connections but larger payloads and stricter delivery requirements. NoLag's 900KB message size limit accommodates most LLM outputs. The binary MessagePack protocol keeps overhead low even for large payloads. And for worker pools, load-balanced groups with persistent sessions keep tasks from being lost while a worker restarts.
Access Control Carries Over
In human systems, access control prevents unauthorized users from reading messages in rooms they haven't joined. In agent systems, the same ACL mechanism prevents agents from accessing workflows they shouldn't participate in.
Each agent connects with an actor token that grants access to specific apps and rooms. A research worker can only subscribe to topics in the research workflow. A monitoring dashboard can observe events but not dispatch tasks. The same per-topic ACL that protects chat messages protects agent coordination.
The Insight
Agent coordination is a real-time messaging problem wearing a different hat. The primitives are the same: publish, subscribe, presence, retained state, and access control. The patterns change (task handoff instead of chat messages, blackboard instead of typing indicators, approval gates instead of read receipts), but the infrastructure doesn't.
That's why we built @nolag/agents as a high-level SDK on top of the same core infrastructure. No new servers, no new protocols, no new persistence layer. Just new patterns mapped onto proven primitives.