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).

FieldRequiredEffect
md_pathone of md_path / md_urlabsolute path whose realpath must remain inside /home/loca/dev/ (src/lib/server/importer.ts:77-90)
md_urlone of md_path / md_urlHTTP(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_fromnocopy image assets from a directory into the deck (src/lib/server/importer.ts:207-239)
base_urlnoprefix an origin onto root-relative image sources; copy nothing (src/lib/server/importer.ts:242-253)
idnoexplicit 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_from for files under /home/loca/dev/ or DATA_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_url when another site keeps serving the pixels. Only root-relative sources gain the origin; absolute URLs and data: 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 nav after 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 finding badge stays amber while unruled and becomes outline after a verdict; the decision edge distinguishes keep, recapture and kill. The caption uses caption, otherwise alt, 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 kbd chips k r x and n 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 are Note text, Tags and Task title; the primary buttons read Save note and Save 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>.png and 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 done posts route_done, and done routes show a check (src/lib/components/StickyContext.svelte).
  • Header: Findings only keeps finding-badged frames while retaining empty group headers. The progress ring counts done sections over sections that contain frames; route_done is the source of done state (src/routes/s/[sessionId]/+page.svelte).

Keyboard map

PurposeKeysAction
navigatej / ↓next frame
navigate↑previous frame
navigateunext unruled
navigatefnext finding
rulek / 1keep
ruler / 2recapture
rulex / 3kill
annotatennote
annotatettask
annotatesscribble
annotatempush to talk
viewddensity
viewspacestop speech
view?shortcuts
viewescclose

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 say event 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 to en; 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 speech event 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 unsupported or Voice 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

  1. Run /collab in the OMP TUI and keep the printed room link secret.
  2. Open Collab rail, choose Link collab session, paste the link into roomId.key, and submit.
  3. 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