Three calls take you from a markdown file to a live tandem review: import the document, create a session, open the session URL. Everything after that is the review loop.

1. Import a document

POST https://tndm.loca.zone/api/decks/import reads one markdown document, parses it into frames and persists decks/<id>/deck.json (src/routes/api/decks/import/+server.ts:14-27).

FieldRequiredEffect
md_pathone of md_path / md_urlabsolute path, read from disk; the realpath must land inside /home/loca/dev/ (src/lib/server/importer.ts:77-90)
md_urlone of md_path / md_urlfetched over http(s) with a 20 s timeout; any other scheme is a 400 (src/lib/server/importer.ts:92-107)
copy_fromnodirectory the document’s images are copied from into the deck (src/lib/server/importer.ts:207-239)
base_urlnoorigin prefixed onto root-relative image srcs; nothing is copied (src/lib/server/importer.ts:242-253)
idnoexplicit deck id; otherwise slugged from the filename (src/lib/server/importer.ts:121-134)

Passing both md_path and md_url is a 400 pass exactly one of md_path or md_url; passing neither is a 400 md_path or md_url is required (src/lib/server/importer.ts:109-118). If both copy_from and base_url arrive, copy_from wins (src/lib/server/importer.ts:272-276).

The copying form, which is how the first deck was imported:

curl -sX POST https://tndm.loca.zone/api/decks/import \
  -H 'content-type: application/json' \
  -d '{"md_path":"/home/loca/dev/wrdp/q5vault/audits/qwizz/2026-08-20-feedback-tour.md","copy_from":"/home/loca/dev/wrdp/q5vault/assets/qwizz-tour/2026-08-20","id":"qwizz-2026-08-20"}'

The pointing form, for a document whose images are already served by a website:

curl -sX POST https://tndm.loca.zone/api/decks/import \
  -H 'content-type: application/json' \
  -d '{"md_url":"https://wiki.example.zone/page.md","base_url":"https://wiki.example.zone","id":"page"}'

Which one applies:

  • copy_from when the images exist as files on this host, under /home/loca/dev/ or under the data dir (src/lib/server/importer.ts:160-172). Each image is matched by the longest suffix of its src that exists under that root, copied once even when several frames re-reference it, and its src is rewritten to /api/decks/<id>/assets/<subpath> (src/lib/server/importer.ts:183-238). The deck then owns its pixels: rebuilding, moving or rewriting the source site cannot change what the deck shows.
  • base_url when the pixels stay where they are and you only need a prefix. Root-relative srcs get the origin prepended, absolute URLs and data: srcs are left alone (src/lib/server/importer.ts:56-59,242-253). Nothing is copied, so nothing is counted as missing, and the deck breaks if that site stops serving those paths.
  • Neither, when the document carries no images or already uses absolute URLs.

A successful import answers {ok, id, sections, frames, imagesRewritten, assets} where assets is {copied, missing, missingSamples}, and missingSamples lists up to 20 srcs that resolved to no file under copy_from (src/lib/server/importer.ts:16-22,28,221-224). Re-importing the same id re-parses and overwrites deck.json and re-copies assets; nothing is pruned and sessions/ is untouched (src/lib/server/importer.ts:255-261).

2. Create a session and open it

curl -sX POST https://tndm.loca.zone/api/sessions \
  -H 'content-type: application/json' -d '{"deckId":"qwizz-2026-08-20"}'

The deck is read first, so a bad deck id fails before anything is written; the id is 8 random bytes in hex, and the session directory is created with its scribbles/ subdirectory and an empty events.jsonl (src/lib/server/store.ts:153-168). The response is the session record {id, deckId, createdAt, lastSeq}.

Open https://tndm.loca.zone/s/<sessionId>. The deck list at / reaches the same place: each deck card carries a plus button labelled New session, and every existing session is a row with its done-section ring, short id, relative last-event time and an open arrow (src/routes/+page.svelte:68-104).

3. The review loop

The left rail is the map. Sections at level 2 or shallower become group headers, deeper sections become the rows under them, and the group collapses from its chevron (src/lib/components/Rail.svelte:19-35,67-99). A row shows an icon, the section title, a frame count badge and a check once the section is done; the icon is a warning triangle when the section carries findings, otherwise it is typed from the section’s first frame — image, table, code or text (src/lib/components/Rail.svelte:54-62,100-117). Clicking a row jumps to that section and moves the focus cursor to its first frame (src/routes/s/[sessionId]/+page.svelte:145-150).

Endless scroll is the default: every visible section is rendered in one column, an IntersectionObserver tracks which one you are in, and one nav event per section is posted after a 1 s debounce (src/routes/s/[sessionId]/+page.svelte:317-349). Focus mode is the header button labelled Focus mode: it dims every card except the active one, and the keys become the primary way to move (src/routes/s/[sessionId]/+page.svelte:197-200,447-457,553).

A frame card carries, in order: a filename badge, the verdict chip, your own decision chip once you have made one, the frame’s tags, the frame body, the caption, and the action row (src/lib/components/FrameCard.svelte:53-189).

  • The verdict chip is badge-warning reading finding when the frame carries the finding badge, and badge-success reading clean when it does not (src/lib/components/FrameCard.svelte:35,55-59). It reports what the source document said about the frame, not what you decided.
  • Your decision adds a second chip: keep is badge-success, kill is badge-error, recapture is badge-info (src/lib/components/FrameCard.svelte:60-66).
  • The caption is the frame’s alt text and its <sub> metadata line joined with an em dash, clamped to one line, and expanded or re-collapsed by clicking it (src/lib/components/FrameCard.svelte:37,95-107).
  • Clicking the image opens a full-size lightbox with role="dialog" and aria-modal; clicking anywhere in it or the close button dismisses it (src/lib/components/FrameCard.svelte:74-86,193-213).

The action row is six icon buttons, each aria-labelled:

IconActionWhat it posts
checkkeepverdict event with verdict: keep
xkillverdict event with verdict: kill
rotate-ccwrecaptureverdict event with verdict: recapture
notebook-pennoteopens a modal for text plus tags, then a note event
square-checktaskopens a modal for a title plus tags, then a task event
pencilscribbleopens the canvas overlay; saving stores a PNG and logs a scribble event

Verdicts post from src/routes/s/[sessionId]/+page.svelte:152-154, notes and tasks from :586-600. The scribble canvas uploads its strokes as a PNG data URL to POST /api/sessions/:id/scribble (src/lib/Scribble.svelte:183-188); the server writes sessions/<id>/scribbles/<frame>--<seq>.png and logs only that filename, never the data URL (src/lib/server/store.ts:383-386,411-414). The pencil is disabled for non-image frames and while the scribble module has not loaded (src/lib/components/FrameCard.svelte:38,176-188).

Two header controls shape what you see:

  • Findings only, the warning-triangle toggle, keeps only frames badged finding and keeps group-header sections even when they end up empty, so the rail structure survives the filter (src/routes/s/[sessionId]/+page.svelte:57-62,436-446).
  • The progress ring counts sections marked done over sections that hold at least one frame, and prints both numbers inside the ring (src/routes/s/[sessionId]/+page.svelte:71-78,426-435). A section counts as done when a route_done event names it. No control in the browser emits route_done: it arrives from the attached agent or from a plain POST /api/sessions/:id/events (src/routes/s/[sessionId]/+page.svelte:71-75, and see agent-cookbook).

Keyboard map

KeyAction
j, ArrowDownnext frame
ArrowUpprevious frame
kkeep the active frame
xkill the active frame
rrecapture the active frame
nnote on the active frame
ttask on the active frame
sscribble on the active frame
marm or disarm the microphone
spacestop the speech queue
?open or close the keyboard map
Escclose the modal, overlay or scribble

The handler ignores anything with a modifier key and anything typed inside an input, textarea, select or contenteditable element (src/routes/s/[sessionId]/+page.svelte:352-356). Esc and ? work while a modal is open; every other binding is suppressed until it closes (src/routes/s/[sessionId]/+page.svelte:356-368). space only cancels speech when something is actually speaking, so it still scrolls the page otherwise (src/routes/s/[sessionId]/+page.svelte:400-405). The same table is available in the app as the ? overlay (src/lib/components/KeymapOverlay.svelte:6-19).

Voice

The speaker toggle is on by default — TTS-first (src/routes/s/[sessionId]/+page.svelte:31). Exactly one class of event is spoken: an event that arrives live with actor: 'agent', kind: 'say' and non-empty text (src/routes/s/[sessionId]/+page.svelte:93). Your own events are never spoken, and the deck’s own captions are never spoken. Before speaking, markdown is reduced to prose — fences dropped, links and images collapsed to their label, heading, quote, list and emphasis markers stripped (src/lib/voice.ts:81-94). Utterances queue at rate 1.05 in the deck’s language (deck.meta.lang, else en) — the queue and the rate live at src/lib/voice.ts:46,96-121, the language setter at src/lib/voice.ts:52-56, and the wiring that reads the deck’s lang on mount at src/routes/s/[sessionId]/+page.svelte:230-236. Turning the speaker off cancels the queue and the current utterance (src/routes/s/[sessionId]/+page.svelte:192-195, src/lib/voice.ts:133-142).

The mic is click-to-arm, not hold: one click starts a recogniser, the first final transcript is posted as a speech event and the mic disarms itself; clicking again while armed stops it (src/routes/s/[sessionId]/+page.svelte:165-190, src/lib/voice.ts:162-216,232-245). Recognition runs non-continuous with a single alternative, so one press means one utterance (src/lib/voice.ts:183-187).

Both paths degrade to a disabled button with an explaining tooltip rather than an error. The speaker needs window.speechSynthesis plus SpeechSynthesisUtterance; the mic needs SpeechRecognition or webkitSpeechRecognition (src/lib/voice.ts:58-74). When the predicate fails the tooltip reads TTS unsupported or Mic unsupported, and when the voice module itself failed to load both read Voice unavailable (src/routes/s/[sessionId]/+page.svelte:461,476).

Voice is the one unproven part of this platform. This host has no audio device and the headless browser used for verification ships neither speechSynthesis voices nor a microphone, so neither speaking nor push-to-talk has been observed working end to end; only the code paths and the degradation are verified. Confirming them needs a real desktop browser. By the predicates above, a Chromium-family desktop browser can satisfy both toggles, while Firefox exposes speechSynthesis but not the recogniser, which leaves its mic button disabled and typed notes as the input path. See evidence for what was and was not observed.

Collab: pair the deck with a live OMP agent

The collab rail embeds the agent’s own transcript and composer beside the frames, so the pair works in one window instead of two.

  1. In the OMP TUI, run /collab. It prints the room link.
  2. Open the collab rail — the header radio icon labelled Collab rail (src/routes/s/[sessionId]/+page.svelte:488-498).
  3. Click the radio button labelled Link collab session and paste the link into the field (placeholder roomId.key), then submit (src/lib/components/CollabRail.svelte:226-276).

Submitting posts the link to POST /api/sessions/:id/collab, which normalises it and answers {ok, link, webUrl} (src/routes/api/sessions/[id]/collab/+server.ts:166-175). The parser accepts the bare <room>.<key> secret, the legacy <room>#<key> and %23-mangled variants, a schemeless host[:port]/r/<room>... (wss:// inferred), explicit wss:///ws:// relay URLs, https:// web URLs whose fragment carries the secret, and one layer of surrounding shell quotes; ws:// and http:// are accepted only for localhost origins (src/routes/api/sessions/[id]/collab/+server.ts:49-152). Anything else is a 400 reading link is not a recognizable collab link (src/routes/api/sessions/[id]/collab/+server.ts:30-32). The browser client URL is always <origin>/#<room>.<key>, defaulting to https://my.omp.sh (src/routes/api/sessions/[id]/collab/+server.ts:26-27,71-74), and that is what the rail loads in an iframe with referrerpolicy="no-referrer" (src/lib/components/CollabRail.svelte:313-329). Once linked, the rail header offers open in a new tab, copy link and unlink (src/lib/components/CollabRail.svelte:184-219); unlink is a DELETE /api/sessions/:id/collab (src/routes/api/sessions/[id]/collab/+server.ts:193-200).

The link is the room secret: whoever holds it can read and steer the shared agent session. It is never logged, never written to events.jsonl and never echoed inside an error message; it lives only in the session record, and the session-list rows are built field by field so it cannot leak into a listing (src/routes/api/sessions/[id]/collab/+server.ts:7-15, src/lib/server/store.ts:148-151,191-194,221-229). Treat pasting it as granting access.

Only one of the two right-hand rails is open at a time: opening the dialog rail collapses the collab rail and the reverse (src/routes/s/[sessionId]/+page.svelte:202-212). The dialog rail is the chronological event feed, collapsed by default, with status events hidden and an unread counter for agent traffic (src/routes/s/[sessionId]/+page.svelte:35,92,202-207, src/lib/components/DialogRail.svelte:22).

Resuming a session

Reopening /s/<sessionId> restores the whole review state from the log. The loader fetches the session with its deck, then the full event history, then the deck list; the last two are allowed to fail so the page still renders (src/routes/s/[sessionId]/+page.ts:6-33). The most recent nav event sets the active section and, 300 ms after mount, scrolls back to the frame or section it named (src/routes/s/[sessionId]/+page.svelte:120-129,272-275). Verdict chips, section done ticks and the progress ring are all derived from the replayed events rather than stored separately (src/routes/s/[sessionId]/+page.svelte:66-78). The SSE stream then reconnects with ?after=<last seq> and retries with exponential backoff up to 30 s, so a dropped connection loses nothing (src/routes/s/[sessionId]/+page.svelte:282-302).

Troubleshooting

SymptomCause and fix
Import answers 400 md_path must be inside /home/loca/dev/the realpath of the document resolved outside the allowed root (src/lib/server/importer.ts:79-81); move the document under /home/loca/dev/ or import it over md_url
The collab rail rejects a pasted link with 400 link is not a recognizable collab linkthe link is not one of the accepted forms (src/routes/api/sessions/[id]/collab/+server.ts:30-32,49-152); re-run /collab and paste the printed link unedited
Deck list shows No decks and the hint POST /api/decks/importnothing has been imported yet (src/routes/+page.svelte:48-53)
Frames render as broken images, asset requests answer 404 asset not foundthe import ran without copy_from or base_url, or those srcs matched no file; check assets.missing and missingSamples in the import response (src/routes/api/decks/[id]/assets/[...path]/+server.ts:25, src/lib/server/importer.ts:221-224)
The collab iframe shows Embed failedthe embed did not report loaded within the 6 s watchdog; use the open-in-new-tab or retry buttons in that alert (src/lib/components/CollabRail.svelte:161-169,299-311)
The collab rail shows Embed URL unavailablea link is stored but no client URL could be derived from it; the endpoint degrades instead of failing (src/routes/api/sessions/[id]/collab/+server.ts:181-187, src/lib/components/CollabRail.svelte:285-298)
The mic button is disabled, tooltip Mic unsupportedthe browser exposes no SpeechRecognition; the speaker may still work (src/lib/voice.ts:66-74, src/routes/s/[sessionId]/+page.svelte:461-464)
Both voice buttons disabled, tooltip Voice unavailablethe voice module failed to load; notes, tasks and verdicts are unaffected (src/routes/s/[sessionId]/+page.svelte:230-239,461,476)
A red event failed toastthe event POST was rejected; the optimistic ingest is skipped, so nothing local is out of step (src/routes/s/[sessionId]/+page.svelte:96-110)

Next

  • first-deck — the QuizWizz tour as a worked example, including the round trip back into the source document.
  • api — every endpoint and the event schema.
  • agent-cookbook — attaching an agent to the bus.
  • security — who can reach this instance, which is currently everyone.