Operator console
The operator console is protoAgent's UI: a React + Vite single-page app served at /app, and the same app wrapped as a Tauri desktop binary with a frozen Python sidecar. It's the default and only UI — Gradio was removed; / redirects to /app. This guide covers running it, its layout, and how its surfaces behave. For the HTTP it speaks to, see the Operator REST API; to run with no UI at all, see Run headless.
Run it
python -m server # console at http://localhost:7870/app ( / → /app )The server mounts the console when apps/web/dist/index.html exists; otherwise it boots API-only. The --ui tier (env PROTOAGENT_UI) selects it:
console(default) — the React console at/app+ the full API/A2A surface.none— API + A2A +/metricsonly (headless servers, fleet members).fullis a deprecated alias forconsole(the old Gradio tier; it logs a warning).
Build the console from the @protoagent/web workspace:
npm ci
npm run build --workspace @protoagent/web # tsc + vite build → apps/web/dist
npm run dev --workspace @protoagent/web # Vite dev server (proxies the API)The repo ships a prebuilt dist/; rebuild after changing console source or pulling frontend changes. For an isolated dev instance (separate port + data), use scripts/dev.sh (:7871).
Layout
The shell (DS AppShell) is a left rail of grouped surfaces, a right sidebar of the agent's live state, a utility bar, and an optional bottom panel. The core rail surfaces — Chat, Activity (thread + inbox), Knowledge (a searchable store), Studio (workflows), Agent, Plugins, Settings — each fan out to sub-views via an in-surface segmented control. Enabled plugins add their own views (ADR 0026), each declaring a placement: rail, right (right-sidebar panel), or bottom. Press ⌘⇧K / Ctrl-Shift-K for the command palette to jump anywhere.
The Agent surface is the agent's own makeup, tabbed: Identity (edit its name + SOUL.md inline — saving merge-applies config + hot-reloads the graph) · Tools (live inventory by source) · MCP (servers) · Subagents (the delegate roster) · Skills (the skill index) · Middleware (per-turn graph middleware).
The right sidebar holds the agent's working state + triggers — Goals (standing conditions, set in chat with /goal) · Tasks (its task board) · Schedule (cron/one-off fires), plus Notes (its notebook, which ships as the notes plugin).
In a fleet, the console is slug-routed (/app/agent/<id>/) and the hub reverse-proxies each window to its agent — switch agents in place or open two at once.
Chat
Multi-session: sessions persist in localStorage, hidden ones stay mounted so background streams keep running, and each carries its own status + goal panel. The composer has slash-command autocomplete (from GET /api/chat/commands) and renders assistant markdown.
- Live tool-call cards — each tool the agent invokes streams in as a collapsible card (name, running→done/error, input/result), via the
tool-call-v1DataPart (see Extensions § tool-call-v1). - Skill loads — when the agent loads a skill's procedure on demand it appears as an ordinary
load_skilltool-call card (progressive disclosure, Skills). - Mid-turn steering — send a message while a turn runs and it folds in at the next model call (Mid-turn steering).
Streaming uses A2A SendStreamingMessage in the browser.
Desktop (WKWebView) exception. WKWebView won't deliver a
text/event-streambody throughfetch(), so the desktop app detects the shell (isDesktopWebview()) and routes the turn through the non-streamingPOST /api/chat— one request, full reply, rendered once (no live token/tool-card streaming in the desktop chat; browsers keep the streaming/a2apath).
Reactive surfaces (ADR 0003)
The console holds one EventSource open to GET /api/events for its lifetime (lib/events.ts), backed by an in-process EventBus. The topbar live dot reflects the connection; producers bus.publish(...) and every connected console receives it.
Playwright note: a long-lived SSE connection never lets
networkidlesettle — navigate e2e withwaitUntil: "load".
- Activity (
activity/ActivitySurface.tsx) — the durable Activity thread: agent-initiated turns (e.g. scheduled fires) land here; the operator can reply into thesystem:activitycontext. An unread badge counts events that arrive while you're elsewhere. - Inbox — the read/dismiss view of the authenticated
POST /api/inboxintake channel (webhooks, scripts, sister agents). Items have anow/next/laterpriority;nowfires an Activity turn immediately, the rest queue for the agent'scheck_inboxtool.
Agent, Settings & Telemetry
- Settings is schema-driven:
GET /api/settings/schemareturns fields grouped by section (type, value, default, description,restartflag); the surface renders inputs generically, so new config fields appear without a UI change. Saving POSTs only changed fields, writes the YAML (secrets split tosecrets.yaml), and hot-reloads the agent in-process — most changes apply without a restart (those that don't carry arestartbadge). Secrets are never echoed ((set)/unset). Registry:graph/settings_schema.py. - Telemetry (Settings ▸ Overview, ADR 0006) — the local per-turn cost/latency rollup (totals, by-model table, recent turns) from
GET /api/telemetry/{summary,recent}. - Skills (Agent ▸ Skills) — browses the skill index: each skill is pinned (a
SKILL.mdon disk) or learned (non-disk, curated), with confidence + last-used, a search filter, and delete. (Surfaced viaGET /api/playbooks.)
Working memory & the filesystem fence
The agent's stores are agent-global — one instance-scoped store each, shared by the agent's tools and the console (no per-project selector). Tasks lives at $BEADS_DB_PATH; notes ship as the notes plugin (/api/plugins/notes/note).
filesystem.projects in langgraph-config.yaml — the Work folders editor under Tools ▸ Filesystem — is the filesystem security fence for the agent's file/shell tools (unrelated to notes/tasks). Each entry is a named root with its own write flag; every read_file / write_file / run_command path is joined to a root and re-resolved, so out-of-fence paths are rejected before any I/O (.. and symlinks resolved before the containment check). Configure none and the agent gets a single default workspace root. See ADR 0007.
Every path-valued setting (a type: "path" field in graph/settings_schema.py, plus the Work-folders rows) renders a Browse… picker over GET /api/fs/browse — a read-only listing of the server's directories. It has to be server-side: the console frequently configures a machine it isn't running on, and the browser's own pickers (webkitdirectory, showDirectoryPicker()) describe the client's filesystem and can't produce an absolute path on the server at all. Plugin-declared fields can set type: path (and path_kind: file) to get the same control.
A path that is not on the server's filesystem must stay type: "string" — secrets_manager.path is a folder inside a remote vault (1Password/Bitwarden), so a local browser would point at the wrong machine entirely. The test for type: path is "would ls on this box resolve it?", not "does it look like a path?".
The code pane
A read-only file and diff viewer docked beside chat (ADR 0112) — the Code surface, on the right dock by default and always on a dock that isn't chat's. It's built for the operator as navigator: the agent points at evidence, and you read it at your own pace.
It's an opt-in toolset, off by default. Turn it on per agent in Settings ▸ Capabilities ▸ Tools ▸ Filesystem ▸ Shell & filesystem tools ▸ Code pane (config filesystem.code_pane: true). It needs the filesystem toolset itself switched on too (filesystem.enabled: true). With filesystem tools off, code_pane: true does nothing, so enable both. The switch applies on save — the Code surface appears (or goes) without a reload, because the console reads it from /api/runtime/status code_pane.enabled, per fleet window. While it's off: the agent has no show_code tool, GET /api/fs/file and GET /api/fs/diff answer 404 {code: "disabled"}, there's no Code surface in the rail, command palette or launcher, no follow mode, file links open your external editor (below), and a show_code chip from an earlier chat renders as plain text (project/path:lines — note). Everything below describes the pane with the toolset on.
- The agent points. The
show_code(project, path, line, end_line, note)tool drops a chip in the transcript (path:12-18plus a one-line "why") and, on the live turn, opens the pane at that range with the note as a banner. A reload or a replayed transcript never reopens it, and on a phone nothing opens by itself: tap the chip. - You click. A file path in a tool result opens in the pane at the line (see below).
- File tab. Syntax-highlighted and line-numbered, with the range highlighted and scrolled into view. The header has copy-path and ↗ to open the file in your external editor. Recent lists the last 20 files you opened; click one to go back to it. The open file and Recent survive a reload of the tab (sessionStorage). A file longer than 20,000 lines is shown as a window around the target line, with Earlier / Later paging. A line longer than 2,000 characters is cut by the server, and the pane says so. Secret-like files (
.env, keys,secrets.yaml, …) show as Hidden. A binary file shows its size, and a deleted file says it no longer exists. - Diff tab. The project's working tree vs
HEAD(GET /api/fs/diff): changed files with +/- counts (untracked ones included, secret-like ones listed but hidden), and a unified patch for the file you pick. A pure rename shows Renamed from …, and a new file over the server's 256 KB limit shows Too large to show. Click a line to open it in the File tab. A deleted line opens the current file where that line used to be. - Follow (desktop only, off by default). While it's on, each
read_file,search_files,edit_fileorwrite_filethe agent finishes moves the pane to that file, at most once every 800 ms. Pin holds the pane where it is while you read.
The pane reads through GET /api/fs/file, the same fence read_file uses. It needs no /api/fs/roots, so it works for a remote fleet member too. The highlighter (@pierre/diffs over Shiki) is a lazy chunk that loads the first time the pane opens. Files over 5,000 lines render as plain text, and the view is virtualized.
Open files in your editor
File paths in the fs tools' results are links: read_file gets a header link to the file (at its offset), each file:line hit in search_files opens at that line, and every path from find_files / write_file / edit_file opens the file. Settings ▸ Chat ▸ Open files in picks where a click goes: protoAgent (the default while the code pane toolset is on) opens the code pane; Zed, VS Code or Cursor open your editor; Off keeps paths as plain text. With the code pane off, protoAgent isn't offered and a saved protoAgent choice opens your external editor (Zed unless you picked another) instead, until the pane is turned back on. With protoAgent selected, External editor picks what ⌘/Ctrl-click and the pane's ↗ open. With an editor selected, ⌘/Ctrl-click opens the pane instead. Both choices are saved per browser (localStorage protoagent.openFilesIn and protoagent.editor), not in agent config. They describe the machine you're sitting at, so they stay put when you switch fleet agents. If you had set the editor to Off before the pane shipped, links stay off.
The tools speak project-relative paths, so the console joins them onto each project's absolute root from GET /api/fs/roots ({"roots": {"<project>": "<abs root>"}}) — the live fence the tools actually resolve through, not the ADR 0095 registry /api/projects reports (explicit filesystem.projects or the workspace default can shadow it). The links are zed://file/<abs>:<line> (and vscode://, cursor://), so they only resolve when the console and the agent share a filesystem — a local server or the desktop app; a remote fleet member's paths don't exist on your machine. When an editor is the click target, results render as plain text exactly as before if the preference is Off, the project is unknown, or the roots haven't loaded.
Desktop app: WKWebView/WebView2 can't load
zed://themselves, so the Tauri shell (apps/desktop/src-tauri/src/navigation.rs) hands these links to the OS throughtauri-plugin-opener— on both the same-window navigation path (serve_navigation) and the new-window path (route_new_window). It is a strict allowlist: onlyzed://file/…,vscode://file/…andcursor://file/…pass (is_editor_link); every other custom scheme is still dropped, so web content can't launch arbitrary URL handlers.
operator.allowed_dirsandoperator.project_dirare not that fence, despite the names.allowed_dirsis inert (its enforcement helper has no callers since tasks went instance-global and notes became a plugin);project_dironly names the console's current project for the setup wizard and runtime status. Neither grants the agent any file access.
Desktop app (Tauri)
apps/desktop/ wraps the console as a Tauri v2 binary. apps/desktop/sidecar/build_sidecar.py PyInstaller-freezes the headless server (binaries/protoagent-server-<triple>), and src-tauri/src/sidecar.rs spawns it via externalBin with --ui console on port 7870. The frozen build bundles the plugins/ tree and --collect-alls tools/websockets/mcp (plugins load by file path, which PyInstaller's scan misses; a runtime-installed comms plugin, ADR 0058, can only import what's bundled). Signed macOS DMG / Linux AppImage+deb / Windows NSIS artifacts + an in-app updater ship from the desktop-build CI, dispatched manually per release (gh workflow run desktop-build.yml -f tag=vX.Y.Z) — see docs/guides/releasing.md § Desktop. The shell also runs one update check at launch, in parallel with engine startup (#2203): the in-app UpdateNotice pulls that result the moment it mounts and opens the changelog modal if a newer build exists — so the prompt lands before the engine finishes booting, then the normal 10s-settle + 6h re-check cycle takes over.
On a frozen build, execute_code (and the document skills behind it) need the one-click managed Python runtime — Settings ▸ Tools shows the install card until it's provisioned.
On macOS, spawn_sidecar augments the sidecar's PATH with the user's login-shell PATH (via $SHELL -ilc, plus the Homebrew/local fallbacks) before spawning. A Finder/Dock launch otherwise inherits only launchd's minimal PATH, so npx/node/ACP coding-agent adapters would be invisible and a delegate_to ACP launch would fail with binary not on PATH (#1299).
Testing the console
A Playwright smoke suite (apps/web/e2e/) drives the built SPA against a deterministic mock backend (mock-server.mjs serves dist/ + the API/a2a subset from fixtures.mjs) — no Python, model, or network. Specs cover tool-call cards, slash autocomplete, and that every surface mounts.
npm run test:e2e --workspace @protoagent/web # builds, boots the mock, runs headlessCI runs it as the Web E2E smoke job. When you add a console feature, extend the mock fixtures + a spec rather than reaching for a live backend.