# The command line What commands didbot has, who runs which, where each is installed, and how they fit together. [cli](../plan/cli.md) is the epic that owns the decisions; `crates/didbot-dispatch`, `crates/didbot-cli`, `crates/didbot-operator` and `crates/didbot-agentd` are the code, and [the shape of the system](architecture.md) draws the hosts these run on. ## Six binaries A person types `didbot`. What runs is one binary per place the code runs, each versioned and installed on its own. | Binary | Runs on | Commands | |---|---|---| | `didbot-pds` | the server, as a container of the release image under one systemd unit | `didbot-pds --help` lists its flags. No verb reaches it | | `didbot` | the operator's own machine, and every agent host | `didbot --list`, `didbot help `, and every verb below — each one run by the binary it is listed under | | └─ `didbot-operator` | the operator's own machine | `didbot operate`, `didbot login`, `didbot estop`, `didbot announce` | | └─ `didbot-oauth` | an agent host | `didbot oauth pending`, `didbot oauth show`, `didbot oauth approve`, `didbot oauth decline` | | └─ `didbot-register` | the machine that will be the account | `didbot register` | | `didbot-agentd` | an agent host, as one long-running process | none; the environment is its whole configuration | `didbot` is a router and nothing more. It carries a table of the first-party verbs, the binary that runs each and the crate that ships it, so a verb whose binary is absent is met with what to install rather than "unknown command". ## What `didbot` does with a verb `didbot …` looks the verb up in its table, finds the binary on `PATH`, and replaces itself with it. The verb receives the rest of the command line as typed and this process's environment with every credential variable removed: `DIDBOT_ACCOUNT_TOKEN` and `DIDBOT_ACCOUNT_TOKEN_FILE`. Nothing is added. On unix the verb's process replaces the dispatcher's, so the verb's exit status is the shell's.
didbot <verb> … THE VERB TABLE operate · login · estop · announce → didbot-operator <verb> … oauth → didbot-oauth … · register → didbot-register … any other word → didbot-<verb> … each row names the crate that ships its binary the first match on PATH found a table verb, absent another word, absent runs the binary, then the rest of the command line as typed the environment, minus DIDBOT_ACCOUNT_TOKEN and DIDBOT_ACCOUNT_TOKEN_FILE its exit status is the shell's install it didbot: `register` runs didbot-register, which is not on PATH; install didbot-agentd exit 1 unknown command didbot: unknown command `foo`; `didbot --list` shows what is installed exit 2 WHAT IS INSTALLED WHERE THE SERVER didbot-pds in the release image, under one unit THE OPERATOR'S OWN MACHINE didbot didbot-operator operate · login · estop · announce AN AGENT HOST didbot didbot-oauth pending · show · approve · decline didbot-register once, to make this machine an account didbot-agentd one process, reached over a socket the didbot-claude plugin adds the hook that reports to the daemon
`didbot --list` scans `PATH` for `didbot-*`, asks each for `--version`, and prints name, version and path; a binary that does not respond inside three seconds is listed with `?`. A first-party binary that is not installed is named after the list, with the verbs it would run: ```text didbot 0.1.0 /home/operator/.cargo/bin/didbot didbot-operator 0.1.0 /home/operator/.cargo/bin/didbot-operator didbot-oauth: not installed (oauth) didbot-register: not installed (register) ``` `didbot help ` is `didbot --help`, resolved the same way; `didbot help` alone prints the dispatcher's own help. ## The operator's commands `didbot-operator` holds two sign-ins that never meet. `operate` authenticates to the operator's *own* atproto account and writes `bot.did.operator` into their own repository; the session it keeps is checked by the operator's own PDS, and it is the only sign-in that admits anything. `login` starts the deployment's own sign-in and keeps the session the deployment minted; `estop` and `announce` present that session to the same `/dashboard/api/*` routes the dashboard's buttons call, and admit nothing. Neither verb can use the other's session.
session files on the operator's machine the operator's shell didbot-operator, one run per command the operator's own PDS their personal account the deployment didbot-pds at pds.example didbot operate --check pds.example DNS, TLS, the did:web document, describeServer · reads only, no credential claimable, or the first failing check · exit 1 until it passes didbot operate pds.example operator.example sign in to your own account · a browser opens once ~/.config/didbot-operator/sessions.json the session with your own PDS wait for TLS, check the server's identity · reads only write bot.did.operator naming pds.example prints the record's URI; does not wait for the server later: the server's poll finds the claim didbot login --server pds.example prints https://pds.example/dashboard/login?cli=http://127.0.0.1:<port>/done and listens on that loopback port the browser signs in on your own PDS · identity only the session the deployment minted, back on loopback ~/.config/didbot/operator.json · 0600 the deployment's session, one per server didbot estop --server pds.example [--pause|--revoke|--release] /dashboard/api/estop · estop/throw · estop/release — carries the deployment's session what the stop is doing and what it has refused didbot announce [--resume] --server pds.example /dashboard/api/relay/announce — the same session the relay's answer two files, two checkers: the first session is checked by your own PDS, the second by the deployment
`didbot operate ` signs in to the operator's own account through OAuth, with a scope no wider than writing `bot.did.operator` and, for an account, one service-auth call at its server. A browser opens for the consent screen once; the session is kept in `$XDG_CONFIG_HOME/didbot-operator/sessions.json` and resumed on the next run while its grant covers what the run asks for. What follows depends on the name: a name that answers `describeServer` with its own DID is a server, and any other name, or one given with `--kind` or `--oidc`, is an account. A server's own hostname is the top edge. The command waits for the server to answer over TLS, checks its identity, writes the record naming it, prints the record's URI and returns. The server polls the operator's repository on its own interval and finds the claim there. An account is admitted beneath a server the operator already claimed: `didbot operate kestrel.pds.example op.example --kind agent`. Which server is read from the operator's own repository, with no credential: the name must end in `.` for a server that repository holds `bot.did.operator/` for, and `--server ` picks one when it sits under more than one. Nothing that answers at the name has a say. The sign-in asks for `rpc:bot.did.createAccount?aud=did:web:` beside the write, the operator's own PDS mints a service-auth token for that one call first, then the record naming the account goes into the operator's repository, keyed by the name, and the server creates `did:web:` with the token: the operator's credential never reaches the server. A server that refuses the create has the record taken back — deleted, or put back as it stood before the run — and the refusal says which. A key `didbot register` parked under the name is shown with its kind and fingerprint and admitted on a `y`; `--fingerprint SHA256:...`, copied from what `register` printed, admits it without the question and refuses when it differs. Its kind wins over `--kind`, and `--oidc` is refused while it stands. With nothing parked, `--kind` says what the account is and `--oidc ...` makes a token from that issuer its way of signing in: every claim named must equal its value, a number or a boolean spelled as JSON spells it, and a name beginning with `/` is a JSON pointer into a nested claim (`/kubernetes.io/namespace=agents`). [From AWS OIDC to didbot](pipelines.md) has the lines for each platform. `--label` is free text kept on the registration. The session the server returns is written under `$DIDBOT_STATE/accounts/.token`, the file `DIDBOT_ACCOUNT_TOKEN_FILE` points `didbot-oauth` at. `didbot operate --check ` signs in to nothing and writes nothing, and exits 1 until it passes, so a script can wait on it. For a server it resolves the hostname, opens HTTPS to it, reads its `did:web` document and cross-checks `describeServer`. For an account it walks the tree: the account's `bot.did.registration/self` names an operator, that operator's `bot.did.operator/` names the account back, and the walk climbs to the operator while its repository is on the same server, printing each edge and refusing past three. The last edge printed is the server's own: the operator the walk ended at holds `bot.did.operator/` naming it. `didbot login --server [HANDLE]` prints a URL and listens on a loopback port. The deployment runs its own sign-in against the operator's account, identity only, and mints a session; the browser brings it back to the loopback port and the command stores it in `$XDG_CONFIG_HOME/didbot/operator.json`, mode `0600`, one entry per server. When `estop` or `announce` is refused with `401`, the fix the refusal names is to run `didbot login --server ` again. `didbot estop --server ` with no setting reports what the stop is doing and what it has refused. `--pause` stops new tokens and new provisioning, `--revoke` stops outstanding work too, and `--release` clears the stop; one setting at a time. `didbot announce --server ` asks the configured relay to crawl the deployment, and `--resume` calls the deprecated `notifyOfUpdate` instead. ## The agent host's commands An agent host runs `didbot-agentd` once, and the hook the [didbot-claude](https://tangled.org/permadeath.com/didbot-claude) plugin installs reports every harness event to it over a unix socket. The daemon provisions one account per context, holds each account's token, and follows the sign-in requests the server holds for those accounts. `didbot oauth` is the one command an agent runs, and everything in it names a request — a token, or the URL of one — and never an account. [The daemon on an agent host](agentd.md) draws the daemon's inside; this picture is the commands and the socket between them.
AGENT HOST agent the model hook · didbot oauth one per event or command socket unix didbot-agentd holds the account tokens the app third party, on this host agent pds off the host $XDG_RUNTIME_DIR/didbot-agent/agent.sock directory 0700 · file 0600 · the peer's uid is checked began · SessionStart, SubagentStart the harness fires the hook provision one account for this context the account and its account token · kept in memory, written nowhere its name, for additionalContext the agent learns which account it is start an ordinary client, naming that account an authorization request listPendingAuthorizations, held open a pending decision: who asked, what for, what policy allows report · the next tool call what is waiting for this context, with its token the agent reads it didbot oauth pending or didbot oauth show <url> pending → one line per request, with its token printed didbot oauth approve <token> a token, not an account approve(token) approveAuthorization, as the account the record names the granted scopes, and the client's redirect the code, fetched on loopback done · the scopes requested, the scopes allowed printed
`didbot oauth pending` prints one line per request waiting for this agent, each with its token. `didbot oauth approve ` lets one in and `didbot oauth decline [--reason WHY]` turns it down. A token names one request, is good once, and is not an account: the daemon acts as the account named by the record it holds that token in. When no hook saw the client print its URL and the poll has not come back, `didbot oauth show ` looks the request up by that URL, and `didbot oauth approve --url ` and `didbot oauth decline --url [--reason WHY]` settle it. `didbot register ` makes the machine it runs on an account at the server `--server ` or `DIDBOT_PDS` names, as a hostname or a URL; `` must end in `.` as that server's `describeServer` reports it, and nothing is asked at the name. It mints a key under `$DIDBOT_STATE` — the daemon's own key for a `host`, `keys/.key` for a `service`, `pipeline` or `agent` — and parks the public half at the server under the name, printing the fingerprint and the `didbot operate --fingerprint ...` to run on the operator's machine. A key already parked under the name is somebody else's turn: the command refuses, says when that park expires, and mints nothing. It then waits, `--timeout` seconds (default 600), for the operator to admit the key, signs in as `did:web:` with a JWT the key signs, and keeps the session under `$DIDBOT_STATE/accounts/.token`. With `--token-file ` or `--token-stdin` and `--under `, an OIDC token is the parent instead: the account is created now, beneath the account whose document names that issuer, with the fresh key in its document. A token is never taken on the command line, where `ps` would show it. `didbot-agentd` takes no options. `DIDBOT_PDS` names the server that mints accounts, as a hostname or a URL, and is required; `DIDBOT_SOCK` moves the socket from `$XDG_RUNTIME_DIR/didbot-agent/agent.sock`; `DIDBOT_STATE` moves the host's key from `$XDG_STATE_HOME/didbot/agentd`. The hook and `didbot oauth` read `DIDBOT_SOCK` too, so all three agree on where the socket is. The daemon creates one account per context beneath the host `didbot register host` made it, presenting a JWT its key signs; the server checks it against the host's document. A host with no daemon runs the binary by name. With `DIDBOT_PDS` and either `DIDBOT_ACCOUNT_TOKEN` or `DIDBOT_ACCOUNT_TOKEN_FILE` set, `didbot-oauth pending`, `show`, `approve` and `decline` talk to that server as that one account; `--direct` insists on it rather than trying the socket first. Through the dispatcher both credential variables are removed before the hand-off, so `didbot oauth` reaches the socket and nothing else. An account whose document names an OpenID Connect identity signs in with `--token-file ` or `--token-stdin` instead: `didbot oauth pending --token-file /run/secrets/id-token` opens a session at `DIDBOT_PDS` through `bot.did.createSession`, then runs the command as that account. `--token ` is refused by name, so a token is never an argument `ps` can show. ## Installing Every binary is a crate in one workspace, and `cargo install` takes the crate's name. The operator's own machine: ```sh cargo install --git https://tangled.org/permadeath.com/didbot didbot-dispatch cargo install --git https://tangled.org/permadeath.com/didbot didbot-operator ``` An agent host, where the `didbot-agentd` crate carries `didbot-agentd`, `didbot-oauth` and `didbot-register`: ```sh cargo install --git https://tangled.org/permadeath.com/didbot didbot-dispatch cargo install --git https://tangled.org/permadeath.com/didbot didbot-agentd ``` The hook that reports to the daemon is the `didbot-claude` plugin, installed from Claude Code: ```sh claude plugin marketplace add git@tangled.org:permadeath.com/didbot-claude claude plugin install didbot@didbot ``` The server runs `didbot-pds` as a container of the release image, built from the repository's `Dockerfile` and started by one systemd unit; [deployment](deployment.md) says how the image is built and run, and [operations](operations.md) how it is upgraded and rolled back. The same binary runs on a laptop: it is the `didbot-serve` crate's, so `cargo install --git https://tangled.org/permadeath.com/didbot didbot-serve` installs it, and `scripts/dev-pds.sh` runs it from a checkout. ## Extending `didbot foo` runs `didbot-foo` from `PATH`. The program receives the rest of the command line as its own arguments and the ordinary environment, less `DIDBOT_ACCOUNT_TOKEN` and `DIDBOT_ACCOUNT_TOKEN_FILE`; nothing is added, and no credential from the shell reaches it. It authenticates for itself, the way the first-party verbs do. `didbot --list` shows it beside them, with whatever its `--version` prints, and `didbot help foo` runs `didbot-foo --help`. ## Conventions Every binary accepts `--help` and `--version`, prints on stdout and exits 0. `--version` prints ` `, which is what `didbot --list` shows. Three exit statuses: 0 did it, 1 understood the request and could not or would not do it, 2 did not understand the request. A refusal is one line on stderr, `: `, where the program is the spelling the person typed: `didbot estop: --server is required`. Every `didbot-operator` verb takes `--server `, the deployment to act on, and `--json`, the result as one line of JSON on stdout for a script. Both read the same before or after the verb. `didbot register` takes `--server ` or reads `DIDBOT_PDS`; `didbot oauth` names its server through the daemon, or through `DIDBOT_PDS` when run by name. A server is spelled the same way in both, a hostname such as `pds.example` or a URL such as `http://nc.localhost:3413`; a `.localhost` name is reached over plain HTTP. `DIDBOT_PDS` is not a credential, and the dispatcher passes it through.