The Prompt API (LanguageModel)
Ok. The Prompt API — the bare LanguageModel global — is the one you actually
came for: the raw chat surface onto Gemini Nano, running on your own GPU with no
OpenAI key, no Anthropic invoice, and no network request at all. Conversational
state, a system prompt, streaming, tuning, cloning, a token budget. By the end
of this page you'll have a working on-device chat and know every knob worth
turning.
What you'll build
- Create a
LanguageModelsession behind anavailability()gate, always passingoutputLanguage: 'en'. - Steer output with a system prompt and N-shot
initialPrompts. - Stream replies with
promptStreaming()and append the deltas (never replace). - Tune
temperatureandtopKfromparams(), and fork a session withclone(). - Track
inputUsageagainstinputQuota, survive context overflow, anddestroy()on teardown.
Desktop Chrome with built-in AI switched on. If LanguageModel isn't there yet,
work through Setup & the availability lifecycle
first, and check the compatibility matrix for
what's stable where. This page assumes you know the availability states — it
won't re-teach them.
Create a session
The loop is always the same: ask whether the model is there, create a session,
use it, tear it down. Feature-detect the global, check availability(), then
create() — and pass outputLanguage: 'en' every single time. Skip it and
Chrome still answers, just worse: JSON wrapped in code fences, invented IDs,
fewer tool calls. It's load-bearing.
- JavaScript
- TypeScript
if (typeof LanguageModel === 'undefined') {
// No built-in AI on this browser — degrade gracefully (see lesson 2).
}
const status = await LanguageModel.availability();
// "unavailable" | "downloadable" | "downloading" | "available"
if (status !== 'unavailable') {
const session = await LanguageModel.create({
outputLanguage: 'en', // always. this is load-bearing.
monitor(m) {
m.addEventListener('downloadprogress', (e) => {
// e.loaded is a 0..1 fraction — there is no e.total in current builds
console.log(`Downloading model… ${Math.round(e.loaded * 100)}%`);
});
},
});
const reply = await session.prompt('Say hi in one word.');
console.log(reply);
session.destroy();
}
type Availability =
| 'unavailable'
| 'downloadable'
| 'downloading'
| 'available';
const status: Availability = await LanguageModel.availability();
if (status !== 'unavailable') {
const session: LanguageModel = await LanguageModel.create({
outputLanguage: 'en', // always. this is load-bearing.
monitor(m: AICreateMonitor) {
m.addEventListener('downloadprogress', (e: ProgressEvent) => {
console.log(`Downloading model… ${Math.round(e.loaded * 100)}%`);
});
},
});
const reply: string = await session.prompt('Say hi in one word.');
console.log(reply);
session.destroy();
}
First create() on a fresh machine blocks on a multi-GB download. That's why
the monitor is wired from line one, not bolted on later.
Set a system prompt and initial (N-shot) prompts
The system prompt is how you hand the model a job. It goes into initialPrompts
as a {role:'system'} message — and it has to be first. Index 0, exactly once.
Put it anywhere else, or pass two, and create() throws a TypeError before you
ever hold a session. There is no top-level systemPrompt string; that shape is
legacy, gone.
After the system message you can prime the model with example turns — user says this, assistant answers that — so it copies the pattern instead of guessing at it. That's N-shot prompting, and it's the cheapest steering you'll ever do.
- JavaScript
- TypeScript
const session = await LanguageModel.create({
outputLanguage: 'en',
initialPrompts: [
{ role: 'system', content: 'You are a concise, friendly assistant.' },
{ role: 'user', content: 'ping' },
{ role: 'assistant', content: 'pong' },
],
});
// The system message steers every turn and survives context overflow.
const reply = await session.prompt('marco');
const session: LanguageModel = await LanguageModel.create({
outputLanguage: 'en',
initialPrompts: [
{ role: 'system', content: 'You are a concise, friendly assistant.' },
{ role: 'user', content: 'ping' },
{ role: 'assistant', content: 'pong' },
],
});
const reply: string = await session.prompt('marco');
Choose prompt() or promptStreaming()
Two ways to get text out. prompt() hands you the whole reply once it's done —
one await, one string. promptStreaming() hands you a ReadableStream and you
loop over it with for await. Here's the part people get wrong: the chunks are
deltas, not snapshots. Each chunk is the next slice of text, so you accumulate
them. Render the raw chunk on its own and the previous words vanish.
Append. Don't replace.
- JavaScript
- TypeScript
// Whole reply at once:
const reply = await session.prompt('Write a haiku about TypeScript.');
// Or stream incremental deltas and append them:
let text = '';
const stream = session.promptStreaming('Write a haiku about TypeScript.');
for await (const chunk of stream) {
text += chunk; // deltas — append, never replace
render(text);
}
const reply: string = await session.prompt('Write a haiku about TypeScript.');
let text = '';
const stream: ReadableStream<string> = session.promptStreaming(
'Write a haiku about TypeScript.',
);
for await (const chunk of stream) {
text += chunk; // deltas — append, never replace
render(text);
}
Tune temperature and topK from params()
Two decoding knobs. temperature is how much the model gambles — low is
deterministic and repetitive, high is creative and occasionally unhinged.
topK caps how many candidate tokens it samples from each step. Don't guess the
ranges, ask for them: LanguageModel.params() returns the defaults and the
ceilings. But feature-detect it first — some builds ship the model without
shipping params(), and calling it blind throws.
- JavaScript
- TypeScript
let temperature;
let topK;
if (typeof LanguageModel.params === 'function') {
const params = await LanguageModel.params();
// { defaultTopK, maxTopK, defaultTemperature, maxTemperature }
temperature = params.defaultTemperature;
topK = params.defaultTopK;
}
const session = await LanguageModel.create({
outputLanguage: 'en',
temperature, // undefined falls back to the model default
topK,
initialPrompts: [{ role: 'system', content: 'You are a concise, friendly assistant.' }],
});
let temperature: number | undefined;
let topK: number | undefined;
if (typeof LanguageModel.params === 'function') {
const params: LanguageModelParams = await LanguageModel.params();
temperature = params.defaultTemperature;
topK = params.defaultTopK;
}
const session: LanguageModel = await LanguageModel.create({
outputLanguage: 'en',
temperature,
topK,
initialPrompts: [{ role: 'system', content: 'You are a concise, friendly assistant.' }],
});
These are locked in at create(). Want different settings mid-chat? New session.
Fork a conversation with clone()
clone() branches a session. The copy inherits the system prompt, the initial
prompts, and every turn up to the moment you cloned; after that the two diverge
and never speak again. It's cheaper than rebuilding a primed session from
scratch, and it's the honest way to run "give me two answers to the same
question" or to keep an undo snapshot before a risky turn.
- JavaScript
- TypeScript
const base = await LanguageModel.create({
outputLanguage: 'en',
initialPrompts: [{ role: 'system', content: 'You are a concise, friendly assistant.' }],
});
const branch = await base.clone(); // shares history up to now, then diverges
const [a, b] = await Promise.all([
base.prompt('Pitch me a startup name.'),
branch.prompt('Pitch me a startup name.'),
]);
base.destroy();
branch.destroy();
const base: LanguageModel = await LanguageModel.create({
outputLanguage: 'en',
initialPrompts: [{ role: 'system', content: 'You are a concise, friendly assistant.' }],
});
const branch: LanguageModel = await base.clone();
const [a, b]: [string, string] = await Promise.all([
base.prompt('Pitch me a startup name.'),
branch.prompt('Pitch me a startup name.'),
]);
base.destroy();
branch.destroy();
Track tokens, quota, and context overflow
A session has a budget — inputQuota, usually around 4096 input tokens — and
inputUsage climbs as the conversation grows. Watch the gap. When a new prompt
would blow past the quota, the session quietly evicts your oldest turns to make
room, one user/assistant pair at a time. The system prompt is never evicted;
that's the whole reason it's special. And if even that can't free enough room,
you get a QuotaExceededError and nothing is dropped.
- JavaScript
- TypeScript
console.log(`${session.inputUsage} / ${session.inputQuota} input tokens`);
try {
const reply = await session.prompt(veryLongText);
} catch (e) {
if (e.name === 'QuotaExceededError') {
// Couldn't evict enough to fit — start fresh (or summarize the history first).
session.destroy();
session = await LanguageModel.create({ outputLanguage: 'en', initialPrompts });
}
}
console.log(`${session.inputUsage} / ${session.inputQuota} input tokens`);
try {
const reply: string = await session.prompt(veryLongText);
} catch (e) {
if (e instanceof DOMException && e.name === 'QuotaExceededError') {
session.destroy();
session = await LanguageModel.create({ outputLanguage: 'en', initialPrompts });
}
}
No maxTokens, no tokensSoFar, no countPromptTokens() — those names are from
an older draft. inputUsage and inputQuota are the whole vocabulary now.
Destroy the session
A session pins real GPU memory, and Nano isn't small. Leave one hanging and you
starve every other feature on the page. So call destroy() when you're done and
wire it to teardown, so a closed tab doesn't leak. One catch: a destroyed
session is gone. Touch it again and you get an InvalidStateError.
Recreate. Don't resurrect.
- JavaScript
- TypeScript
session.destroy();
// Wire it to teardown so a closed tab doesn't leak GPU memory:
window.addEventListener('beforeunload', () => session?.destroy());
// Prompting after destroy() throws InvalidStateError — create a new session instead.
session.destroy();
window.addEventListener('beforeunload', () => session?.destroy());
// Prompting after destroy() throws InvalidStateError — create a new session instead.
Run it locally: open 03-prompt-api/index.html from the
chrome-ai-course repo in desktop
Chrome. Or use the hosted demo:
on-device chat (with the
API walkthrough).
Expected: you type a prompt, the reply streams in a few words at a time, and a
token readout updates to something like 48 / 4096 after each turn.
Requires: desktop Chrome with Gemini Nano available — see Setup & the availability lifecycle.
Gotchas & troubleshooting
Most of these are the same three mistakes wearing different hats: you didn't
wait for the download, you didn't pass outputLanguage, or you treated a
streaming delta like a snapshot.
Symptom: the first create() hangs forever with no error. Cause: the multi-GB
model is downloading and you didn't wire a monitor, so there's nothing visible
to wait on. Fix: always pass monitor(m) and read e.loaded (a 0..1 fraction)
for progress. create() resolves when the download finishes.
Symptom: replies come wrapped in code fences or full of invented specifics.
Cause: you omitted outputLanguage on create(). Fix: always pass
outputLanguage: 'en'. On current Chrome it's load-bearing, not a nicety.
Symptom: the output flickers and ends on a fragment. Cause: you're replacing the
output with each chunk, but the chunks are deltas, not the full text so far.
Fix: append — text += chunk — then render the accumulated text.
Symptom: a TypeError before you ever get a session. Cause: your
{role:'system'} message isn't at index 0 of initialPrompts, or there's more
than one. Fix: exactly one system message, first in the array. No top-level
systemPrompt.
Symptom: a prompt rejects with QuotaExceededError. Cause: even after evicting
the oldest turns, the history plus the new prompt won't fit inputQuota. Fix:
destroy and start a fresh session, or run the history through the Summarizer and
prime a new session with the summary.
Symptom: InvalidStateError on prompt(). Cause: you already called
destroy() on that session. Fix: sessions don't come back — create a new one.
Recap
availability()gatescreate(); everycreate()getsoutputLanguage: 'en'.- The system prompt lives at
initialPrompts[0], exactly once; N-shot examples follow as user/assistant pairs. promptStreaming()yields deltas — accumulate them, don't replace.params()gives you thetemperature/topKranges; feature-detect it before you call it.clone()forks a primed session;inputUsage/inputQuotatrack the budget; overflow evicts oldest turns or throws.destroy()frees GPU memory, and a destroyed session is done for good.
Everyone else is still renting tokens.
Next steps
- Structured output & tool calling — force the model to return JSON you can parse, and wire it to real functions.
- Setup & the availability lifecycle — the availability states and the download flow in depth.
- Shipping & compatibility — what's stable, what's flagged, and what stays desktop-only.