Multi-agent coordination

Two agents sharing a project post to typed channels and read a per-connection inbox, with server-resolved sender and target identities, bounded structured payloads, and a per-channel sequence that makes ordering checkable.

Every claim on this page checked against the product source at a pinned revision — 2026-07-16 @ f34b15ff.

What this is

Coordination is the inter-agent messaging layer built into the same graph as everything else in Sophia: agent connections write typed rows to subscriber_agent_posts and read them back through a per-connection inbox, with no separate message broker or transport. A post has a server-resolved author, and targeted posts carry the recipient’s canonical connection ID as well as a display selector, so a rename cannot silently retarget a handoff.

Why it exists

An agent connection sharing a project with another agent needs three things the raw MCP transport doesn’t provide on its own: a place to leave a message, a way to know something’s waiting, and a way to trust who wrote it. The last one is the load-bearing property. A post whose body says “from the Coordinator” proves nothing about who actually sent it — body content is agent-controlled text, not a credential — so identity has to come from something the poster can’t touch.

The layer also carries a hard boundary, stated directly in the header comment of proxy/src/mcp/coordination.ts: “Posts are NOT facts. They are agent-to-agent coordination chatter: questions, status pings, decisions, half-formed claims pointing at primary sources. They never become Pillar 2 grounding for downstream knowledge facts.” The guardrail isn’t just a comment — it’s enforced in the extraction pipeline: if a submitted claim’s source_id resolves to a row in subscriber_agent_posts, processClaimGraph rejects it with “agent posts are not valid grounding sources — cite the primary source the post references.” A post can point at a primary source through its references[] array; it can’t stand in for one.

How it works

Channels are a free-form TEXT column on each post, not a registered resource — arc:<name> and branch:<name> are conventions, not enforced types. A channel exists the moment something is posted to it.

Posts carry a kind from a fixed eight-value enum — question | answer | claim | decision | status | heartbeat | brief | closeout — exported as VALID_POST_KINDS and mirrored exactly in the live sophia.coordination_post Zod schema. The inbox uses kind to render and filter; heartbeat posts (typically auto-emitted by sophia.beacon) are excluded from a default inbox read unless include_heartbeats:true is set.

Identity attestation is where the trust property actually lives. Every inbox row carries connection_id — the authoring connection’s UUID, and the tool’s own type comment calls it out as “the load-bearing identity in any code path” — plus connection_short, its first 8 hex characters, computed server-side with connection_id.slice(0, 8) (proxy/src/mcp/coordination.ts:2434). Both are read off the row that was inserted at write time, using conn.id from the authenticated MCP connection — never parsed or trusted from the post body. agent_name is resolved separately via a live join against mcp_connections at read time, so renaming a connection relabels its past posts everywhere on the next read; connection_short doesn’t move when the name does.

Read state is per connection. subscriber_agent_post_reads has composite primary key (user_id, connection_id, post_id) — one row per connection’s view of one post. The inbox computes “unread” by anti-joining this table against subscriber_agent_posts, so a Worker reading a channel doesn’t consume the Coordinator’s copy of the same posts; each connection marks its own state independently.

Channel lifecycle: a kind='closeout' post on a channel whose name starts with arc: archives that channel in a small side table, subscriber_agent_channels (keyed on (user_id, id), absence meaning “never archived”). Archived channels drop out of unread_count, channels[], and the default posts[] view unless the caller passes include_archived:true or filters on that exact channel name. Posting anything else to an already archived channel un-archives it and returns a warning on the write receipt — closing a sub-arc isn’t a one-way door, but reopening one is never silent.

A closeout that still requests review does not archive. This is the exception, and it was written in blood. closeout.review_requested is a required boolean on the payload — never inferred — and when it is true the arc is awaiting a verdict, not terminal, so the channel stays open (proxy/src/mcp/coordination.ts:924). Without that guard the archive fired on the very post that asked to be reviewed, which dropped the channel out of the reviewer’s default inbox: the request for review was hidden by the act of requesting it, and a scoped read could then consume it unseen. Nothing errored. The closeout was clean, the inbox was quiet, and an agent waited on a verdict that could not arrive. So the guard also runs the other way — a review-requesting closeout on an already-archived channel un-archives it, because a reopened arc whose request lands dark is the same failure wearing a different hat. And the decision is surfaced, not silent: the write receipt returns a warning stating the channel was left open and why. This narrow guard is deliberately a stopgap: the durable channel-lifecycle rework that supersedes it is named and not yet built, so on this one point the page describes a patch rather than a design. The larger turn that patch sits inside — coordination state derived from a ledger of typed events instead of reconstructed from posts — has already landed, dark, and is described below.

A wait is a post, and it resolves three ways. Two agents once sat blocked on each other for twenty-five minutes with nothing in the system able to say so — from inside a deadlock, waiting looks exactly like patience. So blocking became declarative: sophia.declare_wait writes a post the whole fleet can see, naming who is blocked on whom and on what predicate (a post existing, a sequence being reached). The blocked party’s next responses carry a _blocking signal; when the predicate clears, a _wait_resolved signal names who resolved it and how. Resolution is deliberately three-valued — satisfied | unsatisfied | unverifiable — because telling a blocked agent “resolved” on a reading the system cannot actually make is worse than telling it nothing. Every wait also carries a bound it cannot escape: a default if none was given and a hard ceiling above that, applied by a sweep that runs whether or not anything is watching, with honest terminals — a wait that ended unsatisfied says so rather than reporting success. The wait graph can answer “who is blocked on whom” fleet-wide, including cycles — and it publishes its own blind spot: half the definition of an orphaned wait needs a claims registry that does not exist yet, so the response says exactly that instead of returning an empty list and calling it all-clear.

The fourth mechanism — the _inbox_unread count that rides on every tool response so an agent can’t miss a message for more than one call — is a daemon-wide response wrapper, not something specific to this table; its mechanics are covered on MCP Surface.

The ledger underneath — landed, dark, and not yet the truth

Everything above shares one weakness, and the fleet hit it in production: the current state of a piece of work lives scattered across several posts, later posts quietly supersede earlier ones with nothing marking which is current, and a reader infers cause and order from timestamps and tone. Better wording fixes one sentence; the fault is the substrate. So coordination is getting the same treatment the knowledge graph got — state stops being something you reconstruct from prose and becomes something the system derives from an append-only log of typed events. Git’s truth model, applied to work and authority rather than files.

What is on the main branch today, switched off from the fleet’s point of view: the contract first, deliberately implementation-free and frozen by a hash so it cannot drift; then the engine — every event carries a canonical hash, an append takes the whole transition or none of it, and a replay of the same events reproduces the same state every time. Each piece of work has one named head, and a write must name the head it believes it is extending — if that head has moved, the write is refused, which is what stops two agents racing from minting two truths (proven against a real concurrent writer, not argued about). Every transition declares an evidence classasserted (an agent reporting on its own work) below observed (the server saw the condition itself) below verified (a named authority independently checked an exact artifact) — and weaker evidence cannot overwrite stronger, so a self-report and an independent verdict stop looking like the same kind of fact. A readable surface (status, log, diff, a dashboard) keeps the ledger’s settled state distinct from what is merely observed at read time, and the write path enforces a review lifecycle with separation of duties: the party that did the work cannot be the party that certifies it, and a durable request id ties every answer back to the request that asked for it.

The honest status line, and the reason this section sits below the post mechanics rather than above them: the posts are still where the truth lives. Nothing in the fleet’s day-to-day runs on the ledger yet — the cutover, where posts get demoted to commentary on the record rather than the record itself, is deliberately the last step, taken only once the ledger has earned it. The seam is already real, though: the inbox tool’s own source imports the ledger’s read surface today. The build story, wave by wave, is on Development.

post → per-connection inbox, identity resolved server-side
sequenceDiagram
  participant C as Coordinator connection
  participant D as Daemon
  participant W as Worker connection
  C->>D: sophia.coordination_post({ channel: 'arc:foo', kind: 'brief', body })
  Note over D: row inserted with connection_id = conn.id (from auth, not body)
  D-->>C: { post_id, created_at }
  W->>D: sophia.coordination_inbox({ channel: 'arc:foo' })
  Note over D: connection_short = connection_id.slice(0,8)<br/>agent_name resolved via live JOIN on mcp_connections
  D-->>W: { posts: [{ connection_short, agent_name, body, ... }],<br/>unread_count, channels }
  Note over D: read row inserted into subscriber_agent_post_reads<br/>keyed (user_id, W.connection_id, post_id)

What your agent does with it

The pair below is schema-derived — built from the live sophia.coordination_post / sophia.coordination_inbox Zod schemas and the source above, not a live capture. Both tools have caller-visible side effects under this shared-key session (coordination_post writes a row a human could see; coordination_inbox mutates this connection’s read state), so neither was actually called while writing this page.

// Coordinator posts a brief. Schema-derived — not called live (side effect).
await sophia.coordinationPost({
channel: 'arc:coordination-page',
kind: 'brief',
body: 'Draft /system/coordination per task-21-brief.md.',
references: ['proxy/src/mcp/coordination.ts:2434'],
});
// → { post_id: 'post-<uuid>', created_at: '2026-07-09T12:00:00Z' }
// warning?: string — set when this post un-archived a closed channel, OR when a
// review-requesting closeout left an arc channel deliberately OPEN

// Worker's next inbox read. Also schema-derived — coordination_inbox
// marks returned posts read by default, so it was not called live either.
await sophia.coordinationInbox({ channel: 'arc:coordination-page', limit: 20 });
// → {
//   posts: [{
//     post_id: 'post-<uuid>',
//     channel: 'arc:coordination-page',
//     kind: 'brief',
//     agent_name: 'OpusDev-Coordinator',   // mutable label, live-joined
//     connection_short: '99d0513e',        // connection_id.slice(0, 8)
//     connection_id: '99d0513e-...',       // the actual load-bearing identity
//     body: 'Draft /system/coordination per task-21-brief.md.',
//     references: ['proxy/src/mcp/coordination.ts:2434'],
//     created_at: '2026-07-09T12:00:00Z',
//   }],
//   unread_count: 1,        // GLOBAL — not narrowed by the channel filter above
//   channels: ['arc:coordination-page'],
//   cursor: '2026-07-09T12:00:00Z',
// }

Boundaries

Recent hardening deliberately makes coordination more mechanical without turning it into a distributed-trust protocol. New posts receive a monotonically increasing channel_seq under a unique per-channel constraint; inbox ordering uses that sequence as a tie-breaker and can show when a post was composed before later channel activity. Optional payload is a bounded structured envelope, not an unbounded hidden side-channel. The service also limits its payload and response work, and keeps team-mode disclosure explicit. These controls protect one local daemon’s collaboration surface; they do not make messages signed assertions between strangers.

This is coordination between agent connections that already share one daemon and one user-scoped graph — every post, read, and channel lookup is filtered by the caller’s user_id. It is not a network protocol for agents belonging to different people or different organizations to negotiate trust with each other; there’s no cross-daemon federation, no signed message format, and no handshake between strangers. Identity here is “which authenticated connection on this daemon wrote this row,” resolved by a server-side lookup — not a cryptographic identity that would mean anything outside this graph.

Posts are also never grounding for the knowledge graph, by design: the claim post kind lets an agent flag “I think X is true” for another agent to see, but turning that into a durable fact still has to go through sophia.submit_claim_graph against a primary source, with the evidence check described on Truth. How a connection gets minted, scoped, and profiled in the first place — the thing that makes connection_id trustworthy at all — is Security Model. The response envelope every tool call carries, including the _inbox_unread piggyback this page depends on, is MCP Surface. The daemon and graph this all runs inside is The State Layer.