kamila kuatova, ulzhan khamidolla, and i built rehearsal room in three and a half hours at the mongodb ai hackathon, and it placed in the top 5 of 94 teams. the idea came from something most people have done before a hard conversation: replaying it in your head over and over. we wanted a place where you could actually say it out loud to someone who answers back.
you pick a counterpart, press start, and talk. the browser handles speech recognition, next.js coordinates the session, claude writes the counterpart’s reply, and elevenlabs speaks it.
the first thing a voice loop like this has to get right is not hearing itself. if the microphone is still open while the counterpart’s reply plays through the speakers, speech recognition transcribes the generated voice and the counterpart ends up answering its own lines. instead of trying to filter that audio out afterwards, we made it part of the session’s structure. the session moves through a small set of phases, and the mic is only open in two of them. it closes as soon as a line is sent, stays closed while the reply is generated and played, and only reopens once playback has actually ended.
the part that made the project different was the director interrupt. in most roleplay tools, if the other person reacts in a way that doesn’t feel real, all you can do is keep going or start over. we wanted it to feel more like directing an actor. while the counterpart is talking, you can cut it off and say something like “she’d get quieter, not louder.” that sentence isn’t a line in the scene. it’s a note about how the counterpart should behave.
the harder question was where that note should go. the easiest version would have added it to the chat prompt, and it would have worked for a few turns. but a prompt only lasts as long as the conversation, and we wanted a correction to change who the counterpart is. so every counterpart lives in mongodb as a set of behavioral traits, with one active trait per category, like how it responds to conflict.
when a note comes in, claude classifies it into the categories that already exist and rewrites the trait’s claim. the categories are passed in as a fixed list, so the model can’t invent a new one that nothing else knows about. then the backend runs a single transaction: it saves the note as evidence, marks the old trait as superseded, and inserts the new version pointing back at the one it replaced. the session reloads the active traits, and the counterpart retakes the same moment with the corrected behavior.
keeping the old trait instead of overwriting it was a small decision that ended up mattering a lot. every trait has a history: what it used to be, which note changed it, and when. that’s what let the trait panel show a correction happening live, and it means a correction is something you can go back and inspect instead of something that disappears when the tab closes. doing all three writes in one transaction also meant the counterpart could never end up with two active traits in the same category, or with a new trait that had no evidence behind it.
three and a half hours is not a lot of time, and plenty of things we talked about didn’t make it in. the thing i took away from it is that memory turned out to be a data modeling problem before it was a prompting problem. once corrections became versioned documents instead of extra text in a prompt, the rest of the design got a lot simpler.