Three calls move a markdown document into a live tandem review: import the deck, create a session, then open its URL. The frame loop does the rest; workspace descriptions below reflect the 2026-08-21 polish pass.
https://tndm.loca.zone uses Authelia one_factor. A browser returns from auth.loca.zone with a session cookie; non-browser /api/ calls need the bearer token at .api-token (mode 600):
export TNDM_TOKEN=$(cat /home/loca/dev/tandem-audit/.api-token)Without either credential the edge redirects to Authelia instead of returning JSON. See api for the contract and private page (sign-in required) for the mechanism.
1. Import a document
POST https://tndm.loca.zone/api/decks/import parses one markdown document 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 whose realpath must remain inside /home/loca/dev/ (src/lib/server/importer.ts:77-90) |
md_url | one of md_path / md_url | HTTP(S) only; the hostname must resolve exclusively to public addresses or import is refused with 400. Redirects are re-validated per hop and capped at 5; the response is capped at 8 MiB (413); the 20 s timeout still applies (src/lib/server/importer.ts:32-33,186-,225-,256-) |
copy_from | no | copy image assets from a directory into the deck (src/lib/server/importer.ts:207-239) |
base_url | no | prefix an origin onto root-relative image sources; copy nothing (src/lib/server/importer.ts:242-253) |
id | no | explicit deck id; otherwise slug the filename (src/lib/server/importer.ts:121-134) |
Both source fields return pass exactly one of md_path or md_url; neither returns md_path or md_url is required. If both asset strategies arrive, copy_from wins (src/lib/server/importer.ts:109-118,272-276).
# Copy local assets, as used for the first deck
curl -sX POST https://tndm.loca.zone/api/decks/import -H "Authorization: Bearer $TNDM_TOKEN" -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"}'
# Point at already-hosted assets
curl -sX POST https://tndm.loca.zone/api/decks/import -H "Authorization: Bearer $TNDM_TOKEN" -H 'content-type: application/json' -d '{"md_url":"https://wiki.example.zone/page.md","base_url":"https://wiki.example.zone","id":"page"}'Importing from a loopback, LAN or link-local address fails by design, so a document served only on the host itself must be imported with md_path instead (src/lib/server/importer.ts:186-,256-).
- Use
copy_fromfor files under/home/loca/dev/orDATA_DIR. The longest matching source suffix is copied once, rewritten to/api/decks/<id>/assets/<subpath>, and thereafter owned by the deck (src/lib/server/importer.ts:160-172,183-238). - Use
base_urlwhen another site keeps serving the pixels. Only root-relative sources gain the origin; absolute URLs anddata:sources stay unchanged, and remote failure can break the deck (src/lib/server/importer.ts:56-59,242-253). - Use neither for image-free documents or documents with absolute image URLs.
Success returns {ok, id, sections, frames, imagesRewritten, assets}; assets holds {copied, missing, missingSamples}, capped at 20 missing samples. Re-importing an id overwrites deck.json and re-copies without pruning assets or touching sessions (src/lib/server/importer.ts:16-28,221-224,255-261).
2. Create and open a session
curl -sX POST https://tndm.loca.zone/api/sessions -H "Authorization: Bearer $TNDM_TOKEN" -H 'content-type: application/json' -d '{"deckId":"qwizz-2026-08-20"}'The store reads the deck before writing, then creates an eight-random-byte hex id, scribbles/, an empty events.jsonl, and the response {id, deckId, createdAt, lastSeq} (src/lib/server/store.ts:153-168). Open https://tndm.loca.zone/s/<sessionId>.
The landing deck card shows a visible New session button. Existing session rows show the done-section ring, short id, relative last-event time and a visible Open session link (src/routes/+page.svelte).
3. Rule the deck
flowchart LR A["Arrive at frame"] --> B["Inspect"] --> C{"Verdict"} C -->|k| D["keep"] C -->|r| E["recapture"] C -->|x| F["kill"] D & E & F --> G{"Optional annotation"} G -->|n / t / s| H["note / task / scribble"] G -->|skip| I["Next unruled"] H --> I --> J{"Route complete?"} J -->|no| A J -->|yes| K["Mark route done"]
- Map: the left rail groups level-2-or-shallower sections and collapses their deeper routes. Its columns show ruled frames, finding-badged frames and done state; amber means outstanding unruled finding frames, not callout count. Clicking a route moves both viewport and keyboard caret (
src/lib/components/Rail.svelte). - Scroll: visible sections stay in one windowed column. Real section changes post one
navafter a 1 s debounce; initial load and reload silently prime navigation and append none (src/routes/s/[sessionId]/+page.svelte). - Active card: focusing any real control activates that card. Clicking dead space does not. Focus mode dims every other card.
- Card readout: metadata shows filename, non-duplicate tags and frame position. A source
findingbadge stays amber while unruled and becomes outline after a verdict; the decision edge distinguisheskeep,recaptureandkill. The caption usescaption, otherwisealt, and expands from its one-line clamp (src/lib/components/FrameCard.svelte). - Controls: six icon-only actions are check/keep, rotate/recapture, x/kill, notebook/note, square-check/task and pencil/scribble. The active card shows
kbdchipsk r xandn t s; inactive cards put each key in its tooltip (src/lib/components/VerdictBar.svelte). - Dialogs: note, task, shortcuts and full-size lightbox are native dialogs with a real focus trap, native
Escape, and focus returned to the opener. Visible field labels areNote text,TagsandTask title; the primary buttons readSave noteandSave task(src/lib/components/NoteModal.svelte;src/lib/components/TaskModal.svelte;src/lib/components/KeymapOverlay.svelte;src/lib/components/FrameCard.svelte). - Scribble: available only for image frames after the module loads. Save uploads a PNG data URL; the server writes
sessions/<id>/scribbles/<frame>--<seq>.pngand logs only its filename (src/lib/Scribble.svelte;src/lib/server/store.ts:383-386,411-414). - Route bar:
{route} · {ruled}/{n} frames ruled · {open} findings open; the warning chip appears only while findings remain.Mark route donepostsroute_done, and done routes show a check (src/lib/components/StickyContext.svelte). - Header:
Findings onlykeeps finding-badged frames while retaining empty group headers. The progress ring counts done sections over sections that contain frames;route_doneis the source of done state (src/routes/s/[sessionId]/+page.svelte).
Keyboard map
| Purpose | Keys | Action |
|---|---|---|
| navigate | j / ↓ | next frame |
| navigate | ↑ | previous frame |
| navigate | u | next unruled |
| navigate | f | next finding |
| rule | k / 1 | keep |
| rule | r / 2 | recapture |
| rule | x / 3 | kill |
| annotate | n | note |
| annotate | t | task |
| annotate | s | scribble |
| annotate | m | push to talk |
| view | d | density |
| view | space | stop speech |
| view | ? | shortcuts |
| view | esc | close |
The handler ignores modified keystrokes and typing inside inputs, textareas, selects or editable content. While a native dialog is open, its focus trap and native Escape own interaction; the same grouped map is available from ? (src/routes/s/[sessionId]/+page.svelte; src/lib/components/KeymapOverlay.svelte).
Theme
Forest dark is the default daisyUI theme; no data-theme attribute means forest. The theme toggle sits in the workspace header and landing navbar, switches to light, and persists light or forest in localStorage key tndm.theme; the pre-paint guard applies a saved choice before rendering (src/app.css; src/app.html; src/lib/components/ThemeToggle.svelte). Page titles are tndm — decks and {deck title} — tndm.
Voice
- Speaker is on by default. Only a live, non-empty agent
sayevent is spoken; human events and deck captions are not (src/routes/s/[sessionId]/+page.svelte). - Before TTS, markdown fences disappear and links, images, headings, quotes, lists and emphasis collapse to prose. Utterances queue at rate 1.05 in
deck.meta.lang, falling back toen; disabling the speaker cancels current and queued speech (src/lib/voice.ts:46,52-56,81-121,133-142). - Mic is click-to-arm: the first final transcript posts one
speechevent and disarms; a second click stops it. Recognition is non-continuous with one alternative (src/lib/voice.ts:162-216,232-245). - Missing browser APIs disable the affected button with
TTS unsupported,Mic unsupportedorVoice unavailable; verdicts, notes and tasks remain available. Desktop Chromium can satisfy both predicates, while Firefox leaves the recognizer unavailable. Neither path was proven end to end on the audio-less host or headless browser during the 2026-08-20 evidence pass; verify in a desktop browser and see private page (sign-in required).
Collab: pair a live OMP agent
- Run
/collabin the OMP TUI and keep the printed room link secret. - Open
Collab rail, chooseLink collab session, paste the link intoroomId.key, and submit. - Work beside the embedded agent transcript and composer; opening either right rail closes the other.
POST /api/sessions/:id/collab accepts the documented bare, legacy hash, %23, schemeless relay, WebSocket and HTTPS-fragment forms, strips one shell-quote layer, permits insecure schemes only on localhost, and returns {ok, link, webUrl} with an iframe URL defaulting to https://my.omp.sh and referrerpolicy="no-referrer" (src/routes/api/sessions/[id]/collab/+server.ts:26-32,49-175; src/lib/components/CollabRail.svelte). Linked controls open, copy or unlink; unlink calls DELETE /api/sessions/:id/collab (src/routes/api/sessions/[id]/collab/+server.ts:193-200).
The room link grants read-and-steer access. It stays only in the session record: never in events.jsonl, listings or error messages (src/lib/server/store.ts:148-151,191-194,221-229). The dialog rail is a collapsed-by-default chronological feed: it hides status, shows agent unread count, renders nav as dim ambient telemetry with consecutive runs collapsed, uses real buttons for frame jumps, and follows new rows only when already near the bottom (src/lib/components/DialogRail.svelte).
Resume safely
Reopening /s/<sessionId> loads deck, session and event history, tolerating deck-list or history fetch failure so the workspace still renders (src/routes/s/[sessionId]/+page.ts:6-33). After mount, the latest nav restores its named frame or section; when it names only a section, the keyboard caret moves to that section’s first frame, so the first j advances there rather than at deck top. Loading or reloading adds no nav; only the first genuine section change adds one. Verdicts, route checks and progress replay from the append-only bus, then SSE resumes at ?after=<last seq> and retries with exponential backoff up to 30 s (src/routes/s/[sessionId]/+page.svelte).
Troubleshooting
Import returns 400 `md_path must be inside /home/loca/dev/`
The document’s realpath escaped the allowed root. Move it under /home/loca/dev/; md_url only works for a publicly resolvable host, so it is not a workaround for a file outside /home/loca/dev/ (src/lib/server/importer.ts:79-81,186-,256-).
`curl` receives HTML or a 302 to `auth.loca.zone` instead of JSON
The request has no credential or a stale token, and curl cannot complete the interactive login. Send -H "Authorization: Bearer $TNDM_TOKEN", including on SSE.
The browser returns to `auth.loca.zone` during a review
The Authelia session lapsed after its 1 h expiry or 5 m inactivity window. Log in and reopen the session URL; recorded events remain server-side.
The collab link is rejected as `link is not a recognizable collab link`
The value is outside the accepted forms. Re-run /collab and paste its printed link unchanged (src/routes/api/sessions/[id]/collab/+server.ts:30-32,49-152).
The landing page says `No decks` and shows `POST /api/decks/import`
No deck has been imported. Run the import in step 1 (src/routes/+page.svelte).
Frames show broken images or assets return 404 `asset not found`
The import omitted copy_from or base_url, or sources matched no file. Inspect 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 reports `Embed failed`
The iframe did not report loaded within its 6 s watchdog. Use the visible open-in-new-tab or retry control (src/lib/components/CollabRail.svelte).
The collab rail reports `Embed URL unavailable`
A link is stored but no client URL can be derived; the endpoint degrades instead of failing (src/routes/api/sessions/[id]/collab/+server.ts:181-187; src/lib/components/CollabRail.svelte).
The mic is disabled with `Mic unsupported`, or both voice buttons show `Voice unavailable`
Mic unsupported means the browser exposes no SpeechRecognition; speaker may still work. Voice unavailable means the voice module failed to load; notes, tasks and verdicts are unaffected (src/lib/voice.ts:66-74; src/routes/s/[sessionId]/+page.svelte).
A red `event failed` toast appears
The event POST was rejected. Optimistic ingest is skipped, so local state does not advance ahead of the bus (src/routes/s/[sessionId]/+page.svelte).
Next
- first-deck — the QuizWizz worked example and merge-back.
- api — endpoints and event schema.
- agent-cookbook — attach an agent to the bus.
- private page (sign-in required) — the gate and its limits.