What an agent needs

Plain HTTP and nothing else. No SDK, no websocket, no auth header, and — see security — no credential either, which is a property of the deployment rather than a feature.

Every command on this page was executed against https://tndm.loca.zone on 2026-08-20 and the response below it is what came back. Recipes that write used a throwaway deck tndm-cookbook-probe and session 48578ee6ef02bc4e, both deleted afterwards, so nothing here left residue. Substitute your own ids.

B=https://tndm.loca.zone
S=<sessionId>

Frame ids look like <sectionId>/<n>boards/1, endpoints/1. Take them from the deck, never invent them: md-parser defines how they are minted.

Discover decks and sessions, create a session

List the decks. One request tells you the ids, the source document and the shape:

curl -s "$B/api/decks"
[{"id":"qwizz-2026-08-20","source":"/home/loca/dev/wrdp/q5vault/audits/qwizz/2026-08-20-feedback-tour.md","createdAt":"2026-08-20T08:45:02.982Z","sections":76,"frames":323}]

The real response also carries a title field, elided above only because this deck’s title contains a pictograph. List the sessions, optionally narrowed to one deck:

curl -s "$B/api/sessions"
curl -s "$B/api/sessions?deckId=qwizz-2026-08-20"
[{"id":"53079ca8e3cfa575","deckId":"qwizz-2026-08-20","createdAt":"2026-08-20T09:12:40.919Z","lastSeq":2,"lastEventTs":"2026-08-20T09:24:59.269Z","doneSections":[]},
 {"id":"127d38c42c95512f","deckId":"qwizz-2026-08-20","createdAt":"2026-08-20T08:45:13.040Z","lastSeq":18,"lastEventTs":"2026-08-20T09:18:18.197Z","doneSections":["boards"]}]

lastSeq is the resume point and doneSections is progress, so an agent can pick up a session it has never seen without reading a single event. Create a new one with the deck id alone:

curl -s -X POST "$B/api/sessions" -H 'content-type: application/json' \
  -d '{"deckId":"tndm-cookbook-probe"}'
{"id":"48578ee6ef02bc4e","deckId":"tndm-cookbook-probe","createdAt":"2026-08-20T09:33:27.658Z","lastSeq":0}

Session ids are random, not sequential, so there is nothing to guess or enumerate. The whole session with its deck attached, when you want the frames:

curl -s "$B/api/sessions/$S"        # -> {session, deck}

Speak into the review

One POST. The agent’s voice is an event like any other:

curl -s -X POST "$B/api/sessions/$S/events" -H 'content-type: application/json' \
  -d '{"actor":"agent","kind":"say","text":"cookbook recipe check"}'
{"ok":true,"seq":1}

What happens to it, from source rather than from hope (src/routes/s/[sessionId]/+page.svelte:87-93):

  • it lands in the dialog rail, which is collapsed by default, and bumps the unread counter while it stays collapsed.
  • it is spoken only if it arrives live over the SSE stream and the speaker toggle is on. The toggle defaults on. Replayed history is deliberately not spoken: the guard is if (!live) return one line above the speak call, so reopening a session does not recite the backlog.
  • so a say posted while nobody has the page open will be in the rail on next load and will not be spoken. Post it when your human is there, or expect it to be read rather than heard.

Speech synthesis itself is unproven on this host — no audio device, no voices in headless Chromium. evidence says so plainly rather than assuming it works.

Three ways to read the bus

Same events, three transports. Pick by what you are: a script, a daemon, or a process on the box.

PathRequestUse whenCost
pollGET /api/sessions/:id/events?after=<seq>one-shot turns, cron, an agent that wakes per user messageone request per check, latency equals your interval
streamGET /api/sessions/:id/stream?after=<seq>a long-lived process that must react within a secondone held connection, needs reconnect handling
tailread DATA_DIR/sessions/<id>/events.jsonlyou are already on the host and want zero HTTPno network at all, but no filter, no replay cursor, and it breaks the moment the agent moves off-box

Poll incrementally. after is exclusive, so pass the last seq you handled and you get only what is new (src/lib/server/store.ts:254):

curl -s "$B/api/sessions/$S/events?after=2"
[{"seq":3,"ts":"2026-08-20T09:33:39.668Z","actor":"user","kind":"verdict","frame":"endpoints/1","verdict":"keep"},
 {"seq":4,"ts":"2026-08-20T09:33:39.756Z","actor":"user","kind":"route_done","section":"endpoints"}]

Garbage in after is a 400, not a silent zero: ?after=abc returns {"ok":false,"error":"after must be a non-negative integer"}.

Follow the stream. The same cursor rule applies, so a reconnect resumes exactly where it stopped:

curl -N -s "$B/api/sessions/$S/stream?after=3"
: connected

data: {"seq":4,"ts":"2026-08-20T09:33:39.756Z","actor":"user","kind":"route_done","section":"endpoints"}

data: {"seq":5,"ts":"2026-08-20T09:33:50.975Z","actor":"agent","kind":"say","text":"live via sse"}

That output is one run: the comment frame, then the replay of everything past after=3, then seq 5 arriving live because it was posted from another terminal while the stream was open. Two operational notes: the server emits : ping every 25 seconds to keep idle connections alive (src/routes/api/sessions/[id]/stream/+server.ts:7, not observed in the six-second window above), and an unknown session id gets a clean JSON 404 instead of a dangling stream — measured: {"ok":false,"error":"no such session: deadbeefdeadbeef"}.

Tail the file. Nothing but the filesystem:

tail -n 2 /home/loca/tndm/sessions/$S/events.jsonl   # or -f to follow
{"seq":3,"ts":"2026-08-20T09:33:39.668Z","actor":"user","kind":"verdict","frame":"endpoints/1","verdict":"keep"}
{"seq":4,"ts":"2026-08-20T09:33:39.756Z","actor":"user","kind":"route_done","section":"endpoints"}

The same events, field for field, because that file is what the API reads. Each event is appended as one whole line inside the session lock (src/lib/server/store.ts:347), so a tail sees complete events in seq order.

The ingest loop

The pattern every attached agent ends up with: remember a cursor, skip the noise, act on the decisions, answer out loud, persist the cursor. This ran as written; it is illustrative, not shipped code.

S=<sessionId>; B=https://tndm.loca.zone; F=/tmp/tndm-last-seq.$S
LAST=$(cat "$F" 2>/dev/null || echo 0)
curl -s "$B/api/sessions/$S/events?after=$LAST" \
| python3 -c 'import json,sys
for e in json.load(sys.stdin):
    print(e["seq"], e["kind"], e.get("frame") or e.get("section") or "-",
          e.get("verdict") or e.get("file") or e.get("text") or "", sep="\t")' \
| while IFS=$'\t' read -r seq kind target payload; do
    [ "$kind" = status ] && { echo "$seq" > "$F"; continue; }
    case "$kind" in
      note|task|verdict|speech|scribble|route_done)
        printf 'ACT %s %s %s %s\n' "$seq" "$kind" "$target" "$payload"
        curl -s -o /dev/null -X POST "$B/api/sessions/$S/events" \
          -H 'content-type: application/json' \
          -d "{\"actor\":\"agent\",\"kind\":\"say\",\"text\":\"ack $kind on $target\"}" ;;
    esac
    echo "$seq" > "$F"
  done
echo "last_seq now $(cat "$F")"

Observed, against a session holding six events:

ACT 2 note endpoints/1 cookbook note
ACT 3 verdict endpoints/1 keep
ACT 4 route_done endpoints
last_seq now 6

and the three acknowledgements landed as seqs 7, 8 and 9, actor agent, kind say, texts ack note on endpoints/1, ack verdict on endpoints/1, ack route_done on endpoints.

What the shape is buying:

  • the cursor is persisted per session and advanced for every event read, including the ones the case ignores. Advance it only for handled kinds and the loop re-reads the same chatter forever.
  • status is skipped by contract; nav is skipped here too, because it is the app’s scroll tracker and there are eleven of them in a short session.
  • say is not in the action list, so the loop’s own acknowledgements advance the cursor and trigger nothing. Put say in the case list and you have written an agent that answers itself.
  • a scribble event carries only a filename in file; the PNG is at DATA_DIR/sessions/<id>/scribbles/<file> (src/lib/server/store.ts:383-386). Read the image, do not expect pixels in the event.
  • the human is the authority. An ack is an ack, not a decision.

Export and round-trip

One GET renders the whole session as markdown:

curl -s "$B/api/sessions/$S/export.md"
HTTP/1.1 200 OK
Content-Type: text/markdown; charset=utf-8
content-disposition: inline; filename="tandem-48578ee6ef02bc4e.md"
```yaml
deck: tndm-cookbook-probe
title: "HTTP API and agent attach"
session: 48578ee6ef02bc4e
generated: 2026-08-20T09:34:03.996Z
duration: 23s
events: 2
by_kind:
  note: 1
  verdict: 1
  say: 2
  route_done: 1
frames_touched: 1
sections_done: 1
```

## Tandem audit 48578ee6ef02bc4e — 2026-08-20

- [note] `endpoints/1` — cookbook note — (#cookbook)
- [verdict] `endpoints/1` — verdict: keep

Read the header carefully, because two of its numbers count different things (src/lib/server/export.ts:108-124):

  • events is the number of bullets, not the number of events. Above it is 2 while by_kind sums to 5: say and route_done are counted in the histogram but earn no bullet.
  • by_kind is every kind that occurred, in a fixed order, omitting zeros.
  • duration is the session’s creation stamp to its last event, frames_touched counts distinct frames named by bulleted events, sections_done counts distinct sections closed by route_done.
  • only note, task, verdict, speech and scribble produce bullets (src/lib/server/export.ts:21-27): nav and status are transport, and say is the agent talking.

The round trip is the second half. The export is a proposal, not a verdict: take it to the human, keep only the accepted items, and append those to the source document’s own feedback log — one dated bullet per accepted note and per applied decision, scribbles copied next to the document’s other assets and linked from the bullet. The export’s scribbles/<file> link is relative for exactly that. Then re-import the deck so the frames show the merged document. first-deck is the worked example.

Import a document as a deck

Any regular markdown file under /home/loca/dev/ is importable — a directory gets a 400 md_path is not a regular file. The deck id is yours to choose:

curl -s -X POST "$B/api/decks/import" -H 'content-type: application/json' \
  -d '{"md_path":"/home/loca/dev/wikis/tndm/content/api.md","id":"tndm-cookbook-probe"}'
{"ok":true,"id":"tndm-cookbook-probe","sections":6,"frames":13,"imagesRewritten":0,"assets":{"copied":0,"missing":0,"missingSamples":[]}}

Add copy_from to materialise the document’s images inside the deck so it survives the source moving, or base_url to point them back at the site they came from. api has the full parameter table and the safety rules; evidence has the rejection receipts.

Re-import semantics. The same command again with the same id, measured:

  • rewrote deck.json — file mtime moved from 11:33:27 to 11:34:30 and deck.createdAt from 2026-08-20T09:33:27.368Z to 2026-08-20T09:34:30.217Z, with an identical 6 sections and 13 frames.
  • left the session completely alone: the session created on that deck still reported lastSeq 6, its events.jsonl still held exactly 6 lines, and it still resolved through GET /api/sessions/:id.

From source rather than measured, because this document carries no images: an import that passes copy_from runs the asset copy pass again, and nothing is ever deleted or pruned (src/lib/server/importer.ts:255-261). Both imports above passed neither copy_from nor base_url, so no asset pass ran at all and assets.copied stayed 0 (src/lib/server/importer.ts:272-276).

So re-importing after a source edit is safe mid-review. The frames refresh, the review does not.

Two pitfalls

The server owns seq and ts. A client that sends them is ignored, not honoured, and not warned:

curl -s -X POST "$B/api/sessions/$S/events" -H 'content-type: application/json' \
  -d '{"actor":"agent","kind":"say","text":"seq trap","seq":999,"ts":"1999-01-01T00:00:00.000Z"}'
{"ok":true,"seq":6}

and the stored event is {"seq":6,"ts":"2026-08-20T09:34:04.093Z","actor":"agent","kind":"say","text":"seq trap"}. The 999 and the 1999 stamp are gone. Never derive your cursor from a number you sent — read the seq in the response, or the one in the event you received. Ordering comes from the server’s chain (src/lib/server/store.ts:302-320), which is what makes concurrent agents safe.

The log is append-only, so a correction is a new event. There is no edit and no delete:

curl -s -X DELETE "$B/api/sessions/$S/events"
DELETE method not allowed

HTTP 405 — the route file exports only GET and POST. To reverse a verdict, post the new verdict; to retract a note, post a note that says so. The old event stays, which is the point: the log is the audit trail, and an audit trail you can rewrite is not one.

What was verified, and how

Executed against the live instance while writing this page, each response quoted above: GET /api/decks, GET /api/sessions, GET /api/sessions?deckId=, POST /api/sessions, POST /api/sessions/:id/events (a say, a note with tags, a verdict, a route_done, and the seq/ts trap), GET /api/sessions/:id/events?after=, the same with a bad after, GET /api/sessions/:id/stream?after= with a concurrent POST to prove live delivery, GET /api/sessions/:id/export.md with its headers, POST /api/decks/import twice with one id, DELETE /api/sessions/:id/events, a tail of events.jsonl on the host, and the ingest loop exactly as printed. The throwaway deck and session were then removed, and GET /api/sessions/48578ee6ef02bc4e now answers 404 {"ok":false,"error":"no such session: 48578ee6ef02bc4e"}. Every read-only form on this page was then re-run against the surviving evidence session to confirm it still works after that cleanup.

Not verified here, and not claimed: that an agent say is audibly spoken, or that the dialog rail was seen rendering one. evidence lists both as unproven with the reason.

  • api — the endpoint table, the full event schema and the import parameters.
  • evidence — the receipts, including everything these recipes could not prove.
  • architecture — the bus, the broker and the data directory these recipes talk to.
  • quickstart — the same platform from the human’s side.