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) returnone line above thespeakcall, so reopening a session does not recite the backlog. - so a
sayposted 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.
| Path | Request | Use when | Cost |
|---|---|---|---|
| poll | GET /api/sessions/:id/events?after=<seq> | one-shot turns, cron, an agent that wakes per user message | one request per check, latency equals your interval |
| stream | GET /api/sessions/:id/stream?after=<seq> | a long-lived process that must react within a second | one held connection, needs reconnect handling |
| tail | read DATA_DIR/sessions/<id>/events.jsonl | you are already on the host and want zero HTTP | no 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
caseignores. Advance it only for handled kinds and the loop re-reads the same chatter forever. statusis skipped by contract;navis skipped here too, because it is the app’s scroll tracker and there are eleven of them in a short session.sayis not in the action list, so the loop’s own acknowledgements advance the cursor and trigger nothing. Putsayin the case list and you have written an agent that answers itself.- a
scribbleevent carries only a filename infile; the PNG is atDATA_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):
eventsis the number of bullets, not the number of events. Above it is 2 whileby_kindsums to 5:sayandroute_doneare counted in the histogram but earn no bullet.by_kindis every kind that occurred, in a fixed order, omitting zeros.durationis the session’s creation stamp to its last event,frames_touchedcounts distinct frames named by bulleted events,sections_donecounts distinct sections closed byroute_done.- only
note,task,verdict,speechandscribbleproduce bullets (src/lib/server/export.ts:21-27):navandstatusare transport, andsayis 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 anddeck.createdAtfrom2026-08-20T09:33:27.368Zto2026-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
lastSeq6, itsevents.jsonlstill held exactly 6 lines, and it still resolved throughGET /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.
Where to read next
- 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.