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.
The agent loop also has thinking (portable reasoning levels or exact
budgets), token limits (input / output / thinking / total per run),
context compaction (automatic and agent.compact()), prompt caching,
skills (SKILL.md bundles), tool consent modes with an autopilot switch,
tool search for catalogues of hundreds of MCP tools, subagents in
Web Workers, image / PDF / file / URL input (with a clear error for models
that can't take them),
and a virtual file system in IndexedDB with fs_* tools — see
docs/capabilities.md.
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.
Pass several servers at once — they connect concurrently, each with its own
deadline (connectTimeoutMs, default 30 s), every page of a paginated
tools/list is read, and tools a server marks readOnlyHint are treated as
read-only by the consent gate. A failing server is reported in results; the
others still mount. With hundreds of tools the agent switches to tool search
automatically (see docs/capabilities.md).
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.
import { createAgent, createSubagentTool, defineSkill } from '@dudko.dev/agent-web'
const agent = await createAgent({
model,
tools: { ...tools, ...mcp.tools },
thinking: 'high', // or { level, budgetTokens }; per stage via stageThinking
limits: { maxTotalTokens: 200_000, maxReasoningTokens: 20_000 },
maxToolCalls: 40,
compaction: { contextWindowTokens: 128_000 }, // auto-compacts history and long runs
skills: [defineSkill({ name: 'triage', description: 'Triage a bug report', content: '…' })],
toolApproval: {
mode: 'ask-writes', // autopilot | ask-writes | ask-all | read-only
onRequest: async (req) => window.confirm(`Allow ${req.toolName}?`),
},
})
agent.setToolApprovalMode('autopilot') // flip the switch at any time
await agent.compact() // summarise the stored transcript nowWith more than 40 tools (several MCP servers) the executor switches to tool
search: it starts each step small and calls find_tools to activate what it
needs. Subagents are tools too — createSubagentTool({ config }) runs a child
agent in-process, createSubagentTool({ worker, workerConfig }) isolates it in a
Web Worker served by serveSubagentWorker(). Details, defaults and events:
docs/capabilities.md.
| 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) |
toolSelectionStrategy |
'auto' |
all | plan-narrowed | search | auto (search above toolSearchThreshold, 40) |
toolApproval |
autopilot | consent policy: { mode, rules, onRequest, timeoutMs }; agent.setToolApprovalMode() |
skills |
— | SKILL.md bundles (defineSkill / parseSkillMarkdown / loadSkillFromUrl) |
thinking / stageThinking |
provider default | true, a level ('low'…'xhigh', 'none'), or { level, budgetTokens, includeThoughts } |
limits |
— | run caps maxInputTokens / maxOutputTokens / maxReasoningTokens / maxTotalTokens + perCall output caps |
maxToolCalls / maxPlanSteps |
∞ / 8 | tool calls per run / steps per plan |
compaction |
auto, ½ of 128k | { auto, contextWindowTokens, thresholdTokens, keepRecentTurns, keepRecentSteps, maxToolOutputChars } |
promptCaching |
true |
stable system prefixes, Anthropic breakpoints, OpenAI cache key |
vision / inputs |
inferred | what the model takes as attachments (run(goal, { images, files })); see agent.capabilities |
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 | legacy: summarize old history past this size (when compaction is unset) |
- docs/design.md — architecture & rationale.
- docs/capabilities.md — thinking, limits, compaction, caching, skills, tool consent / autopilot, large MCP catalogues, subagents, autonomy, event reference.
- 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