Inspiration
My mom has always been passionate about acting. She was an actress before pausing to raise her family. Now an empty nester, she has returned to the stage. She walked back into a rehearsal room and decided to focus on Shakespeare.
Then she ran straight into the oldest problem in the craft: you cannot learn a part alone. Lines do not live on the page, they live in you. You have to say them out loud, over and over, with somebody feeding you the other half of the scene.
That somebody was usually my dad. He is a good sport. He is not, however, especially interested in reading Mistress Ford at 6am on a Tuesday.
So the problem was never memorization. It was availability. And that is a problem I actually know how to solve.
There is a job in Shakespeare's own company called the book holder: the person backstage with the script open, feeding you the line the second you forget. Not a director, not a critic. A steady presence with the words ready before you ask. That job has existed for hundreds of years because actors have always needed it, and my mom needed it on her schedule, not somebody else's.
But I did not want to build her a flashcard app in a doublet. What makes a rehearsal partner worth having is that they were there last time. They know you always lose the middle of Ford's speech. They say so on the way in, not after you fall over it again. Take the memory away and you have a tape recorder with a Shakespeare skin.
Which, when I sat down and looked at it, is exactly an agentic memory problem. The hackathon brief and my mother's actual need turned out to be the same shape. That does not happen often, so I took the hint.
What it does
The Book Holder reads every part but hers, listens while she says her lines, remembers what she has mastered, and tells her what to work on next time.
- It plays the other characters aloud, a distinct voice each, synthesized a whole speech at a time and cached, so a scene runs at the pace of a rehearsal instead of a page turn.
- It listens. Her voice streams to Amazon Transcribe over a WebSocket with partial results, so she can watch the app hearing her while she is still talking. A beat cursor walks the growing transcript against the expected text, which means it stays with her through all the places a 400-year-old printed text and a modern transcriber politely disagree.
- It marks each thought solid, close, or dry. Never a percentage. A per-speech "72%" is homework, and she is not doing homework.
- It remembers between sessions. Mastery is read before she starts and written as each speech lands, and the beats she actually fumbled get embedded, so the coach can notice she has been losing this same line for two weeks.
- It recommends what to run next, and then, the part I care most about, it goes back and checks whether its own advice actually worked.
Two decisions came before a single line of architecture, and I would defend both in a room full of people who disagreed with me.
This tells her whether she knows the lines. It will never tell her how to play them. There is no correct inflection for a Shakespeare line. That is the entire art form. Two great actresses play Mistress Page completely differently and both of them are right. Accent is where this gets sharp, and personal: a machine telling my mother her natural speech is wrong is not a hypothetical harm, it is a real one, aimed at exactly the instincts she came back to rebuild. Her director gets to give that note. The book holder holds the book.
It does not record her. Her voice goes to Transcribe, gets transcribed in flight, and is gone. It is never written to disk, never written to S3, and there is no recordings bucket to forget about later. The only audio this thing stores is synthesized character speech: public domain text read by a machine. What persists is text, scores, and vectors. Rehearsal is where you are allowed to be bad at something, and a room that records you is a different room. I was not going to hand my mom a microphone that keeps everything.
The focus play is The Merry Wives of Windsor, 1,636 beats across 1,060 speeches, because that is the one she is doing. But nothing is hardcoded to it. The importer eats the whole Moby Shakespeare corpus, so adding a play is an import, not a schema change. When she moves on, the app moves with her.
How we built it
The loop is the whole design. The agent reads memory to decide what happens next, writes memory as a direct consequence of what happened, and then grades its own advice against what changed. READ, DECIDE, ACT, WRITE, EVALUATE, and the verdict on the last note is what shapes the next one.
EVALUATE is the step almost every agent demo skips, and it is the one that turns the rest into something other than a log. When she taps a recommendation, the session that follows stamps its id back onto the row that suggested it. So next time the agent gets to ask the easy question, "did she run what I told her," and then the hard one: "and did her marks actually move." If the advice worked, it says so and moves on. If she took it and nothing improved, the brief tells the model in plain words that its advice was wrong: do not repeat it, find another angle. An assistant that never has to face its own track record is just a very confident stranger.
The stack, and why each piece is there:
- CockroachDB Cloud Serverless holds the memory. Session writes are multi-statement serializable transactions with retry handling. Twelve migrations, each one carrying its reasoning in the file header, because in six months I will not remember why.
- Distributed vector indexing is agent-facing, not garnish. The coach calls
find_similar_beatsin the middle of its reasoning to work out whether a mistake is a one-off or a pattern, and the answer genuinely changes what it recommends. - Amazon Bedrock, three models with three jobs. Nova Micro scores a whole speech and hands back a judgement per beat. Nova Lite runs the coach agent's multi-turn tool loop. Titan Text Embeddings V2 makes the 1024-dimension normalized vectors CockroachDB indexes.
- Amazon Transcribe for streaming speech to text. Polly and S3 for the voices and their per-speech cache. ECS Express Mode on Fargate runs the Deno and Hono API and provisions its own ALB with TLS, which mic capture's secure-context rule makes non-negotiable. Plus ECR, Amplify Hosting, Secrets Manager, IAM with GitHub OIDC so there is not a single long-lived AWS key anywhere in this repo, and Budgets, because I am paying for this myself and I would like to know before I am not.
- Supabase for identity and nothing else.
- React 19 and Vite on the front, with an AudioWorklet pulling 16 kHz PCM off the mic.
The agent is Nova Lite running a real tool loop, not a prompt with a database bolted to it. I hand it the run that just finished and it decides for itself what to go look up: get_part_progress, get_recent_misses, get_last_recommendation, find_similar_beats, and submit_recommendation to end its turn. Three things about it I am glad I fought for:
- It is allowed to say nothing.
submit_recommendationacceptsaction: 'none', and I deliberately did not force the tool call. A run with nothing worth saying should say nothing rather than invent a drill to look busy. An assistant that always has advice is an assistant whose advice is worthless. - It has to show its working, in her own marks. "The speech has 11 beats, with 2 solid, 8 close, and 1 dry." That turns an instruction into an argument she can push back on, which is the difference between a coach and a notification.
- Its reasoning is inspectable. There is a
tool_calls JSONBcolumn for exactly one reason: so I can answer "why on earth did it say that" without re-running the whole thing.
Beats and blocks is the distinction the whole codebase rests on. A beat is one thought: a row in lines, the thing that gets scored, the thing mastery keys on. It is not a line of verse, and conflating the two will quietly ruin everything downstream. A block is one speech, cut wherever a stage direction falls inside it: the unit of display and of a single Polly render. Score per beat. Render and display per block. Coaching is scored per beat but called per block, because the whole speech is the context that makes "she paraphrased it but she has it" a judgeable call instead of a coin flip.
IDs are content-derived, UUIDv5 over play, act, scene, speakers, and text. Re-import the same text and you get the same IDs, so cached audio and, much more importantly, her practice history survive. Change the text and you get new IDs, so it re-renders and she re-learns it. An earlier version used randomUUID() and silently threw away both on every import, which is the kind of bug that only announces itself weeks later.
The design system came first and put real constraints on the build. Warm parchment and candlelight, but the atmosphere stays in the headline moments and every working surface stays a clean, high-contrast reading app. A well-made library app wearing a theater costume, not a costume shop. The accessibility rules are brand rules here, not a compliance pass, because the first user is an actor coming back to the stage later in life and I am not going to make her squint. Body text never under 16px. Layouts that survive 200% text scaling. 44px tap targets. Color never carries meaning by itself, so every state pairs its color with an icon or a fraction. The vocabulary is real backstage vocabulary used correctly (the book, cue, run it, dry), and solid / close / dry are not just copy, they are the literal values in the database. She should never have to translate the app into theater.
Challenges we ran into
Bedrock ARN shapes are not interchangeable, and this one ate more of my life than anything else here. Invoking a model through a geo inference profile needs two ARN shapes in the policy: the inference-profile ARN, and the bare foundation-model ARN in every region that profile can route to. Grant only the profile and you get an AccessDenied naming a region that appears nowhere in your deployment, which is a wonderful thing to read at midnight. Titan is the exact opposite: in region, no profile, one bare ARN. Pattern-match either onto the other and it fails in both directions. The us. prefix on us.amazon.nova-micro-v1:0 is load-bearing for the same reason, and without it you get an error that reads like a typo in the model name rather than a regional gap.
I refused to fix that with a note in a runbook. The deploy workflow now diffs the live task role against task-role-policy.sh and flatly refuses to deploy when the role is behind the code. It caught a real one immediately: the task role had no Bedrock grants at all. A checklist only protects whoever remembers to read it.
A vector query that silently ignores the index. ORDER BY embedding <-> (SELECT …) plans as a FULL SCAN. No error. No warning. Identical rows. Fast enough on this corpus that nothing looks wrong at all. Bind the probe vector as $1::VECTOR and it plans as • vector search. EXPLAIN is the only thing in the world that tells you the difference.
BEGIN … DDL … ROLLBACK does not undo the DDL in CockroachDB. Schema changes run as asynchronous jobs, so the standard Postgres trick for dry-running a migration cheerfully leaves the objects behind. The ROLLBACK returns successfully. information_schema in the same session still reads the objects. It looks exactly like it worked. I found this out by "validating" migration 006 that way and then discovering it had shipped itself. There is no safe dry run here, and now that is written down in three places.
Polly's generative engine started making up sentences. It is LLM-based and non-deterministic, and on three cached speeches it rendered text past the end of the input. My mother's scene partner, ad-libbing Shakespeare. The engine is pinned to neural now, which returns byte-identical audio for identical input. The deeper bug was worse than the symptom: the engine was not in the cache key, so those bad renders survived the engine change entirely. A cache hit is an S3 objectExists, and existence is not validity. Neural also throttles hard, which I learned by losing 254 of 1,064 speeches to ThrottlingException on a first bulk warm at concurrency 6.
The scoring rubric did not know it was reading a transcript, so it kept critiquing punctuation she never typed. She speaks the line. There is no comma. Three revisions to teach it that.
Safari's ITP forced an entire auth rewrite. The frontend and the API live on unrelated domains, so a session cookie between them was necessarily third-party, and ITP blocks that outright. The fix was not a flag, it was that the credential had to stop being a cookie.
A stage-direction tiebreak was splitting 79 of 1,060 speeches into two display entries sharing one block_id, so a whole speech played its audio twice. It compiled. It typechecked. It looked completely fine right up until row order mattered.
Accomplishments that we're proud of
The loop actually closes. The agent's first move is to look up its own last recommendation and find out what became of it, and that question is only answerable because acting on a recommendation writes back to the row that made it. I track two signals rather than one, because they come apart exactly where it matters: she took it up (tapped the recommendation) and she followed it (every recommended speech actually got scored, however she got there). Running the whole scene instead of the three-speech drill is following the advice by a better route, and the app should be smart enough to know that.
I cut a feature after measuring it, instead of shipping it because it sounded good. The plan was to cluster her mistakes into themes. Then I measured: her real mistakes sat at 1.195 to 1.369 against a random-pair baseline of 1.321. There were no clusters. There was nothing there. So rather than ship something that manufactures patterns out of noise and hands them to my mother as insight, the tool now reports the distance scale to the model as raw numbers and lets it judge. (Re-measured since over 3,600 random pairs of the 1,636 embedded beats, the baseline comes back at 1.342, so the scale that decision rests on still holds.)
I retuned segmentation without invalidating a single cached render or losing an hour of her practice. blockId hashes the speech's joined text; beatId also hashes the beat's own text. That asymmetry is the entire point. Merging lowercase continuations back into the sentence they continue took the corpus from 1,705 beats to 1,636, correctly reset the practice history that should reset, and left all 1,060 speech IDs untouched. I snapshotted the IDs before the re-import and diffed them after, because "it should be fine" is not a thing you get to say about somebody else's work.
Nothing can stop her rehearsing. Every coaching failure path falls back to a deterministic word-recall scorer and says so honestly in source. Bedrock down, network flaky, token expired mid-speech: she loses the writing-down, never the take. The capture socket is auth-aware but not auth-gated, so a guest can walk in and rehearse. The worst thing this app is allowed to do is be less clever. It is never allowed to interrupt her.
We store nothing about her voice, and I left the argument against that decision standing. Recordings would give us ground truth. Without them, nothing can prove whether she fumbled a beat or Transcribe mangled it, and that ambiguity is genuinely what still blocks our confidence thresholds. That tension is written up as an open item rather than quietly resolved in my own favor, because a design doc that only records the arguments you won is a marketing document.
And the production posture is real, not claimed. OIDC deploys with no long-lived credentials. CI that refuses to ship when the task role is behind the code. A /health endpoint that returns the served commit, so "is the new version actually live" is a fact you can check instead of a vibe. An audio-duration guard that throws away an implausible render rather than caching it forever.
What we learned
A model will not be argued into a rule a machine can check. I learned this three separate times, which is two more than I would like to admit. Three rubric revisions failed to stop Nova reciting a beat back at her instead of saying something useful. A mechanical check that drops any note sharing no three-word run with the written speech worked on the first try and has not needed touching since. Same story with the agent: the brief could not stop it returning the quoted line and nothing else, and a check that rejects a bare quotation and rebuilds from structured fields could.
A procedure works where a principle does not. "Mangled proper nouns are the transcriber's fault" failed twice. "Strike out every proper noun and archaic word, then judge what is left" worked immediately. Models are much better at following steps than at holding a value.
Never show a model a good example of what you want it to write. It gets parroted verbatim. I learned this on the rubric, then made the exact same mistake in the agent brief I wrote after learning it, and then made it a third time in a tool schema. That third one hurt, because the example was a sentence full of numbers, so what got parroted was a confident false claim about my mother's own rehearsal: "two of its nine beats are dry," when the truth was one of eleven. That reached a real screen before I caught it. Every figure in a recommendation is now verified against the database before she ever sees it, and the prompts contain only failing examples on purpose. It is one thing to ship a bug. It is another to have your app lie to your mom about her own work.
Verify against reality, not just types. Polly, S3, ECS, the importer: all of them will typecheck perfectly and still be wrong. Where a claim is checkable (duration, byte count, row count, served commit, embedding distance) I check it and write down the number.
Non-interruptive does not have to mean invisible. My original design rule said coaching waits until the scene ends. What I actually needed was: nothing blocks, nothing demands a response, and nothing moves under her eye mid-scene. Coaching now lands per speech, in a slot reserved from the very start so nothing reflows, because text shifting under an actor mid-run reads as a bug and breaks her concentration, which is the one thing this app exists to protect.
And the smaller lesson, which is really the same one: the Agent Skills paid for themselves the moment the CockroachDB schema-design reference told me the database supports three vector distance operators rather than L2 alone, correcting a claim in my own design doc. I had been confidently wrong in writing. It is good to have something check your work. That is, after all, the entire product.
What's next for The Book Holder
- Fit the confidence thresholds to real data. Where the cuts fall between solid, close, and dry is the biggest open question in the whole product, and it needs real transcripts from real rehearsals, not another afternoon of me guessing at constants.
- Hesitation timing. Transcribe already hands back per-word start and end times, the client already carries them, and nothing downstream reads them yet. A long pause in the middle of a beat is evidence about recall, which sits squarely inside the line-mastery boundary instead of wandering into direction. She knows the words. She is reaching for them. That is worth saying.
- Drill a section. You cannot fix beat 7 by running 27 lines. A beat plus its neighbours is the right unit for a second pass. And mastery on a long speech should be the weakest beat, not the average, because averaging politely hides the one place she drops every single time, which is the only place she needed help.
- More plays. The importer already handles the full corpus, so this is an import and a voice-casting pass. The history plays will stress the segmenter harder than the comedies do, and Henry V's French scene needs a real answer before Polly reads it aloud in an English accent.
- Reading a second character, so she can work both sides of a scene when nobody else is around, which was the original problem and deserves the fuller answer.
- A low-light rehearsal mode. The candlelight palette has wanted it from day one, and it is not the same thing as a generic dark mode.
- Session history in the Prompt Book, deferred at MVP to keep that screen honest about what needs work. Worth revisiting now that there is a real history worth looking back on.
Built With
- amazon-web-services
- amplify
- cockroachdb
- deno
- ecs
- nova
- polly
- react
- supabase
- titan
- typescript
Log in or sign up for Devpost to join the conversation.