Move the HUB role from one machine to another — promote a new machine to hub and demote the old one to a browser viewer, a spoke, or retire it. The ceremony that makes the swap DATA-SAFE — it freezes the source board BEFORE the final backup (so no write is stranded), enforces a schema precondition on the promoting machine, restores the LEASE-HOLDER's newest snapshot producer-aware, verifies the archived store's digest against the final snapshot after demotion, and prints the non-automatable r...
Scanned 9/2/2026
Install to Claude Code
npx -y skills add philipyaz/cos --skill hub-handover --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Hub Handover?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/philipyaz-hub-handover)More formats (shields.io, HTML) on the badges page.
---
name: hub-handover
description: >
Move the HUB role from one machine to another — promote a new machine to hub and
demote the old one to a browser viewer, a spoke, or retire it. The ceremony that
makes the swap DATA-SAFE — it freezes the source board BEFORE the final backup (so
no write is stranded), enforces a schema precondition on the promoting machine,
restores the LEASE-HOLDER's newest snapshot producer-aware, verifies the archived
store's digest against the final snapshot after demotion, and prints the
non-automatable residue checklist. Use when making your Mac mini (or any machine)
the main machine while an existing hub steps down, promoting a spoke to hub, doing a
planned hub migration, or recovering from hub failure onto a warm-standby machine.
NOT for adding a client (that's spoke-setup) or first-run setup (that's cos-setup).
allowed-tools: Bash, Read
---
# Hub handover — move the hub role between machines, safely
The **hub** is the one machine that runs the state machine (board, store, backups, routines). Handover
promotes a **new** machine to hub and demotes the **old** one (to a browser **viewer**, a spoke, or full retirement). The whole
risk is a **write that lands after the final backup** (stranded forever) or a **restore under a live
board** (silently reverted). This ceremony closes both, in order — do NOT freelance the sequence.
> Reusable for any hub swap; the worked example is **MacBook → Mac mini** (the mini becomes the main
> machine; the MacBook becomes a spoke). Prereqs: both machines on the same Tailscale tailnet, the
> **same recovery key** provisioned on both (backup-recovery §1.1 / §2), and the new machine at a code
> checkout whose `SCHEMA_VERSION` is **≥** the store's schema (the migration's safe direction).
## In plain terms — explain this to the user BEFORE you start
Everything below this section is written for the machine (you, the agent). **This** section is for the
**person** — most people asking for this just want *"make my new computer the main one without losing
anything."* Before you touch a single command, walk the user through what's about to happen, in their
words. Don't show them the ceremony; tell them this:
- **What this does.** Right now one computer is the "main" machine — it holds your real board, notes,
and to-dos, and makes the nightly backups. This moves that "main" job to a *different* computer. The
old one keeps working afterwards, just as a remote screen onto the new main — you install nothing on it
to keep using it, you just open the new main's web address in a browser (a spoke is only if you also
want the AI to *act* on the board from the old machine).
- **Why it's fussy (not just "copy the files").** In the wrong order, a to-do you add mid-switch could
vanish, or an old copy could quietly overwrite newer data. The steps are ordered so **nothing you've
typed can be lost** — that's the entire reason it looks elaborate.
- **What you need ready** (I'll check these for you): both computers on the **same Tailscale network**;
the **same backup recovery password** set up on both (the key that unlocks your encrypted backups);
and the new computer running an **up-to-date copy of the app**.
- **How long — two phases.** First a **"try it out" period of a day or more**: the new computer runs
alongside the old one so you can confirm everything looks right. Then a **~30-minute switchover** where
I briefly stop the old machine, take one final backup, and hand the "main" role to the new computer.
- **The one rule for you.** Until I say the switchover is finished, **keep doing your real work on the
OLD computer.** Anything you type on the *new* one during the try-it-out period is just a rehearsal — it
gets wiped when we do the real handover, so don't rely on it yet.
- **Can we undo it?** Yes. If the new machine turns out wrong after the switchover, there's a clean
rollback — no step here is a point of no return as long as we follow the order.
Only once the user understands the above and says go, follow the exact sequence below — and narrate each
CHECKPOINT to them in the same plain language ("the new machine now shows your real to-dos" rather than
"SSE stream stays open"). **Do not improvise the order.**
## The order (each step gates the next — do not reorder)
### 1 — Soak the new machine FIRST (old hub stays authoritative)
Stand the new machine up as a hub **hydrated by restore**, and run it read-mostly for a day+, while the
**old hub keeps producing backups** (it is still the lease-holder — do NOT install the new machine's
backup LaunchAgent yet; a second producer would fight the old hub over the lease + remote).
```sh
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
# On the NEW machine: same recovery key, then dry-run restore of the OLD hub's newest snapshot.
node "$REPO_ROOT/backup/restore.mjs" --list # note the old hub's deviceId
node "$REPO_ROOT/backup/restore.mjs" --device <old-hub-id> # DRY RUN: auth tag / sha256 / JSON verified
```
Then `--apply` it **with no board running** (`restore.mjs` refuses `--apply` while anything answers on
`$BOARD_URL` — that guard is the point). First board boot migrates the store on read (code ≥ store =
the safe direction). Wire guard/mcp-bridge/add-ons, fresh WhatsApp QR (session DBs are non-portable),
and — since this machine now serves other devices over the tailnet proxy — run the **production** board
here (`cd board && npm run build && npm run start`, or install the `boardapp` LaunchAgent), **not**
`next dev` (whose on-demand compilation + lazy chunks are unreliable behind a reverse proxy — the page
loads but opening a case can silently fail for a remote browser), behind `tailscale serve --bg 3000`.
**CHECKPOINT** — the new machine's board shows the right case counts + add-on nav + a working vault
query, and an SSE stream stays open >10 min over the tailnet. **Treat it as read-mostly**: any writes
you make on it during soak are throwaway soak-state — the cutover re-restore (step 2.4) **OVERWRITES**
them with the old hub's final snapshot, so anything you typed on the new machine during soak is lost.
Don't do real work on it until after cutover.
### 2 — Cutover (do these five in THIS order; ~30 min)
1. **Pause/delete every scheduled routine on the OLD hub** (Cowork tasks / cron).
2. **STOP the OLD hub's board** — quit `next dev` / `launchctl bootout` the boardapp. The source is now
FROZEN: no write can land after the snapshot in the next step. (This is the step a naive "pause
routines + back up" misses — a still-running board keeps accepting browser/MCP writes.)
3. **Forced final backup FROM the OLD hub**, verify **exit 0 AND pushed**, then `launchctl bootout` its
backup agent (it must stop producing).
```sh
node "$REPO_ROOT/backup/backup.mjs" && echo "final backup OK (exit $?)" # 0 = written + pushed
```
4. **Promote on the NEW machine**: stop its soak board, then re-restore the **OLD hub's FINAL** snapshot
(producer-aware: pick `--device <old-hub-id>`, NOT whichever `--any-device` newest — the new
machine's own soak snapshots must not win), no board answering:
```sh
node "$REPO_ROOT/backup/restore.mjs" --device <old-hub-id> --apply
```
Then flip this machine to the hub. The old hub's lease is still **FRESH** at cutover (it renewed
minutes ago in step 2.3), so a plain backup would refuse and stand down — use **`--claim`**, the
handover takeover, which claims the lease (epoch bump) even over a fresh one and produces the new
hub's first snapshot:
```sh
node "$REPO_ROOT/scripts/gen-launchd.mjs" --install backup # install the new hub's daily agent
node "$REPO_ROOT/backup/backup.mjs" --claim # take the lease + first backup (exit 0)
```
Recreate the scheduled routines in the new hub's Cowork.
5. **Copy the machine-local sweep watermarks** (they are gitignored, NOT in the backup scope, and
created on first sweep — copy each that exists, old → new) so nothing re-processes:
`config/whatsapp-triage-state.json`, `config/unanswered-messages-state.json`, and (if voice ingest
is scheduled) `mcp/openwhispr-server/state/watermark.json`. The **Gmail-side** watermarks are labels
(`cos/processed`, `cos/answer-checked`) — they live in Gmail, shared across machines, so nothing to copy.
### 3 — Demote the OLD hub (to a browser viewer, or a spoke)
Order matters — the safety-net backup run must happen while the old machine is still a **hub**
(`backup.mjs` exits 1 immediately for a spoke, before the lease/quarantine logic):
```sh
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
# a) DIGEST-DISCIPLINE run — WHILE STILL A HUB, before spoke-setup:
node "$REPO_ROOT/backup/backup.mjs" # sees the new hub's fresh lease → exit 4, orphan-quarantines any late write
# b) Archive the retired store (keep board/data/backups/):
mv "$REPO_ROOT/board/data/cases.json" "$REPO_ROOT/board/data/cases.retired-$(date +%F).json"
```
- **Digest discipline:** step (a) is why the new hub's `--claim` in step 2.4 matters — now that the new
hub genuinely holds the lease, the old hub's run sees a fresh foreign lease and **quarantines any
changed local state** to `orphan/<id>-<ts>.enc` (exit 4). Then compare the archived store's digest
against the final snapshot; if they differ, a late write slipped in — it's in the orphan blob (the old
hub already pushed it), recover it via the new hub, never discard it.
- c) Now **pull `main` freely** (with no live store, the schema-skew deadlock is gone) and `launchctl
bootout` the old hub's stateful agents (boardapp, guard, sidecars, vault, backup) — its hub job is over
regardless of what it becomes next. Then choose what the old machine BECOMES:
- **Pure viewer (the common case, zero setup).** If you only want to keep *seeing/using* the board on
the old machine, install NOTHING more — the person opens the new hub's **portless**
`https://<new-hub>.<tailnet>.ts.net` (its `tailscale serve` URL) in a browser and gets the full
read/write UI over the tailnet. That is a viewer, not a spoke; skip the rest of this step.
- **Agents on the old machine too?** ONLY then run `spoke-setup` — it sets `COS_DEVICE_ROLE=spoke` +
`BOARD_URL=<new hub>` and wires the board-facing MCP wrappers so Claude Code/Cowork there drive the new
hub. A browser can't hand a local agent those tools; wanting agents on the old machine is the SOLE
reason to add a spoke.
**CHECKPOINT** (spoke path only; a pure viewer needs no check beyond the browser loading the new hub's
portless URL) — from the old machine (now a spoke): the new hub's board loads over the tailnet with a
"Connected to <hub>" chip; a write from the spoke appears on the new hub's screen (SSE); `npm run dev`
**and** a direct `npx next dev` write both REFUSE on the spoke (predev abort + the store's 503
`spoke-role-refusal`).
## Unplanned failover (the hub died — no graceful cutover possible)
If the hub is gone (dead/lost), promote a **warm-standby** machine that already has the recovery key +
a pulled backup clone (backup-recovery §1.1/§2): restore the latest snapshot with no board running
(`restore.mjs --device <dead-hub-id> --apply`), then `backup.mjs --claim` to take the lease (the dead
hub can't contest it), install its backup agent, and flip it to hub. The window's exposure is bounded
by the standby clone's freshness (≤24h) and GitHub reachability. The lost hub, if it ever returns,
sees the new fresh lease and stands down (exit 4) rather than double-producing.
## Rollback (before step 2 the old hub is untouched; after, symmetric)
After cutover, if the new hub is wrong: **take a final backup FROM the new hub first** (its post-cutover
writes must not be stranded — the mirror of step 3's archive), then restore the pre-cutover snapshot on
the old machine and `backup.mjs --claim` to re-take the lease. Then flip roles back.
## The non-automatable residue (print + tick by hand — nothing here can be scripted safely)
- [ ] Scheduled routines RECREATED in the new hub's Cowork, DELETED on the old.
- [ ] WhatsApp re-paired on the new hub (QR — session DBs don't move).
- [ ] Obsidian vault registered on the new hub (its `obsidianVaultId` is machine-local; a cross-machine
restore deliberately keeps the *restoring* machine's id — so register the vault in Obsidian there).
- [ ] `COS_HUB_PUBLIC_URL` set on the new hub (its `tailscale serve` URL) so its Devices panel can emit
join blobs.
- [ ] The old machine's backup agent booted out; the new machine's installed + holding the lease
(check `/backups`: green, lease held by the new hub).
## Gotchas
- **The freeze is the whole point.** Routines-paused + backup-pushed is NOT enough — a running board
keeps accepting writes. STOP the source board BEFORE the final backup (step 2.2), always.
- **Producer-aware selection.** At promote, pick the OLD hub's snapshot by `--device`, never
`--any-device` — the new machine's soak snapshots are newer by wall-clock but are throwaway state.
- **Schema precondition.** Promote only on a checkout whose `SCHEMA_VERSION` ≥ the snapshot's schema;
otherwise the board fails closed (`SchemaAheadError` → 503) after restore until you `git pull`.
- **One producer at a time.** Never have both machines' backup agents installed simultaneously — the
HUB.json lease is the single-producer tripwire, but two agents racing it wastes an exit-2/4 cycle and
muddies the history. Old off, new on, in that order (step 2.3 → 2.4).
- **Soak writes are quarantined, not merged** — that trade-off is accepted (the alternative is merge
machinery this design exists to avoid). Keep the new machine read-mostly during soak.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!