Writing with a voice
A Voice is three things: the person's writing, a model that writes, and evaluators that check the result sounds like them. It holds no state of its own and never changes itself, so the same voice with the same settings retrieves the same exemplars for the same request. The drafts, and any model judge's scores, can still differ from call to call.
The request is the contract. Exemplars demonstrate the person's voice; they aren't permission to ignore the requested facts, form or length. Everything the copy must say, and the shape it must take, belongs in the request.
import { openai } from "june/sdk/openai";
import { copyCorpus, judge, stylometry, voice } from "june/copy";
const fonzie = voice({
name: "fonzie",
corpus: copyCorpus("./fonzie.jsonl"),
model: openai("gpt-5-mini"),
evaluators: [stylometry(), judge({ model: openai("gpt-5-mini") })],
});
const result = await fonzie.write({
type: "homepage-hero",
audience: "startup founders",
intent: "introduce the recruiting agent",
brief: "A headline and a one-sentence subhead, under 25 words.",
});
result.text; // the best candidate
result.score; // { overall: 0.86, dimensions: { "stylometry.sentenceLength": 0.91, …, "judge.judge": 0.8 } }
result.exemplars; // the samples the model saw, and why each was picked
| Option | |
|---|---|
name | the voice's name; the writer is told it writes as name |
corpus | a corpus, or the result of prepareVoice() |
model | the model that writes: a base model, or one that serves a tuned voice |
evaluators | how candidates are scored; [stylometry()] by default |
retriever | how exemplars are picked; metadataRetriever() by default |
exemplars | how many samples the writer sees (5 by default) |
candidates | how many drafts to write per request (3 by default) |
threshold | below this overall score, the best draft gets one revision (0.75 by default) |
instructions | extra instructions for the writer, such as words the person never uses |
exemplars and candidates must be whole numbers of at least 1, and a voice needs at least one evaluator.
Requests
write(request) takes a CopyRequest: what kind of writing, for whom, to what end, and the brief. Every field is optional:
| Field | |
|---|---|
type | the kind of copy, in the corpus's own vocabulary |
audience, intent, tone, topic | short labels, like the corpus's metadata |
length | "short" (up to 30 words), "medium" (up to 120) or "long" |
brief | free text: the facts to use, and anything else the writer needs |
The labelled fields do two jobs. They pick the exemplars, since retrieval compares them with each sample's labels, and they're written into the writer's prompt. brief only does the second: it never affects which samples are retrieved. So put what the copy is about, its facts and its exact form in brief, and put what kind of writing it is in the labels. Facts should come only from the request: a voice never looks things up, and anything an exemplar happens to say about a product is the person's old writing, not a source. The writer is told not to copy exemplars' content, but it sees their full text, so a brief that states every fact it needs leaves the least to chance.
type: "tweet", audience: "startup founders", intent: "announce a feature",
brief: "Fonzie now schedules interviews. One sentence, under 20 words, no hashtags."
▼ the writer's prompt
Write a tweet for startup founders.
Goal: announce a feature.
Fonzie now schedules interviews. One sentence, under 20 words, no hashtags.
Fields are trimmed and empty ones dropped, and type is lowercased. Use the same type labels the corpus uses: a request for homepage-hero against a corpus labelled page and essay matches nothing, and retrieval falls back to length and recency. Retrieval explains why.
Form and length
Treat form and length as hard constraints of the request. The writer doesn't yet treat them that way on its own, so this section is how you make it.
The writer is told to match its exemplars' voice exactly, "their rhythm, vocabulary, punctuation, formality and length", and it does. In practice it follows the exemplars' form as well: shown three whole README pages, it tends to write a whole page, with a heading and a list, even when the request asked for one paragraph. A request's length is a single word in the prompt, and when it disagrees with the exemplars, the exemplars usually win.
Until that changes, two things help:
- Say the length and form in the request, in
brief, as concrete constraints: "one paragraph, under 90 words, no heading, no lists", or "a headline and a one-sentence subhead, under 30 words". June.dev's own homepage hero was requested this way: "a headline of exactly three words, then a one- or two-sentence subhead, under 40 words in total, no heading markup". - Make the exemplars the right form. A
typeandlengththat match short samples retrieve short samples. If the corpus has little writing of the form you need, add some; a voice can only imitate forms it has seen.
Then check the result, since stylometry compares sentence and paragraph lengths, not a whole draft's length or structure. An evaluation with a grader for the brief catches this; picking among candidates doesn't.
Exemplars
Before writing, the voice's retriever picks exemplars samples to show the writer. The default retriever ranks them by how well their labels match the request's, type first; it never reads the brief. Retrieval explains the ranking and how to change it. What matters here: the exemplars decide most of how a draft sounds and, today, much of its shape, so a request whose type retrieves the wrong kind of writing gets the wrong kind of draft.
How write() works
- Retrieve the
exemplarssamples most like the request, with the voice's retriever. - Write
candidatesdrafts in parallel. Each sees the same system prompt: the voice's name, the instruction to match the examples' voice without copying their content,instructionsif set, and every exemplar's text with its labels. The prompt is the request, as above. - Score every draft with every evaluator, against the exemplars, and keep the best. On a tie, the earlier draft wins. This is a different score from retrieval's: retrieval scored how well each sample's labels matched the request, while the evaluators score how much each draft sounds like those samples. Neither checks the brief, and Scoring explains why the best-scoring draft isn't evidence that the voice is good.
- Revise if the best scores below
threshold: one more call shows the writer that draft and its three weakest dimensions, and asks for a rewrite that sounds more like the examples. The revision is scored the same way and kept only if it scores higher.
Drafts are trimmed, and the writer is asked to reply with the copy only. With the defaults, a write makes three or four writer calls, and each judge() adds one call per draft it scores, the revision included.
The result
write() returns everything you need to see why it chose what it did:
| Field | |
|---|---|
text | the best text: the best candidate, or the revision if it was kept |
score | its VoiceScore: overall, dimensions and any evaluator notes, all from 0 to 1 |
candidates | every draft with its score, in the order they were written |
revision | the revision with its score, and kept; only when the best draft was under threshold |
exemplars | each retrieved sample with its retrieval score and reasons, such as ["type"] |
request | the request as normalized |
calls | every model call: each candidate, the revision, and any an evaluator made |
usage | tokens summed across calls |
Each call in calls has a label ("candidate", "revision" or "judge"), its model, usage, finish reason and duration. Pass onCall to see each one as it finishes: fonzie.write(request, { onCall }). Pass signal to cancel.
When a draft reads wrong, debug it from the result:
- Wrong form or length? Look at
exemplars. If they're a different kind of writing than you asked for, the request'stypeisn't matching the corpus's labels; see inspecting retrieval. - Right form, wrong voice? Compare the
candidates' scores andscore.dimensionsto see which evaluator dimensions dragged the best one down. - Wrong facts? Check
request.brief, then theexemplars: the writer sees their full text too, and may carry a detail over from one.
Keep the result, or at least the request, exemplar ids and candidate scores, next to whatever you publish. The exemplars are usually why a draft reads the way it does, and this is the only place they're recorded.
In a workflow
<Write> is the JSX form of voice.write(), so a write is a step in a June workflow. Its trace node shows each model call as it finishes, their usage counts toward the run's usage, and an agentResponder journals it with the turn. Children render once the write finishes, and a render-prop child receives its WriteResult.
import { openai } from "june/sdk/openai";
import { Agent, Step, run } from "june";
import { copyCorpus, voice, Write } from "june/copy";
const fonzie = voice({
name: "fonzie",
corpus: copyCorpus("./fonzie.jsonl"),
model: openai("gpt-5-mini"),
});
const { steps, usage } = await run(
<Agent model={openai("gpt-5-mini")}>
<Step id="topic" prompt="Pick a topic for today's tweet">
{(topic) => (
<Write id="tweet" voice={fonzie} request={{ type: "tweet", topic }} />
)}
</Step>
</Agent>,
);
Its cache key covers the voice's name and artifact and the request. Pass cacheKey when other settings change, so an earlier run's snapshot isn't reused.
Writing from a conversation
write() takes a request, not a conversation: the writer never sees a chat, only the request and the exemplars, which keeps it focused on the voice. A chat about copy needs a step before the write that reads the conversation into a CopyRequest, with any facts the person gave and any earlier draft quoted in brief.
That step is application code, and two lessons from the Copy Agent apply to any version of it:
- Give it the voice's types. Have it pick
typefrom the labels the corpus actually uses, most common first, and choose the closest in form. A step that invents labels (homepage-herofor a corpus ofpageandessay) leaves retrieval with nothing to match. - Have it carry facts, never style. A step that writes "use hashtags and an emoji" or an example structure into
briefoverrides the exemplars, and the writer will follow it.