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. (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 an availability({ expectedInputLanguages }) check and a download monitor.
  • Open a language-scoped session and call proofread().
  • Read the ProofreadResult — a correctedInput string plus a corrections array 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.
Prerequisites

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.

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

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

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
console.log(input.slice(c.startIndex, c.endIndex), '→', c.correction);
}

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.

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

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

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

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

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

AbortError after you tear down

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 }) gates create() — and stays unavailable until #proofreader-api is enabled.
  • create() opens a language-scoped session; the shipped options are just expectedInputLanguages and a download monitor.
  • 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.
  • 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.

Next: Embeddings (SemanticEmbedder)