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 class — asserted
(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.
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.