Skip to content

Local models on the web

The Browser clients guide called a model that lived on a server. This guide removes the server: interact_with_gemma loads a Gemma-family model into the page and runs it on the browser's GPU through WebGPU. Nothing leaves the device, and the reply streams onto an AsyncNode exactly as a remote backend's would.

interact_with_gemma is an ordinary A11 action. It has the same ports as every other backend — an interactions input, a unary config input, and text_output / new_interactions outputs — so the code that drives it is the same code you already write for interact_with_llm. The only new idea is that its handler runs a model locally instead of calling an API.

Before you start

You need npm install a11@npm:@curiositystack/a11, a browser with WebGPU enabled, and a URL to a hosted Gemma model asset (.task / .litertlm) that MediaPipe can load. The model file is large; serve it from a location with permissive CORS. No API key and no server are involved.

1. The action contract

Import the backend and the SDK names you need. INTERACT_WITH_GEMMA_SCHEMA already describes the ports; you register it like any other schema.

import {
    Action,
    ActionRegistry,
    INTERACT_WITH_GEMMA_SCHEMA,
    interactWithGemma,
    makeTextMessageInteraction,
    parseInteraction,
    isOk,
    StatusCode,
    type Status,
} from '@curiositystack/a11';

const need = <T>(value: T | Status): T => {
    if (!isOk(value)) throw new Error(`${StatusCode[value.code]}: ${value.message}`);
    return value as T;
};

2. Register and run the action locally

There is no session and no transport. Create the action from its schema, bind the handler, and run() it. run() starts the handler on the same node map, so the ports you open next are the very ones the handler reads and writes.

const registry = new ActionRegistry();
need(registry.register('interact_with_gemma', INTERACT_WITH_GEMMA_SCHEMA, interactWithGemma));

const action = need(Action.create(INTERACT_WITH_GEMMA_SCHEMA, {
    handler: interactWithGemma,
    registry,
}));
need(action.run());

3. Feed the conversation and the model URL

The interactions port takes the whole conversation; the unary config port carries the browser-specific knobs, most importantly model_asset_path — the URL of the Gemma model to download and run. makeTextMessageInteraction builds a portable text turn.

const user = need(makeTextMessageInteraction('Explain WebGPU in one sentence.'));

const interactions = need(await action.getInput('interactions'));
need(await interactions.put(user, {final: true}));

const config = need(await action.getInput('config'));
need(await config.putFinal({
    model_asset_path:
        'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it-web.litertlm?download=true',
}));

That HuggingFace URL is also the SDK default, so omitting model_asset_path loads the same model. The asset is fetched with redirects followed (the resolve URL 302s to a CDN), then handed to the runtime as bytes. The downloaded bytes are stored in the browser's Cache Storage, so a page reload serves the model from disk instead of downloading it again. The first turn triggers the download and WebGPU compilation; later turns reuse the loaded model.

4. Stream the reply as it arrives

text_output is an AsyncNode. Reading it in a loop lets tokens appear the moment the model produces them — next() returns each streamed piece and null once the turn is complete.

const output = need(await action.getOutput('text_output', false));
let reply = '';
while (true) {
    const token = need(await output.next({timeoutMs: 120_000}));
    if (token === null) break;
    reply += token;
    render(reply); // append to your chat bubble
}

The completed assistant turn also lands, structured, on new_interactions. Keep it and prepend it to the next interactions write to continue the conversation:

const newInteractions = need(await action.getOutput('new_interactions', false));
const assistant = need(parseInteraction(need(await newInteractions.next())));
need(await action.wait(5_000));
history = [...history, user, assistant];

5. Swap the runtime (optional)

By default the handler dynamically imports Google's MediaPipe LlmInference task and runs it on WebGPU. You can replace that runtime — to cache the loaded model across turns, report download progress, or plug in a different engine — with setGemmaEngineFactory:

import {setGemmaEngineFactory, type GemmaEngine} from '@curiositystack/a11';

setGemmaEngineFactory(async (config) => {
    const engine: GemmaEngine = {
        async generate(prompt, onToken) {
            // stream pieces via onToken(delta); resolve with the full text,
            // or return a Status on failure — never throw.
            return '…';
        },
    };
    return engine;
});

A factory returns a StatusOr<GemmaEngine>: on failure return a status built with a helper such as unavailableError(...) rather than throwing, and the runtime aborts the output ports with it.

6. Display failures

Every A11 call returns a StatusOr<T>: success values pass isOk, failures carry a code, message, and details. Convert them at the UI boundary — a missing model URL, an absent WebGPU adapter, or a load error all arrive as ordinary statuses, not exceptions:

try {
    await runTurn(text);
} catch (error) {
    errorRegion.textContent = error instanceof Error ? error.message : String(error);
}

Try it

Paste a hosted Gemma model URL, then chat. The first message downloads and compiles the model in your browser (this can take a while); after that, generation is local and the reply streams token by token into the bubble. The download is cached, so reloading the page skips it. A WebGPU-capable browser is required.