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.
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,
the replacement, and a types array — 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 with
includeCorrectionTypes,includeCorrectionExplanations, andcorrectionExplanationLanguage. - Call
proofread()and read theProofreadResult— acorrectedInputstring plus acorrectionsarray. - Slice each original span out of the input with
startIndex/endIndex, and render a highlighted inline diff. - Read the
typesarray andexplanationon each correction, anddestroy()the session on teardown.
Desktop Chrome with built-in AI switched on — plus one extra catch for this API.
The Proofreader is flag-gated (a lapsed origin trial), 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 five languages — en, es, ja, de, fr — 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 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, same as everywhere else in this
course. Two options do the heavy lifting: includeCorrectionTypes fills the
types array on each correction, and includeCorrectionExplanations adds a
plain-language explanation. Leave them off and you get corrections with no
labels and no reasons, which is a worse editor UI for no saving.
correctionExplanationLanguage is the sly one — it sets the language of the
explanation text, independent of the language you're proofreading. Proofread
Spanish, explain in English.
- JavaScript
- TypeScript
const proofreader = await Proofreader.create({
expectedInputLanguages: ['en'], // 'en' | 'es' | 'ja' | 'de' | 'fr'
includeCorrectionTypes: true, // fill each correction's `types` array
includeCorrectionExplanations: true, // add a human-readable `explanation`
correctionExplanationLanguage: 'en', // explanation language, independent of the input
monitor(m) {
m.addEventListener('downloadprogress', (e) => {
// e.loaded is a 0..1 fraction — there is no e.total in current builds
console.log(`Downloading adapter… ${Math.round(e.loaded * 100)}%`);
});
},
});
interface ProofreaderCreateOptions {
expectedInputLanguages?: string[];
includeCorrectionTypes?: boolean;
includeCorrectionExplanations?: boolean;
correctionExplanationLanguage?: string;
signal?: AbortSignal;
monitor?: (m: AICreateMonitor) => void;
}
const proofreader = await Proofreader.create({
expectedInputLanguages: ['en'],
includeCorrectionTypes: true,
includeCorrectionExplanations: true,
correctionExplanationLanguage: '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, c.types, c.explanation
console.log(input.slice(c.startIndex, c.endIndex), '→', c.correction, c.types);
}
type ProofreaderCorrectionType =
| 'spelling' | 'punctuation' | 'capitalization'
| 'preposition' | 'missing-words' | 'grammar';
interface ProofreaderCorrection {
startIndex: number;
endIndex: number;
correction: string;
types?: ProofreaderCorrectionType[]; // plural — an array, filled by includeCorrectionTypes
explanation?: string;
}
interface ProofreadResult {
correctedInput: string;
corrections: ProofreaderCorrection[];
}
const input = 'i think there going to a meetting tommorow.';
const result: ProofreadResult = await proofreader.proofread(input);
The name that trips people is types. It's plural, and it's an array — one
correction can carry ['spelling', 'grammar'] when a single fix repairs both.
Not type. Not a string. Read it as a list.
Know the correction types
Six values, and a correction reports the ones that apply. This is the label you render as a chip next to each suggestion.
types value | What it flags |
|---|---|
spelling | A misspelled word. |
punctuation | Wrong, missing, or extra punctuation. |
capitalization | A capitalization rule — sentence start, proper nouns, the pronoun "I". |
preposition | The wrong preposition for the context. |
missing-words | A word dropped from the original. |
grammar | Agreement, tense, and the general grammar bucket. |
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 in your address bar — 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), types: c.types });
segments.push({ kind: 'inserted', text: c.correction, types: c.types });
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;
types?: ProofreaderCorrectionType[];
}
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), types: c.types });
segments.push({ kind: 'inserted', text: c.correction, types: c.types });
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
InvalidStateError — 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, its types, and a short
explanation.
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 (a lapsed origin trial) and 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 — the Proofreader ships adapters only for
en, es, ja, de, and fr. Fix: stick to those five, and open one session
per language instead of expecting a single session to be multilingual.
Symptom: proofread() throws InvalidStateError. 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;includeCorrectionTypesandincludeCorrectionExplanationsfill thetypesarray and theexplanation.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. typesis a plural array from a fixed set of six;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.