rendered from docs/first-hour.md 0c82598a3761
The first hour
The commands that take a fresh checkout to a server with a human behind it, an agent made by hand, a laptop host whose daemon makes agents, a pipeline whose runs sign in, and a revocation, in the order they work. Who operates an account is what each step means and the command line is every flag; this page is the sequence.
Every server here serves TLS, so before anything else do the two one-time
steps in running the stack locally:
trust the local certificate authority, and let didbot-pds bind 443. This
page puts its server on 3413 instead, which is what DIDBOT_RESOLVE_PORTS
below is for — a did:web carries no port, so every tool has to be told
where the zone answers.
export DIDBOT_RESOLVE_PORTS=nc.localhost=3413
Two stand-ins
didbot operate signs in to your own atproto account in a browser, and
a .localhost PDS cannot be that account: an atproto handle may not end
in .localhost. Locally the human’s PDS and the OpenID Connect issuer
are the harness’s, left running on fixed ports with the session
didbot operate resumes already written:
export NEWCOMER_CONFIG=$HOME/first-hour/human/config
export DIDBOT_LOCAL_CA=$HOME/.local/state/didbot/local-ca
cargo test -p didbot --test scenarios the_stand_ins_by_hand -- --ignored --nocapture
It prints the human’s DID, did:web:localhost%3A3415 — a bare loopback
hostname is the one place a did:web may carry a port — the issuer at
http://127.0.0.1:3416, a mint at http://127.0.0.1:3417/ that answers an
ID token for ?aud=<server did>&<claim>=<value>, and the authority it
signed the human’s certificate with. Every didbot operate below runs with
XDG_CONFIG_HOME=$NEWCOMER_CONFIG. On a real deployment the two are your
own PDS and your platform’s issuer, and the browser opens once.
The server
printf '[operator]\ngrace_window_hours = 0\n' > nc.toml
didbot-pds --port 3413 --zone nc.localhost \
--operator did:web:localhost%3A3415 --config nc.toml --data ./nc-data
The grace window is how long a deleted operator record keeps writing; zero makes the revocation at the end land at the next poll. Then, as the human:
didbot operate nc.localhost did:web:localhost%3A3415
curl -X POST https://nc.localhost:3413/xrpc/bot.did.pollOperatorClaim # look now
The server’s log says found the operator's record; the claim stands.
Until it does, every write is refused as ServerNotReady.
An agent by hand
didbot operate kestrel.nc.localhost did:web:localhost%3A3415 --kind agent
didbot operate --check kestrel.nc.localhost
The first run has no operator session for the server yet, so it prints a
link to open, the way didbot login --server nc.localhost:3413 does. The
browser lands on a page saying whether the sign-in worked, and the command
keeps the session for every later run. It then prints the
export DIDBOT_ACCOUNT_TOKEN_FILE= line for didbot-oauth. The second
command walks one edge and exits 0. --check on the server’s own name needs
DNS delegation a .localhost zone has none of; --check nc.localhost --server nc.localhost walks the server’s own edge instead.
What the server holds
The operator session is kept under the address it signed in at,
nc.localhost:3413. The commands that carry it name the server the same
way:
didbot account list --server nc.localhost:3413
didbot account show kestrel.nc.localhost --server nc.localhost:3413
list prints one line per account. show prints one account’s records,
recent ledger entries and credentials. https://nc.localhost:3413/dashboard
shows the same accounts once you sign in there, and an account’s detail adds
and removes its credentials.
A laptop host and its daemon
On the laptop, with DIDBOT_STATE chosen and DIDBOT_SOCK left alone, so
the daemon binds the path the hook connects to:
export DIDBOT_STATE=$HOME/first-hour/laptop
didbot register host laptop.nc.localhost --server nc.localhost
It prints a fingerprint and waits. On the human’s machine, with the fingerprint it printed:
didbot operate laptop.nc.localhost did:web:localhost%3A3415 --fingerprint SHA256:... \
--creates agent --creates-beneath agent
register then signs in and exits 0. --creates agent lets the daemon
create each session’s account beneath the laptop, and --creates-beneath agent lets each session create its subagents’ beneath itself; without them
those creates are refused. Only then start the daemon: it holds the same key, and
register refuses while a daemon holds it.
DIDBOT_PDS=nc.localhost didbot-agentd
In a second terminal, report a session’s first tool call the way the
didbot-claude plugin’s hook does. Claude Code installs the hook under
~/.claude/plugins/cache/didbot/didbot/<version>/bin/, one directory per
version, so pick the newest:
hook=$(ls -d ~/.claude/plugins/cache/didbot/didbot/*/bin/didbot-hook | sort -V | tail -n 1)
echo '{"hook_event_name":"PreToolUse","session_id":"sess-1","tool_name":"Bash","cwd":"/tmp"}' \
| "$hook"
A session’s account is created at its first tool call. The daemon waits up to
1.5 seconds for that create, and the hook prints hook JSON whose
additionalContext names the new agent as did:web:<name>. If the hook
prints nothing, the create took longer; send the same event again. A context’s name is
built from its session and its subagent together, so read it off that answer.
The walk climbs two edges:
didbot operate --check <name>
A pipeline and its runs
didbot operate deploy.nc.localhost did:web:localhost%3A3415 \
--kind pipeline --oidc http://127.0.0.1:3416 repository_id=456789
curl -s 'http://127.0.0.1:3417/?aud=did%3Aweb%3Anc.localhost&repository_id=456789' > id-token
DIDBOT_PDS=nc.localhost didbot oauth pending --token-file id-token \
--account deploy.nc.localhost
The token’s aud is the server’s DID, encoded once for the URL. Each
token is a session once: presenting it again is refused as already
presented.
Revoking the host
The human deletes bot.did.operator/laptop.nc.localhost from their
own repository, and nudges the poll:
curl -X POST https://nc.localhost:3413/xrpc/bot.did.pollOperatorClaim
didbot operate --check <name> # exit 1
The host and every agent beneath it are quarantined.
didbot account list --server nc.localhost:3413 shows each as active, paused: the edge above it no longer stands. Their writes are refused, the daemon’s next create is refused as
OperatorRecordMissing, and the walk fails at the missing record.