docs GitHub

Architecture

ADR 0021: The box reads the paired Mac through the link

Status: accepted, 27 Sep 2026 · Workstream: federation · Amends ADR 0002 (the link) and carries out ADR 0008 step 3

Context

ADR 0008 step 3 says the Mac's sessions stay on the Mac and "the box reaches them through the link". Nothing built that. The link ran one way only: the Mac calls the box's tools (ctx.remote, link.call) and follows its events. The Mac has no listener, so the box had no way to ask it anything, and a new box showed "0 sessions" to a person whose whole history is on the Mac.

Decision

  1. A reverse channel over the Mac's own connection. While paired, the Mac holds one request to the box's link.serve { key }. The box answers with the next question for that Mac, or null after 60 s. The Mac runs the question and answers with link.reply { key, id, result }, then asks again. Both tools check the pairing key and the Mac's pinned node, as link.hello does. No port opens on the Mac, so there is no firewall prompt and nothing new to reach from the tailnet. When a call to the box fails the loop stops, and the next call that reaches the box (the minute's heartbeat at the latest) starts it again, so an absent box costs nothing extra.
  2. An allowlist at both ends. Only projects.catalog, projects.list, recall.search, recall.sessions, recall.thread and threads.list cross (core/link/allow.js). The box refuses anything else before queueing it, and the Mac refuses it again before running. The Mac runs them as module:link. link.macs.call is internal (modules only), and link.macs lists the paired Macs with online, for surfaces.
  3. Federation through each module's contract. The six read tools on the box take machines: "all"|"local" and merge the Macs' rows, labelled source ("box" or "mac") and machine, with sources saying which machines answered. They federate only for the person (the Deck, the terminal, the Capsule, the owner over the tailnet) or a module that passes machines: "all"; agents, MCP and guests get the box's rows alone. On a Mac nothing federates, so the Mac never asks the box back. There is no link.federate tool: each module owns its merge.
  4. No transcript on the box. Only recall.thread carries turns, and only when a surface opens a session the box does not have. Nothing a Mac answers is written to the box's store.
  5. Offline is an answer, not a wait. A Mac that is not polling answers mac_offline at once, and the box returns its own rows. The Deck shows an offline chip read from link.macs, never by asking the Mac.

Known limits (accepted for v1)

  • The Mac answers one question at a time, so a busy Mac can delay a read by up to the link's 5 s timeout. The box's own rows never wait on it.
  • A Mac that drops off while holding its request looks online for up to the 60 s hold. A read in that window answers timeout, not mac_offline.
  • Sending to a Mac thread and answering its asks are the two writes (below). Taking or releasing its keyboard, starting or stopping it from the box are not in this version.

Sending to a Mac session

Added 27 Sep 2026, so the person can message the Mac's Claude Code sessions from the Deck and the phone.

  1. The rule. threads.send on the box, for a thread the box does not have (not running, not recorded, no transcript it could adopt), goes to the paired Macs when the caller is the person: the Deck, the terminal, the Capsule, or the owner over the tailnet (wantsMacs in core/modules/federate.js, with no machines input, so a module never qualifies). An optional machine input names one Mac. The Mac's answer comes back unchanged, plus source: "mac" and machine. Agents, MCP, guests and modules get the box's own answer (no thread <id>), and the Mac never sees their call. A thread the box has is the box's.
  2. One write at both ends. WRITE in core/link/allow.js holds only threads.send. The box queues it only when link.macs.call is given as: "person", and the request carries as to the Mac; the Mac runs a write only when the request says as: "person". Writes wait 15 s, since a send that resumes a stopped session headless takes a moment.
  3. The Mac runs it as the person's. The Mac calls threads.send as the caller link:box (a label only core modules can use, and only the ones the registry's fixed CALL_AS map gives them; no manifest can grant it), so the switchboard's queuesFor treats it as a person: a session busy in a terminal gets the words queued and handed over at its next Stop, exactly as for the person on the Mac. Its surface is box:<surface> (box:deck), so the Mac's lease and inbox rows show the words came from the box.
  4. The events come back. After a send that did not fail, the Mac follows that thread's thread.queued, thread.sent, thread.text, thread.finished, thread.stopped, thread.contended and thread.limit, and sends them to the box's link.events in batches, at most every 250 ms while they flow. Events that arrive while the send runs are held and sent only if it succeeds. The box takes them only for a thread it sent to in the last 30 minutes, only from the Mac it sent to, and re-emits each with source: "mac" and machine added to the payload, the thread id in the envelope and no project (the Mac's slugs are not the box's). One that looks like a secret is dropped alone. 4a. The box never takes a Mac session's keyboard (decided with capsule-now, 27 Sep 2026). While any surface on the Mac holds the lease (a terminal, the Capsule, the Mac's Deck), the words are queued, and the answer's busy is "terminal" or the holder. A free session is typed into as before and its lease goes to box:<surface>. The Mac's caller link:box is a caller kind of its own in the switchboard (fromLink): it queues, is never an agent, and its surface is always box:<surface>. The box's note names the Mac: "<name> is busy in your terminal on <mac>. I'll hand it your message when this turn ends."
  5. The follow ends at the thread's thread.finished or thread.stopped, 30 minutes after the last send, on unpair, on revoke and when vyred stops. Queued words: a thread.queued adds its id to the follow's waiting set, and the thread.sent { queued } that hands it over removes it. A thread.finished while any is still waiting is another turn ending, not this answer, so the follow goes on. Each send starts or extends the follow. No listener or timer runs while nothing is followed. A batch the box does not take is dropped, never retried: the Deck can read the thread again with recall.thread.
  6. Not forwarded in v1: threads.answer (answered on the Mac in v1, the Deck saying "Answer it on <mac>"; forwarded since v2, below), threads.lease and threads.release (the box cannot hold a Mac's lease; the send takes the Mac's lease for box:<surface> as any send does), and every other thread tool.
  7. Offline is an answer. A Mac that is not polling makes threads.send fail at once with mac_offline, "<name> is offline; your message was not sent". Nothing is queued on either machine: the box does not keep words for a Mac that is away.

Trust. The Mac trusts its paired box's as: "person", because on the box only the switchboard's person rule produces it, and link.macs.call is internal (modules only). A box that was taken over could claim it; what it gains is typing into the Mac's sessions as the owner would from the Deck, the same reach the owner's Deck already has. It still cannot run any other write or read beyond the allowlist, because the Mac checks those itself, and an answer to a permission question needs the box's signed assertion as well (v2).

v2: answering a Mac's permission question from the box

Built 27 Sep 2026 (ADR 0030 step 7), so the person answers a Mac session's ask from the Deck and the phone.

  1. The box's key. The box keeps an Ed25519 key, made on first need and stored at 0600 in its home (link-assert-key.json, core/link/assert.js). link.pair.poll hands the public half to the Mac with the pairing key (box.assertKey), with the Mac's own node as the box sees it (you.stableId), and the Mac saves both in its link.json. A Mac paired before v2 takes them once from link.hello, over the channel it already pinned to the box's node (trust on first use of that pin). A pinned key is never replaced: a box with a new key means pairing again.
  2. The Mac's asks reach the box. While paired, the Mac forwards every ask.raised and ask.answered (its decision is cancelled when an ask closed unanswered) for all of its threads, not only the ones the box sent to: the point is that the phone sees every ask, and asks are few. They ride the same link.events batches (a listener, no timer). The box re-emits them with source: "mac", machine (the Mac's name, as listings label rows) and node (its stableId), the thread in the envelope and no project, and remembers which Mac each open ask is on (memory only, at most 500, for a day at most).
  3. The forward. threads.answer on the box, for an ask the box does not have, goes to the Mac that raised it, only for the person's own callers (the same rule as threads.send: wantsMacs; an agent, MCP, a guest and a module never forward, and a call traced to a session never does). The ask names its Mac; machine names one when the box has not seen the ask (after a box restart). An ask no Mac raised and no machine goes nowhere, and the box answers as before (no ask <id>). The box signs, for that Mac alone, A = { v: 1, tool: "threads.answer", mac: <the Mac's stableId>, ask, thread?, decision: <sha256 base64url of the canonical JSON of the exact input sent>, caller, device: <the calling device's stableId or null>, person: <the person session's id, or null for a socket caller>, presence: <the proof's method, or null>, iat, exp: iat + 60 s, nonce: <16 random bytes> }, and sends it with the write, as: "person". 3a. The person, and gated asks (the lead's conditions, 27 Sep 2026). An owner device over the tailnet or the relay (tailnet:<login>, not tailnet:agent:*, or device:<id>) forwards an answer only inside a person session (meta.person, ADR 0032); without one the box refuses with person_session_required and signs and sends nothing. This is defence in depth: the registry's own person-session rule is on work/e2e, not yet here. The socket's callers (the Deck, the terminal, the Capsule) are the person already. An ask is gated when allowing it approves a floor tool that needs a fresh proof (gatedAsk in core/modules/federate.js): its tool is a HUMAN_ONLY name, or mcp__vyre__<name> or mcp__plugin_vyre_vyre__<name> with the name spelled as Vyre's MCP server spells it (each character outside [A-Za-z0-9_-] as _), exactly; or the ask says presence.required: true. On the box threads.answer carries a presence rule that asks only for an answer bound for a Mac that is gated, or for an ask the box never saw that names a machine, or (e2e review of 0f2a8752, LOW 1) that names no machine either on a box that has ever paired one, since macAsks is memory-only and a restart forgets which open asks were gated (it could approve anything, so it fails closed). A box that has never paired a Mac has nothing to fail closed on, and asks nothing for an unknown ask with no machine (cohesion, 2026-09-28: this had regressed to failing closed there too, asking a plain single-box install for presence on every already-closed or mistyped ask id); the registry verifies the proof, and the forward refuses with presence_required when there is none or it is a presence session. Every other answer asks nothing (the no-nag rule). On the Mac, the Mac looks up its own ask; for a gated one it refuses an assertion whose presence is null or session, however good its signature. The answer comes back unchanged plus source: "mac" and machine. It is never retried: "no ask" or "cancelled" from the Mac is final, and a retry would need a new nonce.
  4. The Mac's checks. threads.answer is in WRITE at both ends. Before it runs as link:box, the Mac checks the signature against the pinned key, tool is threads.answer, mac is its own node, ask is the input's, the hash matches the input exactly, now is before exp, iat is at most 60 s ahead, the life is at most 60 s, the nonce is unseen (kept in memory until its exp, so for the whole window, at most 1000; past that, answers are refused rather than a nonce forgotten early), and a gated ask carries a fresh proof (3a). Any failure answers denied with the reason, and threads.answer never runs. The assertion is read for threads.answer only: threads.send keeps its own rule. threads.answer lists link:box among its callers, and a socket client can no longer claim a link: label (core/daemon), so only the link reaches it that way.
  5. No follow. The Mac's write() follows the thread of its input, and an answer names none, so an answer follows nothing. The ask's end reaches the box through (2); what the session says next reaches the box only if the box is following that thread for a send.

Trust. The key proves the answer came from the paired box, for that ask, that answer and that Mac, once, within a minute. For most asks it does not prove that a person pressed anything: on the box, only the switchboard's person rule (the Deck, the terminal, the Capsule, the owner's devices in a person session) makes the call, as for threads.send, and answering takes no presence proof (ADR 0024, "No nagging"). A gated ask is the exception: the box signs the proof's method, and the Mac refuses without a fresh one. A box that was taken over could sign answers, the same reach the owner's Deck on the box already has. A captured assertion is no use on another Mac, another ask, another answer, a second time or after a minute.

Fixed (v2, e2e review, LOW 2): seen nonces are persisted to the Mac's home (link-assert-nonces.json, 0600, best-effort write), not kept in memory alone, so a Mac that restarts inside a used assertion's 60 s window still remembers it saw that nonce and refuses a replay. Loading prunes what has already expired by the Mac's own clock. A write that fails (a full disk, say) never blocks the answer; that one restart's window is the only one it could widen, and only for a captured assertion bound to one Mac, one ask and the exact answer hash.

Consequences

  • A paired Mac keeps one held request open to the box: one request a minute while idle.
  • A send to a Mac thread costs the Mac at most four link.events calls a second while its answer streams, and nothing once it has finished.
  • Every ask on a paired Mac costs two small events to the box (raised, then answered or cancelled), batched with the rest; an answer from the box is one link request.
  • The box's reads can take up to 5 s longer when a Mac is slow, never longer.
  • Onboarding's history step counts the Mac's sessions, held for 30 s and keyed on the Macs' online state, so its 2 s poll asks the Mac at most twice a minute.