Problem
An agent on one agent-box cannot directly send a message to an agent on another box. The missing piece is not a global user directory: the user can explicitly pair two boxes and thereby supply discovery, consent, and authentication.
This should be GitHub-identity-free. A pairing belongs to an agent-box Linux user (the box's trust boundary, per Users vs sessions), not to a Claude/Codex session. The remote peer's name is a local address-book label, not a globally asserted identity.
This is distinct from SMTP intake (#231), hosted group chat (#205), and same-box session addressing (#585).
Proposed v1
Add a small per-user peer/address-book CLI, tentatively:
agent-box-peer invite --label "other box"
agent-box-peer accept <invite> --label "workstation"
agent-box-peer confirm <response>
agent-box-peer list
agent-box-peer send <peer> --inbox default --message "..."
agent-box-peer revoke <peer>
The initial implementation can use copy/paste tokens; the settings page can add links/QR codes later.
Pairing
- The initiator generates a random pair ID, a high-entropy one-time pairing secret, its per-user webhook endpoint, a protocol version, and a short expiry.
- The recipient explicitly accepts that out-of-band invitation, stores a local label, and returns an authenticated response containing its endpoint.
- The initiator explicitly confirms the response. Neither side is considered paired until its local user accepts/confirm it.
- Derive independent
initiator -> responder and responder -> initiator HMAC keys from the pairing secret with HKDF and protocol/pair-ID context. Store only the needed inbound/outbound keys in per-user 0600 state.
This is a two-way exchange so it needs no directory or new public pairing service. Anyone who receives an unused invitation can accept it, so the invitation is a short-lived bearer secret and must travel out of band.
The peer record needs only: pair ID, role, local label, remote HTTPS endpoint, key references, protocol version, creation time, and revoked state. Labels may differ on each box. The authenticated pair record is how a user identifies the sender; fields such as from in a message are descriptive and do not establish identity.
Transport and local delivery
Create one local-channels generic source per pair, for example:
{
"format": "generic",
"secretFile": "peer-<pair-id>.secret",
"signatureHeader": "x-agent-box-signature-256",
"keyPath": "inbox",
"senderPath": "from"
}
The outbound sender serializes the envelope once, signs those exact bytes with the directional HMAC key, and POSTs them over HTTPS to the stored peer source endpoint:
{
"version": 1,
"messageId": "uuid",
"sentAt": "RFC3339 timestamp",
"inbox": "default",
"from": "optional session/display context",
"body": "message text"
}
peer-<pair-id>:default is then an ordinary local-channels topic. Existing subscriptions choose whether it reaches a live Claude/Codex session or an explicitly configured standing watch. Delivery retains local-channels' UNTRUSTED framing and payload limits; authenticating the paired transport does not make message text trusted instructions.
Do not add a new session router. local-channels already supplies HMAC ingress, per-session topic filters/TTLs, Claude channel delivery, and Codex delivery via codex queue at the next turn boundary.
Queue boundary
Reuse the queues that already exist for final-mile backpressure:
- Codex's queue wakes an idle thread, waits for the next turn boundary when busy, and can hold a message for an existing stopped thread until it is resumed.
- The standing-watch dispatcher coalesces bursts and retries a spawn declined with
EX_TEMPFAIL (75). Its pending state is deliberately in-memory and bounded (currently 200 lines per key and five minutes by default), so it handles short capacity pressure, not offline peer delivery.
A durable cross-box outbox is a separately scoped follow-up. V1 may fail the send when the peer is offline and report that clearly. A later Maildir-style/SQLite outbox can add retry, acknowledgement, expiry, and deduplication without changing the pairing or wire envelope.
Security and delivery semantics
- The pair is between Linux users, because sessions under one user share files, environment, processes, and tmux. Do not claim per-session isolation for pair keys.
- Use a different directional key for each direction and a different secret per pair. Compromise/revocation of one pair must not affect another.
- Require HTTPS for confidentiality; HMAC authenticates and protects integrity but does not encrypt the body.
- Never put pairing secrets in prompts, transcripts, process arguments, logs, or the peer listing. The settings UI should reveal an invite only on explicit user action.
- Invitation expiry/one-time use and explicit confirmation prevent accidental lingering invitations. Revocation removes the peer record, inbound local-channels source, and its subscription(s).
- HMAC alone does not prevent replay.
messageId and sentAt reserve the wire fields for a bounded replay cache. Until durable deduplication is implemented, document v1 as at-least-once delivery and never treat a repeated authenticated message as new authority.
- Bound envelope/body size and outbound rate. Standing-watch/session spawning remains opt-in so a paired peer cannot silently consume unbounded session slots or tokens.
Acceptance criteria
- Two users on different agent-box instances can complete invite/accept/confirm without GitHub, another identity provider, or a central service.
- Each side can assign its own label;
list shows the label, endpoint/fingerprint, and state, but no secret.
- Pairing creates a generic local-channels source with
keyPath: "inbox", senderPath: "from", and the correct inbound directional key.
- A message from box A is HMAC-verified on box B and reaches a subscribed Claude session and a subscribed Codex session through their existing local-channels paths.
- Unsigned messages, a key from the opposite direction, an unknown pair, and messages after revocation are rejected.
- Messages are visibly attributed to the local pair label and visibly framed as untrusted data.
- Offline/timeout/HTTP failures produce a clear send failure; the CLI never claims that HTTP acceptance means an agent processed the message.
- Tests cover pairing token expiry/one-time use, directional derivation, exact-body signatures, pair isolation, source generation, revocation, size limits, and the cross-box happy path with two local receiver instances.
Explicitly out of scope for v1
- Global usernames, GitHub identity, directory search, presence, or a relay/control plane.
- Durable offline outbox/inbox, delivery/read receipts, and exactly-once delivery.
- Sharing one pair across different Linux users or claiming session-level secret isolation.
Problem
An agent on one agent-box cannot directly send a message to an agent on another box. The missing piece is not a global user directory: the user can explicitly pair two boxes and thereby supply discovery, consent, and authentication.
This should be GitHub-identity-free. A pairing belongs to an agent-box Linux user (the box's trust boundary, per Users vs sessions), not to a Claude/Codex session. The remote peer's name is a local address-book label, not a globally asserted identity.
This is distinct from SMTP intake (#231), hosted group chat (#205), and same-box session addressing (#585).
Proposed v1
Add a small per-user peer/address-book CLI, tentatively:
The initial implementation can use copy/paste tokens; the settings page can add links/QR codes later.
Pairing
initiator -> responderandresponder -> initiatorHMAC keys from the pairing secret with HKDF and protocol/pair-ID context. Store only the needed inbound/outbound keys in per-user0600state.This is a two-way exchange so it needs no directory or new public pairing service. Anyone who receives an unused invitation can accept it, so the invitation is a short-lived bearer secret and must travel out of band.
The peer record needs only: pair ID, role, local label, remote HTTPS endpoint, key references, protocol version, creation time, and revoked state. Labels may differ on each box. The authenticated pair record is how a user identifies the sender; fields such as
fromin a message are descriptive and do not establish identity.Transport and local delivery
Create one local-channels generic source per pair, for example:
{ "format": "generic", "secretFile": "peer-<pair-id>.secret", "signatureHeader": "x-agent-box-signature-256", "keyPath": "inbox", "senderPath": "from" }The outbound sender serializes the envelope once, signs those exact bytes with the directional HMAC key, and POSTs them over HTTPS to the stored peer source endpoint:
{ "version": 1, "messageId": "uuid", "sentAt": "RFC3339 timestamp", "inbox": "default", "from": "optional session/display context", "body": "message text" }peer-<pair-id>:defaultis then an ordinary local-channels topic. Existing subscriptions choose whether it reaches a live Claude/Codex session or an explicitly configured standing watch. Delivery retains local-channels'UNTRUSTEDframing and payload limits; authenticating the paired transport does not make message text trusted instructions.Do not add a new session router. local-channels already supplies HMAC ingress, per-session topic filters/TTLs, Claude channel delivery, and Codex delivery via
codex queueat the next turn boundary.Queue boundary
Reuse the queues that already exist for final-mile backpressure:
EX_TEMPFAIL(75). Its pending state is deliberately in-memory and bounded (currently 200 lines per key and five minutes by default), so it handles short capacity pressure, not offline peer delivery.A durable cross-box outbox is a separately scoped follow-up. V1 may fail the send when the peer is offline and report that clearly. A later Maildir-style/SQLite outbox can add retry, acknowledgement, expiry, and deduplication without changing the pairing or wire envelope.
Security and delivery semantics
messageIdandsentAtreserve the wire fields for a bounded replay cache. Until durable deduplication is implemented, document v1 as at-least-once delivery and never treat a repeated authenticated message as new authority.Acceptance criteria
listshows the label, endpoint/fingerprint, and state, but no secret.keyPath: "inbox",senderPath: "from", and the correct inbound directional key.Explicitly out of scope for v1