ContributingDraft
Vyre anywhere
Implements ADR 0039. Sample world only below (alex, Harlow Legal, Northwind Bakery, juno, kit): no real names.
The capability ladder
Nothing about "server or device" is visible until it has to be. Vyre starts with the smallest shape and grows one rung at a time as the person's setup earns it:
| Rung | What's true | What turns on |
|---|---|---|
| 0. Base | One computer, machine: "solo" | Everything except Tailscale, relay pairing, guests |
| 1. Always-on | That computer stays on and reachable (a Mac mini, a box, vyre server here) | The launchd/service keep-alive, nothing else yet: still "solo" until a second device joins |
| 2. A second device | A phone or another Mac is paired | Tailscale or the relay turns on for real (ADR 0002, 0026); role splits into "server" + "device" |
| 3. Docker | The server has Docker | computers (agent computers), glass (screen + files for them) |
| 4. A public address | The person wants a webhook or the hosted app | hooks, Funnel, network.origins |
A person who never leaves rung 0 never sees a Tailscale prompt, a device list, or a "your server
is offline" banner: there is no server distinct from the device. The ladder is the same whether
the always-on computer is alex's Mac mini at rung 1 or a cloud VM someone spun up starting at
rung 2 directly (a cloud server is never "solo"; there's no local presence to be solo with).
The role choice
Three values, one config key (config.machine, ADR 0039 section 1):
- Solo: "this computer does everything." Default for a fresh single-machine install. Solo is
never a server (
isServer("solo")isfalse): no tailnet listener, no public webhooks, no owner-claim flow, run on this machine until the person chooses one of the other two. - Server: "this computer is always on for the others." Chosen by running vyre server here or by finishing onboarding's "make this the server" path.
- Device: "the server is elsewhere; this is one of my devices." Chosen automatically when a device successfully connects to an existing server (onboarding's "I have a server, connect it"), or when a former Solo/Server machine finishes moving its stuff off (below).
The person is never asked to pick "box" or "device" in the abstract. Onboarding asks the question the way alex would actually decide it:
How will Vyre run?
- Just on this computer: (Solo)
- This computer stays on for me, and I'll use other devices too: (Server)
- I already have a Vyre server: (Device, jumps to "point at a server" below)
Nothing here mentions Linux, macOS, containers, or Tailscale: those are consequences, not inputs. If the person picks the middle option on a laptop that sleeps when the lid closes, the Server flow says so plainly ("Vyre needs this Mac to stay reachable: plug it in and turn off sleep, or pick a Mac mini or a box instead") rather than silently degrading.
The eight box-only modules: which ones Solo needs
Reviewer's HOLD (28 Sep) narrowed this to only what Solo's own story actually needs. Default is
NO for each; a module widens to "local" only when Solo itself, not a later capability, needs
it running:
| Module | Widen to "local"? | Why |
|---|---|---|
onboard | Yes (shipped) | Solo has no box to onboard from; it must onboard itself. Its wizard tools (you, claude, name, tailscale, history, skip, finish, passkey, link) stay refused on a non-server machine through one shared guard, per reviewer; only onboard.machine and (tailnet's) onboard.join are exempt. |
relay | Yes (tailnet, in progress) | The only way a phone reaches a Solo Mac without Tailscale -- exactly rung 2 of the ladder, needed the moment a device joins. |
names | No | The box's own tailnet listener. Nothing to listen for without Tailscale, which Solo never turns on until it becomes a server. |
network | No | Who besides the owner the tailnet listener serves. Meaningless with no listener. |
hooks | No | Public webhooks over Tailscale Funnel. No Funnel without Tailscale. |
releases (core/apps) | No | Serves the Android APK to a paired phone. By the time a phone exists, Solo has already become a server (section on Solo plus a phone) and gets this for free. |
computers | No | Agent computers. Team-lead's own framing: Docker plus an opt-in, a capability layered ON a server, not part of Solo's base -- and it carries real complexity (VNC passwords, tailnet-per-agent-computer, egress) not worth the surface for a story that doesn't need it. |
glass | No | Screen and files for an agent's computer; same reasoning as computers, and already gated on Docker being present. |
The role-choice tool
Agreed 28 Sep, for launch's onboarding cards and tailnet's onboard.join:
onboard.machine(new tool,core/onboard, HUMAN_ONLY):{ machine: "solo"|"server"|"device" }->{ machine, service?: { installed: bool, warning?: string } }. The Deck calls this when alex picks "Just on this computer" or "This computer stays on for me": nothing else, no polling, purely local.machine: "server"on darwin also installs thelaunchd/keep-awake service inline (the same code vyre server here runs) so the card click is enough; no terminal needed. Calling it again with the current value is a no-op on the service side.machine: "device"stays in the input schema (a person or an agent may never choose it directly; run() refuses anyone but module:onboard/module:relay) because it's how onboard.join'sverifystep sets it once a connection to an existing/new server is confirmed (ctx.call("onboard.machine", { machine: "device" })), per ADR 0039 section 5.- Solo needs no call at all until a device later joins (rung 2): matches the capability ladder: nothing about the choice exists until it does something.
- "I already have a server" / "a cloud server" hand off to tailnet's onboard.join (see
ADR 0039 section 5):
statusto offer Tailscale vs. relay,tailscale/relayto run the connect step,verifyto confirm reachability and flipmachineas above.
How the Deck reaches a Solo Mac, day to day
Raised by e2e, 28 Sep, and revised after reviewer's read of the first draft below (which had a security mistake reviewer caught: see the correction inline). This goes to reviewer again, in full, before any code -- team-lead's condition. This is core to "Solo works on day one."
vyred today has three listeners (ADR 0002): the unix socket (same-machine callers with a proven
ancestry: cli, capsule, local), the tailnet listener (the owner's own devices, once Tailscale is
on), and onboarding's loopback HTTP server on 127.0.0.1:7300, which closes once onboarding
finishes. Solo never turns Tailscale on, so without a fourth answer, nothing would serve a
browser Deck to alex once onboarding closes.
- The Capsule (a native Mac app) already talks to vyred over the unix socket directly, the
same as
cli, and ADR 0040's split makes this doubly true: it is one of the two clients (with the CLI) that may reach vyre-core's own socket straight, with its own ancestry proof. Nothing about Solo changes this. - The Deck, in a browser, needs an HTTP surface, because a browser cannot open a unix socket.
The fix: generalize onboarding's own loopback listener (
core/onboard/loopback.js) from an onboarding-only, close-when-finished server into an always-available one on Solo and Server machines with no tailnet address yet, at127.0.0.1:<port>.
The mistake in the first draft: it called the loopback listener "trusted the way local is."
That is wrong, and reviewer's condition corrects it. local on the unix socket means the kernel
told vyred which process connected, and its ancestry was walked back to a known caller (ADR
0032). Loopback HTTP carries none of that: any process on the machine, a model's shell included,
can curl 127.0.0.1:<port> exactly as a real browser would. So:
- The
"loopback"caller label is never person-level, never HUMAN_ONLY, never PERSON_ONLY by itself. It gets exactly what an unauthenticated caller gets: nothing privileged. - The session cookie is won through a one-time link, the same mechanism
onboard.linkalready uses, minted only by an already-trusted caller (the CLI or the Capsule, over the unix socket, or later vyre-core's own socket under ADR 0040) and opened straight into the browser (vyre open, a new small command). The cookie is unguessable (a long random value,HttpOnly,Securewhere the browser allows it on127.0.0.1,SameSite=Strict) and identifies a browser tab, never a person. - A session cookie alone still isn't presence. Reads through the loopback listener (static Deck assets, listing sessions, non-mutating status) are fine once a plain session exists, because a model reading its own transcripts back is not new exposure. Every write or privileged call (sending a message that runs an agent, approving a held draft, revealing a vault secret, anything HUMAN_ONLY or PERSON_ONLY today) still needs an actual presence proof: a WebAuthn passkey signature made in that browser, or a Capsule Touch ID confirm relayed over the socket -- checked cryptographically by the presence verifier (under ADR 0040, by vyre-core itself, never by the person-side vyred, which shares the model's uid and could otherwise be asked to rubber-stamp its own forged proof). This is not a new primitive: it's today's HUMAN_ONLY rule (ADR 0004/0032), enforced strictly rather than loosened for loopback's convenience.
- A Host/Origin allowlist stops DNS rebinding. The listener checks the
Hostheader is literally127.0.0.1:<port>orlocalhost:<port>(never trusting a name that merely resolves to127.0.0.1, which a page controlled by an attacker's DNS can arrange after the browser already loaded it), and checksOriginon any request that carries one against the same allowlist. A request failing either is refused before it reaches a tool. - CSRF protection on every write.
SameSite=Stricton the cookie already stops a cross-site form post from carrying it; writes additionally require a custom header (x-vyre-caller: loopbackor similar) that only same-originfetchcan set, so even a same-site navigation link can't trigger one. - Once a Solo machine becomes a Server (pairs a device, or runs vyre server here), the tailnet listener takes over serving the Deck at the owner's real address, and the loopback listener becomes exactly what it is today: an onboarding-only fallback, open just for the window before the owner's own device first reaches the tailnet address.
Not yet built. Sending this design to reviewer before writing any of it (generalizing
loopback.js, vyre open, the "loopback" caller label and its registry rule, the presence-proof
enforcement, the Host/Origin/CSRF checks) -- coordinating with tailnet (ADR 0002's owner) and
with e2e/ADR 0040 (vyre-core owns presence verification once it exists).
The move-to-server flow
One flow, two entry points, per ADR 0039 section 4:
Entry A: onboarding, fresh install, "I have a server, connect it." Nothing to move yet; the flow's steps 2-4 below are skipped and the new device just pairs.
Entry B: later, from Settings > Server, "Move to a server." alex has been running Solo on a MacBook for three weeks: projects, memory, and a vault full of Northwind Bakery's credentials all live only there. alex buys a Mac mini and wants it to take over.
-
Point at a server.
- Paste a setup code the new Mac mini shows after running its own onboarding as far as "make this the server," or
- Pick "make this Mac the server" right here: the current machine takes over the server role itself (rung 1, no second machine at all: used when someone finally leaves their MacBook plugged in and wants it to act as the always-on one).
-
What moves, and how:
Piece What happens Owner Projects Copied file-for-file to the destination's project root federation Memory (facts, transcript index) Re-indexed on the destination from the copied transcripts, not re-extracted by a model: a straight copy of the store, revalidated federation Vault Every secret decrypted locally on the source, re-encrypted to the destination's device key, sent over the already-authenticated channel (tailnet or relay), never written to disk unencrypted on either side federation, vault-next Sessions The Claude Code sign-in and any live Vyre-owned session state (ADR 0030) federation, sessions The source keeps running, fully itself, for the whole copy. Nothing on it changes until step 3 confirms.
-
The destination confirms, then the source flips. Only after the destination has decrypted and verified every piece does the source's
config.machinechange (to"device", or it can stay"solo"a little longer if alex is only trying this out: the flip is a separate confirm, not automatic). The eight box-only modules stop on the source's nextvyredrestart. Everything else: Capsule, voice, the local Chat: keeps working on the source, now talking to the destination as its server. -
Nothing is ever deleted automatically. The source's pre-move copy sits at
~/.vyre/moved-<date>/, same pattern ascore/projects/move.js's existing box-homes migration, indefinitely. Settings > Server offers "Free up space on this laptop": it previews exactly what would go, by piece (projects, memory, vault, sessions) and with counts, and only clears it once alex confirms. There's no timer and no auto-cleanup; a device's own data is never removed without the person looking at what's leaving and saying yes.
Failure and undo
- The copy fails partway (network drop, destination runs out of disk): the source is untouched: it was never flipped: so alex just retries. Settings > Server shows exactly which piece failed, not a generic "move failed."
- The destination looks fine but disagrees with the source (clock skew made it think it's current when a vault write is still mid-flight, say): the flow refuses to flip and surfaces a red state rather than silently pick one side.
- alex changes their mind after flipping: the same flow run in reverse (destination is now the source) moves everything back; since nothing was ever auto-deleted, the pre-move copy is usually still sitting there to skip the network copy entirely and just point back at it.
- The Mac mini playing server dies: every device stays a device (ADR 0032 keeps them separable from "the person"), so nothing about identity breaks. The person's next move is Entry A on a new machine, restoring from whichever device still has the most recent vault/ memory copy: this ADR does not build automatic failover; it builds a move that's cheap and safe enough to redo by hand.
Solo's one-command install
No new script. docs/get-started/without-docker.md already covers a Mac running Vyre with no
container: npm install -g https://vyre.run/box/vyre.tgz (or npm install -g vyre once
published), then vyre up. scripts/install-box.sh stays exactly what it is: Docker Compose
on Linux: and was never the right script for a laptop.
The gap: vyre up bare today assumes role: local and goes looking for an existing box before
doing anything else ("finds your box on the tailnet, or asks where Vyre should run"). For a
genuine one-command Solo install with no box anywhere, vyre up needs to ask the same
three-choice question as onboarding (or default straight to Solo when nothing answers) instead
of stalling on "where should Vyre run": tracked as anywhere's own follow-up against
core/cli/commands/up.js, not a new install path.
Onboarding and Settings
- Onboarding gets one new step (the "How will Vyre run?" question above) replacing the implicit OS guess. "I have a server, connect it" hands off to Entry A of the move flow. (launch owns the screen; this doc owns the three choices and their copy.)
- Settings > Server (new panel, launch + native-core) shows:
- The current role, in the same plain words as onboarding.
- "Move to a server" (Solo/Server → Device) or "Move off this server" (Device → Solo, taking everything back): both the same flow, run in whichever direction applies.
- On a Mac acting as Server or Solo: the
launchd/keep-awake status (section below), and a warning if the Mac's own sleep settings would break it. - The eight box-only modules' state (on/off), so alex can see at a glance that
glassis off only because there's no Docker, not because something broke.
The Mac-as-server service
vyre server here (ADR 0039 section 3):
- Sets
config.machine(asks Solo-vs-Server if ambiguous: a lone Mac with no device ever paired defaults to staying Solo unless the person says otherwise). - Installs
~/Library/LaunchAgents/com.vyre.vyred.plist:RunAtLoad: true,KeepAlive: { SuccessfulExit: false }: restarts on crash, not on a clean vyre stop.StandardOutPath/StandardErrorPathinto~/.vyre/logs/, matchingcore/config's existing log folder.
- A keep-awake assertion (
caffeinate -sheld by vyred itself while it's the server, or apmset-based system assertion) that blocks idle system sleep only: display sleep is left alone, so the person's own use of the Mac isn't changed. - Offers (never forces) a login item so vyred comes back after a full logout/login, since a LaunchAgent alone only runs while someone is logged in: the realistic case for a Mac used as a home server that stays logged in, not a shared family Mac that logs out each night. FileVault machines that require login before disk unlock are told this plainly: full unattended restart-after-reboot isn't possible without the person's account unlocking the disk first.
--undoremoves the LaunchAgent and login item, and leavesconfig.machinefor the move flow to change, not this command.
Windows later
Out of scope to build here. The seam this design leaves for the windows team:
core/modules/index.js's role→bucket mapping (ADR 0039 section 1) doesn't know or care what OS is running"device"or"solo": only thelocal/*modules do (local/screen-mac,local/hands-mac,local/voice's mac-only pieces). Windows Solo needs Windows equivalents of those, gated the same way, not a change to the role system itself.- vyre server here's
launchdhalf needs a Windows twin (a Windows Service or scheduled task, equivalent keep-awake viaSetThreadExecutionState): same three-step shape (service, keep awake, offer at-login start), different OS primitive.