Endpoints
Eleven route files live under src/routes/api/. Every handler funnels failures through one helper,
so error bodies are uniform.
| Method | Path | Body | Success | Errors |
|---|---|---|---|---|
| GET | /api/decks | none | DeckSummary[] | 500 |
| POST | /api/decks/import | md_path or md_url, optional base_url, copy_from, id | {ok, id, sections, frames, imagesRewritten, assets} | 400 |
| GET | /api/decks/:id | none | FrameDeck | 400 bad id, 404 absent, 500 corrupt |
| GET | /api/decks/:id/assets/* | none | raw bytes | 400 escaping path, 404 not a regular file |
| POST | /api/sessions | {deckId} | SessionMeta | 400 missing or bad deckId, 404 no such deck |
| GET | /api/sessions | none, optional ?deckId= | SessionRow[] | 500 |
| GET | /api/sessions/:id | none | {session, deck} | 404, 500 |
| GET | /api/sessions/:id/events | none, optional ?after= | BusEvent[] | 400 bad after, 404 unknown session |
| POST | /api/sessions/:id/events | an event without seq or ts | {ok, seq} | 400 invalid body, 404 unknown session |
| GET | /api/sessions/:id/stream | none, optional ?after= | text/event-stream | 400 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.md | none | text/markdown | 404, 500 |
| POST | /api/sessions/:id/collab | {link} | {ok, link, webUrl} | 400 unparseable link, 404 unknown session |
| GET | /api/sessions/:id/collab | none | {link, webUrl} or {link: null} | 404 unknown session |
| DELETE | /api/sessions/:id/collab | none | {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:
| Request | Response |
|---|---|
GET /api/sessions/nope | 404 {"ok":false,"error":"no such session: nope"} |
GET /api/sessions/:id/events?after=abc | 400 {"ok":false,"error":"after must be a non-negative integer"} |
POST /api/sessions/:id/events with actor of nobody | 400 {"ok":false,"error":"actor must be 'user' or 'agent'"} |
POST /api/decks/import with md_path of /etc/passwd | 400 {"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.
| Field | Type | Assigned by | Notes |
|---|---|---|---|
seq | number | server | monotonic per session |
ts | string | server | ISO stamp at commit (store.ts:338) |
actor | user or agent | client | required |
kind | EventKind | client | required, see below |
frame | string? | client | frame id, <sectionId>/<seq> |
section | string? | client | section id |
text | string? | client | note body, task title, transcript, agent speech |
tags | string[]? | client | trimmed, empties dropped, capped at 32 (store.ts:283-289) |
verdict | keep, kill or recapture | client | required when kind is verdict |
file | string? | client or server | scribble 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.
| Rule | Implementation |
|---|---|
Exactly one of md_path or md_url, and at least one | importer.ts:109-118 |
md_path is resolved with realpath, then must be inside /home/loca/dev | importer.ts:25, importer.ts:78-81 |
md_path must be a regular file | importer.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 400 | importer.ts:29, importer.ts:102-105 |
copy_from must resolve to an existing directory inside /home/loca/dev or DATA_DIR | importer.ts:160-172 |
| Deck id is slugified and must match the id pattern, else 400 | importer.ts:121-134 |
Asset subpaths drop . and .. segments so markdown cannot escape the copy root | importer.ts:180-181, importer.ts:188 |
| Serving an asset re-checks containment against the deck’s own asset dir | store.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.jsonlThe 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.mdObserved 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.
Collab links
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):
| Input | Stored link | Derived webUrl |
|---|---|---|
bare room.key, legacy room#key, or a %23-mangled variant | room.key | https://my.omp.sh/#room.key |
schemeless host[:port]/r/room.key | host/r/room.key | https://host/#room.key |
wss://host/r/room.key | host/r/room.key | https://host/#room.key |
https://host/r/room#key | host/r/room.key | https://host/#room.key |
ws://localhost.../r/room.key | ws://host/r/room.key | http://host/#room.key |
| a wrapper URL whose fragment is itself a valid link | the wrapper URL, rebuilt | the 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.