The board listens on 127.0.0.1:7777. Each fleet has its own page at http://127.0.0.1:7777/<fleet id>/, and / lists the fleets. Each fleet keeps its own token in board.token in its folder, so logging in to one fleet never logs you out of another. A fleet you add or move shows up without a restart. The board also keeps each fleet's watcher alive (see Keep the watcher and the board running). The same server answers a JSON API under /<fleet id>/api/v1 for a local client and a paired phone (JSON API), with the actions they may take (sending a message, answering a decision, acknowledging a report, and the supervisor's start, resume, stop, interrupt, force and keys); the pages do not use it and do not change because of it. To reach the board from a phone, pair the Hand app on the Phone page (see Pair a phone). Reach the board from a phone also covers an SSH tunnel, Cloudflare Tunnel with Access, WireGuard and a LAN address.
A fleet's page has a slim rail of icons at the left edge (Board, Tasks, Conversation, Screen, Phone and the fleets this browser or phone can open) and, from 900px wide, two panes. The work pane lists what waits on you (Needs), grouped by severity with the most severe first, then the tasks with a search field (Tasks). When the fleet has more than one registered project, each task row, the Tasks page, and each decision and report in Needs show the task's project as a small badge; with one project nothing shows. A task that waits on an outside party (hand wake --waits) shows Waits until Nov 3: reason under its title in the Tasks list and in its page's header, and leaves the Stalled, Failed and Interrupted items in Needs while the wait runs. A sub-task (see Task) sits under its parent in the same group of the Tasks list, indented one step per level up to three, and from the fourth level on a depth N marker replaces further indentation; the parent shows "N sub-tasks", linking to its page. A sub-task whose parent is in another group (a done parent sits under Finished), or not on the page (past the 500-card cap, or off the five-task Finished rail), is listed on its own with an "under tM" link. A task's page says "Sub-task of tM" in its header and lists its sub-tasks with their status, and the search results of the Tasks page stay a flat list. A decision shows its options as a choice list: pick one, add a note (required when the option starts with "Other"), and send. The supervisor pane on the right shows the supervisor controls, the conversation without tool calls or thinking, and the message box. The conversation comes from the supervisor's own session record for every harness with a reader (claude, codex, opencode, agy, copilot and omp); an omp supervisor's served model and context come from its latest assistant message. Drag the line between the panes, or focus it and use the arrow keys, to resize the chat; the board remembers the width, and a double-click resets it. Below 900px the page has three tabs, Needs, Tasks and Chat, and the rail opens from a menu button as a sheet. Tasks, attempts, decisions and reports open in a dialog over the page, so the chat, Needs, the message draft and the scroll positions stay as they were; the rail and the page's own links stay in place. The Tasks page searches every task. Each task and each decision also has its own page. The board is a projection of Hand's state, so restarting it loses nothing, and it updates itself as things change. While the page is open, the board sends a small beat every 15 seconds when nothing else changed. If the page hears neither an update nor a beat for 35 seconds (the laptop slept, or a proxy or tunnel dropped the connection without closing it), a band under the header reads "Connection lost since 12:41", with the time of the last update, a Reload link, the status word reads "unknown" and the status line dims. The page reconnects by itself, and the band goes as soon as the board answers. The same band reads "Reconnecting…" in the first moments of a dropped connection, and "Log in again: hand open --print" when the board refuses the session ("Signed out: pair again on your laptop" on a paired phone, which the board refuses after Revoke or after 90 days).
A file deliverable inside the fleet folder opens in the Board's document viewer, /<fleet id>/doc/<path inside the fleet folder>; a path outside the folder, one that is gone, or one the viewer refuses stays text. The same holds for an absolute path written in a supervisor reply, a decision, a report, a plan or a document, and for a code span that holds only a path: it links to the viewer when the viewer would show it, and otherwise stays text. A click on a link to the viewer anywhere on the Board (a file chip, a path in a reply, decision or report, a deliverable, a sent file card or image in the chat, and a folder entry, Previous, Next, pager step or relative link inside the viewer) opens it in place, in a dialog over the page you are on: the chat, Needs, the tasks, the message draft and the scroll positions stay as they were. The dialog shows the same page, with the same header, Copy path, Find, Follow and toggles; Esc, the close button or Back closes it, and Forward opens it again. The address bar shows the file's address while it is open, so a reload on it shows the normal page and the address opens in a new tab. Open as a page in the dialog's top strip, and a Ctrl, Cmd, Shift or middle click on any such link, open it in a new tab; with no script, every link is the same new-tab link. Text past 8 MiB opens at its end in the dialog too, as it does in the page. The dialog loads the file the same way a tab does, and a path the viewer refuses or cannot find shows its reason in the dialog with a Close button.
The same dialog opens a task, an attempt, a decision and a report, from any link to one on the Board: a task row, a Needs link, a ref in a reply, a Hand line, a report or an event, the Tasks page, the conversation, the attempt page's links, a deliverable. A report opens as its task's page at the report's card. The strip at the top of the dialog has Back, the page's title and Open as a page, which opens the page the dialog shows now in a new tab. A link followed inside the dialog goes one page deeper: Back, the browser's Back button and Alt-Left return one page, Back is disabled on the first page, and the browser's Back button on the first page closes the dialog; Esc, the close button and a click outside the dialog close all of it. The dialog keeps at most 20 pages deep. A task or attempt page in the dialog updates itself every 10 seconds, without moving what you are reading, and stops when the dialog closes or opens another page. What the page does, the dialog does: answering a decision and Mark read work in it, post without leaving the page, show a refusal or a decision answered elsewhere in the form with your text left in place, and on success update the dialog's page and the Needs list and counts behind it at once. The answer box in the dialog keeps its own draft, apart from the one in Needs for the same decision. Places (the rail, the crumbs, the Tasks page, the conversation) navigate in place, and a PR or any outside link opens in a new tab. A Ctrl, Cmd, Shift or middle click, and a page without the script, open the link in a new tab as before.
The viewer only reads:
- Markdown up to 1 MiB renders with tables, code blocks, task lists, and relative links and images that resolve inside the fleet folder; raw HTML stays text, and a remote image stays a link. A Rendered and Source toggle in the header switches a
.mdor.markdownfile between that view and its raw text (numbered lines,#L12anchors, Find, Wrap, Follow and Copy contents of the file, as for any text); the Source view is?as=text, so Previous, Next, the pager, Find and the dialog keep it, and the choice is remembered in this browser (Rendered stays the default; with Source remembered, a Rendered page loads and then switches to Source, keeping a#L12anchor, and a Ctrl, Cmd, Shift or middle click on the toggle leaves the choice alone). A file over 1 MiB, or one that is not text, has no toggle and shows as it did before. A table's first column scrolls with the rest; a table wider than its box shows a Pin first column toggle that keeps the first column in view, and the choice is remembered in this browser. A Markdown or text page has a Wide toggle that lifts the 80ch reading width to the whole page, and the viewer dialog opens wide by default; the choice is remembered in this browser. - Other text of any size shows as numbered lines, never refused for its size. A file up to 8 MiB shows whole; a larger one opens at its end, 4 MiB at a time, with Start, Earlier, Later, End and a Line field to jump. Lines show as text only: ANSI colours become a fixed set of classes, other escape sequences are dropped, control characters and bidirectional marks show as visible symbols, a carriage return keeps the last part of the line, and a line past 256 KiB is cut with a note. Find (
/focuses its field; on a whole file, the browser highlights matches and steps through them with Enter and Shift+Enter; on a larger one, or where the browser lacks the highlight API, it lists up to 200 matching lines of the whole file, matched against the raw bytes of each line up to its first 256 KiB, so it can differ from the highlights on text that holds escape sequences or carriage returns), Wrap (which hides the line numbers), Follow (the end of the file, as it grows; it stops with a notice when the file is replaced or shortened) and#L12links to a line (these work with the script on) sit above the text. Markdown over 1 MiB shows as text too. - JSON up to 16 MiB shows pretty-printed (two-space indent, the same numbered lines, windowed like any large text when the result passes 8 MiB), with an As written toggle for the bytes in the file; a file that is not one valid JSON value, whose pretty form would pass 64 MiB, or that is larger than 16 MiB, shows as written, with a note for the first two. CSV and TSV show as a table of the first 2,000 rows (the first row is the header and does not count; a cell is cut at 300 characters; a column of numbers is right-aligned; the first 8 MiB is read), with a Text toggle for the whole file. A diff or patch (
.diff,.patch) colours added, removed, hunk and file-header lines. Every Source, Text and As written view goes through the same line writer as other text. - HTML (
.html,.htm) shows as a preview "without scripts": a sandboxed frame with no scripts, no forms and no access to the Board, so its scripts, links and relative images do nothing; Source shows the markup as text. SVG shows as a picture, never as a document, with a Source toggle. A PDF shows in the browser's own viewer, in a frame of the same origin, and keeps its Download. - Video and audio (MP4, WebM, MP3, WAV, Ogg and Opus) play in the page, with seeking, only when the type the Board finds in the file's first bytes and its extension agree. A file whose bytes and name disagree is text if it is text and a download if it is not.
- An image shows inline only when both its bytes and its name say PNG, JPEG, WebP or GIF. A link to it from a folder or a neighbouring file opens its own page (
?view), with Fit and Actual size (?view=actual), a Download, and Previous and Next to the neighbouring images; the bare path still returns the image itself. - A folder lists its folders first, then its files (names in any case order, a tie broken by the exact name, so the order is the same on every request), with a thumbnail for each image; dot entries are hidden. Every entry is listed, 500 to a page:
?page=N(an out-of-range number shows the last page, a bad one the first), with the count ("1,200 entries, page 3 of 3"), First, Previous, Next and Last above and below the list, and a Page field to jump when there are more than three pages. A link that leads outside the fleet folder, to a file the Board never shows, to nothing, to a loop or to something that is not a file or folder stays in the list as a row that is not a link and says so in plain words ("leads outside the fleet folder", "points at a file the Board never shows", "points at nothing", "cannot be read"), never naming the target. The server reads only the names of a folder, which for 100,000 entries cost about 20 MiB of heap and 0.3 s, and the page holds the 500 it shows. - Stepping through a folder: a file's page shows Previous and Next to the neighbouring files of its folder, in the listing's order, skipping folders and refused entries (a folder with one file shows neither), and ← and → do the same unless the focus is in a field, a media control, the text or a scrolled picture, or text is selected. The folder link in the crumbs returns to the listing page that holds the file.
- A file that is not text, one with a NUL or invalid UTF-8 in its first 8 KiB, and not a PDF, picture or media file described above (an archive, an APK, an Office file, any binary), says so and offers a Download, which the browser saves (
Content-Disposition: attachment,nosniff, a sandbox CSP) and never runs. Every file has a Download link. - Copy path in the header copies the absolute path shown there, for a file or a folder. Copy contents on a Markdown or text page copies the file as written (up to 8 MiB); on a windowed large file it reads Copy window and copies the lines the page shows. Each says "copied" in the status line, or asks the reader to select the text when the browser refuses the clipboard.
The previews load the file from ?raw, which sets the headers by type, always with nosniff and a sandbox CSP that has no allow-scripts and no allow-same-origin: HTML (text/html, with frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN, inline styles only, no images), SVG (image/svg+xml), PDF (application/pdf, SAMEORIGIN), media and images (their sniffed type), any other text as text/plain (so .js, .md and a .png that is not a PNG show as text) and anything else as an attachment. The page itself allows media-src 'self' and frame-src 'self' and nothing else new. PDF was checked in Chromium only.
It never shows dot files and dot folders, the files at the top of the fleet folder (hand.db, board.token, AGENTS.md and the rest of Hand's own), backups/, cache/, luvus/, locks/, projects/, a FIFO or device, or a path whose real path, after symlinks, leaves the fleet folder, and it says which rule refused the path. It works on a network address too, so the fleet's token also reads the fleet folder's files: keep the board on loopback and reach it through a tunnel.
hand open opens a page in your browser, from inside a fleet:
| Command | Opens |
|---|---|
hand open, hand open supervisor | the fleet page |
hand open screen | the Screen page (/supervisor) |
hand open phone | the Phone page (/connect) |
hand open tasks [--] [QUERY] | the Tasks page, searched for QUERY (/tasks?q=) when given |
hand open tN | the task |
hand open dN | the decision |
hand open rN, hand open aN | the report or attempt, on its task's page |
hand open tN --delivered | the task's newest deliverable: a PR, a document, or a file or folder, in the document viewer when it is inside the fleet folder |
hand open --doc PATH | the file or folder PATH of the fleet folder, in the document viewer; it takes no ref and not --delivered |
--delivered opens a file or folder inside the fleet folder in the document viewer when the viewer serves it, through the same login link as the other pages, and --print prints that link.
--doc serves the same paths as /<fleet id>/doc/<path> and refuses the rest the way the viewer does: a path with .., an absolute or backslash path, a dot path, a link that leaves the fleet folder, a file at the folder's top level, and luvus/, locks/, projects/, backups/ and cache/ exit 2 with the viewer's reason, and a missing file exits 3, all before the board starts. --print prints the login link.
--delivered never opens any other file itself, because the path is text from a worker's report or hand task done --deliverable and the OS opener runs programs: for a path outside the fleet folder, gone or refused, it opens the folder that holds the file, or the folder itself, and it opens a URL only when its scheme is http or https; --print prints that path or URL as it is. It never opens a UNC path (\\host\share\...), because Windows would connect to the host it names and could leak credentials: it exits with an error that prints the path, and when the newest deliverable is a UNC path it does not fall back to an older one.
hand open logs the browser in with the fleet's token through xdg-open (on macOS, open) and never prints the token; hand open --print prints the login link instead, for a phone or another browser, so keep that output private. The login hand open opens lasts 400 days on a loopback board, or until board.token changes, which signs every browser out. Its link carries keep=1, and only a keep=1 login with no proxy header on a loopback board lasts that long. The --print link has no keep=1, so a login from it, through a proxy, through a header-less forwarder or ssh -L, or on a network address, lasts until the browser quits. hand open --print also prints app, the handboard://open/<fleet id>/<page> link that opens the same item in the Hand app on a phone (The open link); it holds no token. The url is always the local address, as it is for plain hand open, and a phone cannot open it. It finds the board through ~/.secondhand/board.addr, which the running board writes, and starts a board first when none answers.
The board is where you start, chat with and resume the supervisor. Those controls work only while the board listens on a loopback address, which a tunnel keeps true. On a network address such as 0.0.0.0 the supervisor controls are off; answering decisions and marking reports read still work.
The Start strip has one form per harness (every harness but Copilot, which runs workers only): a model and an effort to pick, and a Start button. After an earlier supervisor, Start new, same as sN starts one on the same harness, model and effort, and the form of that supervisor's harness preselects its model and effort. A harness that is not on the board's PATH (the one it started with) keeps its form with the button disabled and the reason under it: "claude not found on the board's PATH; restart the board after installing it" (on Windows, "codex found only as a batch file" when the only codex on PATH is a .cmd or .bat, which Hand never starts). Install the harness or add its folder to PATH (for a batch file, install the standalone harness or add the folder of its .exe), then restart the board: hand board stop, then hand board, or restart its unit. The check is the lookup a start makes, runs no harness and is redone on every refresh, so an install shows after the next one. With no supervisor the forms are open and nothing is preselected, so a Start with the defaults posts no model and no effort.
The model menu opens with "Running now" (harness, model, effort, and for opencode the warning 'Asked for X, running Y.' while the running model differs from the one asked for), then a block for changing the current model or effort and one block per other harness, each with labelled full-width fields and one switch button; in a browser with customizable selects a long value wraps instead of being cut. Each of those rows, and the model menu, picks a model from the harness's model list: one select with the models grouped by source (built-in, discovered, custom; the custom group reads "custom, efforts unverified" when Claude Code claims efforts Hand cannot confirm), each option showing the label and the name. The effort select follows the model: it offers only the efforts the list gives for it and is hidden for a model that supports none; an unverified model offers the standard efforts with "efforts unverified" under the row. "Other model" stays beside the select in a Start row and under it, as a quieter field, in the model menu, and wins when it is filled (the effort then offers every effort, marked unverified); choosing from the select clears it. A harness with no list keeps its two text boxes (Model and Effort). opencode's Start row is the same, with the variants of the model it lists as its effort select (none, low, … or opencode's own: fast, thinking); the select is hidden for a model with none and offers every model's variants for "Other model", and a list with no variants at all keeps a box for the variant name; the model menu of a running opencode supervisor offers the same. Under a row, a muted line "listed 3 h ago" appears only when the list is older than its limit, and "refresh failed: …" with the reason when the last refresh failed; a stale list is still used. A live update keeps the choice and the text typed in a row.
Refresh models is the last row of the overflow menu (laptop sessions only; a paired phone does not see it and is refused). It posts to /models/refresh (optionally harness=claude|codex|agy|copilot|omp), starts the refresh of each list in the background (at most a minute), answers at once with "Refreshing models…" and never waits for a harness. Without harness it starts every harness that is not busy or cooling down and answers 204 when at least one started (a refusal of all of them is a 429 with the reason). It runs one refresh per harness at a time and at most one every 30 seconds ("claude models are already refreshing", "claude models were refreshed 12 s ago; try again in a moment"); the page shows the new list at its next live update, and a failure shows as the note under the row. The Board reads the cache and runs a harness only for this action, and for a switch to an opencode model that is not in the cache (it then runs opencode models once).
The attempt page
Every aN on the Board, in a reply, a report, a decision, an event or a Needs item, opens the attempt's page, /<fleet id>/attempt/aN (hand open aN opens it too; a bare number works as well). The task page's attempt rows and "from aN" lines link to it. It shows what hand.db holds, the same facts as hand attempt show aN, and a copilot worker's transcript:
- the header: harness, model and effort, the status with its reason, when it started and stopped and for how long, the branch, the worktree path with a Copy path button, the pane while it runs, the attempt it continues, and the PR, MR, link or file chips of its reports;
- its reports, as on the task page, each with Mark read while unread;
- the brief, the briefing the supervisor gave the worker, which Hand stores as the last word of the launch command, and the launch line, the rest of that command;
- for an opencode worker started on a model, a warning "Asked for X, running Y." above the reports while the model or variant the watcher read back differs from the one asked for (see Harness);
- for a
copilotworker, its transcript: the latest 50 messages and tool calls (the command or path, never the output), with the model that answered under Details, and a warning when that is not the model Copilot was started on (see Transcript); Hand reads it from$COPILOT_HOME/session-state(default~/.copilot), so it shows "waiting for the session…" until the worker's first event; - a timeline of every event that names the attempt, oldest first: its state changes, the turns the watcher saw end or block, the keys pressed on its screen (key names, never text), what was typed into it (a byte count), the folder Codex saved as trusted, its reports and its cleaning.
A worktree that hand attempt clean removed is marked as removed, and its branch is kept. The page does not show the worker's screen or the last screen Hand stored, and has no transcript for a harness other than copilot; see Attempt. A running attempt's page refreshes itself like the task page, every 10 seconds, so the timeline and the report fill in without a reload.
The page holds nothing the task page does not already hold, apart from the brief, the launch line and a copilot worker's transcript, so a paired phone opens it, the network-address board serves it read-only, and Funnel and unauthenticated requests are refused as for every other page.
Pair a phone
The rail's Phone page, /<fleet id>/connect, signs a phone in without giving it the fleet's token. Only a browser logged in with the token, the laptop's, can open the page's controls. The board still listens on loopback. The Hand app reaches it over iroh: choose Pair the Hand app on this page (Reach the board from a phone). Any other phone needs a tunnel you set up, such as an SSH tunnel, and the page's Network card says so. The page sets up no network and Hand never changes a setting; the card shows the state of the iroh sidecar when --iroh is on.
The field takes the address the phone opens, the browser remembers it for the fleet, and it is the one the pairing link and the QR code use.
/<fleet id>/alerts, linked from the Phone page, lists the last 7 days of alerts with when the Hand app fetched and acknowledged each (The alert feed).
- Type the address the phone opens, for example
https://laptop.example(no path); the browser remembers it for the fleet. For the Hand app, skip this and use Pair the Hand app. When this browser holds the token of other fleets, Fleets this phone may open lists them with a checkbox each: only the fleet you are on is ticked, and the phone gets only the fleets you tick (see Several fleets on one phone). Choose Show pairing code. - The page shows a pairing code of 10 characters, such as
K7QM3-XRD2P, and the pairing linkADDRESS/<fleet id>/pair#CODEas text you can copy and as a QR code beside it, for the phone's camera. The QR holds that one link, so it is as private as the link: valid once and for 5 minutes, and anyone who photographs it within that time can pair first. It stays black on white in the dark theme, because scanners need it, and the board draws it on its own, with no request to any other service. A link too long for a QR code (over 213 bytes) shows no picture, only the text link and the code. The code works once and only for 5 minutes. New code shows another and cancels the one before it. Ten wrong tries from any phone cancel every code, and a phone address that tries 5 wrong codes within a minute waits up to a minute. The address is the lastX-Forwarded-Forentry, which a proxy such as Cloudflare sets; behind a proxy that passes a client's own header through, a client picks its own address, and only the ten-wrong-tries rule limits its guessing. Codes live in the board's memory only, so restarting the board or changingboard.tokencancels them. - On the phone, open the link. Its page is a plain form: opening it, or a messenger previewing it, spends nothing and signs nothing in. The script moves the code from the address bar into the code field (the code is in the link's
#part, which the phone never sends to the board) and puts the cursor in the name field. Or openADDRESS/<fleet id>/pairand type the code;IandLread as1,Oas0. Choose Pair. A code that expired, was used, is wrong or follows too many wrong tries says so, and the form stays. - The phone's session is its own. For each fleet it was paired with it holds a cookie
__Host-hand_phone_<fleet id>(hand_phone_<fleet id>over plain HTTP), HttpOnly, SameSite Lax (a link opened from another app, such as an alert, arrives signed in: Alerts that open on the phone), good for 90 days from pairing (the board refuses it after that, whatever the cookie says), and its own form token, so no page it loads holds the fleet's token. It can do what the board allows on that address: chat, answer decisions, control the supervisor, attach files. It cannot show a pairing code or sign out another phone. On a read-only board (a network address), it is read-only like every session there. Its Phone page reads "This phone" with Sign out this phone. - Paired phones lists the phones paired with this fleet and, when this browser is signed in to other fleets, the phones paired with those (see Revoke a phone everywhere): each one's name, its login when it paired over the Hand app (
iroh:and the peer's ID), the time it paired and the last time it asked for a page (the last time is kept in memory and is empty after a restart). A phone paired before one code could pair several fleets readsthis fleet only: pair it again with more fleets ticked, then revoke the old row. Revoke signs that phone out of this fleet at once, including a page that is open, and the phone then reads "This phone is no longer paired"; its other fleets stay paired until you revoke it there too, or press Revoke everywhere. A phone paired over the Hand app is bound to itsiroh:login: its cookie works only from the same peer. The board reads no identity from any header,Tailscale-User-Loginincluded. A phone that an earlier Hand paired throughtailscale servehas a tailnet login, which no request can match now, so it cannot sign in: revoke it and pair the app. Changingboard.tokensigns out every phone and every browser, and the list keeps the phones' names until you revoke them.
The board records board.paired and board.revoked events (the detail is the phone's number and name, never a code or a secret), and keeps only a hash of each phone's secret in hand.db.
Through the phone's address the board also:
- refuses every request that arrives through Tailscale Funnel (
Tailscale-Funnel-Request) with a page that says so, serving only its stylesheet and script. Never publish the board with Funnel or any other public proxy; - shows no fleet list at
/to a request that carriesX-Forwarded-FororForwarded, which a proxy adds, unless the request also carries a valid phone session:/and the rail's All fleets then list exactly the fleets that phone is paired with (see Several fleets on one phone); - names no fleet on a page served without a session (the pairing page, the refusals for a missing, revoked or lapsed login, the Funnel refusal): its title, header and text read "this Board";
- answers a request that carries one of those headers for a fleet whose store cannot be opened exactly as it answers an unknown fleet id (404, "fleet not found"), so it shows neither the error nor whether the fleet exists but is broken. The board writes the full error to its stderr; a direct loopback request still shows it on the page;
- sends
Referrer-Policy: no-referreron every page, so a link out of the board never tells the other site the board's address.
hand open --print still prints a login link that holds the fleet's token, which a browser keeps as the laptop's session: pair a phone from the Phone page instead.
Several fleets on one phone
One code can pair a phone with several fleets. On a fleet's Phone page, Fleets this phone may open lists the fleets this browser holds a valid board.token login for, with a checkbox each: the fleet you are on is always included and cannot be unticked, because the phone redeems the code at its address, and the others start unticked. The list is the rail's: at most 7 other fleets, so one code names at most 8. A phone's own pairing never counts here, so a phone cannot add a fleet, and a fleet this browser holds no valid token for is not listed. The code page then says "It pairs the phone with A and B."
The board checks the choice again when it makes the code: every posted fleet id must be on that list and still registered, and any other id is refused with "not signed in to every fleet you chose", names nothing and makes no code. The code keeps its fleets in the board's memory next to its expiry and its used flag; the phone's form carries only the code and a name, so a phone cannot change them. A code still works once and for 5 minutes, ten wrong tries cancel it, and a phone address that tries 5 wrong codes waits: a code that grants up to 8 fleets has the limits of a code that grants one.
Pair on the phone spends the code once and makes one secret. For each fleet in the code, that fleet's own board stores an ordinary phone record (the name, the login, a hash of the secret keyed with that fleet's own board.token, and a 16-character hex label that is the same in every record of one pairing and is no credential) and records its own board.paired event. No fleet gives another a hash or a token, and a fleet's board refuses a request to pair a phone unless the code that was spent names it. The response sets one cookie per fleet, hand_phone_<fleet id>, each holding that fleet's number for the phone and the secret (about 100 bytes each, so 8 add under 1 KB to every request); the names differ, so they never overwrite each other (Path=/, so all go to every page of the board), and each fleet accepts only its own, with its own form token: a fleet's form token posted to another fleet is refused. A phone's secret for one fleet proves nothing for another.
The records are written first, one fleet after another, and a cookie is set only for a record that was written; every cookie goes out in the one response, after the last record. A fleet that cannot be opened (down, removed) or whose store fails gets no record and no cookie, and the others still pair; the result page then reads "Paired with A and B. 1 fleet was not reachable. Pair it from its own Phone page on your laptop." It does not name the fleet that failed, because the phone is not paired with it. A reply that never reaches the phone leaves records with no cookie: they show on the laptop's list and you revoke them. A code for one fleet redirects to that fleet's page as before; a code for several shows the result page with an Open button for each fleet paired.
Each fleet keeps its own revoke, its own 90 days and its own board.token: revoking in one fleet, or changing its token, signs the phone out of that fleet only. Revoke everywhere cuts a phone off from all the fleets this browser is signed in to (see Revoke a phone everywhere).
On a phone, the rail's All fleets and / list exactly the fleets whose cookie in that request is still valid for that fleet, with the same checks as a page of its own: the secret, the 90 days, the revoke, and the login it was paired for. The board looks only at fleets named by a cookie in the request, and checks each fleet once per request. It never names a fleet the phone is not paired with, and never says how many others exist. A request with no valid phone session at / still reads "pair it on the fleet's Phone page on your laptop". Choosing a listed fleet needs no new login. If its pairing is revoked or lapses between the list and the tap, its page says "This phone is no longer paired with this fleet", with no fleet name, and the next list leaves it out. A board on a network address stays read-only and lists only its own fleet.
Revoke a phone everywhere
The Paired phones card shows a phone paired with several fleets once. A row holds the phone's name, the names of the fleets it is paired with, its login, the earliest time it paired and the latest time any of those fleets saw it, then one line per fleet with its own Revoke, and Revoke everywhere beside the name when the phone is in two or more of them. Records that carry the same phone label are one phone; two pairings of one phone, or two phones with one name, carry two labels and stay two rows. A phone listed for the fleet you are on and no other fleet reads as before, with one Revoke. A record from before the label reads this fleet only and lists only on its own fleet's page.
The card lists only the fleets this browser holds a valid board.token login for, the rail's fleets: at most this one and 7 others. A fleet it holds no valid login for is never read, so a phone's record there is never listed, and the page names no such fleet and counts none: it never says that a phone is "also paired with" others. A fleet that cannot be opened, or whose store fails, is named in a notice, "beta could not be reached, so its phones are not listed here."
The board reads another fleet's records through a method the host gives each fleet's board, PhoneRecords: a number, a name, a login, the label and two times, with no hash and no secret. The host calls it only for a fleet whose hand_board_<fleet id> cookie in the request matches that fleet's board.token, and checks it again at every call; the fleet you are on is read directly. A board with no host around it, a host that cannot check a login, a handler without the method, or a fleet that is down lists nothing from that fleet.
Revoke in a fleet's line, and Revoke everywhere, post to this fleet's page like any laptop form: this fleet's form token (its board.token), as every laptop form needs. That token alone grants nothing in another fleet. For each other fleet the board asks the host to revoke through a second method, RevokePhone, and the host opens that fleet's board only after it has read that fleet's own board.token and compared it with the laptop's cookie for that fleet, for this call. The fleet id and the label the browser posts are never trusted: the fleet must be one of the fleets this request proves, and the label must be 16 lowercase hex characters; a label that no proven fleet holds changes nothing and is answered like any other repeat, so no response tells whether a label exists in a fleet this browser is not signed in to. A fleet's own board refuses the call unless it carries a grant that names that fleet, which only the host makes after the proof and which a nil, empty or other fleet's grant cannot replace. A phone's own session, even one that also carries a laptop's login for another fleet, is refused before any of this runs: a phone can sign only itself out of its own fleet, and it never learns that its label exists elsewhere.
Revoke everywhere goes through the fleets one at a time, this fleet first. In each, one transaction deletes every record with the label and writes one board.revoked event for each (the number and the name, never the label or a secret). The phone's next request to any of those fleets gets "This phone is no longer paired with this fleet", and its open event streams close, as with a single Revoke. When every fleet answers, the page returns to the list. When some do not (down, a store that fails, a board that cannot do it), the others are still revoked and the page says plainly: "Revoked in A and B. C could not be reached: its record of this phone, if it has one, is still active. Press Revoke everywhere again when it is back." with a Revoke everywhere button that posts the same label. Pressing it again asks every fleet again: those already done have nothing left to delete and write no second event, so only what remains is revoked, and a repeat of a finished revoke does nothing. A phone's records in a fleet that the host cannot read stay on that fleet's Phone page once it is back.
Revoke everywhere covers the fleets this browser is signed in to. A record in any other fleet is left alone and not mentioned: open that fleet and its Phone page, or sign in to it, and revoke there. A phone's own Sign out this phone stays per fleet; there is no "sign out of all my fleets" on a phone, because it would act in other fleets from a phone's cookie. On a board on a network address the host gives no fleet reach: the card lists, and Revoke everywhere revokes, this fleet only.