Skip to content

Repository files navigation

@dudko.dev/agent-web

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.

npm npm NpmLicense GitHub last commit GitHub release

Browser sibling of the Node package @dudko.dev/agent.

Using React? @dudko.dev/agent-web-react wraps this core in a useAgent hook, an <AgentProvider> context and drop-in UI components — try the live demo (source).

Install

npm install @dudko.dev/agent-web ai zod

Then 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/sdk

CDN / standalone bundle (no build step)

dist/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>

Quick start — cloud model, bring-your-own-key

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.

Quick start — local WebGPU model (no key, offline)

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: '…' }.

Simple generation & tool-calling (no agent loop)

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,
})

Optional: MCP tools over HTTP

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.

OAuth 2.1 + Dynamic Client Registration

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.

CORS: what the server must send

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-Version in Allow-Headers is the one that bites hardest. The spec has required that header on every request after initialize since 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 and initialize all succeed — and then everything afterwards is blocked. It reads exactly like the consent step failing, and sends you to debug the client.
  • WWW-Authenticate in Expose-Headers, or the browser hides the 401 challenge from JS and OAuth discovery never starts.
  • Last-Event-ID for resuming a dropped SSE stream.
  • The authorization server's /register, metadata and token endpoints need CORS too, OPTIONS included — a browser client calls them directly.
  • If the server uses the MCP SDK's DNS-rebinding protection, its allowedOrigins must contain your origin. Left empty, it answers 403 Origin not allowed to any request that carries an Origin header — 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.

Thinking, limits, consent, skills, subagents

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 now

With 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.

Configuration (highlights)

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

License

MIT © Siarhei Dudko

About

Headless, universal, configurable in-browser LLM agent built on the Vercel AI SDK.

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages