simocracy-v2 · codex-pet-create
End-to-end test of the new spriteKind: "codexPet" path on the
feat/codex-pet-sims branch: dropping the unzipped output of OpenAI's
hatch-pet
skill into the create dialog, persisting the record on a real ATProto PDS, and verifying the
resulting sim renders + paces horizontally in the Senate world.
Headline. The Codex pet path works end to end — dropzone, manifest parsing, dimension
validation, dual-blob upload (thumbnail PNG + spritesheet WebP), record write, slug auto-claim,
carousel rendering, and horizontal-only pacing in the Senate. The PDS record is well-formed:
spriteKind: "codexPet" with petSheet (1.99 MB WebP) and petManifest
preserving id / displayName / description. Blocker:
the dev server fails on Node 25 because the @atproto/oauth-client DPoP retry path
hits a Node 25 + undici regression on binary upload bodies. Switching to Node 22 (the version
the project's engines field actually pins) fixes it; no Simocracy code change required.
I drove the test with browser-harness against the user's real Chrome (CDP via
chrome://inspect remote debugging). Eleven scripted steps; everything between
step 6 and step 7 is what the codex-pet feature actually adds.
satyam2.climateai.org via ATProto OAuth (loopback mode, port 3000).
~/.codex/pets/, accepts a folder, a .zip, or a lone spritesheet.webp. Hard validation: “Sheet must be 1536×1872”.
~/.codex/pets/einstein/ (both pet.json + spritesheet.webp). Idle-frame thumbnail composites in the dropzone via drawPetFrame(); manifest auto-fills the name field with “Einstein”.
org.simocracy.sim record, claimed slug einstein. Toast: “Sim 'Einstein' created!”. Counter goes 2→3.
/my-sims. Card thumbnail uses the auto-generated 128×128 PNG (idle row, frame 0).
/senate) at native cell scale, name tag attached. Counter shows 59 sims indexed for the world.
Read back the freshly-written record via com.atproto.repo.getRecord. The shape is exactly
what the lexicon expansion promises — lexicon-typed spriteKind discriminant, separate
blobs for thumbnail and sheet, full manifest preserved.
$ curl -s "https://climateai.org/xrpc/com.atproto.repo.getRecord?repo=satyam2.climateai.org\
&collection=org.simocracy.sim&rkey=3mkvzj4tmiw2a" | jq .
{
"uri": "at://did:plc:cpoagodpqrgs4t7thi5z37uf/org.simocracy.sim/3mkvzj4tmiw2a",
"cid": "bafyreicrdcvukgsnxg3j4qzwtzw2nut7bonwbkpecaidpy4zp2dbxki4ki",
"value": {
"name": "Einstein",
"$type": "org.simocracy.sim",
"image": {
"$type": "blob",
"ref": { "$link": "bafkreichqtuzru2azh2qhrwybhnq3mfyrhs6tntikl4wahudbjqayjbju4" },
"mimeType": "image/png",
"size": 26880
},
"petSheet": {
"$type": "blob",
"ref": { "$link": "bafkreiauca3trx4d3ay3gohaunnhmsvfreryxfzpdhfltato4i75rtquhi" },
"mimeType": "image/webp",
"size": 1997972
},
"createdAt": "2026-05-03T01:38:13.537Z",
"spriteKind": "codexPet",
"petManifest": {
"id": "einstein",
"description": "A tiny Einstein-inspired chibi scientist companion for thoughtful coding sessions.",
"displayName": "Einstein"
}
}
}
petSheet size 1,997,972 bytes — byte-exact match for the source spritesheet.webp. Round-trip is lossless.image size 26,880 bytes — the auto-generated 128×128 PNG thumbnail rendered from idle row, frame 0.petManifest preserved verbatim from ~/.codex/pets/einstein/pet.json, including the long description string.spriteKind: "codexPet" — the discriminant the rendering pipeline keys off.settings key — confirms the lexicon change to make Pipoya settings optional landed correctly.@atproto/oauth-client DPoP retry + undici body regressionInitial save attempts hung with the dialog stuck on “Saving…”. Server log:
Failed to upload blob: Error: expected non-null body source
at async POST (app/api/upload-blob/route.ts:58:17)
56 | const uint8Array = new Uint8Array(arrayBuffer);
57 |
> 58 | const res = await agent.com.atproto.repo.uploadBlob(uint8Array, {
| ^
59 | encoding: file.type,
60 | });
[cause]: [TypeError: fetch failed] {
[cause]: Error: expected non-null body source
}
Root cause. @atproto/oauth-client/dist/fetch-dpop.js calls new Request(input, init)
with a binary body, fetches once, gets a 401 “use_dpop_nonce”, then attempts a retry by reconstructing
the request via new Request(input, init) a second time. On Node•25 (undici 7+) the
init.body Uint8Array/Blob has already been extracted by the first construction and the
second construction throws “expected non-null body source”. Node•24 and earlier swallowed
this silently. The retry path's existing guard only covers ReadableStream bodies
(fetch-dpop.ts),
not typed-array bodies.
Switching the dev server to Node•22.22.2 (matches the project's "engines": {"node": "22.x"})
resolves it without a Simocracy code change. I tried two in-route workarounds first — (a) passing a
Blob instead of a Uint8Array, (b) warming the DPoP nonce with a cheap
describeRepo call before the upload — neither helped, because Node 25's body-extraction
step is what's strict, not the type. The right place to fix this upstream is in fetch-dpop.ts
by cloning the typed-array body before the first new Request so the retry has its own copy.
User-facing impact today: production Vercel runs Node 22 by default, so this never trips for
end users. It only bites local devs who installed the latest Node from Homebrew. Fix is documenting
nvm use 22 / brew install node@22 in the setup README.
NEXT_PUBLIC_BASE_URL defaults to production OAuth client_idThe dev .env.local shipped with NEXT_PUBLIC_BASE_URL="https://www.simocracy.org",
so http://127.0.0.1:3000/client-metadata.json served the production client id and the
OAuth dance redirected back to simocracy.org/api/auth/callback in prod — where this branch
is not deployed. I had to flip it to http://127.0.0.1:3000 mid-test to switch into the
loopback OAuth mode that AGENTS.md describes (“Local dev: http://127.0.0.1:3000”).
Not a feature regression, but the project should ship the loopback URL in .env.local.example
and call this out in the local-dev section of AGENTS.md.
The /senate header reports “59 sims” but only Einstein appears in any of the four motion
frames (see asset 09). Pipoya sims that should be roaming the world are not visible in the
756 × 716 viewport at the moment of capture. Could be a viewport-size / off-screen issue, an
index propagation lag for that PDS, or an unrelated regression in useWalkingAvatars's
spawn distribution. Out of scope for this run — flagging for follow-up.
type="file" multiple path — the same DOM that handlesdataTransfer drops and the “Browse files” button — succeeds.petManifest.displayName.drawPetFrame (asset 05).spriteKind, petSheet, petManifest, no spurious settings key./sims/einstein on first try (toast confirmed claim).The dev server, the user's Chrome, and the harness all run on the same Mac. The browser is the
user's everyday Chrome via the chrome://inspect/#remote-debugging checkbox, so the
ATProto OAuth session lands in the user's real cookie jar.
feat/codex-pet-sims at e7406e9
Accountsatyam2.climateai.org (PDS at https://climateai.org)
Pet~/.codex/pets/einstein/ (pet.json + 1.99 MB spritesheet.webp, 1536×1872)
Node22.22.2 via brew install node@22 — see Finding #1
BrowserUser's Chrome (Way 1 in browser-harness install.md)
Driverbrowser-harness 0.1.0
Setupgit checkout feat/codex-pet-sims && PATH="/opt/homebrew/opt/node@22/bin:$PATH" npm run dev
Triggerhttp://127.0.0.1:3000/my-sims → Mint a New Sim → Codex Pet tab → upload ~/.codex/pets/einstein/ → Save
Verifycurl -s "https://climateai.org/xrpc/com.atproto.repo.getRecord?repo=satyam2.climateai.org&collection=org.simocracy.sim&rkey=<rkey>"
Walking worldhttp://127.0.0.1:3000/senate
Saved recordeinstein-pds-record.json
Screenshotsassets/01-*.png … assets/15-*.png