Real-estate agents leave a showing with the details in their head: what the client liked, what they objected to, what to follow up on. By the time they sit down to type it into the CRM, the detail is gone. Notes go unwritten, and an unwritten note is a lost signal in a workflow that turns into revenue.
The feature I built lets the agent talk instead. Record after the showing, get back a structured note on the contact. This is the shape of it, the two decisions that made it trustworthy, and the hole that code review found before it shipped.
the pipeline
Audio goes to a transcription model. The transcript goes to a language model with a prompt that shapes it into a note. The note is saved on the contact. Three vendor calls and some plumbing.
Two providers generate the note, one primary and one fallback, and the choice between them is not a preference. They run as an A/B, and an evaluation tool scores the output in production. The fallback covers a provider outage. The A/B answers which model writes better notes for this domain with data instead of an opinion, and the eval tool is what turns “output quality” from an assumption into a tracked number.
the human owns the record
Hallucination is the question every interviewer asks about an LLM feature, and the honest answer is that no prompt makes it go away. So the containment is structural rather than clever.
The generated note lands as a system event on the contact. The agent sees it, and can edit it, at any time. The model drafts, the human owns the record. A bad generation becomes a correction the agent makes in a few seconds, never a silent corruption of a record nobody re-reads. The evals track how often that correction is needed. Between the two, a wrong note is bounded in both damage and detectability.
the client was sending the prompt
The first version worked. It also had the client building the prompt.
There is a reason to build it that way, and it is not laziness. Early on, prompts change constantly. You are still learning what a good note looks like, and every tweak is a one-line change to a template. Keeping the template on the client means iterating without a backend deploy. Under time pressure that feels like the pragmatic choice.
Roughly:
// client
await api.post("/voice-notes", {
audioUrl,
prompt: buildPrompt(context), // assembled here
});
// server
const transcript = await transcribe(audioUrl);
const note = await complete(`${prompt}\n\n${transcript}`);
In code review someone asked what stops a client from sending any prompt at all. Nothing did. The backend would forward whatever string it received to the model provider, on the company’s API keys. Anyone with a valid session could run arbitrary prompts through the backend, at our cost, with our rate limits, and with whatever the backend did to the output afterwards applied to text of their choosing.
A prompt that a client can influence is not data. It is code the client gets to write, and the backend was executing it.
the fix
Prompts moved server-side. The client sends audio and an intent, never prompt text:
// client
await api.post("/voice-notes", { audioUrl, intent: "showing-summary" });
// server
const template = PROMPTS[intent]; // a small, server-owned map
if (!template) throw new BadRequest();
const transcript = await transcribe(audioUrl);
const note = await complete(template(transcript, serverContext));
The intent is a key into a map the server owns. An unknown key is a 400. Context the prompt needs (the agent’s name, the contact, the tenant’s terminology) is read server-side from the session and never accepted from the request. The client lost the ability to iterate on wording without a deploy, and that was the right trade: prompt changes go through review like any other backend change, which is where they belonged once the feature stopped being a prototype.
Nothing about the model calls changed. The vulnerability was entirely in who got to compose the input.
what to take from it
Most write-ups about adding an LLM feature stop at the point where it works. The demo lands, everyone is pleased. The decisions that made this one safe to run in a revenue workflow happened after that: two providers scored against each other instead of one picked on preference, a note the human can always overwrite, and a prompt the client can never write. The last one was caught in review rather than in the wild. That is what review is for.