Agents use plain HTTP and Authorization: Bearer <token> because they cannot complete the browser’s interactive Authelia one_factor redirect. The token file is /home/loca/dev/tandem-audit/.api-token, mode 600; never print, log, paste, or commit its value. See private page (sign-in required) for the edge and private page (sign-in required) for rotation.
flowchart LR H[Human] -->|rules frames| UI[Browser workspace] UI -->|posts user events| B[(Append-only event bus)] B -->|same ordered events| UI B -->|SSE or polling| A[Attached agent] A -->|posts agent events| B B --> X[export.md]
Tandem etiquette
| Rule | Working behavior |
|---|---|
Do not spam nav | Treat it as browser scroll telemetry; skip it unless location matters and never answer it automatically |
Use actor: agent | Never post as user when the agent is speaking or deciding; the human remains the authority |
| Honour the append-only log | Correct a decision with a new event; there is no event edit/delete route, and seq/ts belong to the server |
Use the recipes in order and replace each quoted replace-with-… value.
1. Attach to a session
Set the endpoint, bearer header, and session id, then fetch the session with its deck so every later frame and section id comes from source.
export TNDM_BASE='https://tndm.loca.zone'
export TNDM_AUTH="Authorization: Bearer $(cat /home/loca/dev/tandem-audit/.api-token)"
export TNDM_SESSION='replace-with-session-id'
curl -sS -H "$TNDM_AUTH" \
"$TNDM_BASE/api/sessions/$TNDM_SESSION"Expected: HTTP 200 JSON shaped as {session, deck}; an unknown session returns JSON 404.
2. Tail the bus
Open the SSE stream after the highest server sequence already processed so replay flows directly into live events.
AFTER=0
curl -fsSN -H "$TNDM_AUTH" \
"$TNDM_BASE/api/sessions/$TNDM_SESSION/stream?after=$AFTER"Expected: : connected, then one data: {BusEvent} frame per event with seq > AFTER, plus idle : ping comments every 25 seconds.
3. Post a say
Send agent speech to the shared rail while the human has the workspace open.
curl -fsS -X POST -H "$TNDM_AUTH" \
-H 'content-type: application/json' \
"$TNDM_BASE/api/sessions/$TNDM_SESSION/events" \
-d '{"actor":"agent","kind":"say","text":"cookbook recipe check"}'Expected: {"ok":true,"seq":n} and the full event appears on the stream; only a live arrival may be voiced by the browser.
4. Post a verdict
Rule a frame as the agent using ids read from the attached deck and one canonical verdict: keep, recapture, or kill.
FRAME='replace-with-frame-id'
SECTION='replace-with-section-id'
curl -fsS -X POST -H "$TNDM_AUTH" \
-H 'content-type: application/json' \
"$TNDM_BASE/api/sessions/$TNDM_SESSION/events" \
-d "{\"actor\":\"agent\",\"kind\":\"verdict\",\"frame\":\"$FRAME\",\"section\":\"$SECTION\",\"verdict\":\"keep\"}"Expected: {"ok":true,"seq":n} followed by the stored verdict on SSE with the server’s sequence and timestamp.
5. Mark a route done
Close one reviewed route by posting its section id without rewriting any earlier frame decisions.
SECTION='replace-with-section-id'
curl -fsS -X POST -H "$TNDM_AUTH" \
-H 'content-type: application/json' \
"$TNDM_BASE/api/sessions/$TNDM_SESSION/events" \
-d "{\"actor\":\"agent\",\"kind\":\"route_done\",\"section\":\"$SECTION\"}"Expected: {"ok":true,"seq":n} and the section appears in session progress and the export’s sections_done count.
6. Read export.md
Render the converged human-and-agent log as the review document handed back to the source owner.
curl -fsS -H "$TNDM_AUTH" \
"$TNDM_BASE/api/sessions/$TNDM_SESSION/export.md"Expected: text/markdown with a YAML statistics header, a tandem-audit heading, and one bullet per exported decision.
Folded response, transport, and round-trip notes
| Area | Contract |
|---|---|
| Session selection | Session ids are random; list or create them through api. lastSeq is the resume cursor and doneSections is progress |
| Frame ids | Use <sectionId>/<n> from the deck; never invent one. md-parser defines their construction |
| Poll alternative | GET /events?after=n is exclusive and durable; persist the highest sequence for every event read, including ignored kinds (src/lib/server/store.ts:237-257) |
| Host-only alternative | DATA_DIR/sessions/<id>/events.jsonl is one complete JSON event per line and may be tailed only by a process on the host (src/lib/server/store.ts:246-257,347-350) |
| Stream reconnect | Reuse the last processed seq; invalid after is 400 and an unknown session is a JSON 404 rather than a dangling stream (src/routes/api/sessions/[id]/stream/+server.ts:16-72) |
say delivery | The rail records history and raises unread state while closed; speech synthesis runs only for a live agent say while its toggle is on, never for replayed backlog (src/routes/s/[sessionId]/+page.svelte:176-178) |
| Speech evidence | Audio output was not proven on the 2026-08-20 headless evidence pass; see private page (sign-in required) |
| Cursor ownership | Client-supplied seq and ts are discarded; use the POST response or received event, not a number sent by the client (src/lib/server/store.ts:260-299,334-350) |
| Self-loop guard | Advance past say and do not automatically answer it, or an agent can answer its own acknowledgements forever |
| Scribbles | A bus event carries only file; the PNG is under DATA_DIR/sessions/<id>/scribbles/ (src/lib/server/store.ts:381-416) |
| Export bullets | Only note, task, verdict, speech, and scribble become bullets; by_kind still counts all event kinds (src/lib/server/export.ts:5-27) |
| Export counters | events counts bullets, frames_touched counts distinct bulleted frames, and sections_done counts distinct route_done targets (src/lib/server/export.ts:89-124) |
| Round trip | Human reviews the export, accepted items enter the source feedback log, scribble assets stay relative, then the deck is re-imported without disturbing session history; see first-deck |
| Import scope | Import, image materialization, and re-import safety stay in api rather than being duplicated as a seventh recipe |
Folded live receipts from the former payload dumps
| Date | Probe | Result |
|---|---|---|
| 2026-08-20 | Throwaway session creation | Returned random id, deck id, creation time, and lastSeq: 0 |
| 2026-08-20 | Agent say, note, verdict, route_done | Each append returned {ok: true, seq} and appeared in sequence order |
| 2026-08-20 | Poll after a cursor | Returned only later events; bad after returned 400 |
| 2026-08-20 | SSE replay plus concurrent POST | Replayed the backlog, then delivered the live event on the same stream |
| 2026-08-20 | Export | events counted two decision bullets while by_kind counted five total events, proving the counters differ |
| 2026-08-20 | Re-import same deck id | Rewrote the deck while preserving the six-event session and its lastSeq |
| 2026-08-20 | DELETE /events | Returned 405; correction requires a new event |
| 2026-08-20 | Cleanup | Throwaway deck and session were removed; the deleted session then returned 404 |
| 2026-08-21 | Bearer recheck | GET /api/decks and GET /api/sessions returned 200 with their prior shapes; write recipes were not rerun wholesale after the edge gate, and commit 09fa602 left the API and event contract unchanged |
For the full endpoint and event tables use api; for browser controls use quickstart; for bus internals and the 2026-08-21 no-load-nav policy use private page (sign-in required).