Rondel
End-to-end encrypted retrospectives and team meetings with no accounts, as a static web app plus a tiny room server: the meeting model, recurring series, link keys and sealing, backups, the room protocol and meeting templates specified exactly.
Build this pack
Paste this into Claude Code, Codex or Cursor, with your language. Your agent runs every step below on its own, builds the product the pack describes, and stops when it works and the pack's checks pass.
Build https://symbola.sh/packs/spikinglabs/rondel/ in Python The steps, to run yourself or to see what your agent does
Install the Symbola CLI (Python 3.11+):
uv tool install symbola(orpipx install symbola). Notpip install symbolon: that PyPI name is another project.Download the pack:
symbola pull spikinglabs/rondel --dest spec.Read
spec/AGENTS.mdand follow it. Build only fromspec/: don't look for the original product's source, the registry repository (it holds holdout tests) or other builds. Packs released before the rename saysymbolon <command>: runsymbola <command>instead. This is a product pack:spec/PRODUCT.mddescribes the whole product (its shape, surfaces and flows). Build that product, in the user's stack, with the components below as its core. Don't build a demo page around the components instead.Build each component in its own folder and run its checks as you go, fixing what fails:
symbola impl-init impl/backup --contract spec/components/backup/versions/1 symbola test spec/components/backup/versions/1 impl/backupIn Python, define
run(input)inimpl.py: it takes and returns a dict, and raisesValueErrorfor input the contract rejects. Pin third-party packages with==inrequirements.lock. In another language, setlanguage = "command"andcommand = [...]insymbolon-impl.toml; the program reads one JSON input per line on stdin and writes{"output": ...}per line. A component whosesymbolon.tomlsaysdriver = "cli"is a command line: build the real program, setcommandto it, and symbola runs it once per check.When every component passes, run
symbola keygenonce, then for each componentsymbola attest spec/components/backup/versions/1 impl/backup -o attestations/backup.json. On macOS, attest needs Docker running and accepts Python, Node or a static Linux binary (for Go,GOOS=linux);symbola testruns the same checks without it.
You are done when every flow in spec/PRODUCT.md works in the product you built and attest reports PASSED for every component. Write open questions and the readings you chose in NOTES.md. Every command is described at https://kausalflow.github.io/symbola/cli/.
Components:
backup:spec/components/backup/versions/1link-crypto:spec/components/link-crypto/versions/1meeting:spec/components/meeting/versions/1room:spec/components/room/versions/1series:spec/components/series/versions/1template:spec/components/template/versions/1
Every command: Symbola CLI docs. Agents can read these steps as plain text in llms.txt.
Who has built it
Test results that ship with the pack, one column per build. 6 of 6 components pass a build · 1 build · 1 runner.
| Component | spikinglabs/rondel |
|---|---|
backup | ✓ 26 checks |
link-crypto | ✓ 37 checks |
meeting | ✓ 64 checks |
room | ✓ 33 checks |
series | ✓ 80 checks |
template | ✓ 85 checks |
Runners: runner-h · newest 2026-10-26. Hover a result for details.
What a build must do
Each obligation is one decision a builder could get wrong. Decided is required, default may change in a fork with a note, and open is deliberately left to the builder.
meeting
- A new meeting's title is the explicit title (trimmed), else the template's title with `{date}` (the day, ISO) and `{month}` filled in, else `<template name> – <date>`; titles are trimmed and cut to 200 characters. meeting.title · 5 checks
- A meeting starts in the phase of its first board stage (write, group, vote, discuss or actions); a retro without board stages starts in write; a general meeting without one has no board phase; the template's per-stage `screen` is kept. meeting.start-phase · 3 checks
- Settings start from the type's defaults (a retro: anonymous cards, 5 votes per person, 3 per card, secret voting; a general meeting: names shown, 3 votes per person, 1 per card), then the saved defaults of the browser, then the template's own settings, which win; `settings` later merges fields into the current ones. meeting.settings · 4 checks
- A meeting made for a series stores the series reference and name, and its facilitators are the series' facilitators instead of only the creator. meeting.series-meeting · 1 check
- With a seed, every id a start creates (columns, agenda items, the meeting's own) is a pure function of the seed, the kind and the position: two browsers starting a series meeting from the same seed get identical ids, another seed gives other ids, and the seed is read as UTF-16 code units, so a long seed or an emoji works. Without a seed ids are random. meeting.ids-from-seed · 4 checks
- Notes given at the start become the shared notes of the matching agenda item. meeting.prefilled-notes · 1 check
- A next meeting from a previous one copies the settings (votes hidden again) and the template, carries over open actions (each keeping the origin of its first meeting) and the previous meeting's unfinished extra topics, and records the previous title. meeting.next-meeting · 3 checks
- A shared card carries its author's name only when authors are shown or the board is open, otherwise it is anonymous; the card's text is trimmed. meeting.card-author · 3 checks
- Card text is cut at 2,000 characters. meeting.card-text-limit · 1 check
- Dropping a card on another puts both in one group led by the target, in the target's column, with the dropped card's own title reset; dropping on a member joins that group; moving a card to another column moves its group; ungrouping or deleting down to one card dissolves the group (a title passes to the new leader); grouping a card onto itself or a missing card changes nothing; deleting a card also removes its votes. meeting.grouping · 8 checks
- Columns: a new column's title is trimmed and cut, its colour follows the number of columns; deleting a column deletes its cards; `move_column` puts a column first, last or between others. meeting.columns · 3 checks
- A vote that would exceed the per-person or per-card limit is refused with `limit` and changes nothing; removing votes frees them; 0 means unlimited; votes per person are counted over cards. meeting.vote-limits · 4 checks
- Moving to the discuss or actions phase reveals the votes; other phases leave them as they are; each phase change raises `readyEpoch`, which clears everyone's done mark. meeting.reveal-votes · 2 checks
- Starting an agenda item makes it current, marks the one left as done, sets the phase of a board stage and starts a timer of the item's minutes (none for 0 minutes or a plain item); pause, resume, +minutes and reset act on the timer; closing and reopening a meeting set its status and times; pause or resume with nothing to apply changes nothing. meeting.stage-timer · 6 checks
- Agenda items are appended (title trimmed, minutes, activity; a board stage sets the phase only when none is set); `move_agenda` puts an item at an index; deleting the current item clears the current item and drops its notes. meeting.agenda · 4 checks
- Notes are shared text per agenda item; inserts go at the end or at a given position. meeting.notes · 1 check
- Renaming trims the title, cuts it at 200 characters and ignores an empty one. meeting.rename · 1 check
- Joining refreshes the person's entry only after five minutes or under a new name (a new name replaces the entry); the creator cannot be removed; removing a person drops them from the facilitators and they cannot join again until `allow_back`. meeting.people · 2 checks
- Brainstorm ideas belong to an agenda item; each person has three dots per item, stackable, limited per item; step and hidden flag merge with defaults; an idea can become an action or an agenda topic; deleting an idea removes its dots; ideas can be edited. meeting.brainstorm · 3 checks
- Decisions and actions: texts trimmed, optional owner and due date, optional link to a card. meeting.decisions-actions · 1 check
- A meeting is read back from a meeting file (JSON text or object, as saved by 0.26.0 and 0.27.0), from a bare document update, or created from them, and steps continue from it; text that is not JSON, a file of another kind and a document that is no meeting are errors; reading back an export gives the same meeting. meeting.files · 7 checks
series
- A meeting's period is the calendar period of its date: the day (daily), the ISO week (weekly and biweekly, `YYYY-Www`: weeks start Monday, week 1 holds the first Thursday, two digits), the month (monthly), or `all` for an irregular series. series.period · 14 checks
- A series meeting is named `<period>` for the first and `<period>-<n>` for the n-th (n from 2) in a period; the base period leads back from a name, and a name that is no period stays as it is. series.meeting-name · 8 checks
- The next period is one step later (week, month or day) and handles the ends of week-years, months and years; irregular series have `all`; biweekly counts as weekly. series.next-period · 16 checks
- `next_after` gives the next period's first name, or the next free name when that period already has meetings; for irregular series it fills the lowest free number. series.next-occurrence · 4 checks
- Opening a series link decides from the current period's meetings: none → create the first; a live meeting → open it; only closed ones → report closed and offer the next free name; deleted meetings are skipped and flagged and never reopen; other periods' meetings do not count; a gap does not hide a later meeting; irregular series follow the chain of names. series.decide · 12 checks
- The latest meeting is the one with the latest creation time; none if there are none. series.latest · 2 checks
- A meeting's label is `Week 42, 2026` for weeks (a second meeting of the week keeps the week), `October 2026` for months and `5 Oct 2026` for days. series.label · 6 checks
- A series meeting's room id is the URL-safe base64 of the first 12 bytes of SHA-256 of `rondel-series-room:<series key>:<occurrence>` and its link key the first 18 bytes of SHA-256 of `rondel-series-key:<series key>:<occurrence>`, texts hashed as UTF-8, so everyone with the series link reaches the same room without coordination. series.room-key · 5 checks
- The calendar file is an iCalendar text: CRLF lines folded at 75 octets, text escaped, one event starting at the series' first meeting time for its length, repeating weekly (`FREQ=WEEKLY`), every second week (`INTERVAL=2`), monthly, or daily as every weekday (`BYDAY=MO,TU,WE,TH,FR`), with no rule for an irregular series, and the agenda and link in the description and location. series.calendar · 7 checks
- A series document (Yjs, saved by 0.26.0 and 0.27.0) holds the series settings, its meetings (each with name, room, state, title) and facilitators; adding, renaming and marking meetings keeps it in step; older documents lack optional keys and get defaults; an operation without a source is an error. series.document · 6 checks
crypto
- From the link key and the room, HKDF-SHA256 (salt `rondel:<room>`) gives an AES-GCM content key (info `content-v1`) and a 32-byte proof (info `auth-v1`, sent as standard base64); keys are URL-safe base64 (the last character of a 43-character key carries two bits, so spellings of the same bytes agree), the room is part of the salt, and the server never learns the key. crypto.auth · 6 checks
- A sealed text is base64 of nonce (12 bytes) + AES-GCM ciphertext + tag (16 bytes); every seal uses a fresh nonce; only the right key and room open it, damage and too-short input are detected as errors; sealing nothing leaves nonce and tag. crypto.seal-open · 10 checks
- An update stored by Rondel 0.26.0 or 0.27.0 still opens with its link key, so meetings saved by older releases keep working. crypto.stored-updates-open · 2 checks
- Links: a meeting is `<base>#/m/<room>/<key>`, a series `<base>#/s/<id>/<key>`, with the app under any path; a share address (claude.ai) uses the dotted form `#m.<room>.<key>` and loses its own fragment. crypto.links · 6 checks
- Routing reads the fragment: meeting, dotted meeting (normalised), series, dotted series, series overview, `#/data` (also with a suffix), `#/crash-test` (only exactly); a dotted id shorter than 8 is not dotted; anything else, an empty fragment or a meeting without key is home; extra path after the key is ignored. crypto.route · 12 checks
- A new meeting gets a random room id of 12 and a link key of 18 random bytes, URL-safe base64. crypto.new-ids · 1 check
backup
- A backup (`rondel-backup`, as saved by 0.26.0 and 0.27.0, JSON or text) restores; a protected one needs the passphrase and a wrong one is an error; non-JSON, a meeting file and a newer version are errors. backup.read · 10 checks
- A backup is JSON; with a passphrase its data is encrypted with PBKDF2-SHA256 (310,000 iterations) and AES-GCM with fresh salt and nonce, so two files of the same data differ. backup.make · 3 checks
- Restoring merges into the browser and never removes anything: the browser's own settings, voter ids and identity stay (or the identity is taken over on request, keeping the browser's name if it has one); meeting, series and template lists are unions without duplicates (ties keep the browser's entry, newer wins, own first); drafts and own cards merge by id; layouts the browser lacks are taken; entries that are not JSON are skipped; merging again changes nothing and reports zero counts. backup.merge · 13 checks
room
- The first message must be `hello` (protocol, proof, since); the room answers `welcome` with the seq, the stored entries after `since`, the peer count and the expiry; the first client sees nobody, the second sees the first and the first is told; anything before hello is an error; a second hello, non-JSON, unknown messages and bad fields are errors that keep the connection or close it as the protocol says. room.hello · 7 checks
- The first hello registers the proof (a string of at least 16 characters); later hellos must present the same proof, a different one is `forbidden` and the connection is closed; the first proof stays valid after the room slept. room.auth · 4 checks
- A hello with an old protocol version is told to reload, before any other check. room.version · 1 check
- An `upd` is stored with the next consecutive number, acknowledged to the sender with the sender's id and seq, and passed to the others (never echoed); presence (`aw`) is passed on, never stored or echoed; peers messages carry each receiver's own count. room.updates · 4 checks
- A connection that leaves tells the others (peers); one that never authenticated does not. room.leave · 1 check
- `compact` with `upTo` replaces the stored entries up to that number by one snapshot, keeps the newer ones, and is silent (no reply, nothing sent on). room.compact · 3 checks
- `delete` tells and closes everyone, empties the room and leaves a tombstone: the room is refused afterwards and never expires back to life. room.delete · 2 checks
- A room expires at its last write plus the retention time (null for an empty room or retention 0, which never expires; the welcome says when); presence and joining do not move it; an expiry sweep deletes only rooms that are due and have nobody connected (open connections postpone it by a day); an expired room is free again and a new link key may take it. room.expiry · 5 checks
- A frame holds at most 2,000,000 characters, a room at most 25,000,000 characters of stored data (exactly that many are accepted, one more is refused), and a room at most 100 connections. room.limits · 4 checks
- Room ids are 8 to 64 characters of letters, digits, `-` and `_`. room.ids · 2 checks
template
- A template YAML is read into name, type, title, description, columns, agenda and settings; plain lines in `columns` and `agenda` are titles, defaults fill the rest; only a name is needed; a template of Rondel 0.27.0 with every field reads fully. template.full · 3 checks
- The id is the caller's prefix plus the slug of a given `id`, else of the name (lowercase letters and digits, other runs becoming one `-`); a name with no letters or digits gives `template`; long ids are cut at 48 characters. template.id · 5 checks
- The name is required, trimmed, at most 80 characters (a number is text); an empty or longer one is an error. template.name · 5 checks
- An unknown field or type is an error reported at its line; every error carries its line; all known types are accepted. template.unknown-fields · 5 checks
- Minutes are whole numbers from 0 to 600; 601, negatives, fractions, text and booleans are errors. template.minutes · 8 checks
- Activity is one of the known kinds; null means a plain topic (which is not written out); an unknown one is an error. template.activity · 3 checks
- Agenda items need a title (a number as a whole item is an error); owner and notes are limited and left out when empty; the agenda is a list of at most 40 items. template.agenda · 6 checks
- A stage's `screen` names blocks as open or folded and at most two in `middle` (duplicates collapse, three are too many); errors are reported; an empty screen is left out. template.screen · 4 checks
- Columns need a title (or only a description), have a valid colour or none, are a list, and at most 12. template.columns · 6 checks
- Settings are a mapping of known fields (anonymous is the inverse of showing authors), values are checked, empty settings are left out. template.settings · 4 checks
- Descriptions and titles have maximum lengths; an empty title equals none. template.long-texts · 2 checks
- A YAML syntax error, a repeated key, tabs in indentation, a document that is no mapping, an empty file and comments only are reported as errors with their line. template.yaml-errors · 6 checks
- A template is saved as YAML with names, order and only what is set; a minimal one saves only name and type; texts that look like YAML or numbers survive; reading a saved template back gives the same template. template.to-yaml · 5 checks
- A new version drops the `id:` line, appends ` (v2)` to the name (`(v2)` becomes `(v3)`, `(v7)` counts on from 8, `(my version)` is a version suffix), skips names in use ignoring case, writes names needing quotes as JSON strings and adds a `name:` line when there is none; only the first `id:` and `name:` lines count. template.new-version · 9 checks
- The designer warns, by code, about a meeting without stages, board stages without columns, voting, grouping and discussing out of order, stages without time, and a run over 90 minutes without a break; a calm design has no warnings. template.design-checks · 11 checks
- A meeting saved as a template keeps its type, columns, agenda and settings; a series name names the template; open board is kept. template.from-meeting · 2 checks
- The built-in templates are exactly those listed by `builtin`. template.builtin · 1 check
app
- The app syncs as PRODUCT.md's second flow says: hello with `since`, applying welcome entries, uploading what the server lacks, `upd` with ids kept until acknowledged, re-announced presence, `compact` after 150 entries, ping every 10 s with a 25 s timeout, reconnect back-off from 1 s to 30 s, and `forbidden`/`version`/`gone` as final. app.sync-client · 0 checks
- The Node server answers `GET api/health` with `{"rondel": true, "protocol": 1}`, serves the built app with an `index.html` fallback and the headers PRODUCT.md lists, and takes its settings from PORT, DATA_DIR, STATIC_DIR and RETENTION_DAYS. app.health-and-static · 0 checks
- Meeting data stays in the browser (IndexedDB and the listed `rondel:` localStorage keys); no analytics and no third-party requests; the QR code is drawn in the browser; Markdown is rendered to elements, never raw HTML. app.local-first · 0 checks
- Without a room server the app falls back to peer-to-peer sync, and a built app loads offline through its service worker. app.offline-and-p2p · 0 checks
- The pages, panels and files of PRODUCT.md's Surfaces exist and work as its Flows describe, in English and German. app.surfaces · 0 checks
What it takes for granted
Facts about the world the pack relies on, and how to notice when one stops holding.
K-compatible-clients Every client of a room speaks the same protocol version, and uses a Yjs implementation that reads and writes the same v1 update encoding as Yjs 13.
Watch for: The server answers a hello with another protocol version with the error code version; saved files carry a format and version.
K-device-clocks The devices in a meeting have clocks that agree to within a few seconds, and show the same calendar date at the same moment.
Watch for: none
K-link-stays-private Only the people meant to be in a meeting ever have its full link (the part after #), and the link is never sent to anything that logs or stores URLs with fragments.
Watch for: none
K-names-are-free-text People's names are free text, not unique and not verified; a person is identified by a random id kept in their browser.
Watch for: none
K-one-server-process At most one room-server process serves a given set of meetings: open meetings live in that process's memory and its SQLite file has a single writer.
Watch for: none
K-secure-context Rondel runs in a secure browsing context (HTTPS, or localhost), where the Web Crypto API (HKDF, AES-GCM, PBKDF2) is available.
Watch for: none
K-small-meetings A meeting is small: a few dozen people, at most 100 open connections per room, and far less than 25 million characters of stored updates, because clients compact the stored log long before the limit.
Watch for: none
Rondel
Rondel is an end-to-end encrypted tool for team retrospectives and meetings, with no accounts: you share a link. People write cards, group, vote, discuss and agree actions (a retro), or run an agenda with a timer, shared notes, decisions and action items (any meeting). They can prepare before the meeting starts and carry open actions into the next one. A meeting can repeat daily, weekly or monthly behind one stable link.
It is a static web app plus an optional, very small room server. Everything people write is encrypted in the
browser with a key that lives only in the meeting link (after #), so whatever stores or relays the meeting
cannot read it. Teams use it from their browsers; an organisation can run its own copy on Cloudflare, on any
Node host or container, or as plain static files with browser-to-browser sync.
A rebuild is only faithful if it interoperates with real Rondel: its links open real meetings, its
encrypted updates and backups open in real clients (and the reverse), its room server speaks to real
browsers, and meeting files, backups and templates saved by any earlier release still open. The components in
this pack pin exactly those surfaces. PRODUCT.md says what the rest of the app is.
Non-goals
- Server-side search, reminders, integrations or any feature that needs the server to read meetings. The server must never see content.
- Accounts, passwords or per-person permissions. Anyone with the full link can join and edit; facilitator controls and hidden vote totals are conventions in the app, not enforced.
- Replacing the original's code layout, UI styling, animations or exact wording of interface texts and error messages. Interface languages beyond English are optional.
- Real-time collaboration beyond what the document format gives: Yjs merges concurrent edits; there is no locking or turn-taking.
- Hosting or billing. How an organisation deploys (Cloudflare, Docker) is its own concern; the pack only fixes what the server does.
Priorities
When goals conflict, earlier ones win:
- Confidentiality: nothing readable leaves the browser; the link key is never sent anywhere; the server stores and relays only sealed data.
- Compatibility: documents, encrypted updates, links, series rooms, backups and templates read and write exactly as real Rondel clients do, including everything earlier releases saved.
- Meeting behaviour: the operations and rules in the meeting, series and template components, exactly.
- The room-server protocol, auth check, limits and retention, exactly.
- Working without a server (peer-to-peer) and offline: edits are never lost; they sync when possible.
- The rest of the app (screens, flows, layout) as
PRODUCT.mddescribes; styling is the builder's.
Who resolves gaps
spikinglabs, as the namespace holder. If the pack doesn't settle something, record the
question and the reading you chose in NOTES.md next to your build; obligations marked
open in obligations.toml are yours to choose.
Rondel: the product
A build of Rondel is a static web app plus a small room server, not a set of libraries. The components
(meeting, series, link-crypto, backup, room, template) are the exact core; this file says what
surrounds them. Where it is silent (layout, colours, wording, animations), the builder decides.
Shape
- A single-page web app in the browser (React 19, Yjs, y-indexeddb for the local copy, Vite; installable as
a PWA with a service worker that makes the app load offline). Routes live in the URL fragment (
#/...), so the key in a link never reaches any server. Interface in English and German; the language follows the browser unless chosen. Light, dark or system theme. - All meeting data lives in the browser: one Yjs document per meeting (IndexedDB
rondel-<roomId>) and one per series (rondel-<seriesId>); small settings inlocalStorageunderrondel:-prefixed keys:me({id, name}),prefs,meetings,series,templates(YAML texts),people,lang,layout,voter:<room>,drafts:<room>,mine:<room>. The backup file carries exactly these keys and documents. - Sync, decided once at start from
window.RONDEL_CONFIG.sync(auto|server|p2p; a claude.ai preview addsartifact): inauto,GET api/healthnext to the app (or atserverUrl) answering JSON withrondel: truemeans room server, anything else (or afile:page) means peer-to-peer. With a server every change is sealed with the link-derived key and sent to it; without one, browsers connect directly (WebRTC through Trystero with Nostr or BitTorrent trackers asstrategy, optionalrelayUrlsandturn), with nothing stored anywhere, so a meeting exists only while someone who has it is online. - A room server speaks the
roomcomponent's WebSocket protocol atapi/rooms/<roomId>, answersGET api/healthwith{"rondel": true, "protocol": 1}and serves the built app (unknown paths fall back toindex.html; headers: assets cacheable for a year,referrer-policy: no-referrer,x-content-type-options: nosniff). It stores only sealed entries. Two adapters share oneRoom:- Node 22.5+: HTTP + WebSocket (ping every 25 s), meetings in one SQLite file (
node:sqlite) per data directory. Settings by environment:PORT(8787),DATA_DIR(./data),STATIC_DIR(./dist),RETENTION_DAYS(90; 0 keeps meetings until deleted). An hourly sweep deletes rooms unchanged for the retention time and with nobody connected. Run exactly one copy. A Docker image runs it on/data. - Cloudflare: a Worker serves the app, one Durable Object per room (SQLite storage, WebSocket
hibernation: idle open tabs cost nothing,
{"t":"ping"}is answered with{"t":"pong"}without waking the room, an alarm enforces retention,RETENTION_DAYSin the worker variables; a rate limit of connections per address per minute).
- Node 22.5+: HTTP + WebSocket (ping every 25 s), meetings in one SQLite file (
- Deployment settings live outside the code in a folder (
site.json+ optionaltemplates/*.yaml) applied to a builtdist/byscripts/site-config.mjs: it merges the settings into theRONDEL_CONFIGblock ofindex.html, copies templates, and withsync: "server"adds a Content-Security-Policy allowing only the page's own files and its server. Settings:name,sync,serverUrl,feedback,language,lab,robots,templates,operator {name, url, contact, hosting, retentionDays},strategy,relayUrls,turn,shareUrl. - No analytics, no third-party requests (the QR code is drawn in the browser; no online QR service). Markdown
is rendered to elements, never as raw HTML; links open only
http(s)andmailto.
Surfaces
Pages (hash routes; the dotted forms #m.<room>.<key> and #s.<id>.<key> are rewritten to them, as the
route operation of link-crypto states):
- Name prompt: shown before anything else (except
#/data) until the person has entered a name; the name is remembered in this browser (at most 60 characters). - Home (
#/): the list of this browser's meetings (title, kind, live or closed, open actions) and series, "Join a meeting by link", "Open meeting file…", "New meeting" with the template picker, the series form, "Design a meeting", preferences (name, theme, language, card and voting defaults, "Back up or restore…"), footer with language menu, "Run by", feedback link, data page link. - New meeting: pick a template (built in, "From your organisation", "My templates"), optional title, create.
Template cards offer New version (copy named "… (v2)"), Edit (my templates), Copy, Download .yaml.
Design a meeting / Design new… open the designer: name, stages with minutes, activity, owner, notes
and screen; columns; voting rules; a run of show with clock times and the
design_checkswarnings; a YAML tab editing the same template with errors by line; Import .yaml…. - Meeting (
#/m/<room>/<key>): top bar with title (double-click to rename), who is here, "I'm done" count, sync status, Invite, Minutes, Settings, command palette (Ctrl/⌘ K). Left: the agenda with the timer, "Next item", start/pause/resume/+1/+5 minutes, an item's notes. Middle: up to two blocks (board, brainstorm, discussion notes, action items, decisions, people), newest on top; every block has a fixed home in the right side bar and a ⤢ button to move it; layout remembered per browser inrondel:layout, a stage'sscreenapplies when it starts. Board: columns with colours, private drafts then shared cards (or live cards on an open board), drag a card onto another to group, group names, votes with dots, full-screen column, stage buttons (1 Write … 5 Actions) for facilitators, "Reveal votes". Brainstorm: collect → vote (3 dots) → pick (to action or agenda). People: who is here now (here, writing, done) and who joined earlier; the creator can remove or allow back. Close / Reopen. Settings: title, card authors, vote limits, secret voting, open board, "Use these settings for my new meetings", "Save as a new template…", Delete from server… (facilitators; room servers only). - Invite: copy link, "QR code of the meeting link", "Link to this meeting only", invite text.
- Minutes: formatted view with "Copy for email" (HTML with inline styles plus Markdown as plain text), "Copy Markdown", "Download .md".
- Series (
#/s/<id>/<key>): resolves the current meeting (below) and shows "Opening the current meeting…", or the closed state with "Open it to read the minutes, or start another meeting this week."#/s/<id>/<key>/overview: all meetings of the series, facilitators ("Facilitate this series too"), invite text, "Download calendar file (.ics)", "Open next occurrence", "Delete series from server…". - Data page (
#/data): plain-language statement of what is encrypted, what the server stores, what stays in the browser, what files contain, retention and deletion, the public relays of the peer-to-peer fallback, and "Check it yourself" (thenpm run verifycommands). Readable before entering a name. Keep it true. - Error screen with a reload button for crashes;
#/crash-testshows it on purpose. - Files people touch: meeting file (
.json,rondel-meeting), backup (.json,rondel-backup), minutes (.md), calendar (.ics), template (.yaml),site.json. Formats: see the components. - Server:
GET api/health; WebSocketapi/rooms/<roomId>(8 to 64 URL-safe characters); static files. - Command line:
npm run build,npm start(Node server),npm run verify -- <url>(checks that a site sends exactly the files of a build),node scripts/uptime.mjs <site>(app page,api/health, a real room).
Flows
- Start a meeting. Home → New meeting → pick a template → optional title → Create. The app makes a room
id and link key (
link-cryptonew_ids), builds the meeting from the template with an unseeded start (meetinginit; random ids), adds the creator as the first person (join), stores it locally and opens#/m/<room>/<key>(link-cryptolink). Outcome: an empty live meeting with the template's agenda, board and settings; the link is on the clipboard-ready Invite panel. - Join and sync. Opening a link (or pasting it into "Join a meeting by link"): derive keys from the
fragment (
link-cryptoauth); with a server, connect toapi/rooms/<room>and sendhellowithsince= the last stored seq the client has seen (room). Thewelcomeentries are opened and applied to the local document; if the local document holds anything the server lacks (offline edits, a new meeting, an opened file) it is sent as anupd. Afterwards every local change is sealed and sent asupdwith an increasingid(kept until acknowledged), others' changes arrive asupdand are applied, and presence goes as sealed awareness updates (aw) that are re-announced when someone joins. When the welcome says the log has more than 150 entries, the client sendscompactwithupTo= the last seq it has and a sealed snapshot of what the server is known to hold. The connection pings every 10 seconds and is dropped if nothing was heard for 25 seconds; it reconnects with back-off (1 s doubling to 30 s) or at once when the network returns.forbidden,versionandgoneerrors are final: the screen explains (wrong link / reload / deleted) and nothing is retried. While offline everything is kept locally and sent after reconnecting. Outcome: everyone with the link sees the same meeting, live or later. - Run a retro. A facilitator starts the Write stage; people write private drafts, share them (cards
share_card), then Group (group,group_title), Vote (vote, with limits) and Discuss (votes are revealed; cards in vote order), then Actions (add_action), then Close (close). "I'm done" counts people; everyone done shows a green mark. Outcome: minutes with the board, votes, notes, decisions, actions. - Run an agenda. "Next item" or starting an item makes it current and starts its timer (
set_current); notes are shared text (notes_insert); decisions and actions are added; brainstorm items collect ideas, dots, and turn ideas into actions or agenda topics. Outcome: a record of each topic. - Next meeting. After closing: "Start the next meeting" creates a meeting with the same template and
settings; open actions and unfinished extra topics carry over (
meetinginit withprevious); the new meeting names the old one aspreviousTitle. Also possible from a saved meeting file ("Open meeting file…"). - Recurring series. Home → series form (name, repeats daily / weekly / monthly / irregular, template,
optional first meeting time and length) → a series document is made (
seriesdocument) and the series link#/s/<id>/<key>is shown with invite text and the.ics(seriesics). Opening the link: load the series document, compute the current period (seriesperiod, local date), decide (seriesdecide):opengoes to that meeting;createmakes it with the room and key fromseriesroom, a meeting started from the series template withseed= that room id, the series' facilitators as facilitators, theseriesreference and title "– " ( serieslabel), and records it in the series document;closedshows the closed meeting with "Start another meeting this week" (the next free name). "Open next occurrence" prepares the next period's meeting (next_after). Titles and closed state are kept in step in the series document (sync_occurrence). Outcome: everyone with the link lands in the same meeting without a server telling them which. - Delete from server. A facilitator deletes a meeting: in a series the occurrence is first marked
deleted in the series document (
mark_deleted), thendeleteis sent; the other browsers are told, drop their local copy, and see that it was deleted. "Delete series from server…" deletes every meeting then the series document. - Keep a record. "Save meeting file" downloads
rondel-meetingJSON ({format, version: 1, roomId, key, title, exportedAt, update}); "Open meeting file…" restores it as a meeting in this browser, merged if the meeting is already known. Minutes as Markdown or email HTML. - Move browsers. Preferences → Back up or restore… downloads a backup (
backupmake, with an optional passphrase); restoring in another browser (backupread,merge) adds lists and meetings, never removes any, and optionally takes over the person's identity so they stay a facilitator. - Templates. Write or import YAML (
templateparse, errors by line), design visually (the same template), save to "My templates" in the browser, download.yaml, "New version" (new_version), "Save as a new template…" from a meeting (from_meeting); organisations ship YAML in theirtemplates/folder (listed inRONDEL_CONFIG.templates; a file that fails to parse is skipped with a console warning). The built-in templates are those oftemplatebuiltin. - Run your own copy. Build, apply a
site.json, run the Node server (or deploy the Worker), put HTTPS in front (browsers need it for the encryption except on localhost) and pass WebSocket upgrades on/api/. Retention deletes unchanged meetings afterRETENTION_DAYS; a browser that still has a copy can upload an expired meeting again, but not a deleted one.
Interfaces
- Room server (above); the app also works against a server at another address (
serverUrl). - Peer-to-peer relays (public Nostr relays or BitTorrent trackers, optional TURN) are used only when no
server answers or
syncisp2p; withsync: "server"the app never contacts them. - claude.ai artifact (optional): inside a preview with the
db,roomanddownloadscapabilities the app stores and syncs through them and invite links use the dotted form withshareUrl. - No credentials in the app. Deployment secrets (Cloudflare API token and account id) belong to the release workflow only.
Order
If a build cannot do it all, build in this order: (1) the six components; (2) the Node room server; (3) the browser app against a server: home, new meeting, join, sync, meeting screen with agenda, notes, cards, votes, actions, decisions, close, minutes; (4) templates and the designer, meeting files; (5) series and calendar invites; (6) backup and restore; (7) retros' stages, brainstorm, command palette, layout; (8) peer-to-peer fallback, PWA, German, Cloudflare adapter, the data page and the release tooling.