The Proofreader API
So. Every text box eventually grows a grammar check, and the usual way to bolt
one on is to ship the user's half-finished email to a server that runs a
spell-checker and bills you per call. The Proofreader API is that same job with
the server deleted — on-device grammar, spelling, punctuation, and
capitalization correction, running against a LoRA adapter on top of Gemini Nano.
(A LoRA adapter is a small fine-tuning layer bolted onto the base model — a few
extra megabytes that specialize Nano for one task, not a whole second model.)
The part worth a lesson isn't the fixing; you could nag the Prompt API into
that. It's that every fix comes back positioned — a startIndex, an endIndex,
and the replacement — so you can render inline diffs, hover tooltips, or a
batch-accept panel without a second model call. By the end you'll gate it on
availability(), open a language-scoped session, and turn one messy sentence
into a list of highlighted corrections.
What you'll build
- Gate
Proofreader.create()behind anavailability({ expectedInputLanguages })check and a downloadmonitor. - Open a language-scoped session and call
proofread(). - Read the
ProofreadResult— acorrectedInputstring plus acorrectionsarray of positioned edits. - Slice each original span out of the input with
startIndex/endIndex, and render a highlighted inline diff. destroy()the session on teardown so a closed tab doesn't leak the adapter.
Desktop Chrome with built-in AI switched on — plus one extra catch for this API.
The Proofreader shipped as an origin trial (Chrome 141–145) and is otherwise
flag-gated, so on most machines availability() reports unavailable until you
enable chrome://flags/#proofreader-api and restart. If Proofreader isn't on
self yet, work through Setup & the availability lifecycle
first, and keep the compatibility matrix handy
for what's stable where. This page assumes you already know the four
availability states — it won't re-teach them.
Check availability per language
Same four-step loop as everything else in this course: ask if it's there,
create it, use it, tear it down. Feature-detect the Proofreader global, then
call availability() with the languages you expect to proofread. The Proofreader
ships adapters for a small set of languages — en, es, and ja are the ones
it accepts today — and each one is a separate download, so the check is
per-language, not global.
Here's the uncomfortable part: this API is still gated behind a flag. For most
people the honest answer from availability() is unavailable, and your job is
to say so and point them at the flag — not throw them into a blank page.
- JavaScript
- TypeScript
if (typeof Proofreader === 'undefined') {
// No built-in Proofreader here — it's flag-gated. Degrade gracefully (see lesson 2).
}
const status = await Proofreader.availability({ expectedInputLanguages: ['en'] });
// "unavailable" | "downloadable" | "downloading" | "available"
if (status === 'unavailable') {
// Flag off, or the device can't run Gemini Nano.
// Tell the user to enable chrome://flags/#proofreader-api and restart.
}
type Availability = 'unavailable' | 'downloadable' | 'downloading' | 'available';
const status: Availability =
await Proofreader.availability({ expectedInputLanguages: ['en'] });
if (status === 'unavailable') {
// Flag off, or the device can't run Gemini Nano.
}
Open a language-scoped session
create() opens a session tied to the languages you named. On the first
create() for a language it downloads that LoRA adapter, so wire the monitor
from the start — e.loaded is a 0..1 fraction (and e.total is always 1), same
as everywhere else in this course. That's the whole options surface for the
shipped API: the languages you expect and a download monitor. The explainer also
lists includeCorrectionTypes, includeCorrectionExplanation, and
correctionExplanationLanguage, but Chrome's current build doesn't implement
them — passing them does nothing, so leave them off.
- JavaScript
- TypeScript
const proofreader = await Proofreader.create({
expectedInputLanguages: ['en'], // 'en' | 'es' | 'ja'
monitor(m) {
m.addEventListener('downloadprogress', (e) => {
// e.loaded is a 0..1 fraction; e.total is always 1
console.log(`Downloading adapter… ${Math.round(e.loaded * 100)}%`);
});
},
});
interface ProofreaderCreateOptions {
expectedInputLanguages?: string[];
signal?: AbortSignal;
monitor?: (m: CreateMonitor) => void;
}
const proofreader = await Proofreader.create({
expectedInputLanguages: ['en'],
monitor(m) {
m.addEventListener('downloadprogress', (e) => {
console.log(`Downloading adapter… ${Math.round(e.loaded * 100)}%`);
});
},
});
Adapter downloads happen once per language and stick around. Pool one session per language and reuse it — don't spin a fresh one up per keystroke.
Proofread and read the result
proofread() takes the text and hands back a ProofreadResult with two fields:
correctedInput, the whole fixed string you can drop straight into the box, and
corrections, the array of individual edits. Keep the exact text you passed in.
The indices on every correction point into that original string, not into the
corrected output.
- JavaScript
- TypeScript
const input = 'i think there going to a meetting tommorow.';
const result = await proofreader.proofread(input);
console.log(result.correctedInput);
// "I think they're going to a meeting tomorrow."
for (const c of result.corrections) {
// c.startIndex, c.endIndex, c.correction
console.log(input.slice(c.startIndex, c.endIndex), '→', c.correction);
}
interface ProofreaderCorrection {
startIndex: number;
endIndex: number;
correction: string;
}
interface ProofreadResult {
correctedInput: string;
corrections: ProofreaderCorrection[];
}
const input = 'i think there going to a meetting tommorow.';
const result: ProofreadResult = await proofreader.proofread(input);
Each correction is just three fields: where the mistake starts, where it ends, and what to put there. The indices are the whole trick — they let you line the fix up against the original text without any alignment math.
Slice the original span from the indices
A correction tells you where and what to change, but not the text it's
replacing. That's fine — slice it out of the input yourself with startIndex
and endIndex. Because the ranges point into the original string, one
String.slice gives you the "before" for every edit, no alignment math.
- JavaScript
- TypeScript
function withOriginals(input, corrections) {
return corrections.map((c) => ({
...c,
original: input.slice(c.startIndex, c.endIndex),
}));
}
function withOriginals(input: string, corrections: ProofreaderCorrection[]) {
return corrections.map((c) => ({
...c,
original: input.slice(c.startIndex, c.endIndex),
}));
}
Highlight the corrections as a diff
Grammarly, LanguageTool, the red squiggle under a typo in any text box — they all landed
on the same UI: mark the mistake in place, show the fix on hover. To build that
you need positions, which is exactly what this API hands you. Walk the input
once, emitting the unchanged text between corrections and the changed spans
inside them. Three kinds of segment come out: unchanged, removed (the
original slice), and inserted (the correction). Render removed as <del>
and inserted as <ins> and you've got an inline diff in a single column.
- JavaScript
- TypeScript
function buildSegments(input, corrections) {
const sorted = [...corrections].sort((a, b) => a.startIndex - b.startIndex);
const segments = [];
let cursor = 0;
for (const c of sorted) {
if (cursor < c.startIndex) {
segments.push({ kind: 'unchanged', text: input.slice(cursor, c.startIndex) });
}
segments.push({ kind: 'removed', text: input.slice(c.startIndex, c.endIndex) });
segments.push({ kind: 'inserted', text: c.correction });
cursor = c.endIndex;
}
if (cursor < input.length) {
segments.push({ kind: 'unchanged', text: input.slice(cursor) });
}
return segments;
}
interface DiffSegment {
kind: 'unchanged' | 'removed' | 'inserted';
text: string;
}
function buildSegments(input: string, corrections: ProofreaderCorrection[]): DiffSegment[] {
const sorted = [...corrections].sort((a, b) => a.startIndex - b.startIndex);
const segments: DiffSegment[] = [];
let cursor = 0;
for (const c of sorted) {
if (cursor < c.startIndex) {
segments.push({ kind: 'unchanged', text: input.slice(cursor, c.startIndex) });
}
segments.push({ kind: 'removed', text: input.slice(c.startIndex, c.endIndex) });
segments.push({ kind: 'inserted', text: c.correction });
cursor = c.endIndex;
}
if (cursor < input.length) {
segments.push({ kind: 'unchanged', text: input.slice(cursor) });
}
return segments;
}
One walk, and the same segments render three ways: struck-through inline, a side-by-side before/after, or a bulleted list of suggestions. You never call the model twice to switch views.
Destroy the session
A live session pins its adapter in GPU memory, and Nano is not small. Hold one
per language for as long as the editor is open, then let it go on teardown so a
closed tab doesn't leak. Proofread after destroy() and you get an
AbortError — the session is gone, so make a new one.
Free it.
- JavaScript
- TypeScript
proofreader.destroy();
// Wire it to teardown so a closed tab doesn't leak GPU memory:
window.addEventListener('beforeunload', () => proofreader?.destroy());
proofreader.destroy();
window.addEventListener('beforeunload', () => proofreader?.destroy());
Run it locally: open 10-proofreader/index.html from the
chrome-ai-course repo in desktop
Chrome. Or use the hosted demo:
on-device proofreader (with the
API walkthrough).
Expected: you proofread a sentence full of typos and get back the corrected text, an inline diff with each fix highlighted, and a list of positioned corrections — every one showing its original slice and its replacement.
Requires: desktop Chrome with the Proofreader flag enabled
(#proofreader-api) and Gemini Nano available — see
Setup & the availability lifecycle. On most machines
it reports unavailable until you flip that flag.
Gotchas & troubleshooting
Most of these come from the same two facts: the API is behind a flag, and the indices point into the text you sent, not the text you got back.
Symptom: availability() returns unavailable for everyone, so the feature
never shows. Cause: the Proofreader is flag-gated — it shipped as an origin trial
in Chrome 141–145 and is otherwise off by default — or the device can't run
Gemini Nano at all. Fix: enable
chrome://flags/#proofreader-api and restart Chrome, and confirm the device
meets the built-in AI bar from Setup. Treat
unavailable as the common case and degrade with instructions, not a stack
trace.
Symptom: the first create() for a language hangs with no error. Cause: it's
downloading that language's adapter and you didn't wire a monitor, so there's
nothing to wait on. Fix: pass monitor(m) and read e.loaded (a 0..1 fraction)
for progress. create() resolves when the download finishes.
Symptom: your inline diff marks the wrong letters, or the offsets drift by a few
characters. Cause: you sliced with startIndex/endIndex into correctedInput,
or you edited the textarea after proofreading. The indices are offsets into the
exact input you passed to proofread(). Fix: slice into that original string,
and hold onto it until you've rendered.
Symptom: create() or proofread() rejects with NotSupportedError. Cause: a
language outside the supported set — Chrome's build accepts en, es, and ja
today. Fix: stick to those, and open one session per language instead of
expecting a single session to be multilingual.
Symptom: proofread() throws AbortError. Cause: you already called
destroy() on that session. Fix: a destroyed session doesn't come back — create
a new one. And always destroy() on teardown so orphaned sessions don't exhaust
GPU memory.
Recap
Proofreader.availability({ expectedInputLanguages })gatescreate()— and staysunavailableuntil#proofreader-apiis enabled.create()opens a language-scoped session; the shipped options are justexpectedInputLanguagesand a downloadmonitor.proofread()returnscorrectedInputplus acorrectionsarray of positioned edits, with indices into the original input.- Slice each original span with
startIndex/endIndex, then walk the input once to render an inline, side-by-side, or listed diff. - Each correction is
startIndex/endIndex/correction;destroy()frees the adapter and a destroyed session is gone.
Everyone else rents a server to move a comma.
Next steps
- Writer & Rewriter — the sibling flag-gated task APIs for drafting new text and reshaping existing text.
- The Prompt API (LanguageModel) — drop down to the raw model when you need structured JSON or conversation instead of a fixed set of corrections.
- Shipping & compatibility — what's stable, what's still flagged (the Proofreader among them), and what stays desktop-only.