Audition the voice before you commission the work
Part of my tooling generates long-form written content in a configured voice: an editorial identity that describes the audience, the register, the vocabulary, what the writer sounds like. The identity is prose in a config. And prose configs for tone have a nasty property: they are write-only. You describe the voice you want ("unhurried, curious, explains from first principles, never hypey"), the generator consumes it, and you learn whether your description worked only after paying for a full generation run and reading the result. If the register is off, you edit adjectives and pay again.
The feedback loop is the problem. Tuning a persona through full generations is like adjusting a recipe by catering a wedding each time. What I wanted was a taste: given this identity, say two lines in this voice, right now, for approximately nothing.
The smallest possible agent
The fix is a preview agent, and the design goal was to make it as close to free as an LLM call gets - because a preview you hesitate to run is a preview that does not get run. Everything about it is subtraction:
- No tools. The agent cannot read files, browse, or search. Everything it needs - the identity text and an optional topic - is passed inline in the dispatch. This is not just cost control: an agent with no tools and fully inline context is reproducible. Same input, same class of output, nothing ambient to drift.
- A fast local model. Auditioning a register is narrow work; it needs fidelity to a style description, not reasoning depth. The call routes to the cheapest tier in my worker runtime - a small model running on my own machine. A measured audition costs about 500 tokens end to end, which on local hardware rounds to zero.
- A hard output schema. The agent must return two to six sample lines, each a sentence or two the voice would actually speak, and nothing else - no headings, no stage directions, no commentary about the voice. The schema is enforced at the call layer, so a malformed response retries rather than reaching the UI. A preview is a contract, not a chat.
- Ephemeral by construction. The route that serves it writes nothing: no database row, no artifact, no history. Preview output that persists becomes state - something to list, migrate, and clean up. The whole value of a preview is that it evaporates.
- A timeout and one retry. Ninety seconds, one attempt to recover, then fail visibly. A preview that hangs is worse than one that errors.
The prompt side is one rule repeated three ways: match the identity's tone, register, and vocabulary precisely; each line must be a speakable line of prose on its own; do not perform meta-commentary. Sample lines that describe the voice instead of being the voice are the failure mode, and the instructions attack it directly.
The loop it creates
In the settings UI, next to the identity editor, there is a sample button. Type an optional topic, click, and a moment later: a handful of lines in the configured voice. Edit the identity, sample again. The tuning loop drops from "generate a full piece, read it, wince" to seconds per iteration.
Two UI decisions carry more weight than they look like they should:
The preview invalidates nothing. It is a plain fire-and-return call with no cache updates, because it changes no state. Wiring a preview into the app's data layer as if it were a mutation is a category error that makes every preview cost a refetch.
The empty state teaches. With no identity configured, the button does not disable silently; the endpoint refuses with "identity is unset" and the UI says so. A preview feature whose precondition is invisible reads as broken.
There is also a seam decision underneath: the route awaits the worker synchronously, bounded at two minutes, instead of returning a job id the client polls. Previews are interactive - the human is sitting there. The moment a preview needs a progress bar, it has failed at being a preview, so the API shape encodes the latency budget: if this cannot answer while the user watches, it should error, not stream status updates.
Why this is a pattern, not a feature
The general shape: when a system consumes a human-authored description and produces something expensive, insert the cheapest possible sampler between the two. The description-to-output gap is where confidence quietly dies - you wrote the config, you think it says what you mean, and the only verification on offer costs a full run.
Samplers earn their place when they are:
- Instant enough to be reflexive. Seconds, not minutes. The moment sampling requires deciding whether it is worth it, iteration stops.
- Free enough to be guiltless. A local model or the cheapest API tier. The task is narrow by design; use the narrowest worker that does it.
- Constrained enough to be honest. Schema-forced output in the target format. A sampler that returns an essay about what it would do is theater.
- Stateless enough to be ignorable. No persistence, no history, no cleanup. Run it forty times; nothing accumulates.
I now reach for this shape whenever a prose config drives generation: sample the persona before the piece, the summary style before the batch, the reviewer's severity before the review run. Each sampler is an afternoon of work, because subtraction is fast to build - the entire agent definition fits on one screen, and the worker runtime it rides on already existed.
The quiet lesson underneath is about model routing. The instinct is to send every task to the strongest model available. But a preview's job is to be representative and immediate, not maximal - and a small local model with a tight schema and inline context is more representative of "what will the configured voice sound like" than a frontier model improvising with more freedom. Match the worker to the narrowness of the task, and some tasks turn out to be nearly free.