Appearance
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.
Commands
bash
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 unitBoth 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:
bash
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 |
Files
| 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.
What the unit runs
text
<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'sdashboard.bindinmachines.json(#2636). A machine that declaresdashboard.bind: tailnetanswers 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=1tells the server that a manager owns it (see Self-update).PATHstarts with the node that ran the install, then$HOME/.claude/bin(theghshim) 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.
Project units (#2407)
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.
bob viz and bob warp viz reuse it
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.
Self-update hands over to the manager
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
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.
Which requests the server answers
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:
bash
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.
Which files the server serves
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:
- Its path starts in
journal/,docs/orproject-management/. - It is a document or an asset of one:
.md,.html,.css,.png,.jpg,.jpeg,.gifor.svg. - No part of its path starts with a dot.
- 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:orhttps:(httpUrlinscripts/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.
Dispatch
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.
The operations table
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.
The harness registry
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.
Endpoints
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.
A card's session row
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.
The chat input
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.
Who may dispatch
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.jsondoes not declare, such as a phone - an address
tailscale whoiscannot 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.
Cross-machine dispatch
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:
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.- The machine's
bob servefront door, when itsmachines.jsonentry has aserveblock./bobis routed to its dashboard. - 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>.
See also
- bob viz: what the page shows
- Fleet Snapshot Timer: the timer installed the same way
- Project Monitor Exposure Posture: why tailnet-only
- Warp Drive from the command line:
bob warp start, the launcher dispatch reuses