Skip to content

Latest commit

 

History

History
175 lines (146 loc) · 8.12 KB

File metadata and controls

175 lines (146 loc) · 8.12 KB

Mailbox protocol

The default runtime root is ~/.agentpost:

config.toml
bindings/HASH.toml
PROJECT/.agentpost.toml
agents/AGENT/profile.toml
agents/AGENT/tmp/
agents/AGENT/unread/
agents/AGENT/read/
agents/AGENT/sent/
agents/AGENT/adapter/consumer.lock
agents/AGENT/adapter/consumer.json

Profiles and mailbox contents are durable identity. Profiles are CLI-neutral; an optional cli_hint is only a backward-compatible first-connection hint. A binding connects one adapter at a project root to an existing profile:

version = 1
agent = "cx"
cli = "codex"
project = "/work/project"

Bindings are atomic and replaceable. Changing or removing one never removes a profile or any unread, read, or sent mail.

join also creates machine-local workspace identity:

version = 1
default_agent = "cx"
known_agents = ["cx", "code-reviewer"]

.agentpost.toml is excluded through .git/info/exclude when possible. It survives checkout moves and supplies one unqualified default. Known alternates are available for explicit --agent selection, not additional defaults. Identity resolution uses explicit --agent/AGENTPOST_AGENT first, then the deepest matching marker, binding, or declared project root. At equal depth a marker outranks a legacy binding, which outranks a declared root. Ties fail instead of guessing.

Each letter is immutable UTF-8 Markdown with RFC-822-style headers:

Message-ID: <uuid@agentpost.local>
Date: 2026-07-10T03:00:00Z
From: sender
To: recipient
Audience: recipient,other-agent
Subject: Example
In-Reply-To: <optional-parent@agentpost.local>
X-Agent-Kind: letter
X-Agent-Notify: idle

Markdown body.

Message-ID, Date, From, To, Audience, X-Agent-Kind, and X-Agent-Notify are generated and validated by AgentPost. Header injection and path-like agent names are rejected.

Verified repository review questions add machine-readable artifact headers:

X-Agent-Review-Repository: /work/project
X-Agent-Review-Commit: 0123456789abcdef0123456789abcdef01234567
X-Agent-Review-Parent: 89abcdef0123456789abcdef0123456789abcdef
X-Agent-Review-Path: src/module.py
X-Agent-Review-Path: tests/test_module.py
X-Agent-Review-Test: tests/test_module.py::ModuleTest::test_behavior

review resolves the worktree root, requires the commit and optional direct parent to be explicit commit-object SHAs, verifies every path and test file in that immutable tree, and renders the canonical artifact block before delivery. Structured fields reject command substitution, backquotes, shell separators |, ;, and &, redirection/placeholder characters < and >, and control characters. Any failure occurs before recipient or sender mail is written.

Presence and routing

AgentPost has no coordinator, leader, primary mailbox, or resident post-office process. The durable filesystem is the post office: when every agent process is closed, profiles and mail remain readable and writable, while presence honestly reports each member offline. Starting any registered member attaches only that member's mailbox and catches up its unread spool; it never requires Cx, K, or any other member to start first. Consumer locks are per mailbox, so different members may attach concurrently in any order.

Presence is transient adapter data under agents/AGENT/adapter/, never part of the profile or message protocol. A fresh live heartbeat yields idle or working; no heartbeat yields offline. Presence probes aggregate live adapter markers rather than dispatching from a profile's CLI type. Periodic adapters heartbeat every second; markers expire after five seconds. Legacy PID-only Codex markers are bounded by file modification time, so PID reuse cannot create permanent false presence.

One mailbox-wide flock on consumer.lock owns inbound notification and consumption across every adapter type. consumer.json identifies the runtime instance UUID, adapter, PID, cwd, and acquisition time for diagnostics. Python runtimes and Claude monitors may wait as standbys and take over after release; launchers that cannot attach a bridge after startup reject a second owner. Outbound channel use does not require the inbound consumer lease. Lifecycle-only fallback hooks acquire the lease for one hook event because their host exposes no session-lifetime attachment point; atomic message claim remains the final exclusion boundary on that degraded path.

The low-level watch command is intentionally an unleased observer feed: two watchers may emit the same pointer. Hosts must treat Message-ID as the idempotency key and claim before work; use a native adapter or AgentRuntime when single-consumer notification ownership is required.

Directory search and role/project/specialty selectors exclude offline agents by default. Exact mailbox names and explicit named groups may target offline agents. Delivery still commits to unread/; only notification is unavailable.

An original sender may append an ephemeral attention request under agents/RECIPIENT/adapter/notifications/ for an existing unread Message-ID. Adapters consume that request only after surfacing the exact original ID; the immutable letter is neither copied nor claimed. Stale requests are pruned when their target is no longer unread. Startup catch-up batches every queued unread ID into the first native notification turn instead of allowing the first item to defer the rest.

Human-facing channel resolution is a separate operation from active-agent discovery. resolve and the message/question commands match registered canonical names, display names, project identities, and responsibility handles; bare named groups also expand. They include offline profiles because addressing a known coworker is durable delivery, not availability discovery. Tied matches are rejected instead of guessed. The sender is inferred from explicit process identity or the deepest current workspace identity source.

Python applications expose the same presence contract through AgentRuntime. Its callback/queue receives Message-IDs only and does not alter the unread -> read lifecycle. Synchronous callback failures are retried in order with capped backoff while the affected mail remains unread; callback consumers must use Message-ID as their idempotency key. A successful callback acknowledges host-queue admission only, not completion or claim of the work. After the bounded attempt count, adapter health reports the still-unread exhausted IDs; hosts can reconcile them through AgentRuntime.unread() without adding a mailbox acknowledgment state.

A second AgentRuntime for the same mailbox starts in standby without surfacing mail. When the owner exits or crashes, exactly one standby acquires the mailbox lease and catches up from the full unread spool.

Delivery writes and fsyncs a temporary file, atomically renames it into each recipient's unread/, and writes the sender archive. Fanout uses one logical Message-ID and one recipient copy per audience member. Per-recipient delivery locks prevent duplicate (recipient, Message-ID) commits under races.

list and read have no state transition. next atomically renames exactly one unread letter into read/. Competing claims therefore have one winner even if notification ownership is misconfigured. The lease prevents duplicate wake/model work before claim; atomic claim is the final defense. AgentPost does not promise exactly-once execution if a consumer crashes after claim.

Replying also atomically claims an exact original when it is still unread; already-read originals remain replyable for corrections. Validation failure occurs before claim. A delivery failure after claim leaves the original read because recipient delivery may already have committed; retry uses the already-read correction path. Replies carry In-Reply-To. Group panels are derived from immutable sent/read mail rather than maintained as a mutable database row. The first terminal answer or error from each expected responder counts; later responses remain visible as duplicates.

Notification adapters receive only committed Message-IDs. Adapter failure does not roll back delivery, and adapter state is never authoritative for unread state. Adapter activation always catches up from the full current unread set.