simocracy-v2 · codex-pet-create

Create a sim by uploading a Codex pet

@daviddao

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.

Date: 2026-05-03 · Branch: feat/codex-pet-sims · Account: satyam2.climateai.org · Driver: browser-harness + real Chrome · partial


Headline result

10/11
Acceptance steps passing
3
Sims after upload (was 2)
1
Environment blocker
100%
PDS record fidelity

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.

Walkthrough

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.

Signed-in landing page
01Signed in as satyam2.climateai.org via ATProto OAuth (loopback mode, port 3000).
My Sims before test
02Pre-test state: 2 existing sims (Nyaori Katalog, Nyariko), both legacy Pipoya. “Mint a New Sim” button visible.
Create dialog with Pixel Avatar tab
03Create dialog now has two tabs: “Pixel Avatar” (existing flow, unchanged) and “Codex Pet” (new).
Codex Pet tab
04Codex Pet tab. Dropzone copy points users at ~/.codex/pets/, accepts a folder, a .zip, or a lone spritesheet.webp. Hard validation: “Sheet must be 1536×1872”.
After uploading the einstein folder
05Dropped ~/.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”.
Saved
06Save fired both blob uploads (thumbnail + sheet), wrote the org.simocracy.sim record, claimed slug einstein. Toast: “Sim 'Einstein' created!”. Counter goes 2→3.
Einstein in carousel
07Einstein is the newest card in /my-sims. Card thumbnail uses the auto-generated 128×128 PNG (idle row, frame 0).
Einstein in the Senate world
08Einstein walking in the Senate (/senate) at native cell scale, name tag attached. Counter shows 59 sims indexed for the world.
Four motion frames over six seconds
09Four frames captured ~1.5 s apart. Einstein's y-coordinate stays pinned (lane locked); x drifts left then right then back — confirming horizontal-only pacing for codex pets, exactly per spec. Frame D shows an emote bubble — emote system still fires for codex pets.

PDS verification

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"
    }
  }
}

Findings

1. Blocker — dev server fails on Node 25 due to @atproto/oauth-client DPoP retry + undici body regression

Initial 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.

2. Warning — dev's NEXT_PUBLIC_BASE_URL defaults to production OAuth client_id

The 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.

3. Warning — Senate viewport renders sparse with one sim visible

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.

4. Pass — every codex-pet acceptance criterion held

Reproducing

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.

Branchfeat/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