Coordination Patterns
The @nolag/agents SDK provides six coordination patterns. Each is a standalone class you construct on an AgentRoom, and each maps to one or two of the room's seven topics under the hood.
| Topic | Carries | Notes |
|---|---|---|
tasks | Task envelopes (Handoff) | Load-balanced when the client enables it |
results | Task results and tool responses | Directed to the requesting agent by filter |
state | Blackboard writes | Published retained |
events | Observe events | |
inbox | Direct agent-to-agent messages | |
tools | Tool requests | Load-balanced when the client enables it |
approval | Approval requests and decisions | Published retained |
The nolag-agents-sdk blueprint seeds the default-workflow room with all seven topics. Rooms never exist implicitly, so create any other room in the portal or with api.rooms.create() before an agent joins it.
Every example below assumes this setup:
import { NoLag } from "@nolag/js-sdk";
import { NoLagAgents } from "@nolag/agents";
const client = NoLag(ACTOR_TOKEN);
const agents = new NoLagAgents({
client,
appName: APP_SLUG, // the slug returned when you created the app
agentId: "worker-1",
presence: { name: "worker-1", role: "agent", capabilities: ["research", "finance"] },
});
await client.connect();
await agents.ready();
const room = agents.room("default-workflow");Handoff
The Handoff pattern implements task dispatch and result collection. An orchestrator dispatches a task for one capability. Workers advertise their capabilities through presence and handle the tasks whose capability they registered a handler for. Each result is published back to the dispatcher that asked for it.
When to use
- Distributing work across a pool of specialized agents
- Sharing a stream of tasks across identical workers (with load balancing on the client)
- Fan-out/fan-in workflows where multiple agents process subtasks
Dispatching tasks
import { Handoff } from "@nolag/agents";
const handoff = new Handoff(room);
// Dispatch to any connected worker advertising the "research" capability.
// Throws if no such worker is connected (pass allowNoWorkers: true to publish anyway).
const result = await handoff.dispatch(
"research",
{ url: "https://example.com/article" },
{
priority: "high", // "low" | "medium" | "high" | "critical", default "medium"
timeout: 30000, // rejects if no result arrives within 30 s
waitForResult: true, // resolve with the worker's ResultEnvelope
tags: ["tenant:acme"],
},
);
if (result) {
if (result.status === "success") {
console.log("Summary:", result.payload.summary, "from", result.completedBy);
} else {
console.log("Worker reported:", result.status, result.error?.message);
}
}
// Without waitForResult the call returns once the task is published
await handoff.dispatch("research", { url: "https://example.com/other" });priority and tags travel on the envelope for the worker to read; the SDK does not reorder or queue tasks by priority.
Handling tasks
import { Handoff } from "@nolag/agents";
const handoff = new Handoff(room);
// Only tasks whose capability is in the list reach the handler; pass "*" for all
handoff.onTask(["research", "finance"], async (task, respond) => {
console.log("Received:", task.taskId, task.capability, task.priority, task.payload);
try {
const summary = await doResearch(task.payload);
respond("success", { summary });
} catch (err) {
respond("error", {}, { code: "research_failed", message: String(err) });
}
});The capabilities a worker advertises come from the presence.capabilities option on NoLagAgents; that is what dispatch() checks before publishing. The list passed to onTask is a client-side filter: every worker in the room receives every task and drops the ones it does not handle. For broker-side routing see server-side routing.
Topic mapping
| Topic | Purpose |
|---|---|
tasks | Task envelopes from the dispatcher; one-of-N across a load-balance group when the client enables load balancing |
results | Result envelopes, published with the dispatcher's agent id as filter so only that dispatcher receives them |
Inbox
The Inbox pattern provides direct agent-to-agent messaging. Unlike Handoff, which any capable worker can pick up, Inbox addresses a message to one agent by its agentId. Use it for point-to-point communication between known agents.
When to use
- Sending instructions to a specific agent
- Agent-to-agent conversation threads
- Passing results between pipeline stages with known recipients
Code example
import { Inbox } from "@nolag/agents";
const inbox = new Inbox(room, agents.agentId);
// Send a direct message to the agent whose agentId is "reviewer-agent"
inbox.send("reviewer-agent", {
type: "review-request",
document: draftContent,
deadline: "2026-10-15",
});
// Receive messages addressed to this agent
inbox.onMessage((message) => {
console.log("From:", message.from);
console.log("Payload:", message.payload);
console.log("Sent at:", new Date(message.createdAt).toISOString());
});Every agent in the room receives the inbox topic; each Inbox instance hands your handler only the messages whose to matches its agent id. A recipient that is not connected when the message is published does not receive it later; see Replay for what the broker does and does not replay.
Topic mapping
| Topic | Purpose |
|---|---|
inbox | Direct messages; to names the recipient agent id |
Blackboard
The Blackboard pattern provides shared key-value state. All agents in a room see the same keys. Writes are broadcast in real time, and each write carries a version number so readers can tell a stale value from a fresh one. Writes are last-writer-wins: nothing is rejected, and the version is computed by the writer from the last value it saw.
When to use
- Sharing workflow state across multiple agents
- Maintaining a shared plan or configuration
- Accumulating results from multiple agents into a single view
Code example
import { Blackboard } from "@nolag/agents";
interface Plan {
steps: string[];
currentStep: number;
}
const blackboard = new Blackboard(room, agents.agentId);
// Write shared state
blackboard.set("workflow-plan", {
steps: ["research", "draft", "review", "publish"],
currentStep: 0,
});
// Read shared state (undefined until a write for that key has been seen)
const plan = blackboard.get("workflow-plan") as Plan | undefined;
if (plan) {
console.log("Current step:", plan.steps[plan.currentStep]);
// Read-modify-write; the SDK bumps the version for you
blackboard.set("workflow-plan", { ...plan, currentStep: plan.currentStep + 1 });
}
// Watch one key for changes
blackboard.onChange("workflow-plan", (envelope) => {
console.log(`${envelope.key} v${envelope.version} by ${envelope.updatedBy}:`, envelope.value);
});
// Everything this agent has seen
for (const [key, envelope] of blackboard.getAll()) {
console.log(key, envelope.version);
}state is published retained, so the broker hands a newly subscribed agent the most recent write on the topic. That is one message, not the whole board: an agent that joins after several keys were written sees the last key only, until the others are written again.
Topic mapping
| Topic | Purpose |
|---|---|
state | State envelopes (key, value, version, updatedBy), published retained |
Observe
The Observe pattern provides a real-time event stream for monitoring. Agents emit structured events with a category and a severity; observers and dashboards subscribe to the stream. Only what agents emit appears on it: the other patterns do not emit events on their own.
When to use
- Building monitoring dashboards for agent workflows
- Audit logging of agent decisions, at the points you choose to emit
- Debugging agent behavior in real time
- Triggering side effects on specific events
Code example
import { Observe } from "@nolag/agents";
const observe = new Observe(room, agents.agentId);
// Watch all events
observe.on((event) => {
console.log(`[${event.severity}] ${event.category} from ${event.emittedBy}`, event.payload);
});
// Watch one category, or one severity
observe.on((event) => console.log("Checkpoint:", event.payload), { category: "checkpoint" });
observe.on((event) => alertOps(event), { severity: "error" });
// Emit events (severity defaults to "info")
observe.emit("checkpoint", { step: "research-complete", duration: 4200 });
observe.emit("llm:error", { model: "gpt-4o", message: "rate limited" }, "error");The on() filter runs after the event arrives. For a busy room, observe.setFilters(["checkpoint"]) moves the selection to the broker; emitters then pass { filter: "checkpoint" } as the fourth argument to emit() so the broker can route it. Observers without filters still receive everything.
Topic mapping
| Topic | Purpose |
|---|---|
events | Event envelopes (category, severity, payload, emittedBy) |
Approve
The Approve pattern implements human-in-the-loop gating. An agent can pause its workflow and request approval from a human (or supervisor agent) before proceeding. The request carries an action name and free-form context so the approver can make an informed decision.
When to use
- Gating destructive or high-stakes actions
- Compliance workflows requiring human sign-off
- Multi-level approval chains
Code example
import { Approve } from "@nolag/agents";
const approve = new Approve(room, agents.agentId);
// Agent requests approval; the promise resolves with the decision
// and rejects if nobody answers within the timeout
const approval = await approve.request(
"send-email",
{ to: "investors@company.com", subject: "Quarterly report", bodyPreview: reportSummary },
{ urgency: "high", timeout: 600000 },
);
if (approval.decision === "approved") {
await sendEmail(reportHtml);
} else {
console.log(approval.decision, "by", approval.respondedBy, approval.reason);
}
// Human-facing app (or supervisor agent) handles approval requests
approve.onRequest((request, respond) => {
console.log(request.action, request.urgency, request.requestedBy, request.context);
respond("approved");
// or: respond("rejected", "Budget not approved")
// or: respond("deferred", "Needs finance review first")
});Decisions are approved, rejected, or deferred, with an optional reason string. Nothing else travels back with the decision; if the approver needs to hand an edited payload to the agent, put it on the Blackboard.
Topic mapping
| Topic | Purpose |
|---|---|
approval | Approval requests and decisions, published retained (the most recent message on the topic is delivered to a new subscriber) |
Tools
The Tools pattern enables remote tool invocation. One agent registers tool handlers; other agents call them by name and receive a correlated response. This is similar to MCP tool use but runs over the NoLag messaging layer for real-time, multi-agent scenarios. There is no registry to browse: callers need to know the tool name.
When to use
- Sharing capabilities across agents (search, database access, APIs)
- Building tool-augmented agents that compose external services
- Running several replicas of a tool server behind one name (with load balancing on the client)
Code example
import { Tools } from "@nolag/agents";
const tools = new Tools(room, agents.agentId);
// Tool server: register a handler; its return value is the response
tools.register("web-search", async (args) => {
const results = await searchWeb(args.query as string);
return { results };
});
// Caller: invoke by name and wait for the correlated response
const response = await tools.invoke(
"web-search",
{ query: "latest quarterly earnings ACME Corp" },
{ timeout: 15000 },
);
if (response.status === "success") {
console.log("Search results:", response.result);
} else {
console.log("Tool failed:", response.error?.code, response.error?.message);
}A handler that throws produces a response with status: "error" and error.code: "TOOL_ERROR". A tool server that owns the tool's namespace (the prefix before the first ., or no prefix) but has no handler for the name answers with NO_HANDLER instead of letting the caller wait out the timeout. invoke() rejects only when the timeout passes with no response at all.
Topic mapping
| Topic | Purpose |
|---|---|
tools | Tool requests; one-of-N across a load-balance group when the client enables load balancing |
results | Tool responses, published with the caller's agent id as filter |
Tenant Isolation
All six coordination patterns work with Access Scopes for multi-tenant deployments. When you assign an agent actor to a scope, the broker rewrites every topic that actor publishes or subscribes to from app/room/topic to app/scope/room/topic.
For example, the tasks topic for an actor in the acme scope becomes my-agents-a3f9/acme/default-workflow/tasks instead of my-agents-a3f9/default-workflow/tasks. An orchestrator in one tenant cannot dispatch tasks to workers in another tenant. The isolation applies to all patterns (Inbox, Blackboard, Observe, Approve, and Tools) with no changes to your agent code: the room and topic names you use in the SDK stay the same.
To set up tenant isolation for your agents, create a scope for each tenant and assign each tenant's agent actors to that scope. See the Multi-Tenant Patterns guide for step-by-step implementation details.