High-level map of web-agent. Updated 2026-05-18.
┌──────────────────────────────────────────────────────────────┐
│ Browser (React 19 + Vite + Tailwind) │
│ ─────────────────────────────────────────────────────────── │
│ src/App.tsx ─ src/ui/components/{Terminal, ChatInput, │
│ Sidebar, FilesPopup, MemoryTab, ProfileEditor}│
│ src/ui/stores/{runtime, profile, settings}-store (Zustand) │
│ │ │
│ ▼ │
│ src/core/orchestrator.ts ─ lifecycle, terminal, storage │
│ src/core/workspace.ts ─ WebContainer FS / shell │
│ src/core/persistence.ts ─ idb-keyval + OPFS │
│ src/core/credential-vault ─ AES-GCM encrypted API keys │
│ │ │
│ ▼ │
│ src/agent/adapter.ts ─ spawns embedded agent runtime │
│ in Nodebox / WebContainer │
│ │ stdout/stdin IPC markers │
│ ▼ │
│ src/agent/runtime/ ─ Node-style agent (excluded │
│ ├─ turn.ts from tsc; built via │
│ ├─ tools/registry.ts scripts/build-embed-runtime) │
│ ├─ llm/streaming.ts │
│ ├─ memory/* (sql.js) │
│ └─ logging, channels, ... │
│ │ │
│ ▼ │
│ HTTPS to LLM provider (OpenRouter / Ollama / custom) │
│ via /api/llm/<provider> reverse proxy (vite.config.ts) │
└──────────────────────────────────────────────────────────────┘
The "agent" runs inside the browser tab in a Nodebox/WebContainer sandbox — not on a server. src/agent/adapter.ts spawns it as a Node-like process, then communicates through stdout/stdin.
The embedded runtime cannot directly call fetch (CORS / no origin). To make HTTP requests it writes framed markers to stdout:
<<<WEBAGENT_PROXY_REQ:<id>>>{"method":"POST","url":"…","headers":{…},"body":"…"}<<<END_WEBAGENT_PROXY_REQ>>>
adapter.ts parses these markers (search for WEBAGENT_PROXY_REQ in that file). Non-streaming requests go through /api/proxy (CORS proxy, dev: vite.config.ts; prod: scripts/cors-proxy-server.mjs or Caddy). Streaming LLM requests use same-origin fetch to /api/llm/... when shouldUseIpcStream in src/agent/runtime/llm/streaming.ts applies. Responses are written back to stdin:
<<<WEBAGENT_PROXY_RESP:<id>>>{"status":200,"body":"…"}<<<END_WEBAGENT_PROXY_RESP>>>
The same framing is used for streaming LLM responses (ipcProxyStreamRequest in src/agent/runtime/llm/streaming.ts).
| Layer | Backed by | Versioning | Purpose |
|---|---|---|---|
| Profiles | idb-keyval | envelope {version,…} |
Profile CRUD (src/core/profiles.ts) |
| Credentials | idb-keyval | key-based | PBKDF2 + AES-GCM API keys |
| Settings (UI) | idb-keyval | none | sidebar width, theme, etc. |
| Workspace files | OPFS | none | WebContainer FS |
| Agent memory | sql.js (WASM) | column-add migrations | facts, learnings, jobs, snapshots |
| Debug log | OPFS JSONL | none | tool calls, errors |
src/core/orchestrator.tsboots a profile →adapter.tsspawns runtime.- User input →
src/agent/runtime/turn.ts:agentTurn(). streamOpenAI()issues a request (HTTP direct or IPC-framed).- Streamed chunks parsed → tool calls extracted.
runTools()(registry.ts) executes built-ins or capability tools.- Result spillover exceeds inline caps → written under
memory/snapshots/(see env:WEBAGENT_MAX_TOOL_RESULT_INLINE_CHARSdefault 48k per item,WEBAGENT_MAX_TURN_INLINE_CHARSdefault 256k per tool round). Unwrapped snapshotread_fileresults are never re-spilled. - Tool loop guardrails (
tool-loop-guardrails.ts) detect repeated tool failures and idempotent no-progress reads per turn (Hermes-style deterministic guardrails). Configure viaVITE_WEBAGENT_TOOL_LOOP_*in.env(see.env.exampleanddocs/agent-notes.md). - Max 64 rounds per turn (
WEBAGENT_MAX_AGENT_ROUNDS).
AbortController per turn; /stop triggers abortCurrentTurn().
- Built-in tools:
src/agent/runtime/tools/builtins/— registered at module load. - Capability tools:
src/capabilities/tools/<id>/{manifest.json, handler.js}— loaded lazily; skipped if name collides with a built-in (warns to console + JSONL debug log). - Tool catalog re-exported to browser via
src/agent/tool-catalog.tsfor emoji/icon hints in the UI. - Python execution:
run_pythonsends an IPC request from the Nodebox agent runtime to the browser adapter, which lazy-loads a Pyodide worker after boot and mirrors the selected workspace cwd into Pyodide's virtual FS for the run. This is CPython-in-Wasm, not host Python: no subprocess, system pip, native sockets, or arbitrary compiled wheels.
- In the sandbox: user/imported skills live under
.webagent/skills/<category>/<slug>/SKILL.md(install viaskillaction=manageimport_dirafterextract_archive). Bundled procedures are seeded each launch under.webagent/capabilities/skills/<id>/(read-only; included inskillaction=list — do not copy user skills there). - In the host repo: contributors edit
src/capabilities/skills/<id>/SKILL.md; the adapter copies them into.webagent/capabilities/skills/on profile boot. - Remote imports auto-append a Web Agent execution compatibility section via
memory/skill-compat.tsso skills.sh hosts map to built-in tools (web_fetch,web_post, file tools, etc.). - Every turn injects a Workspace & filesystem directory map (
workspace-map.ts→ system prompt +.webagent/workspace-map.md).
scripts/build-embed-runtime.mjs → dist/agent-runtime/*.js (compiled agent code,
imported as ?raw strings)
vite build → dist/assets/* (browser bundle)
Chunk strategy (vite.config.ts):
| Chunk | Contents |
|---|---|
sqljs |
sql.js + WASM |
xterm |
@xterm/* terminal |
nodebox |
@codesandbox/nodebox (if used) |
markdown |
markdown-it |
icons |
lucide-react |
react-vendor |
react, react-dom, scheduler |
zustand |
state library |
Heavy panels (FilesPopup, MemoryTab, ProfileEditor) are loaded via React.lazy so they don't block first paint.
| Task | Start here |
|---|---|
| Add a tool | src/agent/runtime/tools/builtins/ |
| Add a capability skill | src/capabilities/skills/<id>/SKILL.md |
| Modify the agent loop | src/agent/runtime/turn.ts |
| Add a channel (Telegram, …) | src/capabilities/channels/<id>/ |
| New LLM provider | src/core/providers/<id>.json + manifest |
| UI panel | src/ui/components/ |
| Persistence change | src/core/profiles.ts + bump STORAGE_SCHEMA_VERSION |