Originally published at olund.dev.
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.
Top comments (0)