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).
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.
Related#
- Architecture internals — the turn flow end to end, the mandatory boot ordering, managed state and the store topology.
- Core concepts — the vocabulary this page assumes.
- Agent event contract — the event surface every transport must satisfy.
- Where data lives — every file and directory the process writes.
- Security & credentials — the trust boundaries drawn across the diagram above.