Skip to content

bob viz Dashboard Service

The dashboard server that answers /bob, /briefings and the BoB source checkout’s /warp can run as a per-user service on every fleet machine (#3001). Once it is installed, a bookmark to bob viz opens after a reboot or a fresh login with no terminal step, and a server that exits is restarted by the service manager. What bob viz shows is described in bob viz.

Terminal window
bob viz --install-service [--dry-run] [--json] # write the unit, start it now and at every login
bob viz --uninstall-service [--dry-run] [--json] # stop it and remove the unit

Both are idempotent and print what changed. An install whose unit is already on disk and running changes nothing and restarts nothing, so an open page is not interrupted. A changed unit is written, reloaded and restarted. --dry-run prints what would be written and run, and changes nothing. A lone --dry-run is a usage error (exit 2).

The flags go to the engine scripts/dashboard/viz-service.js, which also has status, render, start, restart and decide:

Terminal window
node scripts/dashboard/viz-service.js status [--name <project>] [--platform systemd|launchd] [--json] # bob-viz-service/1
node scripts/dashboard/viz-service.js render [--name <project>] [--platform systemd|launchd] [--out <dir>]
node scripts/dashboard/viz-service.js decide --project <dir> [--name <project>] --healthy 0|1 --fresh 0|1 [--json]

--name <project> works on every subcommand and selects that project’s own unit (see Project units). Without it, the subcommand acts on the BoB source checkout’s unit.

Exit Meaning
0 installed, removed, rendered, reported or started
1 a systemctl / launchctl step failed; the step is named under commands
2 usage error, no service backend on this OS, or a --name that is not a project checked out here
macOS Linux
Manager launchd user agent systemd user unit
Name com.bob.viz bob-viz.service
Unit file ~/Library/LaunchAgents/com.bob.viz.plist ~/.config/systemd/user/bob-viz.service
Starts RunAtLoad (at login) WantedBy=default.target (at login; at boot with linger)
Restarts KeepAlive, ThrottleInterval 5 s Restart=always, RestartSec=5
Template templates/launchd/com.bob.viz.plist templates/systemd/bob-viz.service
Log ~/.local/state/bob/dashboard-<source>.log the same file

The log is the file bob viz and the server’s self-update notes already write ($XDG_STATE_HOME/bob/dashboard-<name>.log, #2560), so there is one place to read the server’s output whoever started it.

On Linux a user unit starts at login. To have it start at boot, before anyone logs in, enable lingering once: loginctl enable-linger $USER. The snapshot timer needs the same thing.

<node> $BOB_HOME/scripts/dashboard/server.js $BOB_SOURCE
  • The deployed server ($BOB_HOME), which exists at login wherever the checkout lives, on the BoB source checkout ($BOB_SOURCE, default ~/projects/bigbrain). Both are resolved when you install.
  • No port and no bind are written into the unit. The server resolves them itself when it starts, as it does under bob viz: the port from the port ledger, the bind from this machine’s dashboard.bind in machines.json (#2636). A machine that declares dashboard.bind: tailnet answers on its tailnet address after a reboot. Every other machine answers on loopback. If tailscaled is not up yet at login, the tailnet bind is refused and the manager retries until it is.
  • BOB_VIZ_SERVICE=1 tells the server that a manager owns it (see Self-update).
  • PATH starts with the node that ran the install, then $HOME/.claude/bin (the gh shim) and the usual toolchain directories. launchd’s default PATH lacks them (#1500).

bob serve (#2407) puts this same server behind the machine’s MagicDNS address. It uses this unit for /bob and the BoB source checkout’s route.

Each project in a machine’s serve.projects list gets its own unit, so its /<project>/warp route still answers after a reboot. The unit is rendered from the same two templates and runs the same deployed server on that project’s checkout. There is one server per project, each on its own ledger port, which is where bob serve sends that project’s route.

macOS Linux
Name com.bob.viz.<project> bob-viz-<project>.service
Unit file ~/Library/LaunchAgents/com.bob.viz.<project>.plist ~/.config/systemd/user/bob-viz-<project>.service
Runs server.js <project checkout> from that directory the same
Log ~/.local/state/bob/dashboard-<project>.log the same file

BOB_SOURCE in a project unit is still the BoB source checkout. The project’s checkout comes from the project registry (projects.json), or ~/projects/<project> when the registry has no row for it. A name that is not checked out on this machine exits 2.

bob serve --apply installs the unit for every project it routes, and that is the fix a missing project unit reports. viz-service.js install --name <project> installs one by hand. Self-update, restart-on-exit and the launcher rules below are the same as for the source checkout’s unit.

With the service installed, the launchers never start a second server beside it (viz-service.js decide):

Request What happens
bob viz / bob viz --page briefings, anywhere Goes to the service’s server, even from inside another project: there is one fleet page per machine.
bob warp viz in the BoB source checkout Goes to the service’s server.
bob warp viz in a project with its own unit Goes to that unit’s server, started or restarted through the manager by name. The source checkout’s unit is not touched.
bob warp viz in any other project That project’s own server, as before. The service is not touched.

For the service’s project:

State Action Printed
running, fresh reuse it Reusing the always-on dashboard service for <name> on port <port>
running, predates the deploy or not answering systemctl --user restart / launchctl kickstart -k Dashboard service: <reason>, then The always-on dashboard service is serving …
installed, stopped systemctl --user start / launchctl kickstart the same two lines
installed, stopped, another server already on the port reuse or replace that server as before names the service as not running and how to start it

bob viz --json adds served_by: service, or shell for a server the launcher started or reused itself.

The launcher passes the project’s name to decide. When the project’s own unit owns the port, decide adds the unit’s project name as a third tab-separated field, and the launcher passes it to start / restart.

A long-running server keeps the code it started with. When a deploy changes the server’s code on disk, the server restarts itself (#1734). Started from a shell, it spawns a successor and checks that the successor answers, retrying once (#2987). Under the service it does not spawn anything. It logs handing over to the service manager, closes its listener and exits 0, and the manager starts the new code. A new server that fails to start is simply restarted again, so a failed successor cannot leave the port with nothing serving.

bob doctor prints one line for the service, right after the snapshot-timer line. It is reported and never changes the machine verdict or the exit code:

State Line
installed and running INFO dashboard service: installed and running (bob-viz.service, serving <source>)
installed but stopped WARN dashboard service: installed but stopped (bob-viz.service) — Fix: systemctl --user start bob-viz.service (macOS: launchctl kickstart gui/<uid>/com.bob.viz)
not installed INFO dashboard service: not installed (systemd) — Fix: bob viz --install-service

A unit that no longer matches what this source renders adds unit differs from source, re-run: bob viz --install-service. The --json document carries it as viz_service: {platform, state, installed, current, source, fix, error}.

On a machine that declares a serve block, the next line is the serve front door and every project unit behind it (#2407), read with bob serve --json, which only reports:

State Line
routes and units converged INFO serve: converged — <scheme>://<host>, <n>/<n> routes, <n> dashboard unit(s) running[, https unavailable (tailnet certificates off)]
a route or a unit drifted WARN serve: drift — <scheme>://<host>: <what> — Fix: bob serve --apply
Tailscale not running WARN serve: declared, but Tailscale is not running — nothing is served

A machine with no serve block gets no line. The --json document carries it as serve: {declared, verdict, host, scheme, https, routes, routes_ok, services, tailscale, error}. Like the service line, it never changes the machine verdict.

The server checks every request before any route runs (#3640). A refused request gets 403 with a JSON error, and the server logs one line: refused 403 GET /bob — Host 'evil.example:6952' is not this server.

The Host header must be one of the server’s own names.

Host Accepted when
localhost, 127.0.0.1, [::1] The port is this server’s port.
This machine’s tailnet address It is the address the server is bound to, or the tailscale_ip of this machine’s machines.json entry.
This machine’s MagicDNS name It is the tailscale_name of this machine’s machines.json entry, alone (farm-01) or with the tailnet’s suffix (farm-01.<tailnet>.ts.net). A declared serve.host is accepted too.

A tailnet name is accepted with any port or none, because bob serve puts the server behind the machine’s front door on another port. Any other Host is refused, and so is a request with no Host.

This stops DNS rebinding. A web page can make its own hostname resolve to this machine and then read the dashboard as if it were its own site. Its requests still carry its own hostname in Host, so they are refused.

A POST must come from a page this server served. Any method other than GET, HEAD or OPTIONS needs an Origin header that names the same host as Host. A request with another site’s Origin, with Origin: null or with no Origin is refused. A browser always sends Origin on a POST. A script that calls the dispatch endpoints must send one itself:

Terminal window
curl -X POST http://localhost:6952/bob/dispatch/resolve \
-H "Origin: http://localhost:6952" \
-H "Content-Type: application/json" \
-H "X-Bob-Dispatch-Token: $(cat "${BOB_HOME:-$HOME/.claude}/.dispatch-token")" \
-d '{"instruction":"brief"}'

The server reads its tailnet names from machines.json and tailscale status, and keeps them for one minute. A machine that is not in machines.json answers only to the loopback names and, on a tailnet bind, to the address and name it is bound to.

Beside its own pages, the server serves files from two folders of the project it was started for (#3641).

Folder What is served
.dash/ The generated dashboard: every file in it.
.claude/ Documents only, by the allow-list below.

A file under .claude/ is served when all four hold:

  1. Its path starts in journal/, docs/ or project-management/.
  2. It is a document or an asset of one: .md, .html, .css, .png, .jpg, .jpeg, .gif or .svg.
  3. No part of its path starts with a dot.
  4. With every symlink followed, it is still inside that folder.

Everything else under .claude/ answers 404: settings.json, settings.local.json, .last-hook-payload.json, the warp-drive state file, loop-notes.json, the session ledger, everything under forge/, hooks, and every dotfile. So does a request for a folder. The rule is the same for every peer, this machine included.

The allow-list is scripts/dashboard/claude-files.js. To serve another folder or file type, add it to CLAUDE_DOC_ROOTS or CLAUDE_DOC_TYPES there.

Three smaller rules keep a crafted link or a crafted row from leaving the page (#3643):

  • /refresh?return=<path> redirects only to a path on the dashboard itself. //host, /\host, a full URL, and a target with a backslash or a control character answer 400 and nothing is regenerated.
  • The 404 page escapes the path it names.
  • A URL from fleet or tracker data becomes a link only when it is http: or https: (httpUrl in scripts/dashboard/fleet-format.js); a link the dashboard builds itself may also be a path on its own origin (linkUrl). Any other value is shown as plain text.

The dashboard server has exactly one write path: /bob/dispatch/* (#3002). It starts and cancels BoB operations as tmux sessions on the machine that serves the page. Decision #3371 amends #1727’s read-only rule for this path only:

  • The page owns no state and computes nothing.
  • The server may call declared launchers and stoppers, and nothing else.

scripts/dashboard/operations.json (schema schemas/dispatch-operations.schema.json, graded by make check-schemas) declares every operation that can be dispatched. The resolver, the chat preview and the tile row all read this one table. Adding an operation means adding one row to it.

Field Meaning
name, aliases What an instruction may start with. The longest match at a word boundary wins.
runs_in harness for an agent session (/warp-drive, bob decompose, bob do). shell for a bob CLI command (bob brief, bob plan build, bob groom, bob ritual run).
launcher The value "warp" means launch.sh start, the warp-drive launcher. The value "exec" means launch.sh exec, which gives any other command the same session shape.
argv, flags The command template. {level}, {issue}, {area}, {flightplan}, {name} and {text} are substituted. A {target} element expands to the flags declared for the resolved target kind. A {harness} element expands to the selected harness’s command from the harness registry. A harness operation starts with {harness} and never names a harness itself.
target The target kinds the operation takes: flightplan (fp 3197), issue (3002, #3002), area (area viz-dispatch), name (a ritual), text (a bob do question), or none.
session The tmux session-name pattern. {repo} is the project directory name. {suffix} is -fp-N, -issue-N, -area-<slug> or -<name>.
stop The declared graceful stop, run in the project root. null means stop is refused.
state_file The run’s state file, relative to the project root, or null.

Today warp (bob warp stop), decompose (bob decompose stop) and do (bob do stop) declare a stop. Groom, brief, plan build and ritual run do not.

scripts/dashboard/harnesses.json (schema schemas/harnesses.schema.json, graded by make check-schemas) declares every agent harness a launcher may start an operation in (#3005). launch.sh and the dispatcher both read it, and WARP_HARNESS_REGISTRY points both at another file (the test seam).

Field Meaning
id, label The id a request names (claude-code) and what the selector shows (Claude Code).
enabled Whether an operation may run in it. A request naming a disabled or unknown harness is refused.
command The argv prefix the harness receives. {level} is the automation level. For Claude Code it is claude -a{level}. null only while the harness is disabled.
rdb Appended when the Remote Decision Bridge is on (-rdb), or null.
gated_by The issues that must land before it may be enabled.

The top-level default is the harness a launch uses when none is named. It must be enabled. Today Claude Code is the one enabled entry. Codex CLI, Gemini CLI and Cursor are listed disabled, gated by #499 (the adapter interface) and #504 (the guardrail-parity matrix).

launch.sh command | send | start take --harness <id> and build the line from the entry: launch.sh command --level 3 --harness codex-cli exits 2 with harness 'codex-cli' (Codex CLI) is disabled until #499 and #504 land, before any session exists. Enabling a second harness is one registry edit plus its adapter, with no launcher change.

An entry may declare the models it can run with (#3818): models, a list of {id, label}, and model_flag, the arguments that pass one ({model} is the chosen id). Claude Code declares fable, opus, sonnet and haiku, passed as --model <id>. launch.sh command | send | start take --model <id> and append the flag after the harness’s command: launch.sh command --level 3 --model opus prints claude -a3 --model opus "/warp-drive". Without --model no flag is sent and the harness runs its own default. A model the entry does not declare exits 2 with unknown model '<id>' for harness 'claude-code' (known: fable, opus, sonnet, haiku). Offering another model is one line in the registry.

resolve, start and stop take a JSON body with instruction. level (1-4) is optional and defaults to _automation.active_level, else 3. A request from another machine may lower that level but not raise it: a higher level is status: refused (#3639). harness is optional and defaults to the registry’s default. It applies only to a runs_in: harness operation: a shell one ignores it. model is optional too: one of the harness’s declared models. It is added to the command, and resolved carries it as model and model_label (null when none was asked for). A model the harness does not declare is status: refused. A shell operation ignores it. resolve, start and stop each return a bob-dispatch/1 document; tell takes a different body, described in its row.

A body may also name a project and the card’s machine (#3003). project must be registered in projects.json on the machine that acts. A machine other than this one is sent to that machine’s dashboard server, as is a project this machine does not have (see Cross-machine dispatch). A project no machine has is 404. Without project, the server’s own project is used. A body may also name checkout (#4028): one of the project’s linked worktrees, by its directory name, so a stop reaches the run in that worktree rather than the main checkout’s. A worktree the project does not have is 404.

Endpoint What it does
POST /bob/dispatch/resolve Resolves the instruction and launches nothing. The result is one of three things. status: resolved carries the operation, the target, command (the exact line the session receives; for warp it is launch.sh command’s own line), host, session, harness (the registry id, such as claude-code, or none for a shell operation), harness_label and level. status: candidates lists the operations an ambiguous or partial instruction could mean. status: no-match gives a reason. status: refused means the operation resolved but its harness is disabled or unknown, and gives the reason.
POST /bob/dispatch/start Resolves the instruction, then calls the operation’s launcher. The launcher refuses a session that already exists (409, naming it). It then runs bob preflight: a failing verdict is returned as preflight.output with 412 and nothing is launched. Otherwise it creates one detached tmux session in the project root and sends the command into it. The response carries session and attach (tmux -CC attach -t <name>). An instruction that does not resolve, or names a disabled or unknown harness, is 422 and launches nothing.
POST /bob/dispatch/stop Takes instruction, or kind (a bob-session-state/1 kind such as warp-drive) plus the optional session it runs in. The session must fit the operation’s name pattern. The kind form is how a card’s Cancel stops the run it shows. Needs confirm: true (400 otherwise). Runs the operation’s declared stop in the project root, reports the state file’s phase (aborted for warp), and only then closes the session (launch.sh close). An operation with no declared stop is refused with 409 and its session is left alone. The dispatcher never falls back to tmux kill-session, because that would leave the run’s state file claiming a live session.
POST /bob/dispatch/tell Takes session (the kind of a running session, such as warp-drive or decompose) and text (one line, at most 2000 characters). It starts nothing: the text is typed into the tmux session that hosts the project’s running session of that kind, followed by Enter, and into no other (launch.sh tell). The tmux session is the one the run recorded, else the one its process runs in, else the one session named as that operation names its sessions. The response carries status: sent, session_kind and session (the tmux session). A session that has ended is refused with 409 and ended: true: no running session of that kind, a finished or dead run, or a tmux session that is gone. The reason names the session and nothing is written. A session that is running outside tmux is 409. Empty or multi-line text is 422. This is what a session screen’s dispatch box sends.
POST /bob/dispatch/new Control’s New session (#4029). Takes kind, plus input, model, harness, level and where (main, new or an existing worktree’s name; without one, new while a warp run holds the main checkout, else main). The kinds are every operations.json row with a kind and every machine scripts/lib/session-machines.json declares. An unknown kind is 422, listing them. The session is named by new_session.name (<kind>-<project>-<n>). A kind the machine’s global manifest forbids (sessions.forbid, 409, rule: machine-role), or a second one_per_checkout run or an excludes clash in the chosen checkout (409, rule: checkout), is refused before anything starts. The response carries session, where, checkout, root, branch and page (/control?project=<p>&session=<name>).

launch.sh exec, launch.sh close and launch.sh tell belong to the launcher, not to the server. tell matches the session name exactly, types the text literally and exits 3, writing nothing, when the session does not exist. exec refuses an existing session with exit 3, runs preflight (exit 1 on failure), creates the session and sends the line. close ends a session, and is used only after the operation’s own stop has ended its run. WARP_TMUX_SOCKET and WARP_LAUNCH_PREFLIGHT_CMD are the test seams for these, as they are for launch.sh start.

GET /bob/sessions/<machine>/<name>.json returns bob-card-sessions/1. It carries that project’s bob-sessions/1 (active, stale, last) through the #2978 reader, and operations[]: for each session kind, the operation that records it and whether its row declares a stop (stoppable), and harnesses[]: each registry entry’s id, label, enabled, gated_by and whether it is the default, for the chat’s harness selector. The card draws its session row and its Cancel control from this document and never decides either one itself. For another machine’s card the document comes from that machine’s own server, with host and forwarded: true, so a session started there shows from its own state record. When that server cannot be reached the answer is dispatchable: false with the reason.

Every expanded project card on /bob has a Dispatch panel above the card, and the /warp page has one in a collapsed Dispatch section for its own project (#3003). Both are drawn by scripts/dashboard/bob-chat.js, which the pages import from /bob/chat.js. For a given machine and project, the card and the /warp page share the same history.

You do You get
Pick a harness The selector in front of the input lists the card document’s harnesses[]. The default (Claude Code) is selected. Every other entry is disabled, and hovering it shows what gates it (disabled until #499 and #504 land). The selection is sent with the instruction.
Pick a model The model picker beside the harness selector lists the selected harness’s models, after Default model. Default model sends no model: the harness runs its own. A picked model is sent with the instruction. Picking another harness redraws the picker with that harness’s models and nothing chosen.
Type an instruction and press Enter A reply from POST /bob/dispatch/resolve. It shows the exact command, host, session name, harness (its label and id, or none (shell) for a shell operation, which ignores the selector), the model (or the harness's default), the automation level with its source, and a Run button. A candidate list or a miss is a reply with no Run button.
Press Run A reply from POST /bob/dispatch/start with the session name and a copyable tmux -CC attach -t <name> line (-L <socket> when the server runs on one). Run starts the harness and the model its preview named.
Hit a refusal The server’s reason, verbatim: the token or a device that may not dispatch (403), another machine or an existing session (409), preflight with the verdict underneath (412), or an unresolved instruction (422).
Watch The session row, re-read every 5 seconds from GET /bob/sessions/<machine>/<name>.json: kind, phase, target, host and start time. A run waiting on a decision says so and names where to answer it (the Remote Decision Bridge when it is on, else the session itself). A record whose process is gone reads dead, never live.
Press Cancel on a live row A confirmation, then POST /bob/dispatch/stop by the row’s kind, naming the session this chat started. The row then becomes the run’s last: line. A row whose operation declares no stop has no Cancel.
Press Escape The input clears. The card stays open.

Focus returns to the input after every reply. The input, the selector and the model picker are drawn once and never repainted, so a refresh keeps what you have typed, the harness and the model you picked.

The history is kept in the page’s localStorage under bob-chat:<machine>/<project>, capped at 30 turns and wrapped in try/catch. The server never reads it back: a submit sends the instruction only.

A card for another machine’s project shows the server’s reason (this bob viz runs on <machine>; dispatch to <other> from its own bob viz) in place of the input. A device that may not dispatch sees the reason in place of the input, for example pauls-iphone (100.101.0.9) cannot dispatch here: it is not a machine declared in machines.json.

Being on the tailnet is not enough: a phone is on the tailnet. The server serves a dispatch request from two kinds of caller only (#3639, decision #3680).

Caller How the server knows it What it may do
This machine (a loopback request no proxy forwarded) The request carries this machine’s token in the X-Bob-Dispatch-Token header. Resolve, start and stop, at any level from 1 to 4.
Another machine declared in machines.json The server asks tailscaled who owns the source address (tailscale whois --json <address>, the Tailscale LocalAPI whois). The node’s MagicDNS name must be the tailscale_name of a declared machine. Resolve, start and stop. level may be at or below the project’s own level, never above it.

Every other caller gets 403. The response and the server log name it: dispatch: refused 403 start from pauls-iphone (100.101.0.9) — it is not a machine declared in machines.json. The refused cases are:

  • a tailnet device that machines.json does not declare, such as a phone
  • an address tailscale whois cannot identify
  • a node shared in from another tailnet, even one that carries a declared machine’s name
  • an address that is neither loopback nor the tailnet (100.64.0.0/10, fd7a:115c:a1e0::/48)

machines.json is read on each request, so a machine removed from it is refused at once, with no server restart.

The token. The server writes it at start to $BOB_HOME/.dispatch-token (mode 0600). Every dashboard server on the machine shares it. It proves one thing: the caller is on this machine, because only a page served to this machine carries it (<meta name="bob-dispatch-token">). It says nothing about a device elsewhere on the tailnet, so it is never served to one and never copied between machines. A server that can neither read nor write the token refuses every request from this machine.

What the page is told. A page served to a declared machine carries <meta name="bob-dispatch-peer" content="<machine id>"> and no token; its chat sends none. A page served to any other device carries <meta name="bob-dispatch-refused"> with the reason.

Behind bob serve. The machine’s tailscale serve front door is a local proxy, so its requests reach the server from loopback. The proxy sets X-Forwarded-For to the real source address. A loopback request that carries that header is treated as the forwarded device, never as this machine.

Another machine’s request must be application/json (415 otherwise). It holds no token, and a web page on another site cannot send that content type without the server’s consent, which the server never gives. The Origin check in Which requests the server answers applies as well.

GET, or any method other than POST, is 405. No refusal has a side effect. Starts and stops are logged too, one line each: dispatch: <action> <operation> <session> → <status>, followed by (from <machine id>) when another machine asked.

A project whose host is another fleet machine is dispatched there (#3004). The laptop’s server sends the request to the owning machine’s dashboard server over the tailnet. The page never opens SSH, and no token leaves the machine that wrote it.

Which machine acts. The server decides this from the body:

Body Who acts
machine is this machine this server
machine is another machine machines.json declares that machine’s server
machine names no declared machine nobody: 404
no machine, and the project is registered here this server
no machine, and one other machine’s fleet-feed row has the project (by name, or by its repo identity, #2929) that machine’s server
no machine, and several other machines have it nobody: status: candidates with hosts[] (one entry per machine), 409

Only a request from this machine is forwarded. A request that came from another machine acts here or is refused; it is never sent on again.

Where the other server answers. In this order:

  1. BOB_DISPATCH_PEER_URLS, a JSON map of machine id to base URL, when it names the machine. Use it for a fleet laid out differently; the tests use it too.
  2. The machine’s bob serve front door, when its machines.json entry has a serve block. /bob is routed to its dashboard.
  3. Its tailnet address on the fleet project’s ledger port, when it declares dashboard.bind: tailnet.

A machine with none of these has no dashboard that can be reached from here. The dispatch is refused with the reason (503).

How the receiving server knows the caller. It runs the check in Who may dispatch: tailscale whois on the source address, which must be the tailscale_name of a machine machines.json declares. So a forwarded request may lower the automation level but never raise it. A caller that does not pass is refused with 403 and logged by name on the receiving machine. The laptop passes that refusal through unchanged.

What comes back. The other machine’s bob-dispatch/1 document, plus:

Field Value
host the machine that acted
forwarded true
via the base URL it was reached at
attach the hop, then that machine’s own line: ssh <tailscale_name> -t tmux -CC attach -t <session>

When the host cannot be reached. If it is refused, has no dashboard service, or does not answer within BOB_FLEET_HOST_TIMEOUT_MS (default 20000 ms), the reply is status: refused naming the host and the cause, with nothing started on any machine. Nothing starts anywhere.

Each forward is logged on this machine as dispatch: <action> <project> forwarded to <machine> → <status>.