PacketBenchDocs

Architecture at a glance#

PacketBench is a single native desktop process that hosts a web UI and spawns children. There is no server, no daemon, and no account: everything the app does happens on the machine in front of you, or over SSH to a host you configured. The one structural surprise is that agents do not arrive through one path — three separate transports serve the provider picker, and they are deliberately indistinguishable from the UI's side.

This page describes the process model, the split between the Rust core and the React front end, the Node sidecar, and the three transports. It is the evaluator's view. Architecture internals carries the contributor's view.

One process, four kinds of child#

A running install is one packetbench process — a Tauri v2 app with a Rust core and a WebView — plus whatever children the work requires.

  ┌─────────────────────────────────────────────────────────────┐
  │ packetbench            one OS process · Tauri v2 · 0.13.1   │
  │                                                             │
  │    WebView: React 19 SPA, built by Vite, served from disk   │
  │        │  invoke(command, args)        ▲                    │
  │        ▼                               │  Tauri events      │
  │    Rust core:  commands/  core/  acp/  mcp_server/          │
  └──────┬───────────┬────────────┬──────────────┬──────────────┘
         │           │            │              │
         │           │            │              └─ loopback HTTP
         │           │            │                 127.0.0.1:<port>/mcp
         │           │            │
         │           │            └─ node agent-sidecar/dist/index.js
         │           │               NDJSON over stdio
         │           │
         │           └─ packetcode acp
         │              NDJSON JSON-RPC over stdio
         │
         └─ portable-pty children
            claude · codex · opencode · packetcode · shells · ssh

Every one of those children is optional. A PacketBench that is only running terminals has no sidecar, no ACP engine and no MCP listener; each is started on first use and torn down with the thing that needed it.

Child Started by Notes
PTY children Opening a terminal pane, or a CLI session Real pseudo-terminals through a vendored portable-pty
Node sidecar The first conversation on a vendor agent SDK One long-lived child, shared across sessions
ACP engine The first PacketCode conversation A separately installed binary, never bundled
MCP listener Turning the built-in MCP server on Loopback only, bound to 127.0.0.1

The PTY dependency is vendored rather than pulled from crates.io, and the reason is recorded in the manifest: upstream's close_random_fds allocates, which aborts in the child after fork() inside a WebKit-threaded host (src-tauri/Cargo.toml:31). PTY children are also allowlisted by program name — the agent CLIs, a fixed set of shells, and ssh, nothing else (ALLOWED_COMMANDS, src-tauri/src/commands/pty.rs:30).

The WebView is a client, not a peer#

The front end is an ordinary Vite/React single-page app with no backend of its own. frontendDist is ../dist and the dev server is http://localhost:1420 (src-tauri/tauri.conf.json:8). It reaches the Rust side only through Tauri's IPC: roughly 245 command paths are registered in one guarded_invoke_handler! block (src-tauri/src/lib.rs:121), and results come back as Tauri events.

That wrapper is the interesting part. Before dispatching, it asks command_allowed_for_window whether the calling window may run that command, so the read-only Monitor window is rejected with "This read-only Monitor cannot invoke that application command." rather than being trusted because it happens to be in the same process. Being in-process is not a capability.

State follows the same asymmetry. The Rust core owns the durable record — ~/.packetbench/state.v1.json, the usage ledger, the keyring — while the browser's localStorage holds only per-view preferences under a packetbench: prefix. The front end is where things are rendered and where conversation JSON is shaped; it is not where the truth lives. Where data lives enumerates every file.

Three transports, one event contract#

The provider picker shows nine chat rows. They resolve to exactly three backends, and the row you pick is the only place the difference is visible:

Transport Rows it serves Where it runs
In-process LlmProvider Claude (API), OpenAI (API), MiniMax, OpenRouter, Ollama, Custom endpoint Rust, inside the app process
Node sidecar Claude Agent SDK (API), OpenAI Agents SDK (API) A node child speaking NDJSON
ACP PacketCode (ACP) The packetcode binary, over Agent Client Protocol v1

Every one of them emits the same api-agent:* Tauri event stream keyed on the session id, so the front end cannot tell which backend served a turn — and does not try. That is what makes the picker a picker rather than three separate features. The full event surface is in Agent event contract.

Why three and not one#

The in-process path exists because most providers are an HTTPS SSE stream and nothing more, and PacketBench wants to own what surrounds it: its own tool definitions, its own permission gate, its own MCP trust bridge, its own cost ledger. The provider trait is one method wide, and the supported ids are a flat list — anthropic, openai, minimax, minimax-api, openrouter, ollama, custom (src-tauri/src/core/llm_provider.rs:37).

The sidecar exists because two vendor agent SDKs — Anthropic's Claude Agent SDK and OpenAI's Agents SDK — are JavaScript-only and own their own agentic loop. PacketBench cannot reimplement those in Rust, so it hosts them and translates their output instead (SIDECAR_PROVIDERS, src-tauri/src/commands/agent_sidecar/mod.rs:43).

ACP exists because PacketCode is a separate product with its own credentials and its own session store. PacketBench drives it over a protocol instead of absorbing it, gating on a minimum engine version reported by packetcode doctor --json before it will speak (src-tauri/src/acp/mod.rs:42).

Important

The three transports carry three different id vocabularies, and a row id is not a provider id. api-claude maps to the provider anthropic, not claude. Deriving one by stripping the api- prefix is correct for seven of eight executors and silently wrong for the default — see Invariants & tripwires.

Session identity is uniform across all three. PacketBench mints the conversation id and that id is the session id everywhere in the app; the ACP engine mints its own on session/new, and that one never leaves the ACP module except on the wire (src-tauri/src/acp/routing.rs:10).

PacketBench is also an MCP server#

The same process that consumes MCP servers can publish its own state to other clients over Streamable HTTP at /mcp, bound to loopback with bearer and Origin auth (src-tauri/src/mcp_server/mod.rs). Reads never mutate; the two writes are gated behind allow_writes, which defaults off. It lives in Rust because it reads the same state.v1.json the core owns. See MCP hub.

Two features are modules, and only two#

src/modules/registry.ts:5 registers exactly two: qualityModule (category analysis) and dictationModule (category integration). Modules can be turned off, which is why resolveStartupView takes an enabled-predicate (src/lib/bootstrap.ts:196) and falls back to Welcome when the saved view belongs to a disabled module. Everything else in the app — workspaces, agents, Flights, issues, memory, MCP — is a first-class surface, not a plug-in. There is no plug-in API.