A frontend component library built on the Agent Client Protocol (ACP). With a "data-layer / UI-layer split + orthogonal Platform abstraction" design, it lets you run a multi-agent workbench across Web, desktop, and IDE plugins using one set of components.
Why does it exist?
If you've ever built an AI Agent frontend, you've likely hit these walls:
- Re-implementing the UI for every host: one version for the browser, another for Electron/Tauri, yet another for a VS Code plugin — chat, tool calls, permission dialogs, and file trees all rewritten each time.
- Multi-agent chaos: when you want to connect OpenCode, Codex, and Claude simultaneously, who manages the connections? Who organizes sessions by directory (workspace)? What happens to the file tree when you switch workspaces?
- Protocol comms tangled with state: NDJSON streams, session updates, and permission requests all glued inside React components — hard to test, hard to maintain.
- File I/O and other high-risk capabilities in the wrong place: either the frontend hardcodes filesystem calls, or permission decisions leak into the agent communication layer — a muddy security boundary.
acp-components targets exactly these four problems. It does not implement an Agent runtime, nor does it take over system permissions on the host's behalf. It provides a set of embeddable frontend protocol, state, and UI primitives that draw those boundaries cleanly.
Core Design: Three Principles That Run Through Everything
Principle 1: A clean data/UI split with a framework-agnostic core
The project ships as two packages:
@acp-components/react (UI layer: 18 component dirs, 13 fine-grained hooks, Contexts, theme, i18n)
↓ depends on
@acp-components/core (Data layer: createAcpProvider + AcpClient + Transport + vanilla Zustand stores + Actions)
↓ built on
@agentclientprotocol/sdk (ACP protocol types + ClientSideConnection)
The hardest rule: core has zero React dependency. Not just in words — it's enforced in code:
- core's
package.jsonhas neitherreactnor@types/react; - all four stores use
createStorefromzustand/vanilla(not the React-boundcreate), producing a{ getState, setState, subscribe }triple; - the React layer subscribes to these vanilla stores via
useSyncExternalStore(throughzustand/react'suseStore).
What does this mean? core can be used without React. The README explicitly encourages it: want Vue, Svelte, or Solid? Take core as-is for comms, state, and Actions, and pick your own UI framework. The React layer is merely the "official default implementation."
Bonus dividends of this split:
-
Actions are pure functions: each action takes an
AcpClientas its first argument explicitly — stateless and unit-testable; theagentId → clientmapping lives in the provider closure and never leaks globally; -
Components are testable: UI components don't directly touch
window.prompt/localStorage, so they run under jsdom.
Principle 2: A three-tier state model for multi-agent × multi-workspace
In real scenarios you don't run just one agent, and you don't work in just one directory. core organizes state around a clean three-tier abstraction:
| Abstraction | Meaning | Home |
|---|---|---|
| Agent | One independent ACP connection (transport + status + capabilities) | acpStore.agents: Map<agentId, AgentConnection> |
| Workspace | A directory (cwd), holding sessions from multiple agents | acpStore.workspaces: Map<cwd, WorkspaceState> |
| Session | Belongs to a workspace + agent pair |
SessionMeta.agentId + SessionMeta.cwd
|
The clever bit: the active workspace is derived by looking up SessionMeta.cwd from the global activeSessionId — you don't maintain "current workspace" separately; switching sessions switches workspaces.
createAcpProvider connects all agents in parallel via Promise.allSettled, non-blocking; the scopedClientRegistry in its closure isolates clients by agentId, so one agent's connection failure doesn't drag down the rest. On workspace switch, it only fetches session lists for agents that declare the sessionCapabilities.list capability — idempotent, with pagination cursors.
This model directly solves: writing code with OpenCode, reviewing with Codex, asking questions to Claude — three sessions in one frame, archived by directory, no flavor bleed.
Principle 3: Platform and AcpContext are orthogonal; high-risk capabilities stay host-side
This is the most easily overlooked yet most critical design. The project abstracts host-native capabilities into a Platform interface:
interface Platform {
platform: 'desktop' | 'web';
// Native interaction: open links, directory picker dialog, system notifications
openLink(url); openDirectoryPickerDialog(); notify();
// Filesystem: file tree, read, write, watch (write/watch optional)
readDirectory(path); readFileContent(path);
writeFileContent?(path, content); watchFileTree?(cb);
// Persistence: namespaced KV store + workspace list
storage(name?); loadWorkspaces?(); saveWorkspaces?();
// External editor integration, updater (all optional)
onOpenFile?(path, line?); updater?; restart?(); exportDebugLogs?();
}
The key word is orthogonal: Platform / usePlatform() owns native capabilities; AcpContext / useAcpContext() owns agent connection and session state — the two never reference each other at the interface or assembly level. Agent transport config lives on AgentConfig.transport attached to AcpProvider and is not part of Platform.
Three reasons for the split:
-
One component set, many hosts: web demo, Tauri desktop, Electron, IDE plugins share one component tree — only the
Platformimplementation changes; -
High-risk capabilities governed by the host: security-sensitive operations like file read/write, directory dialogs, and updaters stay host-side; core never makes security decisions for the host. CLAUDE.md spells it out: core does not implement ACP's
readTextFile/writeTextFilereverse callbacks — file access is a UI-side capability consumed viausePlatform(), handing security decisions back to the host; -
Test isolation: components don't directly depend on
@tauri-apps/plugin-*or browser APIs, so they're testable under jsdom.
Two ready-to-use Platform implementations prove the abstraction is buildable:
-
createWebPlatform()(pure browser):readDirectorygoes throughfetch('/api/readdir')proxied to the bridge server; file-tree watching usesEventSourcesubscribing to an SSE stream; persistence is localStorage with a namespaced prefix; the browser has no native directory picker, so it degrades towindow.prompt. -
createTauriPlatform()(desktop):readDirectoryuses@tauri-apps/plugin-fs'sreadDir; the directory picker usesplugin-dialog's native dialog; the workspace list is read/written by Rust viainvoke('load_workspaces')toapp_data_dir/workspaces.json.
The host only needs to wrap a <PlatformProvider platform={...}>> at the top, which by default auto-mounts <PlatformFileTreeAuto> — zero-config wiring of platform.readDirectory / watchFileTree into fileTreeStore, preloading the root tree only for the active workspace and subscribing to watch events only for the active workspace, preserving the old tree's expanded state on switch.
Transport: Four Options, Pluggable
The ACP protocol's transport carrier is NDJSON (one JSON message per line). core abstracts transport into a minimal AcpTransport interface:
interface AcpTransport {
connect(): Promise<Stream>; // returns { readable, writable }
disconnect(): void;
onClose?(handler): () => void;
onError?(handler): () => void;
}
Three built-ins plus a custom entry point:
| Transport | Scenario | Implementation |
|---|---|---|
StdioTransport |
Desktop primary |
spawn a child process, take over stdin/stdout, split with ndJsonStream; disconnect calls kill()
|
WebSocketTransport |
Web demo | Native browser WebSocket, onmessage parses and enqueues |
HttpTransport |
Stateless/short-lived |
fetch-based, AbortController for cancellation |
{ type: 'custom', transport } |
Any host | Implement AcpTransport
|
Custom transport isn't an empty promise — the repo ships a production-grade Tauri IPC implementation, TauriIpcTransport (147 lines): connect() first invoke('start_agent') to have Rust spawn the child process, then listen('agent-output'/'agent-closed'/'agent-error') to register events, JSON.parse-ing the payload and pushing it into a ReadableStream; WritableStream.write encodes messages as JSON.stringify + '\n' and writes back to stdin via invoke('write_to_agent').
Agent stdout ──> Rust (line by line) ──> Tauri event ──> ReadableStream
Agent stdin <── Rust (write) <── Tauri command <── WritableStream
Following this pattern, Electron IPC (ipcRenderer.invoke / on) and iframe postMessage are trivially the same idea.
Streaming UX: 16ms Batching + Virtual Scrolling
Whether a chat feels good is 80% about streaming rendering. Here are a few concrete engineering details:
-
Per-session text-chunk batching: agent text chunks come in at high frequency; writing the store on every chunk would thrash React re-renders. core batches high-frequency text chunks for the same session within a
BATCH_WINDOW_MS = 16(about one frame) window into a single store write. The key is per-session buffer + flush timer — otherwise one session's frequenttool_callwould prematurely flush another session's accumulating text. Non-text blocks (e.g.tool_use) flush first, then write, preserving message order. -
O(1) text-block merge fast path:
appendContentchecks whether the last message matches the messageId; on a hit it merges in place; adjacent text blocks without annotations concatenate directly aslastBlock.text + block.text, reducing part fragmentation. -
react-virtuoso long-list virtualization:
ChatViewvirtualizes by "user turn → agent turn" groups; thousands of messages stay smooth. -
Fine-grained hook slice subscriptions:
useSessionUsagesubscribes only to the usage slice; when usage updates,ChatView(subscribing to messages + isStreaming) doesn't budge. Selectors return a stable empty array (EMPTY_MESSAGES) soObject.isholds and spurious re-renders are avoided.
18 Component Directories Out of the Box
Not a shell — a complete workbench. packages/react/src/components has ~24 named components (18 by directory count):
-
Skeleton:
Workbenchthree-column resizable layout +AcpProvider+ accessibleResizeHandle; -
Sessions:
SessionList(workspace→agent→session three-level grouping, fork/delete/load more),Sidebar(session-list/file-tree toggle); -
Chat family:
ChatView+MessageBubble+ChatComposer(/triggers command palette, attachment upload) +ToolCallCard+StreamingIndicator+ThoughtView+PlanView+UserMessage; -
Files:
FileTree(directories sorted on top),FileViewer(Monaco lazy-loaded, theme-aware); -
Dialogs:
PermissionDialog(allow_once/allow_always/deny),LoginDialog(EnvVar auth, 300s timeout); -
Misc:
DiffView,CommandPalette,SessionConfigPanel,Select(portal-rendered),Dropdownfamily (4 placements),SettingsMenu,ConnectionStatus+UsageBar(SVG ring token progress).
Every component follows the same design contract: state is driven by core's vanilla stores and fine-grained hooks, native capabilities are injected via usePlatform(), and the components themselves are responsible only for rendering and interaction.
Three Agents, Two Examples, One Bridge
-
examples/demo: Vite + React 19 pure-browser app, WebSocket to a local bridge server.
vite.config.tsaliases straight to source — no build step during dev. -
examples/server: a Node bridge service. One HTTP server mounts three things — a WebSocket↔stdio NDJSON bridge (letting the browser drive a local agent's child process), an HTTP filesystem API (
/api/readdir,/api/readfile), and an SSE file-watch stream (/api/watch). The rootpackage.jsonships one-click launch scripts for OpenCode / Codex (@zed-industries/codex-acp) / Claude (@agentclientprotocol/claude-agent-acp). -
examples/tauri: a Tauri 2 desktop template with a full Rust backend (348 lines, 5 tauri commands);
start_agenthandles Windows.cmdcompletion,CREATE_NO_WINDOWto hide the console, and 4 threads bridging stdin/stdout/stderr/exit. Production builds can even bundle the agent binary intobundle.resources.
Screenshots
One component set, two host forms — the Web workbench in the browser on the left, the Tauri-packaged desktop app on the right. Both share every component of @acp-components/react and the state layer of @acp-components/core; only the Platform implementation (createWebPlatform ↔ createTauriPlatform) and the transport (WebSocket ↔ Tauri IPC) differ.
Web Demo (browser) — connects to a local bridge server over WebSocket, with the file tree driven by SSE:
Tauri Desktop — connects to the Rust-spawned agent child process directly via Tauri IPC, with directory selection via a native dialog:
Engineering Baseline: Not a Toy
-
TypeScript 5.9 strict + ES2022 + bundler resolution,
noUnusedLocals/noFallthroughCasesInSwitchon; -
Vite 6 library mode, dual ESM/CJS output +
tsc --emitDeclarationOnlyfor.d.ts; - Zustand 5 vanilla/react split; React 19 (peer-compatible with 18);
-
SCSS Modules
camelCaseOnly, 24.module.scssfiles, design tokens all viavar(--acp-*), hardcoded colors forbidden; -
i18next 26 + react-i18next 17, built-in en-US / zh-CN, detection order platform storage → localStorage → navigator.language, with
customLocalesto extend any language; -
Dual themes:
data-acp-theme="dark|light", Dark "Midnight" (GitHub Dark + One Dark Pro blue), Light "Dawn" (GitHub Light + bright blue), including global reset, themed scrollbars, focus-visible focus ring, andprefers-reduced-motionaccessibility degradation; - Vitest 3 + @testing-library/react 16 + jsdom 25 testing baseline in place.
In One Sentence
The selling point of acp-components isn't "yet another chat UI" — it's a set of pluggable contracts with clean boundaries:
- core is React-free, so you can pair it with any frontend framework;
- a three-tier multi-agent × multi-workspace state model handles real complexity;
- Platform and AcpContext are orthogonal, with high-risk capabilities governed host-side;
- four pluggable transports, with Tauri IPC as a production-grade example;
- 18 component directories + a complete bridge service + a Tauri desktop template — runnable out of the box.
If you're wrestling with "how do I slot an agent workbench into my Web/desktop/IDE host," spin up the demo with pnpm dev, or read docs/ARCHITECTURE.md directly. Protocol-native, cleanly layered, restrained at the boundaries — that's what it aims to deliver.
GitHub repository: https://github.com/zvzuola/acp-components
Built on the Agent Client Protocol — transport pluggable, UI replaceable, platform extensible.


Top comments (0)