# 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.

```ts
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](https://june.dev/docs/copy/corpora.md), or the result of [`prepareVoice()`](https://june.dev/docs/copy/preparation.md)        |
| `model`        | the model that writes: a base model, or one that serves a [tuned](https://june.dev/docs/copy/training.md) voice |
| `evaluators`   | how candidates are [scored](https://june.dev/docs/copy/scoring.md); `[stylometry()]` by default                 |
| `retriever`    | how exemplars are [picked](https://june.dev/docs/copy/retrieval.md); `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](https://june.dev/docs/copy/retrieval.md) 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.

```text
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](https://june.dev/docs/copy/retrieval.md#use-the-corpuss-own-labels) 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 `type` and `length` that 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](https://june.dev/docs/copy/scoring.md#stylometry) compares sentence and paragraph lengths, not a whole draft's length or structure. An [evaluation](https://june.dev/docs/copy/scoring.md#evaluating-a-voice) 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](https://june.dev/docs/copy/retrieval.md) 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

1. **Retrieve** the `exemplars` samples most like the request, with the voice's retriever.
2. **Write** `candidates` drafts in parallel. Each sees the same system prompt: the voice's name, the instruction to match the examples' voice without copying their content, `instructions` if set, and every exemplar's text with its labels. The prompt is the request, as above.
3. **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](https://june.dev/docs/copy/scoring.md) explains why the best-scoring draft isn't evidence that the voice is good.
4. **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's `type` isn't matching the corpus's labels; see [inspecting retrieval](https://june.dev/docs/copy/retrieval.md#inspecting-retrieval).
- **Right form, wrong voice?** Compare the `candidates`' scores and `score.dimensions` to see which evaluator dimensions dragged the best one down.
- **Wrong facts?** Check `request.brief`, then the `exemplars`: 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`.

```tsx
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 `type` from the labels the corpus actually uses, most common first, and choose the closest in form. A step that invents labels (`homepage-hero` for a corpus of `page` and `essay`) 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 `brief` overrides the exemplars, and the writer will follow it.
