Penwright
Engineering · 8 min read · May 2026, updated August 2026

How Penwright cleans up your dictation

Dictation is a typing replacement, not a writing tool. The moment cleanup feels slower than typing, the trick stops working. We spend a lot of time pushing the latency down, which means we spend a lot of time choosing not to call a large language model.

Here's the design we settled on, and why.

A rough paragraph rewritten by the local model in Penwright

Three tiers, smallest first

Every dictation you speak goes through a pipeline of up to three tiers. The first one always runs. The second runs by default. The third only runs when you ask for "Polish" mode. Each tier is allowed to escalate to the next; each tier is also allowed to fail silently and let the previous tier's output stand.

Tier 1, the rules engine

The first stage is a plain Rust function. Under fifty milliseconds, it does the boring jobs that don't need a model:

None of this needs probability. It needs a finite state machine and some good defaults. We get back text that's already 80% of the way to publishable, and we got there in single-digit milliseconds.

Tier 2, a small local model (default)

The rules engine knows what to do; it doesn't know how to read tone. For the last mile, "does this sentence still sound like me", we run a small instruct model over the rules output, loaded through llama.cpp with Metal acceleration. It is a 4B model, quantised, and it is the same one on every Mac Penwright supports. On an M-series machine that is a sub-second round trip, masked by the floating bar's "Cleaning up…" state so it does not feel like waiting.

The system prompt is short and protective: preserve the speaker's wording, don't rewrite for style, don't answer the user, don't summarize. The model's job is to nudge, not to take over.

The size is a latency decision, not a budget one. We have run this stage on everything from a 1.7B up to a 14B. Bigger models are sharper on edge cases and slower on every single dictation, and the edge cases are rare while the latency is constant. A 4B was where accuracy stopped improving faster than the wait grew, in English and in German, on a Mac that might only have 8 GB of memory. That is the whole argument.

Tier 3. Polish, opt-in

Sometimes you want more than a nudge. Ask for a rewrite and Tier 2 runs again with a different prompt, one that is allowed to restructure sentences and fix grammar properly. It is opt-in for a reason: rewriting is the one thing a dictation tool can do that makes people distrust it. A prompt you dictate into Claude or ChatGPT is never rewritten at all, no matter what is switched on.

Why we don't escalate further

The fashionable thing right now would be to dump every sentence into a frontier model and let it work out what you meant. That sounds great in a demo. In practice, three things go wrong:

Local + small + tier-routed isn't a compromise. It's a different bet about what dictation should feel like. Pressing a hotkey, speaking a sentence, and seeing it appear in your editor before you finish the next thought, that's the experience we're tuning for. Nothing else.

Except when you are not waiting

Everything above is about the keystroke path, where a second of latency is the entire product. Not all of the work is on that path. When Penwright writes up an hour-long meeting, you are not watching a cursor; you have left the room. There is no latency ceiling to respect, so the argument reverses.

For that work the app uses the strongest model your Mac's memory can actually hold, fetched quietly in the background: an 8B from 16 GB, a 12B from 24 GB, a 30B mixture-of-experts from 48 GB. It is picked by installed memory, and checked again against free memory before a cold load, because a model that swaps is worse than a smaller model that fits. Dictation keeps its fast 4B throughout.

So the rule is not "small models are better". The rule is that the size should be decided by whether a human is waiting on the result, and by the machine in front of you, rather than by which plan you are on. Nothing about that changes where it runs.

Graceful degradation, everywhere

Pipelines this layered fail in interesting ways. The rule we built around it: at every tier, if anything throws, we drop to the previous tier's output and keep going. Model didn't load? You get the rules output. Rules engine threw on a malformed regex? You get the raw Whisper transcript. We never leave you staring at a frozen state machine because of a bug deeper in the stack, you always get text on screen.

That's the design. It's opinionated. It's deliberately smaller than the competition. And it's the reason your transcript never leaves your Mac, which is, to us, the only promise that matters.

Try it on your own voice.

Free for 7 days. No credit card. Nothing leaves your Mac.