# Multi-Agent Orchestration m3 Memory provides the persistent memory and coordination substrate for multi-agent workflows. Agents share knowledge through scoped memory, pass context through handoffs and inboxes, and coordinate work through tasks, notifications, and a recursive task tree. m3 Memory is not an agent runtime — it does not schedule or execute agents. It is the memory layer underneath your orchestrator, whether that is the bundled `m3-team` CLI, a LangGraph pipeline, or your own polling loop. > **Looking for a wire-up guide for Claude Code + Gemini CLI + OpenCode sharing one m3-memory store?** See the practical setup notes — subscription vs API token tradeoffs, unified tag schema across agents, per-agent install steps — at [Multi-Agent Subscription Models with m3-Memory](./multi_agent_subscription_models.htm) (saved page; covers the day-to-day workflow underneath the primitives below). > **Running a project with two agents?** See > [MULTI_AGENT_HOWTO.md](./MULTI_AGENT_HOWTO.md) for the operational guide — > roles, message discipline, handling disagreement, and the failure modes that > show up in practice. This page covers the primitives those patterns use. ## 🧩 Primitives ### 👤 Agent registry Every agent registers with an ID and description. The registry tracks liveness via heartbeats and provides a directory other agents can query. | Tool | Purpose | |---|---| | `agent_register` | Register an agent with ID, description, and capabilities | | `agent_heartbeat` | Signal liveness | | `agent_list` | List registered agents and their status | | `agent_get` | Get details for a specific agent | | `agent_offline` | Mark an agent offline | ### 📁 Scoped memory Memory is isolated by `scope` so agents can maintain private working notes while sharing project-level knowledge. - **`agent`** (default) — private to the writing agent. Use for implementation notes, scratch work, and internal reasoning. - **`org`** — shared across all agents. Use for requirements, decisions, contracts, and any fact the whole team should see. - **`user`** — scoped to a specific user/data subject. Use for user preferences, profile data, and GDPR-relevant records. All scopes support the same `memory_search`, `memory_write`, and `memory_update` operations. Scope filtering is applied at query time — an agent searching `scope="org"` sees only org-scoped memories. ### 🔐 Governance / Enforcement m3 supports **true SQL-layer agent isolation** — access control injected into the query itself (`WHERE (scope != 'agent' OR agent_id = ?)`), never a post-fetch filter that can leak. It's opt-in, so trusted single-operator setups keep full visibility by default and gain a hard boundary the moment they need one. By default, scope filtering is **caller-applied / advisory**: a search that supplies no `scope` or agent filter sees every agent's memories, including other agents' private `scope="agent"` notes. This is intentional — it fits a trusted, single-operator multi-agent setup where the operator (or the orchestrator itself) is allowed full visibility, and it keeps `memory_search` byte-identical to prior behavior for every existing caller. For setups that want a real access-control boundary, enforcement is **opt-in**: - Pass `requesting_agent=""` on a `memory_search` call — the search returns only that agent's own `scope="agent"` memories, plus every shared-scope memory (`org`, `user`, `session`). It can never surface another agent's private notes. - Or set `M3_ENFORCE_AGENT_ISOLATION=1` to make this the default whenever an `agent_filter` is present, without threading `requesting_agent` through every call site. This is enforced at the **SQL layer** — a `WHERE (scope != 'agent' OR agent_id = ?)` clause added to the query itself — not as an app-layer post-filter over an already-fetched result set. An explicit `scope=` filter combined with `requesting_agent` only narrows further (e.g. `scope="org"` plus `requesting_agent="implementer"` still returns only org rows); enforcement never widens what an explicit scope filter already restricted. **Example — planner vs. implementer:** a planner keeps private scratch notes in `scope="agent"` under `agent_id="planner"`. If the implementer searches with `requesting_agent="implementer"`, the planner's private notes are excluded from the result set — but both agents still see the same `scope="org"` requirements, since shared scopes are never gated by `requesting_agent`. **Audit trail:** isolation is the access-control half of governance; observability is the other half. Every write already records `change_agent`, and the `memory_history` table (see `memory_history_impl`) keeps a full audit trail of which agent mutated which memory row and when — so even in a trusted setup without enforcement turned on, you can always answer "who changed this and when." ### 📨 Handoffs and inbox When one agent finishes and the next needs to pick up, use `memory_handoff` to push context directly into the receiving agent's inbox. The receiver calls `memory_inbox` to read pending items and `memory_inbox_ack` to clear them. ```python # Planner hands off to implementer await tool(session, "memory_handoff", { "from_agent_id": "planner", "to_agent_id": "implementer", "title": "Implementation context", "content": "Contract is GET /health → 200 OK. Keep it dependency-free.", "conversation_id": "release-2026-04" }) # Implementer reads inbox await tool(session, "memory_inbox", {"agent_id": "implementer"}) ``` ### 📋 Tasks Tasks are first-class objects with ownership, status, priority, and results. A planner creates tasks, assigns them to workers, and workers set results when done. | Tool | Purpose | |---|---| | `task_create` | Create a task with title, description, priority | | `task_assign` | Assign a task to an agent (triggers a `task_assigned` notification) | | `task_update` | Update status, description, or priority | | `task_set_result` | Record the output of a completed task | | `task_get` | Read a single task | | `task_list` | List tasks, optionally filtered by agent or status | | `task_tree` | Render a recursive subtree rooted at a parent task | | `task_delete` | Soft-delete a task | ### 🔔 Notifications Agents discover work through a poll-based notification queue. The orchestrator polls each agent's queue and dispatches work when notifications arrive. | Tool | Purpose | |---|---| | `notify` | Send a notification to an agent | | `notifications_poll` | Read pending notifications for an agent | | `notifications_ack` | Acknowledge a single notification | | `notifications_ack_all` | Acknowledge all pending notifications | ### 💬 Conversation grouping Tag related memories with a shared `conversation_id` to create a logical session boundary. This works across agents — a planner and implementer can both write to `conversation_id="release-2026-04"` and later search within that scope. ### ⏳ Bitemporal queries Every memory records when it was created and when it was valid. The `as_of` parameter on `memory_search` enables time-travel queries — useful for debugging past decisions or reconciling conflicting reports across agents. --- ## 🔄 Workflow patterns ### ➡️ Turn-based (sequential handoff) One agent finishes, then passes context to the next. Each agent reads the inbox, searches shared memory, does its work, and hands off to the successor. ``` Planner → (handoff) → Implementer → (handoff) → Reviewer ``` 1. Planner writes requirements to `scope="org"`, creates a task, assigns it, and hands off context. 2. Implementer reads inbox, searches shared memory, writes private implementation notes to `scope="agent"`, and sets the task result. 3. Reviewer searches shared memory, verifies against the contract, and records approval. ### 🔀 Parallel (fan-out / fan-in) Multiple agents work simultaneously on independent tasks, reading from the same shared memory. ``` Planner → assigns Task A to Agent 1 → assigns Task B to Agent 2 → Reviewer merges results ``` Agents read `scope="org"` for shared context while keeping private notes in `scope="agent"`. The reviewer searches across both scopes to verify consistency. ### 🌳 Hierarchical (task trees) A parent task can have subtasks, forming a tree. Use `task_tree` to inspect the full hierarchy. ```python parent = await tool(session, "task_create", { "agent_id": "planner", "title": "Ship release 2026-04", "priority": "high" }) await tool(session, "task_create", { "agent_id": "planner", "title": "Implement /health endpoint", "parent_task_id": parent_id, "priority": "high" }) await tool(session, "task_create", { "agent_id": "planner", "title": "Write documentation", "parent_task_id": parent_id, "priority": "medium" }) # Inspect the full tree await tool(session, "task_tree", {"root_task_id": parent_id}) ``` ### 📝 Blackboard (shared knowledge base) All agents contribute facts and observations to `scope="org"` asynchronously, without a predefined sequence. Any agent can search the shared pool at any time. This is useful when agents are loosely coupled — each contributes what it knows, and others consume what they need. #### Concurrency & scale Concurrent writes from multiple agents do **not** fail on lock contention. Every SQLite connection runs in **WAL mode** (concurrent readers alongside a writer) with a **30-second `busy_timeout`**, a connection pool, and a write-path retry — so simultaneous writers serialize and wait rather than erroring. WAL is verified at init; m3 raises rather than silently running in a slower journal mode. For **high-concurrency fleets** where many agents write to one shared pool continuously, there are two paths: 1. **PostgreSQL as the shared primary store** (`M3_DB_BACKEND=postgres` + `M3_PRIMARY_PG_URL`): every agent reads and writes the *same* Postgres database directly — no per-agent local store, no sync lag. Postgres has no single-writer constraint, so concurrent writers don't serialize the way one SQLite file does. This is the simplest topology when the agents share infrastructure. 2. **Local SQLite + bidirectional warehouse sync** (`bin/pg_sync.py`): each agent writes locally to its own WAL-mode SQLite store and syncs bidirectionally to a shared Postgres *warehouse* (`M3_CDW_PG_URL`). This suits agents that must keep working offline / air-gapped and reconcile later. See [SYNC.md](SYNC.md). The warehouse (path 2) is a *sync tier*, distinct from using Postgres as the primary backend (path 1) — pick based on whether the fleet shares a live database or each agent owns a local one. ### ⚡ Reactive (notification-driven) Agents react to events rather than following a fixed sequence. Add `task_completed` to the orchestrator's `notification_kinds` so a planner automatically wakes up when a subtask finishes. Agents can also use `notify` to broadcast custom signals. --- ## 🚀 Full example The [`examples/multi-agent-team/`](../examples/multi-agent-team/) directory contains a complete, runnable orchestrator: - **`team.yaml`** — declarative agent definitions with provider, model, role, capabilities, and tool allowlists - **`orchestrator.py`** — polling loop that discovers work via notifications and dispatches to agents - **`dispatch.py`** — provider-agnostic multi-turn MCP dispatch with bounded execution (max turns, tool calls, wall clock, loop detection) ```bash pip install -e . m3-team init team.yaml m3-team check m3-team run ``` Any MCP client (Claude Code, Gemini CLI, etc.) can queue work: ``` task_create("Summarize the README", created_by="you") task_assign(, "local-agent") ``` The agent picks it up on the next tick, executes tool calls against m3-memory, and writes results back. See [`examples/multi-agent-team/README.md`](../examples/multi-agent-team/README.md) for provider setup, resilience knobs, and how to add agents without code changes. --- ## 🧭 Design principles **Memory is the coordination layer.** Agents don't talk to each other directly — they read and write shared memory. This decouples agent execution from agent communication. **Scope is the access control.** Private scratch work stays in `scope="agent"`. Shared decisions go to `scope="org"`. User data goes to `scope="user"` with GDPR primitives (`gdpr_export`, `gdpr_forget`) attached. **The orchestrator is pluggable.** m3 Memory exposes primitives, not opinions about scheduling. The bundled `m3-team` is one orchestrator; you can build your own with the same tool catalog. --- ## 📡 Delivery: pull by design, push by adapter Notifications are stored and wait to be asked for. That is deliberate, and it follows from the line at the top of this document: m3 is not an agent runtime. It has no way to enter an agent's execution loop, so promising delivery would be promising something it cannot keep. What each runtime *can* do is wrap the inbox in its own adapter. The adapters are not equivalent, and the differences are large enough to plan around. All figures below were measured between two live agents on one Windows machine, 2026-09-12, by firing timestamped probes. ### The shared floor `notify` → durable write: **~322 ms** (min 315, max 333, n=10). Every adapter pays it; none can beat it. end-to-end = notify-write (~322 ms, shared) + detection (adapter-specific — the column that matters) + delivery (runtime-specific) Quote end-to-end alone and you hide which layer you are comparing. ### Detection: watch the WAL, not the table SQLite has **no cross-process blocking change notification** — update hooks are in-process only and fire for the connection's own writes, so a separate process cannot be woken by m3 inserting a row. The working trigger is the WAL file's `(mtime, size)`, which both change on a notify write. Compare both halves: a checkpoint can *shrink* the WAL, so size alone both misses growth-then-truncate and can false-fire. And a WAL change means *something* was written, not necessarily your notification — always confirm with one `notifications_poll` before acting. | Approach | Detection | Cost while idle | |---|---|---| | WAL-stat waiter (subprocess) | **~0.8 s** at 1 s poll | **zero agent turns** | | In-agent poll loop, 3 s | ~1.8 s | ~1,200 turns/hour | | Cron, 1 minute | 60 s floor | ~60 turns/hour | The scarce resource is **agent turns**, not CPU. Put the polling in a subprocess and the idle cost disappears; a 5 s interval costs 720 `stat()` calls an hour and is still a small fraction of a 30 s budget. One process can serve every inbox. The WAL trigger is shared, so N agents cost N cheap confirm-polls *after* a change — not N watchers. That also avoids keeping live watchers for agents that have been offline for months. ### Receipt is not reading A subprocess can acknowledge without any agent being free — `notifications_ack_all` round-trips in **~426 ms**. That is tempting: it makes a receipt deadline independent of whether an agent is mid-turn. **Do not enable it blindly.** `notifications` carries one timestamp, `read_at`. Acking on *detection* marks a message read that no agent has read, and that flag was the only record the work was pending. Measured on a live store: **29 of 30** recent notifications carried no `task_id`, so "the task state machine tracks it" covers almost none of the real traffic. Auto-ack is therefore opt-in, and is only safe where every watched kind is backed by a task whose own state survives the ack. Losing a message silently is worse than confirming receipt one turn later. ### Coverage is the hard part, not speed Detection is a few percent of any sane budget. What actually breaks delivery: - **nothing armed** — a session-scoped watcher dies with the session; a bounded loop disarms after N iterations; a *single-shot* waiter disarms itself by design on every delivery, so the read-and-re-arm must be one operation, not two steps an agent is trusted to remember; - **the agent is mid-turn** — queued, not dropped, but the wait is a turn length; - **a silently dead watcher** — indistinguishable from a quiet inbox. A bounded in-agent loop is the trap worth naming: at 3 iterations × 5 s it gives about **15 seconds of coverage per arming**, roughly 0.4% of an hour. The rest of the time, latency is unbounded. The fix is not a faster loop — it is an armer that outlives the session. Which means an OS-level service (Task Scheduler, launchd, systemd), registered by the installer. **A non-elevated agent session cannot register one**: two independent agents measured `Access is denied` (0x80070005) attempting it at runtime. Installer-time registration, by a human who can elevate, is the only reproducible path. Copy the shape m3 already uses for its own background work: boot + logon triggers, a repetition interval so a dead process self-heals, and an ignore-if-running policy so a re-fire while alive is a harmless no-op rather than a second instance. ### If you build a watcher Three failure modes that look identical to healthy from outside: - **Unbuffered output is mandatory.** A long-running Python watcher with buffered stdout emits nothing for minutes. `python -u`, or you cannot tell a live supervisor from a dead one. - **Silence must not mean success.** A filter matching only the happy path stays quiet through a crash. Match the failure signatures too. - **Arm it so the runtime owns the process.** A single-shot waiter's contract is that *process exit is the wake signal*. Background it inside a shell call (`nohup … &`) and the process survives as an orphan the runtime has no handle on: detection still works, receipts are still stamped, and the wake signal is delivered to nobody. In Claude Code, launch it with the Bash tool's `run_in_background` (the result confirms *"you will be notified when it completes"*); anything else silently degrades to polling. **Check that the task actually appears in the runtime's background-task list** — its absence there is the only external symptom. That third one is the general shape worth naming: a mechanism can be complete and correct and still be severed at the boundary where its output would have mattered. Ask not "does it work" but "does its output reach the thing that needs it". ### What no adapter can do None of this wakes a session that is not running. A daemon can act out of band — write a file, fire a webhook — but a stopped agent stays stopped until a human or the OS starts one. Plan for delivery on next start, not for always-on.