Case study · December 2025
Brain — pulling your own commitments out of a meeting transcript
A meeting transcript contains commitments you made, and they evaporate unless you write them down. Brain extracts only yours, resolves the dates deterministically, and keeps the source text even when the model fails.
- Role
- Solo — design and build
- Timeframe
- December 2025
- Access
- Public source · demo needs a GitHub sign-in
No public demo
The hosted app is behind a GitHub sign-in, so there is nothing to see without an account. The source is public and is the better read.

At a glance
Problem
Things you agree to in meetings disappear the moment the call ends. Transcript summarisers give you back a summary of the whole meeting — including everyone else’s action items — which is not the same as a list of what you now owe someone.
Approach
Treat the model as one unreliable step in a pipeline rather than the pipeline itself. It gets a single narrow job: identify first-person commitments and hand back the date phrase verbatim. Everything that can be done deterministically — date resolution, persistence, ownership — is done in code around it.
Outcome
Deployed and in personal daily use. The transcript-to-task loop works end to end: paste, extract, store, list by due date. Early versions shipped with public database policies; those were replaced with per-user rows and row-level security scoped to the authenticated user.
Stack
- Next.js 16 (App Router)
- React 19
- TypeScript
- Vercel AI SDK
- OpenAI GPT-4o-mini
- Zod
- chrono-node
- Supabase (Postgres, Auth, RLS)
- Tailwind CSS v4
- Vercel
Decisions
4 choices where the alternative was reasonable. The rejected option is the interesting half.
- Rejected
- Asking the model for prose and parsing it
- Chosen
generateObjectagainst a Zod schema- Because
- Free text means a regex, and a regex means a silent failure the first time the model phrases something differently. A schema makes the output a typed object or an error — there is no third state to handle later.
- Rejected
- Letting the model resolve "next Friday"
- Chosen
- Model returns the date phrase verbatim; chrono-node resolves it against a reference date
- Because
- An LLM asked to do calendar arithmetic will answer confidently and be wrong often enough to matter, and you cannot tell which answers are the wrong ones. Extraction is a language problem, so the model does that; date resolution is arithmetic, so code does that.
- Rejected
- Extracting every action item in the transcript
- Chosen
- Prompt parameterised with the user’s name and the aliases they go by
- Because
- A list of everyone’s commitments is a meeting summary, and there are plenty of those. The useful artefact is the short list of things you said you would do — which means the extractor needs to know who you are and how people refer to you in a transcript.
- Rejected
- Failing the request when extraction fails
- Chosen
- Return no tasks, but always persist the raw note
- Because
- Losing the source text is worse than losing the tasks. If the model call fails, the transcript is still saved and can be reprocessed; the alternative loses the only copy of the input to an upstream error.
The problem
You agree to things in meetings. "I'll send that over by Friday." "Let me pick that up." Those commitments are real, and they exist for about four seconds before the call ends and they're gone.
Transcripts are free now — Zoom and Meet generate them automatically. So the raw material is sitting there. The gap is that a transcript summariser gives you back a summary of the meeting, including every action item assigned to everyone in it. That is not the thing you need. The thing you need is the short list of what you now owe someone.
The pipeline
Paste text, get tasks. Four steps, and only one of them involves a model.
- Persist the raw text first. The note is written to Postgres before extraction runs.
- Extract, with a schema.
generateObjectfrom the Vercel AI SDK against a Zod schema, using GPT-4o-mini. The model returns an array of{ title, description, due_date_raw }— typed objects, not prose. - Resolve dates in code.
due_date_rawcomes back as whatever the speaker said: "tomorrow", "next Friday", "15th December".chrono-nodeturns that into a real date against a reference of now, and it's normalised toYYYY-MM-DDbefore it touches the database. - Store the tasks against the note, scoped to the user, sorted by due date.
The decisions that made it work
The model does one language job and nothing else. Its brief is: find first-person commitments, and hand back the date phrase exactly as it was said. It is explicitly not asked what date "next Friday" is. Asking a language model to do calendar arithmetic gets you a confident answer that is wrong often enough to matter, and — worse — gives you no signal about which answers are the wrong ones. Splitting it means the failure modes are separable: if the extraction is bad, that's a prompt problem; if the date is bad, that's a parser problem with a reproducible input.
It needs to know who you are. The prompt is parameterised with the user's name and the aliases they get called in a transcript, including bare first-person "I'll". Without that, extraction returns everyone's action items and the output is just a worse meeting summary. With it, the tool has an opinion about what belongs to you, which is the entire product.
Failure degrades instead of cascading. If the model call throws, the extractor returns an empty array rather than propagating. The note is already saved, so the input survives and can be reprocessed. The alternative — failing the whole request — loses the only copy of a transcript to a transient upstream error, which is a much worse outcome than a note with no tasks attached.
Constrained output, so there's nothing to parse. Because extraction is schema-validated, there's no regex, no "sometimes it wraps the JSON in a code fence", no branch handling the case where the model got chatty. Output is either a valid typed object or an error.
Security, honestly
The first version stored notes and tasks with no owner column and public database policies. That was fine for a single-user prototype and would have been a real problem the moment anyone else used it.
Fixing it meant a migration that added user_id to both tables with a cascade to auth.users, backfilled the existing rows, made the column NOT NULL, enabled row-level security, and wrote policies scoping select, insert, update and delete to auth.uid(). There is a migration in the repo literally called remove_insecure_public_policies.sql. I'd rather that be visible than quietly squashed — the useful part of the story is noticing, not never having done it.
Every write path checks the session server-side too. The API route resolves the authenticated user before it touches anything and returns a 401 if there isn't one, so RLS is the backstop rather than the only line.
What I'd do next
Source integrations, so transcripts arrive instead of being pasted — that was always the point, and pasting is the deliberately unambitious v0. Beyond that, the interesting problem is evaluation: right now I can tell you the pipeline runs, but I can't tell you what fraction of my actual commitments it catches.