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).
| Field | Required | Effect |
|---|---|---|
md_path | one of md_path / md_url | absolute path, read from disk; the realpath must land inside /home/loca/dev/ (src/lib/server/importer.ts:77-90) |
md_url | one of md_path / md_url | fetched over http(s) with a 20 s timeout; any other scheme is a 400 (src/lib/server/importer.ts:92-107) |
copy_from | no | directory the document’s images are copied from into the deck (src/lib/server/importer.ts:207-239) |
base_url | no | origin prefixed onto root-relative image srcs; nothing is copied (src/lib/server/importer.ts:242-253) |
id | no | explicit 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_fromwhen 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_urlwhen the pixels stay where they are and you only need a prefix. Root-relative srcs get the origin prepended, absolute URLs anddata: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-warningreadingfindingwhen the frame carries thefindingbadge, andbadge-successreadingcleanwhen 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 isbadge-error, recapture isbadge-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"andaria-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:
| Icon | Action | What it posts |
|---|---|---|
| check | keep | verdict event with verdict: keep |
| x | kill | verdict event with verdict: kill |
| rotate-ccw | recapture | verdict event with verdict: recapture |
| notebook-pen | note | opens a modal for text plus tags, then a note event |
| square-check | task | opens a modal for a title plus tags, then a task event |
| pencil | scribble | opens 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
findingand 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 aroute_doneevent names it. No control in the browser emitsroute_done: it arrives from the attached agent or from a plainPOST /api/sessions/:id/events(src/routes/s/[sessionId]/+page.svelte:71-75, and see agent-cookbook).
Keyboard map
| Key | Action |
|---|---|
j, ArrowDown | next frame |
ArrowUp | previous frame |
k | keep the active frame |
x | kill the active frame |
r | recapture the active frame |
n | note on the active frame |
t | task on the active frame |
s | scribble on the active frame |
m | arm or disarm the microphone |
space | stop the speech queue |
? | open or close the keyboard map |
Esc | close 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.
- In the OMP TUI, run
/collab. It prints the room link. - Open the collab rail — the header radio icon labelled
Collab rail(src/routes/s/[sessionId]/+page.svelte:488-498). - Click the radio button labelled
Link collab sessionand paste the link into the field (placeholderroomId.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
| Symptom | Cause 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 link | the 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/import | nothing has been imported yet (src/routes/+page.svelte:48-53) |
Frames render as broken images, asset requests answer 404 asset not found | the 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 failed | the 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 unavailable | a 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 unsupported | the 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 unavailable | the 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 toast | the 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.