Headless, universal, configurable in-browser LLM agent built on the Vercel AI SDK. One package drives any model from the browser:
- ☁️ Cloud providers, bring-your-own-key — OpenAI, Anthropic, Google, xAI, DeepSeek, any OpenAI-compatible server, and the Vercel AI Gateway.
- 🖥️ Local WebGPU models — via WebLLM
(
@browser-ai/web-llm); no server, no key, data never leaves the device. - 🔌 Any AI SDK
LanguageModel— e.g. Chrome/Edge built-in AI.
It gives you simple text generation, streaming, native tool-calling, and structured output, plus a high-level plan → execute → replan → synthesize agent — with host-defined tools, optional MCP, system prompts, IndexedDB storage, and an encrypted token vault. UI-agnostic: it streams typed events; you render them however you like.
Browser sibling of the Node package
@dudko.dev/agent.
Using React?
@dudko.dev/agent-web-reactwraps this core in auseAgenthook, an<AgentProvider>context and drop-in UI components — try the live demo (source).
npm install @dudko.dev/agent-web ai zodThen add only the providers you use (all optional peers, dynamically imported):
# cloud, pick what you need
npm install @ai-sdk/openai # or @ai-sdk/anthropic, @ai-sdk/google, @ai-sdk/xai, @ai-sdk/deepseek, @ai-sdk/openai-compatible
# local WebGPU models
npm install @browser-ai/web-llm @mlc-ai/web-llm
# optional: HTTP MCP
npm install @modelcontextprotocol/sdkdist/agent-web.js is a self-contained ESM bundle of the core — ai,
zod and idb are baked in. It ships in the npm package (so it's on every
CDN) and is attached to each
GitHub release as
agent-web-<version>.min.js:
<script type="module">
import { createAgent, defineTool } from 'https://cdn.jsdelivr.net/npm/@dudko.dev/agent-web/dist/agent-web.js'
// or: https://unpkg.com/@dudko.dev/agent-web/dist/agent-web.js
</script>Bundler users can also opt into it via the @dudko.dev/agent-web/bundle
subpath. Optional provider peers stay external even in the bundle; in a
bundler-less page, map the ones you use with an import map (the runtime
import() respects it) — direct LanguageModels and gateway specs need no
import map at all:
<script type="importmap">
{
"imports": {
"@ai-sdk/openai": "https://cdn.jsdelivr.net/npm/@ai-sdk/openai/+esm"
}
}
</script>Keys are stored encrypted at rest (WebCrypto, IndexedDB) and referenced by id — never inlined into config. See security.
import {
createAgent,
defineTool,
VaultCredentialStore,
} from '@dudko.dev/agent-web'
import { z } from 'zod'
// 1. Store the user's key once (e.g. from a settings form). Encrypted at rest.
const credentials = new VaultCredentialStore()
await credentials.setApiKey('openai', userProvidedKey)
// 2. Tools = your app's actions.
const tools = {
add_text: defineTool({
description: 'Add a text block to the page.',
inputSchema: z.object({ text: z.string(), x: z.number().optional() }),
execute: async ({ text, x }) => addTextBlock(text, x), // your code
}),
}
// 3. Create the agent. The key is fetched from the vault at build time.
const agent = await createAgent({
model: { providerType: 'openai', model: 'gpt-4o-mini', credentialRef: 'openai' },
credentials,
tools,
describeState: () => serializeMyCanvas(), // optional grounding
})
// 4. Run. Every step is a typed event.
const result = await agent.run('Add a centered title and a totals line', {
onEvent: (e) => {
if (e.type === 'plan.created') console.log('plan:', e.plan.steps)
if (e.type === 'step.tool-call') console.log('tool:', e.name, e.input)
if (e.type === 'final') console.log('done:', e.text)
},
})
console.log(result.final, `— ${result.applied} changes`)Direct browser calls & CORS: not every provider allows direct BYOK calls from a browser origin. Google (Gemini) and openai-compatible/gateway are the reliable direct paths; Anthropic works (a required header is injected for you); OpenAI/xAI/DeepSeek usually need a proxy. See docs/providers.md.
import { createAgent, createWebLLMModel, isWebGPUAvailable } from '@dudko.dev/agent-web'
if (!isWebGPUAvailable()) throw new Error('WebGPU required for local models')
const model = await createWebLLMModel('Llama-3.2-3B-Instruct-q4f16_1-MLC', {
initProgressCallback: (r) => console.log('loading', Math.round(r.progress * 100), '%'),
})
// Local models default to the robust "prompted" tool-mode automatically.
const agent = await createAgent({ model, tools /* ...same as above */ })
await agent.run('Summarize the current page and add a heading')You can also pass a model by spec: { providerType: 'web-llm', model: '…' }.
import { generate, stream, generateStructured, runToolLoop } from '@dudko.dev/agent-web'
import { z } from 'zod'
const { text } = await generate(model, { prompt: 'Write a haiku about WebGPU' })
const { textStream } = stream(model, { prompt: 'Explain IndexedDB' })
for await (const chunk of textStream) process.stdout.write(chunk)
const { object } = await generateStructured(
model,
z.object({ title: z.string(), tags: z.array(z.string()) }),
{ prompt: 'Suggest a title and tags for this article: …' },
)
// Tool-calling with feedback (native or prompted), normalized result. In both
// modes the model sees its tool results and can keep going, up to `maxSteps`
// rounds (default 4; prompted models get the results as a TOOL RESULTS turn):
const { text: reply, toolCalls } = await runToolLoop(model, {
mode: 'native',
prompt: 'Add a title',
tools,
})import { connectMcpHttp } from '@dudko.dev/agent-web/mcp'
const mcp = await connectMcpHttp({
docs: { url: 'https://my-mcp-server.example/mcp', headers: { Authorization: `Bearer ${t}` } },
})
const agent = await createAgent({ model, tools: { ...tools, ...mcp.tools } })
// ... later: await mcp.close()Browsers can only speak the HTTP (StreamableHTTP) transport — stdio MCP is
Node-only. The connector lives in the ./mcp subpath so the MCP SDK never
enters your core bundle.
connectMcpHttp returns { tools, catalog, results, refreshServer, close }.
tools and catalog are mutated in place by refreshServer(name), so an
agent built from them picks up a server's new tool list (react to it via the
onToolsChanged option) without being rebuilt. Filtering is plain object work:
delete the keys you don't want before merging.
A server behind OAuth needs more than a header: access tokens expire mid-run.
Pass an authProvider and the MCP SDK discovers the authorization server
(RFC 9728), registers this app dynamically (RFC 7591 — no client_id to
pre-provision), runs PKCE, and refreshes the access token on any 401 and
retries the request.
import { BrowserOAuthProvider, connectMcpHttp, finishMcpOAuth, readOAuthCallback }
from '@dudko.dev/agent-web/mcp'
const provider = new BrowserOAuthProvider({
serverUrl: 'https://my-mcp-server.example/mcp',
redirectUrl: `${location.origin}/callback`, // must match on the way back
})
// 1. On the callback page: finish the flow before anything else.
const callback = readOAuthCallback()
if (callback) await finishMcpOAuth(provider, callback)
// 2. Connect. `needsAuthorization` means "send the user to authorize".
const mcp = await connectMcpHttp({ docs: { url: provider.serverUrl, authProvider: provider } })
if (mcp.results[0]?.needsAuthorization) {
location.href = String(provider.authorizationUrl) // a button, a popup — your call
}Tokens, the dynamic registration and the PKCE verifier are stored in the same
encrypted IndexedDB vault as provider API keys (VaultOAuthStorage; swap in
MemoryOAuthStorage or your own via storage). redirectToAuthorization never
navigates on its own — the SDK can demand authorization from inside a running
tool call, and evicting the user then would discard the run. The URL is recorded
on provider.authorizationUrl and the app decides when to use it.
A browser tells JavaScript nothing about why it blocked a request — a CORS
rejection and a server that is down both arrive as TypeError: Failed to fetch,
with the real reason printed only to the devtools console. So these are worth
checking first rather than last:
Access-Control-Allow-Origin: <your origin>
Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, mcp-session-id,
MCP-Protocol-Version, Last-Event-ID
Access-Control-Expose-Headers: mcp-session-id, WWW-Authenticate
Each line earns its place:
MCP-Protocol-Versionin Allow-Headers is the one that bites hardest. The spec has required that header on every request afterinitializesince 2025-06-18, and CORS lists written before that date omit it. The first request does not carry it, so discovery, the whole OAuth dance andinitializeall succeed — and then everything afterwards is blocked. It reads exactly like the consent step failing, and sends you to debug the client.WWW-Authenticatein Expose-Headers, or the browser hides the 401 challenge from JS and OAuth discovery never starts.Last-Event-IDfor resuming a dropped SSE stream.- The authorization server's
/register, metadata and token endpoints need CORS too,OPTIONSincluded — a browser client calls them directly. - If the server uses the MCP SDK's DNS-rebinding protection, its
allowedOriginsmust contain your origin. Left empty, it answers 403Origin not allowedto any request that carries anOriginheader — which is every request a browser makes.
When a connection does fail this way, connectMcpHttp no longer just repeats the
browser's silence: it re-probes the endpoint with a plain request and, if that
gets through, says which header is being refused. diagnoseMcpCors(url) is
exported so a UI can show the same sentence.
| Option | Default | Purpose |
|---|---|---|
model |
— | a LanguageModel or ProviderModelSpec (the executor/default) |
planner / synthesizer |
= model |
per-stage model overrides (inherit base provider/creds if partial) |
credentials |
— | a CredentialStore for credentialRef keys |
tools |
{} |
host tools (defineTool) |
availableTools / excludedTools |
— | whitelist / blacklist of tool names mounted from tools |
toolMode |
'auto' |
native | prompted | auto (cloud→native, local→prompted) |
systemPrompt |
— | prepended to every phase |
describeState |
— | serialize world state into prompt context |
memory |
— | ContextStore (IndexedDBStore / MemoryStore); recent turns are read back into the planner prompt |
maxIterations / maxStepsPerTask / maxRevisions |
8 / 4 / 2 | loop caps |
chatTimeoutMs |
120000 | per-call watchdog |
replan / synthesize |
true |
toggle phases |
replanAfter |
'failure' |
replan trigger: 'failure' | 'always' | (stepResult) => boolean | Promise<boolean> |
compressAfterChars |
12000 | summarize old history past this size |
- docs/design.md — architecture & rationale.
- docs/providers.md — every provider, CORS & direct-vs-proxy.
- docs/security.md — the token vault & its threat model.
- docs/tasks.md — status & roadmap.
@dudko.dev/agent-web-react— React bindings (useAgent,<AgentProvider>, pre-styled components) · live demo · source.@dudko.dev/agent— the Node/server sibling (stdio MCP, persistence/resume, OpenTelemetry, CLI).
MIT © Siarhei Dudko