bob viz Dashboard Service
bob viz Dashboard Service
Section titled “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
Section titled “Commands”bob viz --install-service [--dry-run] [--json] # write the unit, start it now and at every loginbob 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:
node scripts/dashboard/viz-service.js status [--name <project>] [--platform systemd|launchd] [--json] # bob-viz-service/1node 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.
What the unit runs
Section titled “What the unit runs”<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)
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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:
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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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