---
title: Agent Coordination Patterns
description: "Deep dive on the six coordination patterns in @nolag/agents: Handoff, Inbox, Blackboard, Observe, Approve, and Tools."
---

# 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:

```typescript [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

```typescript [TypeScript]
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

```typescript [TypeScript]
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](/docs/agents/tags#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

```typescript [TypeScript]
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](/docs/concepts/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

```typescript [TypeScript]
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

```typescript [TypeScript]
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

```typescript [TypeScript]
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

```typescript [TypeScript]
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](/docs/scopes/getting-started) 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](/docs/scopes/multi-tenancy) guide for step-by-step implementation details.
