simocracy-v2 · template-event-image-url
Tried to drive the full New-Event flow end-to-end on prod — author an interview template from inside the dialog, then create a gathering with a pasted custom building-image URL. The very first write fails: POST /api/records returns 500 expected non-null body source for every collection. Nothing reaches the PDS; the UI shows no error.
Blocked. OAuth login against the climateai.org PDS succeeds, the events gallery loads, the Create-Event dialog renders correctly, and the nested Author-Template dialog opens and accepts input. As soon as either Save action runs, the internal POST /api/records handler returns {"error":"expected non-null body source"} with 500. Because the client handler catches the error and relies on a sonner toast that never mounts, the user sees nothing — the button just does nothing. Both downstream steps (attaching a template, creating a gathering with a custom buildingImageUrl) are therefore unreachable.
Full clip: sign-in through the dialog interactions and the silent Save failure.

https://www.simocracy.org, signed out.
satyam2.climateai.org entered.
climateai.org — identifier pre-filled.



GatheringFormDialog open — event name, type, sim size, building-image presets, the or custom URL input, and Suggested Interview Templates with the + Add Template / ✎ Author new buttons.

click(), the TemplateAuthorDialog stacks cleanly on top of the outer form, as intended.
QA Template — QA-3754, description, one open question, one yes/no question. Ready to save.
listRecords on the PDS.POST /api/records returns 500 "expected non-null body source"Every write through /api/records fails the same way, regardless of collection (org.simocracy.interviewTemplate, org.simocracy.gathering, with or without $type set by the client). The handler in app/api/records/route.ts successfully reads the incoming JSON (validation errors surface with the right status) — the failure happens inside the ATProto agent's outbound call. Reads via com.atproto.repo.listRecords against the same PDS succeed, so the session/OAuth state is not the root cause.
Sample reproduction from the authenticated browser session:
await fetch('/api/records', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
collection: 'org.simocracy.interviewTemplate',
record: {
name: 'probe',
questions: [{ id: 'q1', type: 'open', prompt: 'test' }],
createdAt: new Date().toISOString(),
},
}),
});
// → 500
// → { "error": "expected non-null body source" }
Same body shape for org.simocracy.gathering and for a record with an explicit $type field — identical response. A different write route (/api/stars) responds with business-logic 403 against the same session, so this is specific to /api/records, not a global write outage.
When the 500 comes back, TemplateAuthorDialog.handleSave catches the error and calls toast.error(err.message). No toast ever renders in this session — document.querySelectorAll('li[data-sonner-toast]').length stays at 0 after the click, and no element with [data-sonner-toaster] exists in the DOM. The user's only feedback is "nothing happened": the dialog keeps showing the filled-in form, no error, no spinner, no retry hint. This mis-classifies a server outage as a dead button.
Using agent-browser click @ref on the ✎ Author new button inside the open GatheringFormDialog closes both dialogs with no replacement. Calling button.click() directly via eval works as expected and the TemplateAuthorDialog opens on top. GatheringFormDialog's onInteractOutside guard is correct in source, and only activates after showTemplateAuthor flips to true — the dismissal appears to be a Radix focus-outside signal fired by playwright's click path in the moment before state flips. Not a product bug — flagged only because anyone scripting this flow with a similar tool will hit the same surprise.
ATProto OAuth against a non-bsky.social PDS (climateai.org) was clean: handle → redirect → password → authorize → bounce back. The events gallery renders; the Create Event dialog is complete and usable; the form controls are correctly wired (building-image presets, custom URL input, treasury, mechanism picker); the nested TemplateAuthorDialog, once open, accepts open and yes/no questions, shows the q-xxxxxxxx id, and switches the prompt placeholder to "Statement the responder agrees or disagrees with" for yes/no. Read paths (com.atproto.repo.listRecords directly against the PDS) work and confirm the repo is empty.
Resolve the handle, follow the DID doc to the PDS, list both collections. Both return empty — the E2E run wrote nothing.
$ curl -s "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=satyam2.climateai.org"
{"did":"did:plc:cpoagodpqrgs4t7thi5z37uf"}
$ curl -s https://plc.directory/did:plc:cpoagodpqrgs4t7thi5z37uf | jq .service
[
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://climateai.org"
}
]
$ curl -s "https://climateai.org/xrpc/com.atproto.repo.listRecords\
?repo=did:plc:cpoagodpqrgs4t7thi5z37uf\
&collection=org.simocracy.interviewTemplate&limit=5"
{"records":[]}
$ curl -s "https://climateai.org/xrpc/com.atproto.repo.listRecords\
?repo=did:plc:cpoagodpqrgs4t7thi5z37uf\
&collection=org.simocracy.gathering&limit=5"
{"records":[]}
satyam2.climateai.org · DID did:plc:cpoagodpqrgs4t7thi5z37uf · PDS climateai.org
Date2026-04-22
Steps
1. Open /, click Sign in → enter handle → complete OAuth on climateai.org./events, click Create an Event..click() — the agent-browser ref-click dismisses).POST /api/records 500 expected non-null body source.
Videowalkthrough.webm (~5 MB)
Screenshotsassets/00…12-*.png
Reproducer probefetch('/api/records', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ collection:'org.simocracy.interviewTemplate', record:{ name:'probe', questions:[{id:'q1',type:'open',prompt:'x'}], createdAt:new Date().toISOString() } }) })
Suggested next step for the eng side: the error string expected non-null body source is a Node undici message emitted when the fetch client is asked to send a body source that has already been consumed or was never set. Most likely somewhere between app/api/records/route.ts reading request.json() and the @atproto/oauth-client-node agent re-issuing a request with a null body — plausibly a serverless cold-path that loses the agent's signed-fetch handler after redeploy dpl_J42DGkfeqmCy1uqHSx1gQ8BnwvTp. A one-line fix candidate is to log the caught exception server-side and to surface a non-generic error for /api/records specifically; independently, the client should fall back to an inline error banner when sonner hasn't mounted.