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:
- It is taking accounts: not stopped, past its readiness gates, under the caller's provisioning rate limit and under its account cap.
- The body:
kindis one of the four,namesits under a zone it serves and is not reserved, andkeyandparkedKeyare not both given. - 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 holdbot.did.operator/<name>, or the create is refused asOperatorRecordMissing. 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. - A parked key was parked for this name and kind, and before the human's record was written.
- 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_childrenandcreates_per_hour. - For a hosted creator, an allowance in the human's repository covers it:
scope: subjecton the creator's own record, orscope: descendantson any ancestor's, withkindsabsent or naming the kind. None does, and the create is refused asNotAllowedToCreate. The human creating from their own repository needs none: the record naming the new account is the authorization. - For a hosted creator, an
oidcidentity 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 asOidcIdentityTaken. Claims compare by the claim they select, so/repositoryisrepository. 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:createsagents.bot.did.operator/pool.pds.example— a service that is a cloud identity:createshosts, and agents beneath those.bot.did.operator/deploy.pds.example— a pipeline that is a CI identity:createsagents.bot.did.policyandbot.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,poolanddeploy, 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 abot.did.credentialfor its key,pool's anddeploy's one for their identity, andkestrel's none.- Beneath
laptop:7f3a9c, an agent the daemon created with a JWT the laptop's key signs.laptop's repository gainsbot.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 it1d4e02, 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].
- Beneath
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:
GET https://1d4e02.pds.example/.well-known/did.json→operator: did:plc:human, and the#atproto_pdsendpoint, 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.- Resolve
did:plc:human's document. It names a PDS that is notpds.example, or one party is serving both halves and the check fails. GET https://<the human's pds>/xrpc/com.atproto.repo.getRecord?repo=did:plc:human&collection=bot.did.operator&rkey=pool.pds.example→subjectmust bedid:web:pool.pds.example, the first entry of the lineage. For an account with no lineage the key is the account's own hostname.GET https://<the human's pds>/xrpc/com.atproto.repo.getRecord?repo=did:plc:human&collection=bot.did.operator&rkey=pds.example→subjectmust bedid: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.