Endpoints

Eleven route files live under src/routes/api/. Every handler funnels failures through one helper, so error bodies are uniform.

MethodPathBodySuccessErrors
GET/api/decksnoneDeckSummary[]500
POST/api/decks/importmd_path or md_url, optional base_url, copy_from, id{ok, id, sections, frames, imagesRewritten, assets}400
GET/api/decks/:idnoneFrameDeck400 bad id, 404 absent, 500 corrupt
GET/api/decks/:id/assets/*noneraw bytes400 escaping path, 404 not a regular file
POST/api/sessions{deckId}SessionMeta400 missing or bad deckId, 404 no such deck
GET/api/sessionsnone, optional ?deckId=SessionRow[]500
GET/api/sessions/:idnone{session, deck}404, 500
GET/api/sessions/:id/eventsnone, optional ?after=BusEvent[]400 bad after, 404 unknown session
POST/api/sessions/:id/eventsan event without seq or ts{ok, seq}400 invalid body, 404 unknown session
GET/api/sessions/:id/streamnone, optional ?after=text/event-stream400 bad after, 404 unknown session
POST/api/sessions/:id/scribble{frame, dataUrl}{ok, seq, file}400 invalid frame or data URL, 404 unknown session
GET/api/sessions/:id/export.mdnonetext/markdown404, 500
POST/api/sessions/:id/collab{link}{ok, link, webUrl}400 unparseable link, 404 unknown session
GET/api/sessions/:id/collabnone{link, webUrl} or {link: null}404 unknown session
DELETE/api/sessions/:id/collabnone{ok}404 unknown session

Route sources, in the same order: decks/+server.ts:7, decks/import/+server.ts:14, decks/[id]/+server.ts:7, decks/[id]/assets/[...path]/+server.ts:13, sessions/+server.ts:7 and sessions/+server.ts:18, sessions/[id]/+server.ts:7, sessions/[id]/events/+server.ts:7 and :18, sessions/[id]/stream/+server.ts:16, sessions/[id]/scribble/+server.ts:12, sessions/[id]/export.md/+server.ts:7, and sessions/[id]/collab/+server.ts:166, :177, :193 — all relative to src/routes/api/.

All rows in the table were exercised against the running instance, the three collab routes included. They were added after the first deployment, so they only became reachable once the app was rebuilt and the unit restarted; measured after that restart, GET /api/sessions/<id>/collab answers 200 {"link":null}, a junk POST answers 400 {"ok":false,"error":"link is not a recognizable collab link"}, and DELETE clears a stored link. The general rule the caveat came from still holds: a new route is not live until npm run build plus sudo -n systemctl restart tndm, and the cheap check is curl -s https://tndm.loca.zone/api/sessions/<id>/collab.

The response helpers are shared. errorResponse re-emits an HttpError with its own status and message and turns anything else into an opaque 500 while logging the original server-side (src/lib/server/http.ts:8-14); HttpError is the status-tagged error class every server module throws (src/lib/server/errors.ts:2-9). Every non-2xx JSON body therefore has the shape {ok: false, error: string} (src/lib/types.ts:58-62).

Observed error bodies from the running instance:

RequestResponse
GET /api/sessions/nope404 {"ok":false,"error":"no such session: nope"}
GET /api/sessions/:id/events?after=abc400 {"ok":false,"error":"after must be a non-negative integer"}
POST /api/sessions/:id/events with actor of nobody400 {"ok":false,"error":"actor must be 'user' or 'agent'"}
POST /api/decks/import with md_path of /etc/passwd400 {"ok":false,"error":"md_path must be inside /home/loca/dev/"}

Body parsing rejects anything that is not a JSON object, including arrays (src/lib/server/http.ts:17-28). ?after= must be a non-negative integer; absent or empty means 0 (src/lib/server/http.ts:46-54).

The event schema

BusEvent is declared at src/lib/types.ts:15-26.

FieldTypeAssigned byNotes
seqnumberservermonotonic per session
tsstringserverISO stamp at commit (store.ts:338)
actoruser or agentclientrequired
kindEventKindclientrequired, see below
framestring?clientframe id, <sectionId>/<seq>
sectionstring?clientsection id
textstring?clientnote body, task title, transcript, agent speech
tagsstring[]?clienttrimmed, empties dropped, capped at 32 (store.ts:283-289)
verdictkeep, kill or recaptureclientrequired when kind is verdict
filestring?client or serverscribble filename, set by saveScribble

Clients may send everything except seq and ts; that split is the type EventInput = Omit<BusEvent, 'seq' | 'ts'> (src/lib/types.ts:35-36) and the server assigns the two it owns in commit (src/lib/server/store.ts:334-338).

The nine kinds are note, task, verdict, speech, say, scribble, nav, route_done and status (src/lib/types.ts:4-13, validated against the table at src/lib/server/store.ts:17-27). The three verdicts are keep, kill and recapture (src/lib/server/store.ts:28-32).

Validation is explicit and each failure has its own message (src/lib/server/store.ts:261-300): the body must be a non-array object; actor must be one of the two literals; kind must be a known kind; frame, section, text and file must be strings no longer than 16384 characters; tags must be an array of strings; and a verdict event without a verdict field is rejected outright (src/lib/server/store.ts:296-298).

Not every kind reaches the export. Only note, task, verdict, speech and scribble produce bullets, because nav and status are transport chatter and say is the agent talking out loud (src/lib/server/export.ts:17-27). All nine are counted in the by_kind histogram (src/lib/server/export.ts:5-15, src/lib/server/export.ts:118-121).

Import safety

Every rule lives in importDeck and travels to the client as an HttpError (src/routes/api/decks/import/+server.ts:6-13).

Citation shorthand in this section: a bare importer.ts means src/lib/server/importer.ts and a bare store.ts means src/lib/server/store.ts.

RuleImplementation
Exactly one of md_path or md_url, and at least oneimporter.ts:109-118
md_path is resolved with realpath, then must be inside /home/loca/devimporter.ts:25, importer.ts:78-81
md_path must be a regular fileimporter.ts:82-84
md_url must parse as a URL and use http: or https:importer.ts:93-101
md_url fetch has a 20 second timeout and a non-OK status is a 400importer.ts:29, importer.ts:102-105
copy_from must resolve to an existing directory inside /home/loca/dev or DATA_DIRimporter.ts:160-172
Deck id is slugified and must match the id pattern, else 400importer.ts:121-134
Asset subpaths drop . and .. segments so markdown cannot escape the copy rootimporter.ts:180-181, importer.ts:188
Serving an asset re-checks containment against the deck’s own asset dirstore.ts:74-81, store.ts:51-57

The realpath check is what makes symlink escapes fail rather than resolve, and the containment test is a prefix comparison against the root plus a separator, so a sibling directory sharing a name prefix cannot slip through (importer.ts:52-54, store.ts:53).

The asset guard is live-verified. A percent-encoded traversal reaches the route and is rejected by the guard rather than by the router:

curl -s 'https://tndm.loca.zone/api/decks/qwizz-2026-08-20/assets/..%2f..%2f..%2fetc%2fpasswd'
{"ok":false,"error":"resolved path escapes the data directory"}   # HTTP 400

A legitimate asset returns the bytes with a derived content type and a five-minute cache header (decks/[id]/assets/[...path]/+server.ts:28-34); observed for assets/boards/board-A-public.png as content-type: image/png, content-length: 329992, cache-control: public, max-age=300.

copy_from versus base_url

Both rewrite image src values; they are mutually exclusive in practice because copy_from is checked first and base_url is only consulted when copy_from is absent (src/lib/server/importer.ts:272-276). With neither, src values are left exactly as the markdown wrote them.

copy_from materialises the images. For each image frame it finds the longest suffix of the src’s path segments that exists under the copy root, so /assets/qwizz-tour/2026-08-20/shots/x.png against a root of .../qwizz-tour/2026-08-20 resolves to shots/x.png (importer.ts:174-205). The file is copied into the deck’s asset directory with its subpath preserved, and the frame src becomes /api/decks/<id>/assets/<subpath> (importer.ts:227-236). A repeated image is copied once and every referencing frame is rewritten (importer.ts:226-233). Unresolvable images are counted, with up to 20 samples returned for diagnosis (importer.ts:28, importer.ts:221-224). Absolute URLs and protocol-relative URLs are skipped, never copied (importer.ts:56-59, importer.ts:219).

base_url only prefixes. It strips trailing slashes from the base and prefixes root-relative srcs, leaving absolute URLs alone (importer.ts:242-252). Nothing is copied, so the deck stays dependent on the origin site remaining available.

Re-importing an existing id overwrites deck.json and re-copies assets. Nothing is deleted or pruned, and sessions/ is untouched (importer.ts:255-261, write at importer.ts:278). That makes re-import the correct way to refresh a deck after its source document changes, without disturbing review history.

Agent attach

No authentication guards any endpoint. Any process that can reach the vhost can read and write any session; see operations for the loopback-plus-TLS reasoning and its limits.

Three ways to read: the SSE stream for live push, ?after= polling for a durable cursor, or tailing the JSONL file directly. Writing is always a POST.

Create a session:

curl -s -X POST https://tndm.loca.zone/api/sessions \
  -H 'content-type: application/json' \
  -d '{"deckId":"qwizz-2026-08-20"}'
# {"id":"127d38c42c95512f","deckId":"qwizz-2026-08-20","createdAt":"...","lastSeq":0}

Speak as the agent. This is the event kind the dialog rail renders as a bubble and the browser speaks aloud when the speaker toggle is on (src/routes/s/[sessionId]/+page.svelte:93):

curl -s -X POST https://tndm.loca.zone/api/sessions/127d38c42c95512f/events \
  -H 'content-type: application/json' \
  -d '{"actor":"agent","kind":"say","text":"tandem link up"}'
# {"ok":true,"seq":1}

Read everything after a known cursor. This is the durable pattern: persist the highest seq you have processed and pass it back.

curl -s 'https://tndm.loca.zone/api/sessions/127d38c42c95512f/events?after=1'
# [{"seq":2,"ts":"2026-08-20T08:45:48.419Z","actor":"user","kind":"nav",
#   "frame":"tour-yamlsum/1","section":"tour-yamlsum"},
#  {"seq":3,"ts":"2026-08-20T08:46:51.575Z","actor":"user","kind":"nav","section":"boards"}]

Follow the live stream. The stream replays past after and then stays open; the first line is a comment and a keepalive comment follows every 25 seconds (src/routes/api/sessions/[id]/stream/+server.ts:58, src/routes/api/sessions/[id]/stream/+server.ts:67):

curl -sN 'https://tndm.loca.zone/api/sessions/127d38c42c95512f/stream?after=1'
# : connected
#
# data: {"seq":2,"ts":"2026-08-20T08:45:48.419Z","actor":"user","kind":"nav", ... }
#
# data: {"seq":3,"ts":"2026-08-20T08:46:51.575Z","actor":"user","kind":"nav","section":"boards"}

Tail the log from disk, for an agent running on the same host that would rather not hold an HTTP connection open:

tail -f /home/loca/tndm/sessions/127d38c42c95512f/events.jsonl

The file is append-only and one JSON object per line (src/lib/server/store.ts:347), and the server publishes to subscribers only after the line is durable (src/lib/server/store.ts:347-350), so a tail never sees an event the API would deny.

Fetch the decision log:

curl -s https://tndm.loca.zone/api/sessions/127d38c42c95512f/export.md

Observed output for a session holding one say and two nav events — none of which earn a bullet:

```yaml
deck: qwizz-2026-08-20
title: "🎞️ QuizWizz UI/UX Feedback Tour — Complete Shot Gallery"
session: 127d38c42c95512f
generated: 2026-08-20T09:02:33.615Z
duration: 1m
events: 0
by_kind:
  say: 1
  nav: 2
frames_touched: 0
sections_done: 0
```
 
## Tandem audit 127d38c42c95512f — 2026-08-20
 
_No decisions recorded._

The events count is the number of bullets, not the number of events (src/lib/server/export.ts:115), frames_touched counts distinct frames among bullet events only (src/lib/server/export.ts:89-90), and sections_done counts distinct route_done targets (src/lib/server/export.ts:92-97). A bullet reads - [kind] `target` — pieces, where the target falls back to deck when the event names neither a frame nor a section, scribbles render as a relative markdown link, and tags render as hash-prefixed chips (src/lib/server/export.ts:49-70). Every untrusted string is collapsed to one line first (src/lib/server/export.ts:29-35), which is what keeps one event on one line.

The collab routes attach an OMP collab room to a session so the agent side of the tandem can be driven from a browser. The link is the room secret: it is never logged, never written into events.jsonl, and never echoed back inside an error message (src/routes/api/sessions/[id]/collab/+server.ts:11-15). It is stored only on the owning session record (src/lib/server/store.ts:360-379) and is deliberately excluded from GET /api/sessions rows, so only the single-session read can expose it (src/lib/server/store.ts:221-229).

POST normalises any accepted link form and returns both the canonical link and a derived webUrl for the browser guest client (collab/+server.ts:166-175). GET returns {link: null} when none is set, and degrades to {link, webUrl: null} rather than a 500 when a stored link can no longer be derived (collab/+server.ts:177-191). DELETE clears it (collab/+server.ts:193-200). Storing the link goes through the session lock so it cannot interleave with a concurrent event append (src/lib/server/store.ts:366-367).

The normaliser is exported as _parseCollabLink; the underscore is required because SvelteKit only permits HTTP verbs and underscore-prefixed identifiers as exports of a +server.ts module (collab/+server.ts:159-164).

Accepted input forms, all handled by one recursive parser capped at four levels of nesting (collab/+server.ts:28, collab/+server.ts:76-77). One layer of surrounding quotes is shed first, because links are pasted from an omp join "<link>" command (collab/+server.ts:79-82):

InputStored linkDerived webUrl
bare room.key, legacy room#key, or a %23-mangled variantroom.keyhttps://my.omp.sh/#room.key
schemeless host[:port]/r/room.keyhost/r/room.keyhttps://host/#room.key
wss://host/r/room.keyhost/r/room.keyhttps://host/#room.key
https://host/r/room#keyhost/r/room.keyhttps://host/#room.key
ws://localhost.../r/room.keyws://host/r/room.keyhttp://host/#room.key
a wrapper URL whose fragment is itself a valid linkthe wrapper URL, rebuiltthe same wrapper URL

Sources for those rows in order: collab/+server.ts:149-151, collab/+server.ts:144-146, collab/+server.ts:139, collab/+server.ts:113-120, collab/+server.ts:133-137, collab/+server.ts:97-109. The default web origin is https://my.omp.sh, matching the collab.relayUrl default of wss://my.omp.sh, and every derived client URL is built by one helper as <origin>/#<room>.<key> (collab/+server.ts:26-27, collab/+server.ts:71-74).

Validation is narrow on purpose. A room id must be 8 to 128 base64url characters (collab/+server.ts:25) and the secret must be base64url of exactly 43 or 64 characters — the bare 32-byte view-only key, or the 48-byte full link that is a 32-byte room key plus a 16-byte write token (collab/+server.ts:23-24, enforced at collab/+server.ts:57). The stated reason for rejecting other lengths is that the guest client validates the same two, so accepting more would only persist a link the client refuses to connect with (collab/+server.ts:17-22). Cleartext transports are restricted to development: http:// and ws:// are accepted only for localhost origins (collab/+server.ts:95, collab/+server.ts:131, host test at collab/+server.ts:44-47). Every rejection is the same opaque 400, {"ok":false,"error":"link is not a recognizable collab link"}, which never contains the submitted link (collab/+server.ts:30-32).

Citation shorthand in this section: collab/+server.ts means src/routes/api/sessions/[id]/collab/+server.ts. That file settled at commit 7e75cde; line numbers were taken after it did.

Read next: quickstart to drive a review, agent-cookbook for attach recipes, architecture for the bus internals, md-parser for what a deck contains, security for what an unauthenticated caller can reach, evidence for the dated receipts, or back to index.