Full design · AI
Meeting notes
AI meeting notes for small teams.
Live preview. Click around, it works.
About this template
AI meeting notes for small teams. Record a meeting in the browser with a live level meter, or upload the audio, and get a transcript with speakers and timestamps, a summary, the decisions made and action items with owners and due dates. Search every transcript, play any moment back from its line, track actions across all meetings with CSV and calendar export, ask questions answered only from your own meetings with cited timestamps, and set a retention period that really deletes old recordings. Admins manage members, vocabulary and retention; members work their own items.
What the agents are asked
The first message sent when you use this template. You can edit the plan before anything is built.
Adapt this meeting notes app to my team. Keep the library with search across transcripts, the in browser recorder with consent and upload, the meeting page (waveform player, transcript with speakers and timestamps that seek the audio, summary, decisions and editable action items), the action items view with CSV and calendar export, the Ask screen that answers only from our transcripts with cited timestamps, and the settings (members, vocabulary, retention with undo, Admin and Member roles). Change the brand, the team names, the members, the sample meetings and the vocabulary to match my business. Keep recordings private to the team and keep the demo labels until a speech to text model and an AI model are connected.
AI meeting notes for small teams. Record a meeting in the browser or upload the audio; get a transcript with speakers and timestamps, a summary, the decisions made, and action items with owners and due dates. Every word is searchable, every timestamp plays its moment, action items export to CSV and calendar files, Ask answers only from transcripts you may read, and a retention period really deletes old recordings.
"Sotto", "Paperbark Goods" (the Melbourne homewares business in the sample), its people, the supplier Ashgrove and the carrier Kestrel Freight are fictional; emails use .example domains. Not modelled on any real product.
Plain HTML, CSS and one ES module; no dependencies, no build step. Light and dark follow the device (the sun or moon button, or Settings, picks one). Bottom tab bar under 960px, navy sidebar above. Dates in en-AU, sample data dated relative to today.
Privacy: read this before you adapt it
Meeting recordings are among the most sensitive data a small business holds.
- Consent to record. Recording or uploading needs the tick "Everyone in this meeting has agreed to be recorded". Each meeting stores who confirmed it and when (
consent_confirmed_by,consent_at) and shows it. The recorder suggests asking out loud so the answer is on the recording. Laws on recording conversations differ between Australian states and territories, and between countries; some require every person's consent. This is general information, not legal advice: check the rules where you and your callers are. - Where audio is stored. In the demo, audio stays in this browser (IndexedDB), or in memory for the visit if IndexedDB is blocked, and the app says so. In the real app it goes to a private Supabase Storage bucket,
meeting-audio, one folder per team (<team_id>/<meeting_id>.<ext>), with storage policies inschema.sqlapplying the team rule, played through short lived signed URLs. Audio reaches a speech to text provider only from the server; choose one that does not keep or train on customer audio, and say so in your privacy policy. - Transcripts are sensitive: row level security per team. A meeting belongs to one team. Only that team and the workspace's admins can read its transcript, speakers, decisions, action items and flags, enforced in the database (
sotto_in_team(),sotto_can_read_meeting()), not only on screen. Ask runs with the asker's token, so it never quotes a meeting they could not open. Ask conversations are private to the asker, admins included. - Retention really deletes. Admins choose 30, 60, 90 or 180 days, a year, or keep. The demo shows which meetings would go on a timeline, confirms in the page, deletes audio, transcript and minutes, and offers Undo; it also runs when the app opens. The real
sotto_apply_retention()runs nightly asservice_role, hides old meetings at once and purges them with their storage objects 7 days later.
Art direction
A quiet room after hours: ink on paper, and one light that means "live".
- Palette, tokens at the top of
styles.css: Night Ink#14233C(text, buttons, sidebar, the recording studio and the player deck), Paper#F1F2EE(a cool ground, not cream), Tally#E5483A, the red of a recording light, used only for what is live: record, the playhead, the line being played, overdue. As text on light it darkens to#B22E1F. Five speaker colours always travel with a name or face. Dark is built on navy, not black. No gradients, no glow. - Type. Funnel Display for headings and the minutes summary, tight tracking, ratio 1.25; Funnel Sans for reading, line height 1.6, transcript lines capped at 68 characters; Martian Mono, narrow, only for timecodes and durations.
- Photography. Five images from
media.json, one shoot: low late afternoon winter sun through a sash window, warm 4500K key, cool navy shadows, natural skin. One subject each, once per screen: the team at the table is the first visit welcome and the empty library; the notebook and phone sit beside upload; three portraits are those people's avatars (others get initials). - Layout. One asymmetric move per screen: the library leads with the latest meeting as a navy deck beside its summary, then a ledger of rows with date blocks and waveform thumbnails; Record is a navy studio beside a paper details column; the meeting opens with who spoke (a talk time bar with faces) and what came of it, then a sticky navy player deck and a sticky minutes column while the transcript scrolls; Action items opens on one oversized overdue figure.
- Motion, each with one job, all off under
prefers-reduced-motionor the in-app setting: a staggered entrance on first load;document.startViewTransitionbetween screens (skipped when hidden);animation-timeline: view()reveals with an IntersectionObserver fallback at 0.2; aclip-path: inset()photo reveal, once; 150 to 200 ms hovers; the recording light breathes only while recording. The orchestrated moment, "minuting": when a new recording finishes, the waveform decoded from the real audio sweeps in, then each decision and action lifts out of its transcript line and files into the minutes. "Show where these came from" replays it on any meeting.
Photos (/template-media/voice-notes/): team-table 1600 x 1211, notebook-phone 1100 x 1366, and 700 x 700 portraits portrait-kiri, portrait-ruby, portrait-declan.
Roles
The top bar reads Demo · Viewing as: Admin / Member (preview only; the real app reads the role from members).
| Admin (Kiri) | Member (Ruby: Product, Growth) | |
|---|---|---|
| Meetings, Ask | Every team | Product and Growth only; Operations meetings are hidden and counted, and their links say "belongs to a team you are not in" |
| Record, edit minutes, name speakers | Any team | Their teams |
| Delete a meeting | Any | Ones they recorded |
| Members, teams, workspace, retention | Change | Read only, with a notice |
| Vocabulary | Change | Change |
The rules live in can and myTeamIds() in script.js and mirror the RLS in schema.sql.
Screens
- Meetings (
#/library). The latest meeting at a glance (summary, the first decision, talk time, counts), then search across every transcript (/to focus; all terms must match,"quoted phrases"exact; matching moments link to that second), filters by team, date and tag, four sorts, grouped rows with waveform thumbnails, a "Due for you" rail and a "Private by team" note. First visit: a welcome with three steps. Empty: a photographic empty state with Record, Upload and "Bring back the sample meetings". - Record (
#/record).getUserMedia,MediaRecorderand anAnalyserNodedrawing a scrolling level meter on canvas. Test microphone runs the meter without recording and names the input. Pause and resume (Space), flag a moment (F), stop, listen back, then save or discard (confirmed in the page). Recording continues on other screens behind a top bar pill; closing the tab asks first; two hour limit. Upload by picker or drop (MP3, M4A, WAV, WebM, OGG, 40 MB); the file is decoded before anything is saved. Plain errors for a blocked or missing microphone, an unsupported browser or a non https page. The label "Demo: the transcript is simulated. Connect a speech to text model to transcribe real audio." sits in the studio and on every recorded meeting. - Processing. Saving, decoding (
decodeAudioData) and finding speech (energy against the file's own noise floor) are real; transcription and minutes are simulated and say so. A tab closed mid way resumes, or offers Try again and Delete. - Meeting (
#/meeting/<id>?t=<ms>). Who spoke, with talk time and faces; decisions, open actions and line counts. The player deck draws the waveform from decoded audio with flags on top and the sources of decisions and actions below; click or drag to seek, arrow keys on the slider,Space/K,J/Lfor 15 seconds,[/]for speed; "now speaking" follows playback. The transcript has faces, speaker names and timestamps that play their moment, follow playback, find with a count, inline correction that keeps what was heard, and vocabulary fixes underlined. Naming "Speaker 2" hands them the unowned actions they took on. Minutes: editable summary; decisions and action items with add, edit, assign, date, tick and delete, each with Undo and a chip that plays its source. Print minutes (a print stylesheet prints a clean document), export Markdown, text, CSV or .ics, rename, tags, delete with Undo. Sample meetings play a labelled synthetic murmur in each speaker's pitch so the player works. - Action items (
#/actions). Overdue, next 7 days, open and done figures; My items or Everyone; Open, Overdue, Done; team, search and three sorts; grouped by due. Edit in place with Undo; every item links to its moment. CSV (UTF-8 BOM, formula injection neutralised, a link per row) and.ics(RFC 5545, CRLF, 75 octet folding, all day events with an 8 am alarm). Demo reminders appear under the bell each morning; "Send a test reminder" shows one. - Ask (
#/ask). Answers only from readable transcripts, quoting each source word for word with speaker, meeting, date and a timestamp that plays it. Overdue, "my actions" and "who is doing" questions answer from action items; "what did we decide" from decisions; anything else is ranked retrieval with light synonyms, and a question in the transcript is cited with the reply that followed. Says so when nothing matches. Scope picker, suggestions, Clear with Undo, "Demo answer" label. - Settings (
#/settings/<section>). Workspace name; members (validated add, roles with a last admin guard, team membership, remove with Undo) and teams; vocabulary (terms with what they sound like, fix counts, remove with Undo, a before and after example, "Try a sentence"); retention with a timeline and live count; reminders; appearance (theme, reduced motion, larger transcript); privacy; sample data (remove in one step, restore, reset). - Keyboard:
/search,Nrecord,Gthen a screen letter,?for the list.
What is demo or simulated
| Part | Status |
|---|---|
| Recording, upload, decoding, waveform, speech detection, playback, mic check | Real, in the browser |
| Transcript words; summary, decisions, actions | Simulated from a script per kind of team, laid onto the speech found in your audio, then editable. Labelled |
| Ask | Real retrieval; answers are quotes. Labelled "Demo answer" |
| Sample meetings | Marked Sample, relative dates, synthetic labelled audio. SAMPLE_VERSION in script.js: when it changes, returning visitors get the new samples and keep their own rows |
| Reminders | In app only; connect email to send them |
| Saving | localStorage (sotto:v1, sotto:prefs) and IndexedDB (sotto-audio), every access guarded. Routing uses location.hash only (the gallery sandbox blocks the History API) |
Data model
schema.sql holds the tables, RLS, grants, storage policies and the seed (the same rows as demo mode). store.data in the browser has the same tables and columns.
| Table | Holds | Access |
|---|---|---|
workspaces | name, retention_days | members read; admins update |
teams, team_members | teams and who is in them | members read; admins write |
members | name, email, role, title, avatar_url, remind_due, user_id once claimed | members read; admins write; you may change your own reminder (a trigger blocks self promotion and keeps one admin) |
meetings | team, title, time, duration, source, status, tags, attendees, summary, consent, audio_path, peaks, deleted_at | team and admins; insert requires being in the team and confirming consent as yourself; delete by creator or admin |
speakers, segments | speakers; lines with text as heard and edited_text | follow the meeting |
decisions, action_items, markers | minutes and flags, each linked to a segment | follow the meeting |
vocabulary | term, sounds like | any member |
ask_messages | questions, answers, citation snapshots | the asker only |
Child rows reference parents through (workspace_id, id). sotto_create_workspace(name, your_name) makes a workspace with you as admin; sotto_apply_retention() is service_role only. The file ends with a VERIFY block to rehearse as a member, an admin and a stranger. It has not been run yet.
Moving from localStorage to Supabase
Screens read store.data and write only through store.insert, update and remove (and helpers built on them). Replace section 3 of script.js:
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); // public key; RLS protects rows
store.load = async function () {
const tables = ['teams', 'members', 'team_members', 'meetings', 'speakers', 'segments', 'decisions', 'action_items', 'markers', 'vocabulary', 'ask_messages'];
const [{ data: ws }, ...rows] = await Promise.all([supabase.from('workspaces').select('*').single(), ...tables.map((t) => supabase.from(t).select('*'))]);
this.data = { workspace: ws, ...Object.fromEntries(tables.map((t, i) => [t, rows[i].data || []])) };
};
store.insert = async (t, row) => (await supabase.from(t).insert({ ...row, workspace_id: store.data.workspace.id }).select().single()).data;
store.update = async (t, id, patch) => (await supabase.from(t).update(patch).eq('id', id).select().single()).data;
store.remove = async (t, id) => supabase.from(t).delete().eq('id', id);RLS refuses updates silently (zero rows): check what .select() returns and tell the person. Swap audioDB for Storage: upload(meeting.audio_path, blob) and createSignedUrl(path, 3600).
Where the AI calls go
Server functions running with the caller's JWT, so RLS applies; keys never reach the browser.
- `transcribe-meeting`: after upload, sends the audio to a speech to text model with diarisation and the workspace vocabulary as hints; writes
speakersandsegments. ReplacessimulateMinutesandturnsFrom. - `summarise-meeting`: asks a language model for a summary, decisions and action items, each citing its
segment_id, with a suggested owner and due date. Keep the rule that every item cites a line. - `ask-meetings`: retrieves segments with the asker's token, asks a model to answer only from them and cite each, stores both messages. Replaces
answer()andretrieve(). - `send-reminders` (daily) emails due items; `apply-retention` (nightly) calls
sotto_apply_retention()and deletes the returned storage paths.
Checks
node tools/check-template.mjs templates/voice-notes prints ok. schema.sql has not been rehearsed; run node tools/rehearse-template-schema.mjs templates/voice-notes/schema.sql before publishing.