rendered from docs/cli.md cbc225b13109

The command line

What commands didbot has, who runs which, where each is installed, and how they fit together. cli 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 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 <verb>, 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 account, didbot app, 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 <verb> … 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 · account · app · 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 · account · app · 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:

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 <verb> is didbot <verb> --help, resolved the same way; didbot help alone prints the dispatcher’s own help.

The operator’s commands

didbot-operator holds two sign-ins. 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. login starts the deployment’s own sign-in and keeps the session the deployment minted; estop, account, app and announce present that session to the same /dashboard/api/* routes the dashboard’s buttons call. Admitting an account uses both: the record goes in with the first, and the create at the deployment carries the second. Neither session works where the other does.

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 says it wrote the record; 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 <NAME> <OPERATOR> signs in to the operator’s own account through OAuth. It asks for one scope, repo:bot.did.operator?action=create&action=update&action=delete, beside atproto, for a server and an account alike. 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, says so 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 .<SERVER> for a server that repository holds bot.did.operator/<SERVER> for, and --server <HOSTNAME> picks one when it sits under more than one. Nothing that answers at the name has a say. The record naming the account goes into the operator’s repository, keyed by the name. Then the server is asked to create did:web:<NAME>, carrying the operator session didbot login --server <SERVER> kept. Before writing anything the command checks that session with one read at the server. The server keeps operator sessions in memory, so a restart ends them. With no session kept, or one the server refuses, the command signs in the way didbot login does, keeps the new session, and then goes on. The server checks the session and reads the record; it creates only when both stand. The credential for the operator’s own account never reaches the server. A server that refuses the create, including for a session that has aged out, 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 admitted by the fingerprint register printed: --fingerprint SHA256:..., or pasted when the command asks for it. The server describes the park that fingerprint names, even when another key was parked under the name after it, and a fingerprint nothing is parked with writes nothing. The key goes to the server in the create and into no record, and the park’s kind wins over --kind. With nothing parked, --kind says what the account is. --oidc <ISSUER> <CLAIM=VALUE>... makes a token from that issuer a way of signing in, beside a parked key or alone: 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 has the lines for each platform. The admitted key and the OIDC identity go to the server in the create, and the server writes each as one of the account’s own bot.did.credential records: that is where what logs in as the account lives.

The record’s creates says what the account may create, and only a record in the operator’s own repository grants it. --creates [KIND...] lets the account create beneath itself and --creates-beneath [KIND...] lets every account beneath it create, at any depth; each names the kinds it allows, one of the four --kind takes, and either flag with no kinds allows any. Both may be given. A laptop that will run agents, whose sessions create their subagents:

didbot operate laptop.pds.example op.example --fingerprint SHA256:... \
  --creates agent --creates-beneath agent

A pool whose hosts, and their agents, create what they need:

didbot operate pool.pds.example op.example --kind host --creates-beneath

The session the server returns is written under $DIDBOT_STATE/accounts/<NAME>.token, the file DIDBOT_ACCOUNT_TOKEN_FILE points didbot-oauth at.

didbot operate --check <NAME> 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 its parent, the parent’s bot.did.operator/<NAME> names the account back, and the walk climbs to the operator while the parent’s repository is on the same server, printing each edge as the account, its kind and its parent, and refusing past three. The last edge printed is the server’s own: the operator the walk ended at holds bot.did.operator/<SERVER> naming it.

didbot login --server <HOSTNAME> prints a URL and listens on a loopback port. The deployment runs its own sign-in against the identity --operator names and no other, 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 a command carrying that session is refused with 401, the fix the refusal names is to run didbot login --server <HOSTNAME> again.

didbot estop --server <HOSTNAME> with no setting reports what the stop is doing and what it has refused. --pause refuses every token and all provisioning until the stop is released, and ends nothing. --revoke refuses the same and ends every token, so a release does not bring them back. --release clears the stop. One setting at a time. A service-auth token is the exception: either setting refuses every new one and every proxied call, but one already minted works at the service it names until its exp, at most an hour, or a minute for the ones the proxy mints.

didbot announce --server <HOSTNAME> asks each configured relay to crawl the deployment, and --resume calls the deprecated notifyOfUpdate instead.

didbot account list --server <HOSTNAME> prints every account the deployment hosts, one per line: its kind, record count, whether it wrote, last commit, creation, state and parent. It reads the dashboard’s own listing, so it answers whatever the [disclosure] table closes to the public. An account wrote when it holds a record outside the collections the server writes itself, or when the write log, kept 30 days by default, holds a write from it. An account with no record of its own reads as unknown when the write log cannot be read, or when the account is older than the log’s oldest row, since the log may have dropped its writes. --kind, --under <NAME> (at any depth), --never-wrote and --idle-for <DURATION> (since the last commit or the creation, such as 7d) narrow it. didbot account show <NAME> prints one account with its record count per collection, its latest ledger entries and its credentials.

didbot account <VERB> <NAME> --server <HOSTNAME> acts on one account the deployment hosts, carrying the same session. <NAME> is the account’s handle or its did:. Each verb but credentials prints the account it reached and the ledger entry it made, and --json prints the route’s own answer:

didbot account lock kestrel.agents.example --server pds.example
didbot account list --never-wrote --idle-for 7d --server pds.example
didbot account delete laptop.pds.example --recursive --server pds.example
didbot account delete --never-wrote --under laptop.pds.example --server pds.example
didbot account delete --never-wrote --under laptop.pds.example --confirm 3f9a0c1d2b7e \
  --server pds.example
didbot account revoke-tokens deploy.pds.example --server pds.example
didbot account credentials deploy.pds.example --server pds.example \
  --oidc https://token.actions.githubusercontent.com repository_id=987654 \
  --remove 3lz2qm6e2dc2h

didbot app end-logins --client <CLIENT> --server <HOSTNAME> ends every login one OAuth application holds on the deployment, carrying the same session. <CLIENT> is the application’s client_id, the URL its metadata document is served at. --account <NAME> narrows it to one account, by handle or did:; without it, every account this deployment holds. The command prints how many logins, unredeemed authorization codes and pending consents it took, so nothing in flight becomes a token afterwards, and the loginsEnded entry each account a login ended on gained in its ledger. If the deployment cannot write the ending to pds.grants, the logins still end on the running server, and the command fails, saying they may return after a restart.

This is what an application whose keys or tokens leaked gets. It ends those logins for good and says nothing about whether the application may sign in again: refusing it is a denyClient policy, written from the policy dashboard, which stops the same logins working while it stands and lets them work again when it lifts.

didbot app end-logins --client https://app.example/client.json --server pds.example
didbot app end-logins --client https://app.example/client.json \
  --account kestrel.agents.example --server pds.example

didbot app logins, refusals and writes --server <HOSTNAME> read the dashboard’s per-app panels with the same session, one row per line, and --json prints the route’s own answer. logins lists every live login: the application, the account, the scope it approved and the scope in force now, and whether policy would issue it a token now. refusals lists the evaluation log: policy refusals at sign-in, token issue and the write, writes outside a token’s scope, and requests a scope ceiling cut back. writes lists the write log: every record written or deleted, and the application whose token wrote it, or - for an account using its own credential. --client <CLIENT_ID> and --account <NAME> narrow all three, and --since <DURATION>, such as 7d, narrows the two logs. A deployment that cannot list a log fails with its reason rather than printing an empty table.

didbot app logins --account kestrel.agents.example --server pds.example
didbot app refusals --client https://app.example/client.json --since 7d \
  --server pds.example
didbot app writes --since 12h --json --server pds.example

didbot operate --list --server <HOSTNAME> reads the operator’s bot.did.operator records back from the deployment, as its operator poll last read them, with the same session. For each account the deployment holds, it prints the account’s kind and what logs in as it: key fingerprints and OpenID Connect identities. The dashboard’s operator records panel shows the same. Changing an admission is didbot operate <NAME> <OPERATOR> on the operator’s machine, since only the operator’s own sign-in writes their repository.

didbot operate --revoke <NAME> is the other direction, and it runs against the operator’s own repository rather than the deployment: it deletes bot.did.operator/<NAME> there, through the delete action of the same scope the claim was written with, after reading the record back and confirming it names that account. The deployment pauses the account and everything beneath it after its grace window, six hours by default. didbot account lock <NAME> stops an account at once, and didbot estop --pause stops a server.

The agent host’s commands

An agent host runs didbot-agentd once, and the hook the 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 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 acted · a session's first PreToolUse 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 · a tool call that showed its URL what is waiting that this context saw, 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 an agent on this host reported seeing, each with its token; --all adds the requests none did, each line starting not-seen-by-any-agent-here. didbot oauth approve <token> lets one in and didbot oauth decline <token> [--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, didbot oauth show <url> looks the request up by that URL, and didbot oauth approve --url <url> and didbot oauth decline --url <url> [--reason WHY] settle it.

didbot register <KIND> <NAME> makes the machine it runs on an account at the server --server <HOSTNAME> or DIDBOT_PDS names, as a hostname or a URL; <NAME> must end in .<HOSTNAME> 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/<NAME>.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 <NAME> --fingerprint ... to run on the operator’s machine. Parks are held per key, so another machine’s park under the same name does not stop this one from parking; didbot operate admits the one whose fingerprint it is given. It then waits, --timeout seconds (default 600), for the operator to admit the key, signs in as did:web:<NAME> with a JWT the key signs, and keeps the session under $DIDBOT_STATE/accounts/<NAME>.token. With --token-file <PATH> or --token-stdin and --under <PARENT>, an OIDC token is the parent instead: the account is created now, beneath <PARENT>, with the fresh key as its own first credential record. The create names <PARENT> as parent, and the server checks the token against that account’s identities alone. A token is never taken on the command line, where ps would show it.

didbot register --rotate <KIND> <NAME> replaces the key an existing account logs in with. It mints the next key beside the current one, sends bot.did.updateCredentials signed by the current key with addProof signed by the next, and renames the next key over the current once the server answers; a JWT the old key signed is refused from then on. A host’s key is the running daemon’s, so the daemon is asked over its socket; with none running, this takes the key’s lock and does it here. A key the account’s records no longer hold, or a key file that is gone, is parked under the name for the operator to admit, and this waits for that --timeout seconds.

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 and its context bindings from $XDG_STATE_HOME/didbot/agentd; DIDBOT_CONTEXT_TTL_DAYS is how long a context keeps its account after the harness last reported it, 1 to 365 days and 30 by default; DIDBOT_KEY_ROTATE_DAYS, 1 to 365, has the daemon replace the host’s key once its credential record is that many days old, checked hourly, and unset it never does; DIDBOT_PDS_RETRY_SECS, 0 to 120 seconds and 30 by default, is how long creating an account waits out a server that is restarting; DIDBOT_SUBAGENT_OPERATOR is agent or host, agent by default. 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. A session’s is created beneath the host didbot register host made it, presenting a JWT its key signs; the server checks it against the host’s bot.did.credential records. A subagent’s is created under agent as its session, with the session’s account token, beneath the session, and under host the same way as a session’s.

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, and pending lists every request waiting for it; --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 credential records name an OpenID Connect identity signs in with --token-file <PATH> or --token-stdin instead, and --account <NAME>, the account’s hostname or DID: didbot oauth pending --token-file /run/secrets/id-token --account deploy.pds.example opens a session at DIDBOT_PDS through bot.did.createSession, then runs the command as that account. The server checks the token against that account’s identities alone and refuses an ID token that names no account, and the command refuses a session the server answers for any other account. --token <JWT> 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:

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:

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:

claude plugin marketplace add https://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 says how the image is built and run, and operations 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 <name> <version>, 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, <program>: <message>, where the program is the spelling the person typed: didbot estop: --server <HOSTNAME> is required.

Output names a did:web account by its hostname, and any other account as @handle when its handle resolves back to its DID. --json carries the DIDs. Logs reach stderr only when RUST_LOG asks for them.

Every didbot-operator verb takes --server <HOSTNAME>, 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 <HOSTNAME> 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 https://nc.localhost:3413. A hostname with no port is reached on 443, unless DIDBOT_RESOLVE_PORTS says that zone answers somewhere else. DIDBOT_PDS is not a credential, and the dispatcher passes it through.