# The daemon on an agent host One process on an agent host issues a credential to every context that acts there. This page draws that process, says which hosts run one, separates proving a host from running the daemon on it, and places both in the chain that runs from an operator to an agent. [node](../plan/node.md) is the epic that owns the decisions; `crates/didbot-agentd` is the code, and [the shape of the system](architecture.md) draws the hosts around it. ## One process, many contexts A harness fires a hook for every event: a session starting, a subagent starting, each tool call. A hook is a process that lives for milliseconds, and a machine running several sessions fires many at once. `didbot-agentd` is the one process those hooks report to. It holds what they must not, and it is the one place their requests can be coalesced: a fan-out of subagents starting together becomes one provisioning request per context rather than one per hook.
AGENT HOST · one user account anything running as this user reaches the socket · that is the ceiling harness process session agent a context in its own right subagent its own account subagent its own account distinct DIDs separate attribution and policy one process holds them all hook adapter another repository · not Rust one short-lived process per event didbot oauth approve <token> run by an agent, with the token from a decision it was shown. no account hooks agent.sock directory 0700 socket 0600 peer uid must be the operator's the path is public reaching it is the authorization report approve one line of JSON each way didbot-agentd contexts keyed session + context told once per asker registrar asks for a name decisions polled, held in memory the host's key the daemon is its only writer agent pds bot.did.createAccount one account per context the write credential is sent once listPendingAuthorizations long-polled, one task per account the daemon calls out, never in approveAuthorization redeems a token, for what was granted provision approve as the record's account the code goes to the client's loopback listener · the daemon reads the answer, never the model
### The socket is the privilege boundary The daemon listens on a unix socket in a directory it creates and owns under the runtime directory. The directory is created at `0700`, or tightened to `0700` when it already exists, which fails when another user owns it; the socket file is set to `0600` after binding rather than left to the umask. Every accepted connection is checked against the credentials the kernel reports for the connecting process, and a peer from another user is dropped. A stale socket is unlinked only when connecting to it is refused, because any other failure means a live daemon may be behind it. `crates/didbot-agentd/src/socket.rs` holds all of this. Reaching the socket is the authorization. The path is a location the client computes, published where any shell command the model runs can print it, so it is guarded by the directory and the peer check rather than by staying unknown. What a caller sends selects which context is asking. It never decides whether the caller may ask: anything running as this user can connect, and [the trust model](trust-model.md) names that as the ceiling. The design defends a credential from leaving the machine, and not from being misused on it. ### The protocol is the contract The adapter that speaks to this socket lives in another repository and is not written in Rust. The wire format in `crates/didbot-agentd/src/protocol.rs` is therefore the schema, and the adapter is checked against fixtures of it rather than against this crate. A change the adapter cannot make without importing the crate has put the two back together. The fixtures are `crates/didbot-agentd/fixtures/protocol/`, one document per ask and one per shape of answer, and `cargo test -p didbot-agentd --test fixtures` fails when the types and the directory have drifted apart. A fixture that changes is a change the adapter has to make too. One line of JSON crosses each way, one exchange per connection, and every request carries the wire version. A request longer than `MAX_LINE` bytes, set in that file, is answered with trouble. Two components installed months apart is the ordinary case, so an adapter a version behind is answered normally: every field it sends is still read, and the fields it has never heard of in the answer are ones it ignores. A version *newer* than the daemon's own is refused, and refused in words rather than by closing, because an adapter given silence cannot tell a version mismatch from a daemon that is down. A report says what the adapter observed, in the daemon's own vocabulary of contexts beginning, acting and resting, rather than in the harness's event names; mapping one onto the other is the adapter's whole job. The answer is quiet unless the daemon has something to say. One thing it says is a context's identity, once per asker — two plugins on one machine watch the same events, so it is counted per plugin, and the second to ask is answered rather than met with the silence that means "already told". An asker is written down as told once the answer carrying the name has been written to the socket, so a hook whose pipe broke is named again on its next call, and two answers in flight for one context can each carry it. The other thing the daemon says is a sign-in waiting on that context's decision, which is the next section. ### What the daemon keeps A context is a session together with the harness's identifier for whatever is acting inside it, and a session with no subagent is a context in its own right. Whichever report mentions a context first creates it, so a context that acts before announcing itself is still named. Hooks fire in parallel, so two reports for a new context can arrive together; the second waits for the first to mint rather than minting again. A context that has ended keeps its row and its name: a name is never returned to a pool, and neither is the record of who held it. `crates/didbot-agentd/src/context.rs` is that store: it decides which context still needs a name, and keeps the answer. A binding outlives the process that made it. The context's key, its account's DID, that account's own credential, the askers already told, and when the harness last mentioned it are written to `contexts.json` in the state directory, a `0600` file replaced whole or not at all. A daemon that starts with the file present serves each of those contexts the identity it already has. One whose file does not parse refuses to start and names the file: the file is the credential for every account in it, and writing a fresh one over it would abandon all of them. A binding also ends. A context the harness has said nothing about for longer than its lifetime is dropped at the next sweep, and the credential goes with it; `DIDBOT_CONTEXT_TTL_DAYS` sets that lifetime, 1 to 365 days and 30 by default. The account stays standing at the server, and nothing is written to the ledger about it. Names come from a registrar. The one that speaks to this project's server calls `bot.did.createAccount` for a name made from both halves of the context's key — a readable prefix and a digest of the session and the subagent together — under the server's zone, kind `agent`, and a JWT the host's key signs — `iss` the host, `aud` the server, `lxm` `bot.did.createAccount`. The server checks it against the host's document, writes `bot.did.operator/` into the host's repository and the agent's `bot.did.registration/self` naming the host, and answers with the account's DID and its session, which it sends once and keeps no copy of; [who operates an account](ownership.md) is that edge. A daemon whose key is not yet an account's signs nothing, so a context on it is answered with trouble naming `didbot register host` and stays owed a name. A server that refuses — a lapsed operator record above the host, a lock on it, no allowance for the host to create — is answered with its name and sentence, and the context stays owed a name, so its first call after the refusal clears creates it. The daemon creates outside its own lock, because every other hook on the machine would otherwise wait behind a round trip to the server. ### The key it holds The daemon mints one secp256k1 key on its first start and keeps it in its state directory, `$XDG_STATE_HOME/didbot/agentd` unless `DIDBOT_STATE` names another. The directory is created at `0700` and tightened to it on every start; the key file is created at `0600` and tightened back to it when found wider. A lock file beside the key carries an exclusive `flock`, so a second daemon pointed at the same directory is refused before it reads a byte, and the kernel drops the lock with the process. `crates/didbot-agentd/src/node.rs` is the custody. The private half never crosses the socket. ### Becoming an account `didbot register host ` on this machine opens the same key, parks its public half at the server under the name, prints the fingerprint, and waits for `didbot operate ` on the human's machine to show the same fingerprint and admit it. The server then mints `did:web:` with the key as its first `bot.did.credential`, and this side signs in with it and writes the DID and hostname beside the key. In a pool, `didbot register host --under --token-file ` creates the host now, beneath the service the token authenticates as. Either way it happens once: a daemon that holds an identity is that host. ### Signing an agent in An app asking to sign in as an agent becomes a decision record at the server: which client asked, what it asked for, and what policy made of that. The daemon follows those records for the accounts it issued and puts them in front of the agent they belong to. It follows them outbound only, because nothing on an agent host is reachable from the server. One task per account long-polls `bot.did.listPendingAuthorizations`, holding the request open for twenty-five seconds at a time and backing off from a second to half a minute when the server is not answering. A task starts when a context is provisioned, and when the daemon starts for each context an earlier run provisioned and the harness has not ended. It stops when the harness says that context has ended. Records are held in memory, keyed by the pushed request, and dropped when they expire or when the server says they were settled. Nothing is pushed at the agent. A sign-in rides the answer to a report from its context, so an agent learns of one at its next tool call. A context is shown only the sign-ins it saw. A report names the `request_uri` of each authorize URL the adapter saw in that context's tool call, and a held request is offered only once a report from its own context has named it. Anyone can push a request that names an account, so one the agent never saw waits unshown until it expires. A named request the daemon is not holding yet is fetched with `bot.did.getAuthorization`, sooner than the poll would find it. When that fetch fails, the report is kept in memory for two minutes, as long as a pushed request lives, and a record a later poll delivers in that time is shown to the context that named it. At most 512 such reports are kept, and past that the oldest is dropped. The agent answers with `didbot oauth approve ` or `didbot oauth decline `. The token names one request, is good once, and is not an account: the daemon finds the record it is holding that token in and acts as the account that record names, so there is nothing here for a caller to fill in with somebody else's identity, and a token the daemon is not holding is refused rather than passed on. Approving as the account means presenting that account's own session, which the daemon has held in memory since the create and writes nowhere. An approval covers a narrowed decision's `granted`, and the login is issued at that set. The server asks its scope ceiling again at every use, so a ceiling tightened afterwards takes granted atoms back. `cut` is outside the approval, so a loosened ceiling still answers `granted`, and a cut atom takes a fresh sign-in to ask for. A narrowed line says so with `approves=granted ceiling-checked=each-use`. An approval's answer lists the scopes requested, then the scopes the scope ceiling allows. The server cannot deliver the resulting code — the client is listening on loopback on this host — so it answers with the redirect and the daemon fetches it. That fetch is bounded to loopback, with redirects turned off, as is every request the daemon makes on something the model influenced. The server finishes only a sign-in whose code the daemon delivers this way. It refuses to approve one whose code a browser would carry — a web app's `https://` callback, a native app's own scheme, or a code asked for in the fragment — before anything is spent, and the daemon passes its words on. The account can still decline it, which sends the app's browser back with `access_denied`. When no report named a sign-in — no hook saw the client print its URL — `didbot oauth show ` looks one up by that URL, and `approve --url` and `decline --url` answer it. Handing over the URL is the agent saying it saw the request, so these act on it whether or not a report named it. The daemon refuses a URL on any origin but this deployment's, then uses the record it holds, or fetches it through `bot.did.getAuthorization` as whichever of its accounts the record names, which is the same authenticated fetch a report makes. `didbot oauth pending` names no context, so it lists the sign-ins held for every context here, and only those their own context reported seeing. `didbot oauth pending --all` lists the rest as well, each line starting `not-seen-by-any-agent-here`. `didbot oauth` is the only agent-facing command, and everything in it names a decision — a token, or the URL of one. There is no `--as ` anywhere in it, and nothing left that takes an account from its caller: the daemon reads the account off the record it is holding the token in. That is what closes the gap the socket section above describes — reaching the socket is still the authorization, and anything running as this user can still connect, but there is no longer a field on the wire for a caller to put another context's identity into. The command that did take one is gone. It handed the daemon an authorize URL to fetch and an account to confirm it as, which meant a model that could read another context's identifier out of a transcript could name it. Approving a decision the daemon already holds needs neither, so the URL, the page it was read from, and the account argument all went with it. ### Without the daemon A host with one agent on it has neither problem the daemon solves: nothing to multiplex and one credential, not one per context. `didbot-oauth`, run by name, works there with no daemon and no hook. Set `DIDBOT_PDS` to the server and either `DIDBOT_ACCOUNT_TOKEN` or `DIDBOT_ACCOUNT_TOKEN_FILE` to that account's own session, and `pending`, `show`, `approve` and `decline` work as they do otherwise, against the same routes as the same `AccountSelf` credential. Here `pending` lists every sign-in waiting for that account. Passing `--direct` insists on that mode; without it a running daemon is preferred, and the environment is consulted only when nothing answers on the socket. This is the bare binary's mode: `didbot oauth` removes both credential variables before it hands off (see [cli](../plan/cli.md)), so through the dispatcher the socket is the only way in. It is the same client code throughout — the same origin check on a URL, the same bounded loopback fetch delivering the code after an approval — so the two modes cannot drift into disagreeing about what a sign-in is. `didbot register` and `didbot operate` put the session there, under `$DIDBOT_STATE/accounts/.token`, and an account that is an OpenID Connect identity opens one from a token with `--token-file` instead. The token is read into a type that has no `Display` and no `Serialize`, so no command here can print or log it. ### Installing it and keeping it running `didbot-agentd`, `didbot-oauth` and `didbot-register` are one crate; [the command line](cli.md) has the `cargo install` line for it and the environment variables the daemon reads. Give the host an identity before starting the daemon — `didbot register host` here, `didbot operate --fingerprint ... --creates agent` on the operator's machine, as above. A daemon with no identity signs nothing, and without the allowance every create beneath the host is refused. `DIDBOT_PDS` is the one variable that has to be set, and it names one deployment, so it goes in a file rather than in the unit: ```sh mkdir -p ~/.config/didbot echo 'DIDBOT_PDS=pds.example' > ~/.config/didbot/agentd.env ``` `crates/didbot-agentd/didbot-agentd.service` is a systemd user unit that reads that file: ```sh mkdir -p ~/.config/systemd/user cp crates/didbot-agentd/didbot-agentd.service ~/.config/systemd/user/ systemctl --user enable --now didbot-agentd journalctl --user -u didbot-agentd -f ``` It restarts the daemon when it dies and stops it when the login session ends, which is where the host's key should stop being resident. A user unit rather than a system one because the socket, the key and the bindings all sit in one person's runtime and state directories, and [the trust model](trust-model.md) puts the ceiling at that user account. ### Where the hook looks for the socket Leave `DIDBOT_SOCK` unset on a host running a harness. The hook is a program the harness spawns, so it inherits the harness's environment and not the shell the daemon was started from; with the variable unset both ends compute `$XDG_RUNTIME_DIR/didbot-agent/agent.sock`, which is the path the daemon binds and the path the hook connects to. Setting it for the daemon alone gives a hook that connects to nothing and a plugin that looks installed and does nothing. A deployment that moves the socket sets `DIDBOT_SOCK` in both places: the unit's environment file, and wherever the harness's own process takes its environment from. Keep the socket inside a subdirectory of its own, the way the default does. The daemon tightens the directory holding the socket to `0700`, and a path directly in `$XDG_RUNTIME_DIR` makes that directory the one it tightens. ## Which hosts run it The daemon exists to multiplex. A host running many sessions, each spawning subagents, has many contexts to keep apart and needs a distinct credential for each. One process holding one key issues all of them, coalesces the burst of a fan-out into one request per context, and is the only place the key is read. That host runs the daemon. A single-purpose host proves itself directly. A CI run signs in as the pipeline with the token its platform issues, `didbot oauth pending --token-file`, and creates the one agent it starts; a cloud instance minted to run one agent registers itself beneath its service with the instance's token and is one context for as long as it lives. Each proves itself once, and the account it is given is the account it uses. The line between the two is the number of contexts a host has to tell apart. One context is one proof and one account; several contexts on one host is a daemon, because that is what keeps one key in one process while many things act. ## Proving the host is a separate choice Running the daemon is about how many credentials a host issues. Proving the host is about what the server checks before it creates anything beneath it, and that check is one proof against the host's own `bot.did.credential` records ([attestation](attestation.md)). A host holds one of two things there, and which one is the human's choice at `didbot operate`. **A key of its own.** The key is minted on the host by `didbot register host` and its private half stays there; the server holds the public half as a credential record and nothing else. A JWT the key signs is the host, so compromising one host yields one host. The key is a file, and a copy of it is indistinguishable from the original; the daemon being its only reader is what keeps it in one process. **An OpenID Connect identity.** Where a platform issues one — a cloud instance's ID token, a CI run's — the account's credential record names the issuer and the claims, and a token that matches is the account. Nothing is generated, nothing is stored, and nothing a copy of the disk carries away. A pool's instances register beneath such a service with the platform's token and then hold a key each ([who operates an account](ownership.md) works the pool through), so the long-lived identity is the platform's and the per-instance key lives as long as the instance. The trade between them is the key. An OIDC identity is preferred wherever a platform exists to issue one, precisely because it leaves no long-lived secret on the host. A key of its own is for the hosts with no platform to issue one for them, which is laptops and workstations, and those are the hosts that run the daemon. ## Where it sits in the chain Two records per edge, each written where the party below it cannot write.
THE HUMAN'S REPOSITORY bot.did.operator/<host> {subject: the host's DID} bot.did.policy · policyBinding what the host and its subtree may do only the human's key writes here PDS HOST agent pds checks each proof against the creator's credential records: its key, or the issuer they name mints one account per proof polls never writes THE AGENT'S REPOSITORY bot.did.registration/self {operator: the host, kind: agent} and in the host's repository, bot.did.operator/<agent> written at birth · read by a stranger writes AGENT HOST · many sessions didbot-agentd · holds the host's key signs a JWT, asks for one account per context session and subagent contexts each told its own identity once JWT signed by the host's key SINGLE-PURPOSE HOST · a CI run, a cloud instance one context, one proof, one account presents the platform's OpenID Connect token fetched fresh, audience the server's DID no long-lived secret on the host to guard an ID token matching its credential record
**The human operates the host.** They write `bot.did.operator/` into their own repository and create the host with the operator session the server minted when they signed in to it. The server reads that repository on a poll and holds no credential for it, so a compromised server or host can lose the relationship and cannot forge one; `crates/didbot-serve/src/operator_poll.rs` is the reader. A host that comes up before the human has run `didbot operate` for it parks its public key at the server and waits. What the host may then do is the human's to set, in the same repository, read by the same poll. **The host operates the agents it spawns.** Every account is created by a proof from its parent, and the server writes both halves of the edge: the host's `bot.did.operator/` and the agent's registration naming the host. On a host running the daemon, the daemon presents the proof and names the context it is for. A single-purpose host proves itself once, for itself. A stranger reads the chain upward from the agent, trusting neither server on its own: the agent's registration names its operator, the operator's record names the agent back, and [who operates an account](ownership.md) is the walk that closes it at the human.