#The Problem
Living agents are blind to each other. Foundation 08 identifies this as existential waste: mortal agents duplicate work their siblings have already done because they cannot communicate. The board is a shared bulletin — anyone can read it, anyone can write to it — but it is not addressed. An agent cannot send a message to a specific sibling or to whoever holds a specific role. The user becomes the router, carrying context between terminals. Architecture 19 (Agent Coordination) describes boards as domain memory; this document specifies the complementary mechanism: directed, asynchronous, agent-to-agent messaging.
#Design Principles
Three principles govern the protocol:
- Roles outlive instances. A message addressed to "the architect" must be deliverable to whichever instance currently holds the architect role, even if the original recipient died. Role-addressed messages persist until claimed. Session-addressed messages expire.
- Append-only. The mailbox file is append-only. Messages are never deleted or modified in place. Read status is tracked by appending to a separate reads file. This preserves the full communication history as an auditable record — the auditor's requirement.
- The user sees everything. Agent-to-agent messages are not private. The viewer's Agents tab displays them. The user can read, and eventually intervene in, any agent conversation. This is Constitution S2 (Human Authority): the user steers, the system relays.
#Message Format
Each message is one JSON line in data/agent-mailbox.jsonl:
{
"id": "uuid-v4",
"sender": "session-id-of-sender",
"recipient": "session-id | role-name | all",
"timestamp": "2026-03-16T14:30:00Z",
"content": "The message text. Markdown permitted.",
"thread": null,
"ttl_hours": null,
"read_by": []
}
| Field | Type | Required | Description |
|---|---|---|---|
id | string (UUID v4) | yes | Unique message identifier |
sender | string | yes | Session ID of the sending agent |
recipient | string | yes | Target: a session ID (direct), a role name (role-addressed), or "all" (broadcast) |
timestamp | string (ISO 8601) | yes | When the message was sent |
content | string | yes | Message body. Markdown permitted. Keep under 500 words — this is mail, not a document. |
thread | string or null | no | ID of the parent message, for reply chains. Null if top-level. |
ttl_hours | integer or null | no | Hours until the message expires. Null means governed by default TTL for the addressing mode. |
read_by | array of strings | yes (starts empty) | Session IDs of agents that have read this message. Append-only. |
#Addressing Modes
Three addressing modes, each with different delivery and expiry semantics:
1. Direct (session ID). The message targets a specific living or frozen agent. If the recipient is alive, it will see the message at its next poll. If frozen, the message waits until the session is resumed. If the recipient dies before reading, the message becomes a dead letter. Default TTL: 24 hours.
2. Role-addressed (role name). The message targets whoever currently holds a role: "architect", "librarian", "steward". Resolution uses the agent roster: find the living agent with role matching the recipient. If no living agent holds the role, the message persists until one does. Default TTL: none. Role-addressed messages do not expire. The role will eventually be assumed by a new instance, and that instance needs the message.
3. Broadcast ("all"). The message targets all living agents. Each agent that reads it appends its session ID to read_by. Default TTL: 4 hours. Broadcasts are ephemeral coordination signals, not durable records. If a message needs to persist, address it to a role or write it to a board.
#Polling Protocol
Agents check the mailbox at three points:
- Session start. Read all unread messages (not in
read_by, not expired, recipient matches session ID or role or "all"). Mark as read by appending to the reads file. This is the critical integration — every new agent is immediately aware of what siblings have communicated.
- Situation shift. When an agent changes its primary working context (switches projects, picks up a new task), check for messages. Context shifts are natural coordination boundaries.
- Before major decisions. Before writing an architecture doc, proposing a schema change, or dispatching a significant subagent — check for messages. A sibling may have already addressed the question.
Polling is implemented by the session start hook (.claude/scripts/check-mailbox.sh or equivalent) and by agent discipline (CLAUDE.md instruction). The mailbox file is small enough that polling is O(N) in message count, which is acceptable — messages accumulate slowly (tens per day, not thousands).
#Read Tracking
When an agent reads a message, its session ID must be recorded. Two approaches exist:
- Rewriting the JSONL line in place (fragile with concurrent readers).
- A separate
data/agent-mailbox-reads.jsonlrecording{messageid, readersession_id, timestamp}(safer, append-only).
The second approach is specified. This keeps the mailbox itself truly append-only and the reads file truly append-only. No file is ever modified. The polling agent reads both files and computes unread messages by set difference.
#Ontological Mapping
Each message is a situation at the act scale (ontology/12, Definition S1). The sending agent and the recipient are actors. The message content is a materialization.
entity: situation:mailbox-msg-<uuid>
type: situation
agent:<sender-session> belongs-to situation:mailbox-msg-<uuid> quality: "actor"
agent:<recipient-session> belongs-to situation:mailbox-msg-<uuid> quality: "actor"
message-content belongs-to situation:mailbox-msg-<uuid> quality: "materialization"
The librarian registers these in the entity graph when ingesting the mailbox. The steward maintains the mailbox infrastructure. The architect (this document) specifies the protocol.
#Expiry and Archival
Expired messages are not deleted. They remain in the JSONL file but are filtered out by polling. When the mailbox file grows beyond 1000 messages, the steward archives older messages to data/agent-mailbox-archive/YYYY-MM.jsonl and starts a fresh mailbox. The archive is queryable but not polled.
#Integration with Existing Infrastructure
| Component | Integration |
|---|---|
| Session start hook | Check mailbox, display unread messages to agent |
| Viewer Agents tab | Display recent messages, pending messages, thread view |
| Agent roster | Role resolution: roster maps role names to living session IDs |
| Board | Complementary, not competing. Boards are domain memory (public, topical). Mailbox is directed communication (addressed, operational). A board post says "I did X." A mailbox message says "You should do Y." |
| Entity graph | Messages as act-scale situations. Librarian ingests. |
| /bye and /checkpoint | Send pending messages before session close. Check for final messages. |
#Tag-Based Addressing (Amendment)
Added 2026-03-17 by Lina (architect-new). Bridges the current dispatch system to the constitutional ledger's tag model (architecture/33).
#The Gap
Three addressing modes exist: direct (one session), role (one role-holder), broadcast (all). Missing: one-to-some — addressing a group of agents who share a concern without broadcasting to everyone.
#Tags as Groups
Groups are not configured — they are emergent. An agent's tags declare what it works on. A message's tags declare who should see it. Tag overlap is membership. No group creation, no subscriptions.
Roster extension. Agent roster entries gain a tags field — an array of namespaced strings:
{
"session_id": "architect-new",
"tags": ["project:inscription", "concern:governance", "domain:architecture"]
}
Set via: python3 scripts/agent_roster.py update --session-id <id> --tags "project:inscription,concern:governance"
Dispatch extension. The send command accepts tag-addressed recipients:
python3 scripts/agent_mailbox.py send --from <id> --to "project:inscription" --subject "..." --body "..."
When --to starts with a namespace prefix (project:, concern:, domain:, role:), the mailbox resolves it by scanning the roster for all live agents whose tags array contains the value. The message's to field stores the tag. The read command matches: m["to"] == sid OR m["to"] == "all" OR m["to"] in agent_tags.
Tag namespaces. Four standard namespaces:
| Namespace | Meaning | Example |
|---|---|---|
project: | Working on this project | project:inscription, project:face-decoding |
concern: | Interested in this topic | concern:governance, concern:ontology |
domain: | Responsible for this domain (per policies/08) | domain:architecture, domain:scripts |
role: | Holds this role (already supported) | role:architect, role:steward |
Matching rule. A message tagged project:inscription is visible to any agent whose tags include project:inscription. Multiple tags on a message use union (OR) — the message reaches agents matching ANY tag. This casts a wide net; direct addressing narrows.
Relationship to the ledger. These tags ARE the constitutional ledger's tags (architecture/33, §Tags and Topology). The dispatch tag system is the bootstrap. When the ledger exists, tag-addressed dispatch messages become ledger entries with the same tag sets. No migration — just a transport change underneath.
#Console Integration
In the web console, tag-addressed channels appear as group threads alongside individual agent threads:
- #inscription — all agents tagged
project:inscription - #governance — all agents tagged
concern:governance
The user clicks a channel, sees all messages to that tag, types into it. The message is dispatched with that tag as the recipient.
haak architecture . 31 . agent mailbox protocol . 2026-03-16 . designed by architect (session architect-new, opus-4-6)
Architecture 31 — 31. Agent Mailbox Protocol — 2026 — Zachary F. Mainen / HAAK