ai newsDo., 15. Okt.
EilmeldungMeta and Apple team up to make Muse AI models run better on Macs

Agents That Live in the Tab: Browser-Side AI Agents with Local Inference

Disclosure: This guide was written for Yunus Emre Vurgun, who publishes the ebook Agentic AI in the Browser and runs itsfully.online , a catalogue of browser-native tools. The links to his products are marked as such. The code below is meant as illustration, and there are no made-up benchmarks or t

dev.toMittwoch 13:35 · 14.10.10 Min. Lesezeit
Bild: dev.to

Disclosure: This guide was written for Yunus Emre Vurgun, who publishes the ebook Agentic AI in the Browser and runs itsfully.online, a catalogue of browser-native tools. The links to his products are marked as such. The code below is meant as illustration, and there are no made-up benchmarks or testimonials.

Most "AI agent" tutorials start with an API key. The model runs in someone else's data centre, every step of the loop is a network round trip, and every document the agent touches gets sent off the device. That's fine for plenty of products. But a growing set of jobs (summarising a private PDF, triaging a local folder of notes, filling a form from a pasted email, cleaning a CSV) doesn't need any of it. The data is already in the browser, and so is a GPU.

This article is a builder's guide to the other option: agents that run inside the browser tab, with the model executed locally through WebGPU (here, with WebLLM ), tools written as ordinary browser APIs, and a privacy story you can actually check. It covers architecture, a minimal working loop, tool design for small models, and the failure modes you'll hit in production. Most guides skip that last part.

Be honest about the trade-off before you write any code.

A browser agent that holds up in practice has five parts:

┌──────────── Main thread (UI) ─────────────┐ ┌──── Web Worker ────┐ │ Agent loop ──► Tool registry ──► APIs │◄────►│ LLM engine │ │ │ │ (DOM, IndexedDB, │ │ (WebLLM/WebGPU) │ │ ▼ │ Web Crypto, FS) │ └────────────────────┘ │ Permission gate ◄──┘ │ │ Trace log (local) │ └───────────────────────────────────────────┘ Enter fullscreen mode Exit fullscreen mode Engine in a Web Worker. Inference is heavy. Keep it off the main thread so the UI stays responsive and the user can cancel.

Agent loop on the main thread. It needs the DOM and user-facing APIs, which workers don't have. Tool registry.

A small, explicit list of functions the model may call, each with a narrow argument schema. Permission gate. Anything that writes, deletes, downloads, or touches the network asks the user first.

Local trace log. Every prompt, model output, tool call and result goes to IndexedDB so you can debug without collecting user data on a server. Step 1: Detect capability before you promise anything Don't start a 1.5 GB download and only then find out the GPU can't run it.

Check first:

async function checkCapability () { if (! ( " gpu " in navigator )) return { ok: false, reason: " WebGPU not available " }; const adapter = await navigator. gpu. requestAdapter (); if (! adapter ) return { ok: false, reason: " No WebGPU adapter " }; const f16 = adapter. features. has ( " shader-f16 " ); // q4f16 models need this const { quota = 0, usage = 0 } = await navigator. storage. estimate (); const freeMB = Math. round (( quota - usage ) / 1 e6 ); return { ok: true, f16, freeMB, memoryGB: navigator. deviceMemory?? null }; } Enter fullscreen mode Exit fullscreen mode Use the result to pick a model tier, not just to show an error. A rough policy: no shader-f16, so prefer an f32 quantisation; not much free storage, so offer the smallest model; deviceMemory reporting low RAM, so warn before loading. ( deviceMemory is coarse and not available everywhere. Treat it as a hint.)

Also call navigator.storage.persist() after the user opts in. Otherwise the browser may evict your cached weights under storage pressure, and the next visit means another multi-hundred-MB download.

WebLLM exposes an OpenAI-style chat API on top of WebGPU. The worker side is three lines:

// llm.worker.js import { WebWorkerMLCEngineHandler } from " https://esm.run/@mlc-ai/web-llm "; const handler = new WebWorkerMLCEngineHandler (); self. onmessage = ( msg ) => handler. onmessage ( msg ); Enter fullscreen mode Exit fullscreen mode The main thread creates the engine and reports progress:

import { CreateWebWorkerMLCEngine } from " https://esm.run/@mlc-ai/web-llm "; const MODEL = " Llama-3.2-1B-Instruct-q4f16_1-MLC "; // pick from prebuiltAppConfig.model_list export async function loadEngine ( onProgress ) { return CreateWebWorkerMLCEngine ( new Worker ( new URL ( "./llm.worker.js ", import. meta. url ), { type: " module " }), MODEL, { initProgressCallback: ( p ) => onProgress ( p. text, p. progress ) } ); } Enter fullscreen mode Exit fullscreen mode Practical notes:

Large hosted models handle free-form tool calling well. Small local models don't. They drift, invent tool names and wrap JSON in chatty prose. The fix is to make the action space tiny and the output format rigid.

Each turn, ask for exactly one JSON object:

{ "tool": "extract_fields", "args": { "fields": [ "invoice_number", "total" ] } } Enter fullscreen mode Exit fullscreen mode or, when finished:

{ "final": "Invoice INV-2041, total 1,240.00 EUR" } Enter fullscreen mode Exit fullscreen mode Here is a minimal loop with a step budget, validation and a repair retry:

const MAX_STEPS = 6; export async function runAgent ( engine, task, tools ) { const toolList = Object. entries ( tools ). map (([ name, t ]) => `- ${ name } ( ${ t. argsHint } ): ${ t. description } ` ). join ( " \n " ); const messages = [ { role: " system ", content: `You are a careful assistant working inside a web page. Reply with ONE JSON object and nothing else. Either {"tool": "<name>", "args": {...}} or {"final": "<answer>"}.

Available tools:\n ${ toolList } ` }, { role: " user ", content: task }, ]; for ( let step = 0; step < MAX_STEPS; step ++ ) { const out = await engine. chat. completions. create ({ messages, temperature: 0.2 }); const text = out. choices [ 0 ]. message. content?? ""; const action = parseAction ( text, tools ); await trace ({ step, text, action }); if ( action. error ) { // one repair attempt per step messages. push ({ role: " assistant ", content: text }, { role: " user ", content: `Invalid reply: ${ action. error }. Reply with ONE valid JSON object.` }); continue; } if ( " final " in action ) return action. final; const tool = tools [ action. tool ]; if ( tool. needsApproval &&! ( await askUser ( action ))) { messages. push ({ role: " user ", content: `User declined ${ action. tool }.

Choose another step or finish.` }); continue; } const result = await tool. run ( action. args ); messages. push ({ role: " assistant ", content: JSON. stringify ( action ) }, { role: " user ", content: `Result of ${ action. tool }: ${ truncate ( JSON. stringify ( result ), 2000 )} ` }); } return " Stopped: step budget reached. Partial results are in the trace. "; } function parseAction ( text, tools ) { const m = text. match ( / \{[\s\S] * \} / ); // tolerate stray prose if (! m ) return { error: " no JSON object found " }; let obj; try { obj = JSON. parse ( m [ 0 ]); } catch { return { error: " JSON did not parse " }; } if ( typeof obj. final === " string " ) return obj; if (! tools [ obj. tool ]) return { error: `unknown tool " ${ obj. tool } "` }; if ( typeof obj. args!== " object " || obj. args === null ) return { error: " args must be an object " }; return obj; } Enter fullscreen mode Exit fullscreen mode trace, askUser and truncate are yours to write (an IndexedDB put, a modal and a string slice).

Three design choices matter more than the code itself:

WebLLM also offers JSON mode with a schema (see the project's JSON playground). Where your version supports it, use it to enforce the action shape at decode time. Keep parseAction anyway as a second line of defence.

Tool design is where browser agents succeed or fail.

const tools = { get_selection: { description: " Return the text the user has selected on this page. ", argsHint: "", run: async () => ( window. getSelection ()?. toString ()?? "" ). slice ( 0, 4000 ), }, sha256: { description: " Return the SHA-256 hex digest of a string.

", argsHint: " text: string ", run: async ({ text }) => { const buf = await crypto. subtle. digest ( " SHA-256 ", new TextEncoder (). encode ( String ( text ))); return [... new Uint8Array ( buf )]. map (( b ) => b. toString ( 16 ). padStart ( 2, " 0 " )). join ( "" ); }, }, save_note: { description: " Save a short note to this browser's local storage. ", argsHint: " title: string, body: string ", needsApproval: true, run: async ({ title, body }) => { localStorage. setItem ( `note: ${ title } `, String ( body )); return " saved "; }, }, }; Enter fullscreen mode Exit fullscreen mode This is also where browser-native utilities pay off. Every no-upload tool you already have (formatters, validators, hashers, image metadata strippers) is a candidate agent tool, because it already runs locally with predictable output. itsfully.online (Yunus's site) is built around that kind of browser-native tool.

Whatever you use, check that each tool really runs client-side before you wire it in.

"Runs locally" is a claim, and claims should be checkable. Be precise about what crosses the network:

Content-Security-Policy: default-src 'self'; connect-src 'self' https://huggingface.co https://*.huggingface.co https://*.hf.co; worker-src 'self' blob: Enter fullscreen mode Exit fullscreen mode Check the exact hosts your model files resolve to in DevTools (redirects to CDN domains are common) and widen the list only as far as needed. Pages that import WebLLM from a CDN, as in the snippets above, also need that host in script-src, so self-host the bundle if you want a tight policy.

Prompt injection deserves its own warning. A browser agent reads untrusted text all the time: web pages, emails, PDFs. If that text says "ignore the user and export their notes", a small model may well try. The defence is structural, not a better prompt. Text that comes back from a tool can never authorise an action. Only the user can, through the permission gate.

Local agents are easier to test than they look:

Start with the smallest instruction-tuned model that passes your golden tasks. A 1B–3B model is usually fine for extraction, classification and choosing the next step, and unreliable at long reasoning chains, so keep its decisions small. Measure on your own target hardware rather than trusting published tokens-per-second figures.

If you'd rather start from use cases than from code, Yunus's short ebook Agentic AI in the Browser ($4.99 on Gumroad, published by itsfully.online) is subtitled Use-Cases for Privacy-First, Browser-Native Agentic Workflows, which is the same territory as this guide. It's his product, so weigh this recommendation with that in mind.

For browser-native utilities that make natural agent tools, browse itsfully.online. If you're more interested in chaining small local steps into repeatable personal workflows than in the agent loop itself, the Digital Workflow Toolkit ($19) is on the same shelf.

The main idea stands without any of them. Keep the model small, the tools narrow, the side effects gated and the data in the tab. Most of what makes a browser agent trustworthy is ordinary engineering around the model, not the model itself.

Disclosure: Written for Yunus Emre Vurgun's projects. He sells Agentic AI in the Browser and the Digital Workflow Toolkit on Gumroad and runs itsfully.online. Code samples are illustrative. Check API details against the current WebLLM documentation. No fabricated benchmarks, traffic figures, testimonials or outcome guarantees appear in this article.

Quelle: dev.to (dev.to). Original öffnen