Identities for entities did.bot
agent llm did
didbot docs ownership.md
20 kB
Markdown
at main

Who operates an account #

Every account a deployment hosts is a did:web with a document and a repository, arranged in a tree under one human whose own repository is somewhere else. This page is the four statements that make the tree, the allowance that grows it, what a stranger can check, how an edge comes back out, and the commands that run each scenario. ownership is the epic that owns the decisions; the trust model says what each statement is worth, and verifying who an agent belongs to is the top edge in detail.

Two questions are asked about every account, whatever its kind. Which credentials log in as it: a key it signs with, an OpenID Connect identity, or several. And may it create beneath itself, and what. Both are answered by records, not by the kind: kind is a label for policy and reporting, and the server assigns it no meaning.

Four statements #

The document says which key signs for this account, and who answers for it. Every account has #atproto, the key its server signs commits with. The top-level operator property names the human at the top of the tree, so one HTTP GET reaches them from any depth. A resolver that does not know the property ignores it.

{
  "id": "did:web:laptop.pds.example",
  "alsoKnownAs": ["at://laptop.pds.example"],
  "operator": "did:plc:human",
  "verificationMethod": [
    { "id": "did:web:laptop.pds.example#atproto", "type": "Multikey",
      "controller": "did:web:laptop.pds.example",
      "publicKeyMultibase": "zQ3sh…" }
  ],
  "service": [
    { "id": "did:web:laptop.pds.example#atproto_pds",
      "type": "AtprotoPersonalDataServer",
      "serviceEndpoint": "https://pds.example" }
  ]
}

bot.did.operator/<hostname> says "I operate this account, and this is what it may create." It sits in the operator's own repository, keyed by the subject's did:web hostname. The human's are written by didbot operate through the human's own PDS. A hosted account's are written by the server, as that account, when it creates something beneath itself. Presence is the relationship, and deleting the record ends it. creates lists allowances: {scope, kinds?}, where subject covers the account itself, descendants covers everything beneath it at any depth, and kinds narrows what may be created (absent, any kind). Only the human's own repository is read for creates: a hosted account's records carry none, and would grant nothing.

A laptop that holds its own key and creates agents:

// at://did:plc:human/bot.did.operator/laptop.pds.example
{
  "$type": "bot.did.operator",
  "subject": "did:web:laptop.pds.example",
  "creates": [
    { "scope": "subject", "kinds": ["agent"] }
  ],
  "createdAt": "2026-09-16T14:02:11Z"
}

A pool that is a cloud identity, whose instances register as hosts, and whose hosts create agents:

// at://did:plc:human/bot.did.operator/pool.pds.example
{
  "$type": "bot.did.operator",
  "subject": "did:web:pool.pds.example",
  "creates": [
    { "scope": "subject", "kinds": ["host"] },
    { "scope": "descendants", "kinds": ["agent"] }
  ],
  "createdAt": "2026-09-16T14:03:40Z"
}

bot.did.registration/self says "this is what I am and who answers for me." The server writes it into the account's repository at creation, never rewrites it, and refuses every write to it from the account. operator is the human at the top of the tree. lineage is every hosted account between them and this one, root first, ending at the parent; absent when the human admitted the account directly. The human's repository holds the operator record for the first entry, each entry's repository holds the record for the next, and the last entry's holds the record for this account.

// at://did:web:1d4e02.pds.example/bot.did.registration/self
{
  "$type": "bot.did.registration",
  "did": "did:web:1d4e02.pds.example",
  "operator": "did:plc:human",
  "lineage": ["did:web:pool.pds.example", "did:web:i-0a9f.pds.example"],
  "kind": "agent",
  "createdAt": "2026-09-16T14:20:13Z"
}

bot.did.credential/<tid> says "this logs in as me." A credential is how a process logs in to this server as the account: a #publicKey it signs with, or an #oidcIdentity, an issuer and the claims a token from it must carry. It has nothing to do with the DID document, which the network reads, and nothing to do with atproto's own sessions and OAuth, which the server issues on top of it. The server writes one record per credential into the account's own repository, keyed by a TID, and refuses every write to the collection from the account; deleting a record withdraws the credential. An account with none is reached only through a session this server issued.

// at://did:web:laptop.pds.example/bot.did.credential/3lz2qk4x7ab2c
{
  "$type": "bot.did.credential",
  "did": "did:web:laptop.pds.example",
  "credential": {
    "$type": "bot.did.credential#publicKey",
    "publicKeyMultibase": "zQ3sr…"
  },
  "createdAt": "2026-09-16T14:02:19Z"
}
// at://did:web:pool.pds.example/bot.did.credential/3lz2qm6e2dc2h
{
  "$type": "bot.did.credential",
  "did": "did:web:pool.pds.example",
  "credential": {
    "$type": "bot.did.credential#oidcIdentity",
    "issuer": "https://accounts.google.com",
    "claims": [{ "name": "sub", "value": "106589273465000000000" }]
  },
  "createdAt": "2026-09-16T14:03:52Z"
}

Creation is an allowance #

POST /xrpc/bot.did.createAccount takes {name, kind, key?, oidc?, parkedKey?} — the child's hostname, its kind, and how the child will log in afterwards — with the creator's proof. A hosted creator's proof is in Authorization: Bearer. The human's is the operator session didbot login keeps, sent as the dashboard's cookie with its CSRF header. The server checks, in this order:

  1. It is taking accounts: not stopped, past its readiness gates, under the caller's provisioning rate limit and under its account cap.
  2. The body: kind is one of the four, name sits under a zone it serves and is not reserved, and key and parkedKey are not both given.
  3. Who is asking. A request with the operator session cookie is the human this server was launched with, when the session is live and the CSRF header matches it; any other session is refused as NotSignedIn. The human's repository must then hold bot.did.operator/<name>, or the create is refused as OperatorRecordMissing. Otherwise the bearer resolves to a hosted account, by the table below. That account must stand — active and unlocked — and the human's record for the root above it must still be readable; that record is re-read at most once a minute per root, and a repository that cannot be read leaves the poll's last verdict standing. A bearer naming a DID not hosted here is refused.
  4. A parked key was parked for this name and kind, and before the human's record was written.
  5. The DID is minted under the zone, the human's record names it as subject, it does not already exist, and the deployment's own bounds hold: [tree] max_depth, max_children and creates_per_hour.
  6. For a hosted creator, an allowance in the human's repository covers it: scope: subject on the creator's own record, or scope: descendants on any ancestor's, with kinds absent or naming the kind. None does, and the create is refused as NotAllowedToCreate. The human creating from their own repository needs none: the record naming the new account is the authorization.
  7. For a hosted creator, an oidc identity names claims of its own: an identity a live account here already logs in with, or one carrying every claim of such an identity and more, is refused as OidcIdentityTaken. Claims compare by the claim they select, so /repository is repository. The refusal names the issuer and the claims, never the account already holding them. The human creating from their own repository may write one account's identity as a superset of another's, which is how a pipeline and its production job share a repository claim.

Then it mints the document, writes bot.did.operator/<name> into the creator's repository, writes bot.did.registration/self and a bot.did.credential for the key, parkedKey or oidc the body carried into the child's, and answers {did, handle, didDocument, accountToken, operator}.

The bearer is Checked against What has to hold
a session as the creator the session store live, and the creator's
a JWT signed by a key the creator's credential records hold the creator's credential records iss the creator, aud this server's DID, lxm bot.did.createAccount, exp in the future, jti unspent
an ID token from an issuer the creator's credential records name the issuer's JWKS aud this server's DID, exp, jti unspent, and every claim the identity lists equal to its value

Allowances compose by union across the human's records, and nothing else grants one. The server lists the human's bot.did.operator collection on the same poll that reads the human's record for the server itself, and installs what it finds; a listing that fails leaves the allowances last installed in force. So an allowance the human writes or deletes takes effect at the next poll. Deleting an operator record ends the edge at the next poll after the grace window, and the account and everything beneath it are quarantined; deleting the record for the server itself pauses the whole server instead. Verifying who an agent belongs to has the timings. What an account may do once it exists is bot.did.policy, which only denies; creation is the one thing it does not decide.

A record grants an allowance when it names an account the human created from their own repository, or a name no account holds yet. A record naming an account a hosted creator made grants nothing, and the poll logs it: the human admits an account by creating it, so a creator that took the name first would otherwise collect what the human meant to grant.

The tree #

One deployment, pds.example, run by did:plc:human from another PDS:

  • The human's repository, on a PDS the server holds no credential for:
    • bot.did.operator/pds.example — the top edge, found by the server's poll.
    • bot.did.operator/kestrel.pds.example — an agent made by hand, no allowances.
    • bot.did.operator/laptop.pds.example — a host: creates agents.
    • bot.did.operator/pool.pds.example — a service that is a cloud identity: creates hosts, and agents beneath those.
    • bot.did.operator/deploy.pds.example — a pipeline that is a CI identity: creates agents.
    • bot.did.policy and bot.did.policyBinding — what everything below may do.
  • pds.example, where every repository is signed with keys the server holds:
    • did:web:pds.example, the server's own account.
    • kestrel, laptop, pool and deploy, each created with the human's operator session after the human's record was written; each registration names the human and no lineage. laptop's repository holds a bot.did.credential for its key, pool's and deploy's one for their identity, and kestrel's none.
      • Beneath laptop: 7f3a9c, an agent the daemon created with a JWT the laptop's key signs. laptop's repository gains bot.did.operator/7f3a9c; the registration's lineage is [laptop].
      • Beneath pool: i-0a9f, a host an instance registered with the cloud's ID token, and beneath it 1d4e02, an agent its daemon created with a JWT. Lineages [pool] and [pool, i-0a9f].
      • Beneath deploy: run-41, an agent a run created with a session the CI token opened. Lineage [deploy].

Every record inside pds.example is the server's word: the registration, the credential records and a hosted parent's operator record all sit in repositories whose signing keys the server holds, so a compromised server can fabricate any of them. The records in the human's repository are the human's: the server reads them with no credential and cannot write there. A check therefore proves exactly this — a human answers for this server and for this subtree, and this server says the rest — and policy, which lives beside the human's operator records, is covered by the same sentence. Below the human, damage is contained one node at a time: a stolen key or leaked token is the account it logs in as, creates beneath it only where the human's allowances reach, and reaches nothing above it.

What a stranger can check #

The check needs no credential and reads only public records and documents. It takes the same number of reads at any depth. From 1d4e02.pds.example:

  1. GET https://1d4e02.pds.example/.well-known/did.json → operator: did:plc:human, and the #atproto_pds endpoint, which is where the repository is. Or read the registration: GET https://pds.example/xrpc/com.atproto.repo.getRecord?repo=did:web:1d4e02.pds.example&collection=bot.did.registration&rkey=self → operator: did:plc:human, lineage: [pool, i-0a9f]. Either is the server's statement; on its own it proves nothing.
  2. Resolve did:plc:human's document. It names a PDS that is not pds.example, or one party is serving both halves and the check fails.
  3. GET https://<the human's pds>/xrpc/com.atproto.repo.getRecord?repo=did:plc:human&collection=bot.did.operator&rkey=pool.pds.example → subject must be did:web:pool.pds.example, the first entry of the lineage. For an account with no lineage the key is the account's own hostname.
  4. GET https://<the human's pds>/xrpc/com.atproto.repo.getRecord?repo=did:plc:human&collection=bot.did.operator&rkey=pds.example → subject must be did:web:pds.example. That is the record the server's own poll reads; the human answers for the server too, or the tree is nobody's.

That confirms a human admitted the subtree this account is in, and that the same human answers for the server. The edges inside the subtree are the server's word either way, so reading them adds nothing a stranger can trust more. didbot operate --check <name> is the thorough option: it walks every edge — the registration names a parent, the parent's repository holds the record naming the account back — climbing until the operator's repository is on another server, and refuses a chain still on this server after three edges. It exits 1 until every hop verifies, and shows which hop failed.

A pair only one side states fails, in both directions. Which server an account is on is its own document's #atproto_pds endpoint at every hop, never the name typed or --server. Each read is of the current record, so a deletion is visible to the next check.

Taking an edge back, and acting on one account #

Two parties can stop an account, and they reach for different things.

The human takes their own statement back: didbot operate --revoke <NAME> <OPERATOR> deletes bot.did.operator/<NAME> from their own repository, after reading the record and confirming it names that account. It runs against their PDS and asks the deployment for nothing. The deployment's operator poll reads the absence on its own interval and quarantines that account and everything beneath it; didbot operate --check <NAME> names the paused edge from then on. Putting the record back releases them the same way.

The deployment's operator acts on one account it hosts, through the dashboard or didbot account <VERB> <NAME> --server <HOSTNAME> — the same session and the same routes, described in the command line. What each verb does lands in that account's ledger with the operator as the actor:

Verb What it does
lock / unlock Refuses the account's writes and gives them back. Its repository stays readable and it keeps what it holds.
lift-quarantine Takes off a quarantine a policy hung. The operator is the only party who may, and the ledger records which operator overrode it.
erase Takes the repository, its history, its blobs and its credentials, and burns the name permanently, whatever locks are hung. The DID document keeps resolving, so signatures the account made stay checkable.
delete Takes the row too; the ledger is what is left, and the name goes back on the ordinary hold. Refused while accounts are beneath it, unless --recursive takes the subtree, deepest first.
revoke-tokens Ends every token that authenticates as the account: its account tokens, every app's login on it and its unredeemed authorization codes. Its state and locks stay as they are. When nothing was live, the ledger gains no entry.

The scenarios #

One server, pds.example, run by did:plc:human from another PDS. didbot operate pds.example has written the top edge and the server's poll has found it. Every command below runs as the command line documents it.

Scenario On the human's machine On the account's machine The check
One agent, by hand didbot operate kestrel.pds.example operator.example --kind agent — one browser sign-in at the human's PDS; the record; a create carrying the operator session didbot login kept; the session lands where didbot oauth reads it nothing agent → the human's record for it
One agent host, automatic didbot operate laptop.pds.example operator.example --creates agent — shows the parked key's fingerprint, writes the record, creates with parkedKey didbot register host laptop.pds.example first: mints the daemon's key, parks its public half, prints the fingerprint, waits, then signs in with the key. didbot-agentd then creates one agent per context with a JWT the key signs agent → the human's record for laptop
Two servers, hosts I registered didbot operate h1.pds-a.example operator.example --creates agent, once per host per server didbot register host h1.pds-a.example on each host; the daemon as above the same, on each server
An autoscaling pool didbot operate pool.pds.example operator.example --kind service --oidc https://accounts.google.com sub=106589273465000000000 --creates host --creates-beneath agent, once at boot, didbot register host i-0a9f.pds.example --under pool.pds.example --token-file /run/instance-id-token (the token's audience is did:web:pds.example); the daemon as above agent → the human's record for pool
Every run of one pipeline didbot operate deploy.pds.example operator.example --kind pipeline --oidc https://token.actions.githubusercontent.com repository_id=812345, once; add --creates agent when a run starts an agent each run: didbot oauth pending --token-file "$RUNNER_TEMP/id-token" is a session as the pipeline, and an agent the run starts is created with it; From AWS OIDC to didbot is the workflow agent → the human's record for deploy

--creates KIND... writes a subject allowance and --creates-beneath KIND... a descendants one; either with no kinds allows any kind. A record written without them admits an account that creates nothing.

The parked key in the second row is how a machine and a human admit an account without a secret crossing between them. bot.did.parkKey {name, kind, key} holds a public key at the server for minutes, answers its fingerprint and expiry, lists nothing and mints nothing; bot.did.describeParkedKey?name= shows didbot operate the newest fingerprint to confirm, the create names it as parkedKey, and the server writes the key as the account's first bot.did.credential. Parks are held per key, so several may wait under one name, and didbot operate --fingerprint admits the one it names whichever is newest. A record older than the park does not admit it, so a stale record cannot admit whoever registers that name next. Once the account exists, bot.did.createSession with a JWT the key signs (lxm bot.did.createSession) is the account's session, and didbot oauth works from it.

Attestation is the server checking the proof against what the creator logs in with, and the creator against the human's allowances, at the moment of creation. Admittance is the records it then writes. Verification is anyone reading them afterwards.