Skip to main content

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 an availability({ expectedInputLanguages }) check and a download monitor.
  • Open a language-scoped session with includeCorrectionTypes, includeCorrectionExplanations, and correctionExplanationLanguage.
  • Call proofread() and read the ProofreadResult — a correctedInput string plus a corrections array.
  • Slice each original span out of the input with startIndex/endIndex, and render a highlighted inline diff.
  • Read the types array and explanation on each correction, and destroy() the session on teardown.
Prerequisites

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.

demo.js
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.
}

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.

demo.js
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)}%`);
});
},
});

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.

demo.js
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);
}

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 valueWhat it flags
spellingA misspelled word.
punctuationWrong, missing, or extra punctuation.
capitalizationA capitalization rule — sentence start, proper nouns, the pronoun "I".
prepositionThe wrong preposition for the context.
missing-wordsA word dropped from the original.
grammarAgreement, 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.

demo.js
function withOriginals(input, corrections) {
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.

demo.js
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;
}

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.

demo.js
proofreader.destroy();

// Wire it to teardown so a closed tab doesn't leak GPU memory:
window.addEventListener('beforeunload', () => proofreader?.destroy());
Try it

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.

availability() is unavailable on every machine you try

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.

create() never resolves

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.

The highlights land on the wrong characters

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.

NotSupportedError on create() or proofread()

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.

InvalidStateError after you tear down

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 }) gates create() — and stays unavailable until #proofreader-api is enabled.
  • create() opens a language-scoped session; includeCorrectionTypes and includeCorrectionExplanations fill the types array and the explanation.
  • proofread() returns correctedInput plus a corrections array 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.
  • types is 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.

Next: Embeddings (SemanticEmbedder)