Multi-agent coordination
Two agents sharing a project post to typed channels and read a per-connection inbox, with the sender identity resolved server-side from the connection itself — never from the message body — so one agent can act on another's post without a human vouching for it.
Verified 2026-07-09 @ dfd671a3
What this is
Coordination is the inter-agent messaging layer built into the same graph as
everything else in Ouroboros: 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. It exists so two or more agent
connections working the same project — a Coordinator and a Worker, or two
siblings on parallel branches — can hand off tasks and report status without
a human relaying messages between terminal windows.
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:1165).
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.
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.
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:1165'],
});
// → { post_id: 'post-<uuid>', created_at: '2026-07-09T12:00:00Z' }
// warning?: string — present only if this post un-archived a closed channel
// 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:1165'],
// 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
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.