# Running the stack locally One command brings up a personal data server, provisions agent accounts against it, and streams what happened. Nothing leaves the machine. One terminal is enough to watch agents arrive: ```sh ./scripts/dev-pds.sh # personal data server: registrations and lifecycle ``` For the read side — an index, something serving it, and a canvas that draws it — see vibescrobble.com, which is its own repository now. This one covers the write side and a population big enough to be worth looking at; see [the whole stack](#the-whole-stack) below. ## Before the terminal The scripts run from a checkout. `didbot-setup` says what this machine has been told about itself and whether it holds together: ```sh cargo run -p didbot-setup -- # what is wrong here cargo run -p didbot-setup -- show ``` A machine that has never been configured runs one of everything on the ports below and needs no configuration file at all. For two personal data servers over one machine, see [didbot-stack](../crates/didbot-stack/README.md). Each `dev-*.sh` sources `scripts/dev-profile.sh`, which puts the current directory's profile in the environment, so a worktree bound to a second stack gets that stack's ports with nothing here edited. A variable already set still wins: `DIDBOT_PDS_PORT=3100 ./scripts/dev-pds.sh` means 3100. Each script rebuilds its own half, runs that half's tests, and then runs in the foreground so the terminal *is* the log. For the server alone, with some traffic to watch: ```sh cargo run --bin didbot-pds -- --demo 5 ``` which provisions five agents a second or so apart and exercises deletion and pinning. Without `--demo` it comes up idle and waits to be driven. ### Naming the agents By default an account's handle is its own hostname, minted from the internal agent id. `--names` gives agents names instead: ```sh cargo run --bin didbot-pds -- --demo 5 --names mineral+creature # the dev stack's default cargo run --bin didbot-pds -- --names temper+colour+creature cargo run --bin didbot-pds -- --names chaos+./our-own-words.txt ``` A *spec* is word lists joined with `+`, and each one contributes a part of the name in the order written, so the spec is the template: | spec | a name it makes | | --- | --- | | `creature` | `kestrel` | | `mineral+creature` | `basalt-kestrel` | | `temper+colour+creature` | `patient-indigo-kestrel` | | `cybernetic+chaos` | `solenoid-kerfuffle` | | `mood+blade` | `wistful-falchion` | Seventeen lists ship, each of a hundred words or more, and `--list-names` prints them with a taste of each: `blade`, `botanical`, `chaos`, `colour`, `confection`, `cosmic`, `creature`, `cybernetic`, `maritime`, `mathematics`, `mineral`, `mood`, `mythic`, `scientist`, `spectral`, `temper`, `weather`. Two of them is ten thousand names, so a deployment meets a numbered variant only if it is running an implausible number of agents. A part that looks like a path — anything with a `/`, or ending `.txt` or `.csv` — is read off disk instead, so `chaos+./our-own-words.txt` mixes a bundled list with your own. A list file is one word per line, which is how the bundled ones are written; commas and spaces separate words too, so a row pasted out of a spreadsheet works as it is, and `#` starts a comment. Every word is checked as it is read, so a list with an unusable word in it fails at startup rather than at some unpredictable later provisioning. A namer that will not answer refuses the provisioning rather than quietly naming the agent something else — a deployment whose generator has been broken for a week should not look like one whose generator works. `--names-if-down` opts into the other behaviour: ```sh cargo run --bin didbot-pds -- --names cmd:./ask-a-model --names-if-down mood+blade ``` Two other forms exist for what a spec cannot say. `toml:` takes a template with literal text in it, and `cmd:` asks a program for each name: it gets one JSON object on stdin — the agent's token, the zone, its parent if it has one, and which attempt this is — and the first line of its stdout is the name. Anything that writes a line works, including a local model. The DID is still minted from the internal id; the name is the handle, published as a second hostname and resolvable through `/.well-known/atproto-did`. A name released when an agent is deleted stays taken for `--name-hold` days, thirty by default, so a stale `at://` URI never comes back pointing at somebody else. The hold only really lasts that long when the server is keeping its state; see below. ### Keeping what it has `--data` points the server at a directory and it keeps its accounts, their signing keys, their records and its name holds in a write-ahead log there. Run by hand, the binary takes no directory and everything lives in memory, which is what a demo run wants: ```sh cargo run --bin didbot-pds -- --names mineral+creature --data ./pds-data ``` `./scripts/dev-pds.sh` passes it for you, at `$XDG_STATE_HOME/didbot/pds` (`~/.local/state/didbot/pds` on a machine that does not set the variable). One directory per user rather than per checkout, because the agents on a machine address one server on one port; a second checkout serving that port is refused by `pds.lock` rather than allowed to interleave its writes. `DIDBOT_PDS_DATA` points the script somewhere else, and `DIDBOT_PDS_DATA=` empties it, which is how a run asks for a clean slate. Stop it however you like — Ctrl-C, `kill -9`, closing the terminal — and start it again on the same directory. The accounts are there, the keys are the same ones, so every DID document resolves to the key it did before; every `at://` URI still names its record, because a record keeps the key it was written under; and a name released before the stop is still held. Startup says what it found: ```text INFO didbot_pds::wal: replayed the write-ahead log entries=13 bytes=2876 from_segment=0 from_offset=0 segments=1 INFO didbot_pds::durable: restored the deployment from its write-ahead log accounts=2 records=3 names=2 held=1 history=13 segments=1 trimmed_segments=0 trimmed_bytes=0 ``` The log is written in segments, `pds.wal.000000`, `pds.wal.000001` and so on, each created `0600` inside a `0700` directory because they hold the agents' signing keys. Once the log since the last checkpoint has grown past the state, a restart writes `pds.checkpoint` — the state as the shortest log that reproduces it, and the position in the log it covers — at the head of a fresh segment and deletes every segment before it, so the segments on disk are the log from the checkpoint forward. Beside them is `pds.layout`, one line saying which layout wrote the directory, and it is checked before the log is read. A directory written by a build whose entries this one would misread is refused by name rather than replayed into something that looks healthy: ```text Error: the data directory at ./pds-data was written in layout 2, and this binary reads layout 1. Nothing here converts one to the other: check out the build that wrote it, or delete the directory and start again ``` Nothing converts one layout to another. In development the answer is to delete the directory, which costs a factory reset, and it is safe to delete whenever you want one anyway. A directory from before stamping existed is adopted and stamped rather than refused. A missing log is refused too, but only when the directory is not really empty. Startup reconciles `blobs/` against what the log names, and a file the log does not name is deleted — which is right after a crash and catastrophic after a restore that copied `blobs/` before the log, because then the log names nothing and every blob is unreferenced. So a directory holding blob bytes and no log does not start: ```text Error: data directory at ./pds-data: blob bytes are present and pds.wal is not; the log that names them has not arrived, and starting would delete them ``` Finish the restore, or move `blobs/` aside on purpose. An empty directory is still a fresh store and starts normally. Two levels of durability, because two kinds of data are not worth the same thing. Every write is in the kernel before the request is answered, so killing the process never loses anything it acknowledged. Provisioning, deletion, the pin and every name claim additionally wait for the disk, so they survive the machine losing power; a record write does not, and a power cut can cost the last quarter-second of records. That asymmetry is the point rather than a corner cut. A write that was interrupted leaves half a frame at the end of the file. The next startup notices, says so, and discards exactly that: ```text WARN didbot_pds::wal: the write-ahead log ends in an incomplete write; truncating it discarded=220 reason="the frame is shorter than its header promised" entries=12 ``` Everything written before it is kept, and the file is repaired, so the start after that one is quiet. ### The record an agent is born with Every repository holds a `bot.did.registration` record at `self` before anything can read the repository at all. The server writes it, from its own bookkeeping, as part of provisioning: ```sh curl -s "localhost:3000/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=bot.did.registration" | jq '.records[0].value' ``` It says the DID, when the server provisioned it, which server that was, who is accountable for it, which admission backend let it in and how much that check proved, every handle the account has held, and what kind of account it is — carried as a union member, so each kind declares the fields it must have. For an agent that member also names the node the account was admitted on. Admitted on, not running on: an agent can be resumed on another machine, so nothing here claims to describe the present. `revision` is how many bookkeeping entries the record reflects. Pin the agent and read it again — the bookkeeping gains an entry, the record is recomposed from the whole of it, and the number moves. If the two ever disagree the bookkeeping is right and the record is a stale copy that says so. The account cannot destroy it. All four routes that could — `createRecord`, `putRecord`, `applyWrites` and `deleteRecord` — reach one guard on the way to the store: ```sh curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:3000/xrpc/com.atproto.repo.createRecord \ -H 'content-type: application/json' \ -d "{\"repo\":\"$DID\",\"collection\":\"bot.did.registration\",\"record\":{}}" ``` 403, and `ServerAuthoredRecord` in the body, and the same for a `putRecord`, a `deleteRecord` and either half of an `applyWrites` batch. Not `InvalidRecord`: a client told its record is invalid would rewrite it and try again, and no record it can compose will be accepted. `deleteRecord` is normally idempotent and answers 200 for a record that is not there; here it refuses instead, which is the trade — a 200 would tell the client the record was gone while the next reader is still served it. Deleting the *account* still takes the record, because that is garbage collection and the whole repository goes with it. What survives is the bookkeeping. ### The face an agent is born with An account is also born holding an `app.bsky.actor.profile` at `self`, and a picture. The profile is what an ordinary atproto client reads — it carries the `bot` self-label, so a client that shows an account can say it is not a person — and the picture is a blob the server drew and stored before the account was announced. ```sh curl -s "localhost:3000/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.actor.profile&rkey=self" | jq '.value' CID=$(curl -s "localhost:3000/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.actor.profile&rkey=self" | jq -r '.value.avatar.ref["$link"]') curl -s "localhost:3000/xrpc/com.atproto.sync.getBlob?did=$DID&cid=$CID" -o avatar.png ``` The picture is drawn from the DID and from nothing else: the same account is always the same image, and no state is kept to make that true. Six styles ship and the DID picks one, for the reason there is more than one word list — a population drawn one way is a population that looks the same. ```sh cargo run --bin didbot-pds -- --list-avatars cargo run --bin didbot-pds -- --demo 12 --avatar truchet # one style, to compare them ``` `--avatar` is for looking at the styles side by side. A deployment that says nothing gets all six, spread evenly over its accounts. To see a page of them without running a server at all: ```sh cargo run -p didbot-avatar --example contact-sheet -- /tmp/avatars 36 ``` Unlike the identity record, this one is the account's. Writing over it is an ordinary `putRecord`, and the server keeps what it finds there: the next time it rewrites the profile — when a handle moves, or the record switch does — a display name, a description or an avatar the account set is left alone. The `bot` label is the exception, and goes back on. ### What the server remembers about agents it no longer has The account store, the name registry and the record store all hold current state, and none of them can say anything about an account that has been deleted. The agent ledger is the part that can. One append-only history per DID — provisioned, named, released, pinned, unpinned, deprovisioned — written beside the account and kept after it: ```sh curl -s "localhost:3000/xrpc/bot.did.getAgentLedger?did=$DID" | jq . curl -s localhost:3000/xrpc/bot.did.listAgentLedgers | jq '.ledgers | length' ``` Delete the agent and ask again. `listAgents` no longer has it and the ledger still does, with the handle it answered to and the moment it stopped. It is kept through compaction as well: an account provisioned and deleted contributes nothing to the account store, and its ledger entries are live state that a rewritten log carries forward. The name travels. `listAgents` reports it, the index keeps it beside the DID — after checking it lands under the zone its server admits to minting, since a name is the field a reader trusts instead of the identifier — and the canvas labels each agent with it, asking the server what its accounts are called and remembering the answer, so a line reads `basalt-otter` rather than the digest the DID was minted from. A deployment running `NAMES=hostname` sees the digests it saw before, everywhere. Resolution goes both ways, and both halves are worth looking at once. The handle's hostname answers with the DID, and the DID's own hostname answers with a document claiming that handle back: ```sh curl -s -H 'Host: basalt-otter.agents.localhost' localhost:3000/.well-known/atproto-did curl -s -H 'Host: kestrel.agents.localhost' localhost:3000/.well-known/did.json | jq .alsoKnownAs ``` A name nobody holds is a 404. `bot.did.listAgents` is how to do both halves for every account at once: it names what this server holds, and each name in it answers the two lookups above or disagrees. ## The whole stack Two terminals for the write side. Each script rebuilds and tests its own part and then runs in the foreground, so the terminal *is* that part's log. ```sh ./scripts/dev-pds.sh # 3000 the personal data server ./scripts/dev-swarm.sh # a population of synthetic agents, talking ``` Every one of them takes `--watch`, which reruns the script — rebuild, tests, banner and all — whenever anything under `crates/` moves. The tests are part of what is rerun on purpose: they are the step most likely to have something to say about the change that triggered them. ```sh ./scripts/dev-swarm.sh --watch ``` The personal data server watches like the rest, because it keeps its state across the restart. Under `DIDBOT_PDS_DATA=` it refuses instead: the swarm holds nothing and restarting it costs the build, while a server in memory rebuilds every account, record and name hold on every save, and a watch there factory-resets the deployment each time a file moves. The swarm refuses to start without a server, because a load generator with nothing to load is just an error message on a delay. Its population size and rate are worth turning up once the server is running: ```sh DIDBOT_SWARM_AGENTS=200 DIDBOT_SWARM_RATE=25 ./scripts/dev-swarm.sh ``` `--sign-in` has each new agent sign itself in over OAuth as it is minted: PAR, `bot.did.getAuthorization`, `bot.did.approveAuthorization` and the token exchange, with a real DPoP proof. Its records are then written with the DPoP-bound token that yields, and its blob uploads with its agent token. The status line counts sign-ins as `signed_in`. ```sh ./scripts/dev-swarm.sh --sign-in ``` For an index, a query service and a canvas in front of this — following the lifecycle stream and the firehose, serving pages of agents and records, drawing the result — see vibescrobble.com, which points its own development scripts at a server started this way. ## Whether it is working ```sh curl -s localhost:3000/health ``` answers with what the personal data server holds and whether it is minting. What each one is for: - **the personal data server** mints agent accounts and holds their repositories. Everything else is downstream of it. - **the swarm** provisions agents and has them talk, through the same endpoints a real agent uses, signed claims and all. Nothing downstream is interesting until something is happening. ## Why no DNS is needed Every agent gets its own hostname, so provisioning normally includes a DNS write against a zone the server controls. Locally that requirement disappears: `*.localhost` resolves to loopback by RFC 6761, so `a1b2c3.agents.localhost` already points at this machine with nothing configured. The development DNS backend therefore publishes nothing. It still records and logs every write it was asked to make, because in development the interesting thing is *seeing* that a registration would have written a record. It refuses any hostname outside `.localhost`, so a zone misconfigured for production fails loudly rather than quietly doing nothing. One practical trap: on a host running systemd-resolved, `*.localhost` resolves to `::1` rather than `127.0.0.1`. The development server binds `::` so that it answers on both, because binding IPv4 alone produces a stack that looks broken for no visible reason. ## What the log shows Provisioning is a sequence, and each step is logged with the agent and its identifier as structured fields rather than interpolated text: attestation checked, identifier minted, keypair generated, DID document built, DNS record published, account stored, lifecycle event emitted. Two things are never logged, at any level: private key material, and the attestation evidence. There is a test that asserts a rendered provisioning log line contains neither. `GET /events` carries lifecycle events as Server-Sent Events, which is what a feed or dashboard consumes. Like `/firehose` below, the first frame is always an `info` frame naming this run of the server, and every event frame after it carries a `seq`; a consumer that remembers the last one it applied resumes with it: ```sh curl -N 'localhost:3000/events?cursor=18e4a2f1b:12' ``` Hand back a cursor the server no longer holds — too far behind its buffer, or from a previous run — and it answers with an `info` frame naming `OutdatedCursor` rather than silently starting from now. `did:web` has no directory to re-derive the world from the way `did:plc`'s PLC log lets a consumer do, so a missed account has to be found some other way: the frame says to reconcile via `bot.did.listAgents`. ## The firehose `GET /firehose` carries every record the server stores and every one it removes, one Server-Sent Event each, with the record itself as JSON: ```sh curl -N localhost:3000/firehose ``` The first frame is always an `info` frame saying which run of the server this is and which sequence numbers it can still replay. Every `commit` frame after it carries a `seq`, and a consumer that remembers the last one it applied resumes with it: ```sh curl -N 'localhost:3000/firehose?cursor=18e4a2f1b:97' ``` A cursor is `:`, not a bare number, because sequence numbers restart with the process and an integer from a previous run would name a frame that exists and is the wrong one. Hand back a cursor the server no longer holds — too far behind its buffer, or from a previous run — and it answers with an `info` frame naming `OutdatedCursor` and the oldest sequence number it still has, then replays everything it does have. The records between the two are gone from the stream forever and can only be read back from the repositories, which is why the index still sweeps them. Every frame names the commit it landed in: `cid` is the record, `commit` is the signed commit that now covers it, and `rev` is that commit's revision. So a consumer can check itself against the repository rather than taking the stream's word for it — apply frames until the stream goes quiet, then ask `com.atproto.sync.getLatestCommit` for the same DID and compare `cid` there with `commit` here. Equal means caught up, exactly. A removal arrives as a `delete` frame rather than a `commit` one, in the same sequence space and the same replay buffer: it names the same `did`, `collection`, `rkey` and `uri`, carries `"action": "delete"`, a null `cid` and a null `record`, and a `prev` naming the CID the record held. Two event names rather than one, so a consumer that only handles `commit` goes on ignoring removals instead of storing a null over a record it still has — it stays exactly as behind as it was before removals were announced, which is a state its own reconciliation against `getLatestCommit` still catches. Compare on the commit and not on `rev`. A revision here is derived from the repository's record keys, so a write to a fixed key such as `self` moves the commit and leaves the revision where it was. An account appearing, changing handle, or ending also reaches this stream, under their own event names, fanned in from `com.atproto.sync.subscribeRepos` at the same lifecycle points: `identity` carries `did`, `seq`, `time` and the handle this server now claims for the account, when it issued one; `account` carries `did`, `seq`, `time`, `active`, and a `status` naming why when it is not — `deactivated`, `suspended` or `deleted`. Both share this stream's one sequence space and replay buffer with `commit` and `delete`, so a consumer resuming from a cursor sees every event of all four kinds after it, once each. A hard-deleted account's records are dropped without a `delete` frame each; what goes out instead is `account` with `active: false, status: "deleted"` — see `docs/conformance.md` on what an account deletion does and does not put on the wire. This is not `com.atproto.sync.subscribeRepos`. That one is served too, at `/xrpc/com.atproto.sync.subscribeRepos`, and it is a different thing: a WebSocket of framed DAG-CBOR carrying signed commits and the blocks that prove them, with an integer cursor that survives a restart. It is what a relay crawls. `/firehose` is what an index that trusts this server reads, because it carries the records themselves and needs no CAR reader. `curl` cannot open a WebSocket, so a look at the real one needs a client that can — `websocat` is the usual one: ```sh websocat "ws://localhost:3000/xrpc/com.atproto.sync.subscribeRepos?cursor=0" --binary ``` The frames are DAG-CBOR and will not print as text. `docs/conformance.md` says what is in one, which fields this server never fills, and which message types it does not send at all. ## Exporting a repository, and checking it from outside `GET /xrpc/com.atproto.sync.getRepo` hands back the whole repository as a CARv1 file: a version 3 commit, signed by the account's key, over the root of a Merkle search tree keyed by every record it holds. ```sh did=$(curl -s localhost:3000/xrpc/bot.did.listAgents | jq -r '.agents[0].did') curl -sG localhost:3000/xrpc/com.atproto.sync.getRepo \ --data-urlencode "did=$did" -o repo.car ``` `--data-urlencode` rather than a plain query string, because an agent's DID carries its port as `%3A` and pasting that into a URL hands the server a DID with a colon in it, which is a different identifier and a 404. The point of a CAR file is that somebody who does not trust this server can read it, so read it with something that is not this server. [`goat`](https://github.com/bluesky-social/indigo) is the reference implementation's own CLI and shares no code with this project: ```sh goat repo inspect repo.car # the commit: version, did, tree root, revision goat repo ls repo.car # every record, by key and cid goat repo mst repo.car # the tree, drawn ``` `ls` listing every record is the interesting one. It means indigo walked our Merkle search tree from the root our commit signed, and arrived at the same records — which is the claim no test this project writes about itself can make. The other half is the signature. Take the account's public key out of the DID document this same server serves and check the commit against it: ```sh curl -s -H "Host: $(echo "$did" | sed 's/^did:web://; s/%3A/:/')" \ localhost:3000/.well-known/did.json | jq -r '.verificationMethod[0].publicKeyMultibase' ``` That string is what `plan/pds-writes.md`'s exit criterion is about, and the loop is closed unattended in `didbot-serve`'s `sync` tests, which decode the commit out of the CAR with a reader written from the specification rather than from this project's encoder. ### Asking for a diff, and telling whether you got one `since` takes a revision you last synced at, and the answer is then the blocks you do not already hold — rooted at the same commit either way, so you walk from the same place and find fewer of the steps already in your hands. A revision this deployment cannot place is answered with the whole repository rather than refused: you asked what you are missing, and all of it is true. The bytes cannot tell you which of the two happened, so the response says: ```sh curl -sG localhost:3000/xrpc/com.atproto.sync.getRepo \ --data-urlencode "did=$did" --data-urlencode "since=$rev" \ -o repo.car -D - | grep -i x-didbot-since ``` `Diff` means the delta was answered. `OutdatedCursor` — the same name `subscribeRepos` uses in its `#info` frame for the same condition — means it could not be, and the file is the whole repository. That happens when the revision was never minted here, or when it fell out of the trail behind the head, which a compaction of the write-ahead log empties. A reader that sees it repeatedly is one falling further behind than `didbot_pds::history::TRAIL` between compactions. ## Driving it by hand ```sh curl -s localhost:3000/health curl -s -X POST localhost:3000/xrpc/bot.did.provisionAgent \ -H 'content-type: application/json' \ -d '{"agentId":"scratch"}' curl -s --resolve 'scratch.agents.localhost:3000:[::1]' \ http://scratch.agents.localhost:3000/.well-known/did.json curl -N localhost:3000/events ``` ### The repository, over `com.atproto.repo.*` Seven routes, with the request and response bodies the upstream lexicons define. `repo` is a DID or a handle on every one of them. ```sh REPO=did:web:kestrel.agents.localhost%3A3000 # or kestrel.agents.localhost curl -s -X POST localhost:3000/xrpc/com.atproto.repo.createRecord \ -H 'content-type: application/json' \ -d "{\"repo\":\"$REPO\",\"collection\":\"com.example.thing\", \"record\":{\"text\":\"driving it by hand\",\"emoji\":\"🔍\", \"createdAt\":\"2026-08-26T10:00:00Z\"}}" # `rkey` is required here, and the lexicon still decides -- see below curl -s -X POST localhost:3000/xrpc/com.atproto.repo.putRecord \ -d '{…,"collection":"app.bsky.actor.profile","rkey":"self",…}' curl -s -X POST localhost:3000/xrpc/com.atproto.repo.deleteRecord -d '{…,"rkey":""}' curl -s -X POST localhost:3000/xrpc/com.atproto.repo.applyWrites -d '{"repo":…,"writes":[…]}' curl -s "localhost:3000/xrpc/com.atproto.repo.getRecord?repo=$REPO\ &collection=app.bsky.actor.profile&rkey=self" curl -s "localhost:3000/xrpc/com.atproto.repo.listRecords?repo=$REPO\ &collection=com.example.thing&limit=5" curl -s "localhost:3000/xrpc/com.atproto.repo.describeRepo?repo=$REPO" ``` Every failure is `{"error":…,"message":…}` with a name a client can switch on: | What went wrong | Status | `error` | | --- | --- | --- | | no repository under that DID or handle | 404 | `RepoNotFound` | | the record is not there | 404 | `RecordNotFound` | | the body did not parse, or a field is wrong | 400 | `InvalidRequest` | | the record does not satisfy its lexicon | 400 | `InvalidRecord` | | the `rkey` is one the collection's key strategy will not admit | 400 | `InvalidRequest` | | a `swapRecord` or `swapCommit` precondition did not hold | 400 | `InvalidSwap` | | `createRecord` named a key that is already in use | 400 | `InvalidRequest` | | a method this server has not built | 501 | `MethodNotImplemented` | Every write response carries a `cid`, and it is the CID the CAR export files that record under: the hash of its canonical DAG-CBOR encoding. It is safe to pin, deduplicate by, or quote from another record. A write may say what it believes it is replacing. `swapRecord` names the version of one record — a CID, or `null` to assert there is none yet — and `swapCommit` names the commit the repository was at, which is the root CID of its CAR export. A precondition that does not hold refuses the write with `InvalidSwap` and changes nothing, and the message carries both CIDs, so re-reading and retrying works: ```sh curl -s -X POST localhost:3000/xrpc/com.atproto.repo.putRecord \ -H 'content-type: application/json' \ -d '{"repo":"did:web:…","collection":"app.bsky.actor.profile","rkey":"self", "swapRecord":"bafyrei…","record":{…}}' ``` `createRecord` creates and `putRecord` replaces, as in atproto: a key already in use is a 400 from the first and an update from the second. `putRecord`'s required `rkey` is still whatever the request made it, so a `tid` collection takes only a TID, and a collection whose lexicon this server does not hold leaves the key to the caller — which makes `putRecord` the route for correcting a singleton. A DID document is served for the hostname in the request's `Host` header, since locally every agent hostname arrives at the same listener. ### Where a record lands The record key comes from the collection's lexicon, not from the caller. `com.example.thing` declares `key: "tid"` and gets a fresh sortable key each time; `bot.did.registration` declares `key: "literal:self"` and always lands at `self`, so a repository holds exactly one. A collection this server holds no lexicon for — `app.bsky.actor.profile` is the one every account has — leaves the key to the caller, which is why the writes below name it: ```sh curl -s -X POST localhost:3000/xrpc/com.atproto.repo.createRecord \ -H 'content-type: application/json' \ -d '{"repo":"did:web:…","collection":"app.bsky.actor.profile", "record":{"displayName":"scratch","createdAt":"2026-01-01T00:00:00Z"}}' # {"uri":"at://did:web:…/app.bsky.actor.profile/self", # "cid":"bafyrei…","validationStatus":"valid"} ``` Writing it again through `putRecord` gives the same `uri`: the second write is an update and the last one wins, unless it carried a `swapRecord` that no longer holds. Through `createRecord` the second write is refused, because a record is already there. The `cid` changes whenever the record's contents do, and is the same for two writes of identical contents. `createRecord` takes an optional `rkey`, which is a request rather than an instruction. A key the strategy admits is used — `"rkey":"self"` on a profile, a real TID on a record — and one it does not is a 400 naming the strategy that refused it, rather than a record quietly written somewhere else. ## Proving one record, without the repository An export is the whole repository, which is a great deal to hand somebody who wants to check one record. `com.atproto.sync.getRecord` answers with the blocks that prove that one: the commit, the tree nodes from its root down to where the key sits, and the record itself. ```sh uri=$(curl -sG localhost:3000/xrpc/com.atproto.repo.listRecords \ --data-urlencode "repo=$did" --data-urlencode collection=com.example.thing \ --data-urlencode limit=1 | jq -r '.records[0].uri') curl -sG localhost:3000/xrpc/com.atproto.sync.getRecord \ --data-urlencode "did=$did" --data-urlencode collection=com.example.thing \ --data-urlencode "rkey=${uri##*/}" -o proof.car goat repo inspect proof.car # the same commit as the export, same tree root ``` `inspect` reads it because a proof is a CAR file rooted at the same commit an export is; what differs is which blocks are in it. Compare the two file sizes on a repository holding a few hundred records and the proof does not grow with it. `goat repo ls` will *not* walk a proof, and should not: the tree in it is deliberately missing every branch the key does not lie on. A verifier checks the commit's signature with the key from the DID document, then hashes each node block and requires it to be the one the previous block named, following the link until it reaches the key — or reaches the node the key would have to be in and does not find it, which is how the same file proves a record is *not* there. The other three sync methods answer the questions a consumer asks around all this: ```sh curl -sG localhost:3000/xrpc/com.atproto.sync.getLatestCommit \ --data-urlencode "did=$did" | jq # {"cid": …, "rev": …} curl -sG localhost:3000/xrpc/com.atproto.sync.getRepoStatus \ --data-urlencode "did=$did" | jq # {"did": …, "active": true, "rev": …} curl -s "localhost:3000/xrpc/com.atproto.sync.listRepos?limit=10" | jq ``` `getLatestCommit` is the cheap way to ask "am I caught up": its `cid` is the root of the CAR an export would return this instant. `getRepoStatus` is whether this server still serves the repository at all, in the words `subscribeRepos`'s `#account` uses, with the same `rev` when it does. `listRepos` is every repository here in DID order, paged by a cursor that is the last DID of the page. ## Blobs A record points at bytes rather than carrying them — an avatar, an attachment, anything a lexicon declares as a `blob`. Uploading one gives back the reference a record puts in a field: ```sh curl -s -X POST localhost:3000/xrpc/com.atproto.repo.uploadBlob \ -H 'content-type: image/png' \ -H "authorization: Bearer $TOKEN" \ --data-binary @some.png | jq # {"blob":{"$type":"blob","ref":{"$link":"bafkrei…"},"mimeType":"image/png","size":2852}} ``` `com.atproto.repo.uploadBlob` takes no parameters: the agent token is which account the blob is for. The CID in that reference is the sha-256 of the bytes, so it can be checked without trusting this server: ```sh curl -sG localhost:3000/xrpc/com.atproto.sync.getBlob \ --data-urlencode "did=$did" --data-urlencode "cid=$cid" -o back.png cmp back.png some.png && echo identical curl -sG localhost:3000/xrpc/com.atproto.sync.listBlobs \ --data-urlencode "did=$did" | jq ``` The server checks the same thing before it answers. Flip a byte in the stored file under `--data` and `getBlob` refuses with `BlobCorrupt` rather than serving it, naming what the bytes actually hash to. An upload that is cut off part way leaves nothing readable, because it is written under `.incoming/` and only renamed to its CID once the whole body has arrived. Blobs are bounded. `--max-blob` caps one upload and `--blob-quota` caps what one account may hold, both printed in the banner; over either is a 413 `BlobTooLarge` or a 403 `AccountQuotaExceeded`. Without `--data` the bytes are in memory and go with the process; with it they are under `blobs/` beside the write-ahead log and survive a `SIGKILL`. Records are bounded too, by the log rather than by a flag: an entry larger than 8 MiB is a frame no reader will believe, so it is refused with a 413 `RecordTooLarge` instead of being written down and then discarded at the next restart. A deployment that runs out of room answers 507 `StorageFull` and goes on serving reads — nothing is half-written, and the same request works once there is room. `--log-budget ` makes that happen at a size you chose rather than at the size the volume turns out to be. ## What is not real yet Without `--data` the account store is in memory and empties when the process exits, and so does the commit history: a repository comes back at a commit this run derived rather than the one the previous run signed, and every `swapCommit` a client is holding is refused once. Records are signed into a repository commit, a commit names the one it replaced, and `com.atproto.sync.subscribeRepos` serves commits, identities and account statuses — but no relay has ever crawled this server, so nothing has checked the stream from the outside. A commit trail is bounded, so a `since` this deployment has trimmed away is answered with the whole repository rather than with a diff. Under `.localhost`, `bot.did.provisionAgent` mints for a request carrying no attestation claim, and the account's registration record says `self-asserted`. Any other zone refuses such a request as `AttestationRequired`.