Architecture
The repo is a strict dependency DAG — the top four layers are platform-agnostic, the bottom three are swappable adapters, so adding a new surface means writing adapters, not touching business logic.
Adding a platform should mean writing adapters, not rewriting logic
Zapvol runs as a web server, an Electron desktop app, and a Chrome extension off one React + TypeScript codebase. The whole architecture exists to make that possible without forking business logic per platform. It rests on three invariants:
- Contract-driven platform abstraction — UI components program against TypeScript interfaces (ports), never against transport mechanisms. A single React codebase serves both web and desktop; the platform boundary is crossed by swapping the adapter behind the port at the application entry point.
- Dependency inversion via factory functions — Following the Dependency Inversion Principle, services accept
repository interfaces as constructor parameters, never concrete database drivers. The same
createTaskService(repo)operates identically over PostgreSQL and SQLite — the caller decides the storage backend at composition time. - Acyclic dependency graph — Packages form a strict DAG:
common → backend → server|desktop,common → app → web|desktop, andcommon → bua(BUA depends oncommononly — see Dependency Graph below). Any import that violates this graph is caught during code review. There is no runtime enforcement — the architecture trusts the graph, and the graph is maintained by convention.
These invariants collectively ensure that adding a new SPA-shaped surface (e.g., mobile) requires writing only
adapters — repositories for the new storage engine, transport wrappers for the new IPC mechanism — while all business
logic, UI components, and type contracts remain untouched. Surfaces with fundamentally divergent runtimes — like the
Chrome extension (BUA), whose Service Worker + content script sandbox cannot host @zapvol/app — sit alongside the
shared codebase rather than reusing it; they share only the @zapvol/common types and the cross-cutting protocol
schemas.
Monorepo Topology
zapvol/
├── packages/
│ ├── common/ @zapvol/common — Pure types + Zod schemas (zero behavior)
│ ├── backend/ @zapvol/backend — Services, repository interfaces, agent engine, infra
│ ├── app/ @zapvol/app — React components, hooks, pages, i18n, client contracts
│ └── http/ @zapvol/http — Shared Hono HTTP layer (route factories + h()), consumed by server + desktop
└── apps/
├── web/ web — Web frontend (Vite + React, port 8000)
├── server/ @zapvol/server — API server (Hono + Node, port 8001)
├── desktop/ @zapvol/desktop — Electron app (port 8002)
└── bua/ @zapvol/bua — Chrome MV3 extension (WXT + React, Browser Use Agent)
The split between packages/ and apps/ reflects a fundamental distinction: packages are libraries (imported,
never deployed), while apps are deployable artifacts (each produces a running process or static site). Turborepo’s
build pipeline respects this — turbo build topologically sorts the dependency graph and builds packages before the
apps that consume them.
Dependency Graph
@zapvol/common sits at the root of the dependency tree. It exports only types, Zod schemas, and pure helper functions
— zero I/O, zero state, zero side effects. This constraint ensures that every package and app can safely depend on it
without pulling in unwanted transitive dependencies.
Any shared logic that involves I/O, databases, or external services is placed in @zapvol/backend. The boundary is
deliberate: @zapvol/app (which ships to the browser) must never import @zapvol/backend (which depends on Node.js
APIs). Violating this would bundle server-side code into the client.
apps/bua is a deliberate exception to the shared-codebase model. It depends on @zapvol/common only — never
on @zapvol/app or @zapvol/backend. The Chrome MV3 runtime has no Node APIs, the workspace bundle exceeds CSP and
size budgets, and the Service Worker + content script lifecycle differs fundamentally from the SPA process model. The
extension therefore mirrors the same 5-layer pattern in its own physically separate codebase, sharing only the type
contracts in @zapvol/common (notably the browser-bridge schemas that define the agent ↔ extension protocol).
Layered Architecture
The layers compose at runtime through dependency injection. Each layer depends exclusively on the interface of the layer below — never on a concrete implementation. This creates a system where the top four layers (UI, State, Contract, Transport) are platform-agnostic, while the bottom three (Route, Repository, Infra) are platform-specific adapters.
The Contract Pattern
The contract layer is the architectural seam that decouples UI from transport. Each business domain defines a plain TypeScript interface — no decorators, no base classes, no framework coupling:
// packages/app/src/contracts/task.contract.ts
export interface TaskClient {
list(options?: TaskListQuery): Promise<TaskListPage>;
get(id: string): Promise<TaskDetail>;
create(data: CreateTaskInput): Promise<CreateTaskData>;
update(id: string, data: UpdateTaskInput): Promise<TaskDetail>;
remove(id: string): Promise<void>;
// …plus getMessages, stream, abort, setMessageFeedback, readArtifact
}
Contracts are provided via a single React Context that holds all domain clients, and consumed through domain-specific hooks:
// packages/app/src/context/service-context.tsx
interface Clients {
auth: AuthClient;
task: TaskClient;
chat: ChatClient;
agent: AgentClient;
// ... one entry per business domain
}
const ClientContext = createContext<Clients | null>(null);
export function ClientProvider({ clients, children }: Props) {
return <ClientContext.Provider value={clients}>{children}</ClientContext.Provider>;
}
// One hook per domain — components never see the full Clients bag
export const useTaskClient = () => useClients().task;
When a component calls useTaskClient().list(), it has no knowledge of whether the call resolves to an HTTP
fetch, an Electron IPC message, or a direct function invocation in a test harness. The platform wires the concrete
implementation at the application root.
Platform Wiring
Each platform provides a factory that satisfies the contract interface through its native transport mechanism.
Web — HTTP modules delegate to a shared request function that handles serialization, error mapping, and auth
headers:
// packages/app/src/api/modules/task.ts
export function createTaskModule(request: RequestFn) {
return {
list: (options?) => request<TaskListPage>("/api/tasks", { params: options }),
get: (id) => request<TaskDetail>(`/api/tasks/${id}`),
create: (data) => request<CreateTaskData>("/api/tasks", { method: "POST", body: data }),
remove: (id) => request<void>(`/api/tasks/${id}`, { method: "DELETE" }),
// …update, getMessages, stream, abort, …
};
}
Desktop — IPC wrappers map each method to an Electron invoke call with a channel name convention
(domain:method):
// apps/desktop/src/renderer/ipc/task.ts
export function createTaskClient(): TaskClient {
return {
list: (options) => window.electron.invoke("task:list", options),
get: (id) => window.electron.invoke("task:get", id),
create: (data) => window.electron.invoke("task:create", data),
update: (id, data) => window.electron.invoke("task:update", id, data),
remove: (id) => window.electron.invoke("task:remove", id),
// …getMessages, stream, abort, setMessageFeedback, readArtifact
};
}
Both implementations satisfy TaskClient through TypeScript’s structural type system — no explicit implements
keyword, no runtime registration. If a method signature drifts, the compiler catches it at build time.
Type-Safe Route Handlers (Server)
The route layer is platform-specific — server uses Hono routes, desktop uses IPC handlers, both delegating to the same
shared services from @zapvol/backend. On the server, routes use a declarative h() wrapper that composes
authentication, Zod validation, and the business handler into a single Hono middleware:
// apps/server/src/routes/tasks.ts
app.post(
"/",
h({ auth: true, body: createTaskSchema, status: 201 }, async ({ user, body }) => {
return taskService.create(user.id, body); // body is typed as z.infer<typeof createTaskSchema>
}),
);
The key design insight is in h()’s type signature — it uses conditional intersection types to derive the handler
context from the config:
type HandlerContext<TAuth, TBody, TQuery> = { params: Record<string, string> } & (TAuth extends AuthOption
? { user: AuthUser }
: {}) & // user exists iff auth is configured
(TBody extends z.ZodTypeAny ? { body: z.infer<TBody> } : {}); // body typed from schema
This eliminates an entire class of bugs: accessing user without authentication, or consuming an unvalidated body. The
type system makes the impossible states unrepresentable.
Platform Abstraction
This section compares the two surfaces that host the agent runtime — Web (Server) and Desktop (Electron). The
Browser Extension (BUA) is intentionally not a column here because it does not host the agent — the agent runs on
Web or Desktop, and BUA is a remote-control client the agent uses through BrowserBridge. See
BUA Overview for its architectural role.
Comparison Matrix
| Concern | Web (Server) | Desktop (Electron) |
|---|---|---|
| Database | PostgreSQL via Drizzle ORM | SQLite via better-sqlite3 |
| Transport | HTTP / SSE (default) or WebSocket | IPC (Electron async handlers) |
| Authentication | better-auth + JWT (HTTP-only cookies) | Hard-coded local-user; no auth flow |
| File Storage | Cloudflare R2 (S3-compatible API) | Local filesystem |
| Key Encryption | Plaintext (no-op KeyEncryption port) | Electron SafeStorage (OS keychain) |
| Sandbox | Node (factory can target Daytona / E2B; still placeholders) | Node (local filesystem only) |
| Stream Recovery | Redis-backed resumable SSE (SSE only) | Direct IPC event channel |
Desktop Composition Root
The desktop main process mirrors the server’s architecture. Repositories and services are assembled once during application startup — the composition root:
// apps/desktop/src/main/handlers/index.ts
export function registerAllHandlers(db: DesktopDatabase, getWindow: () => BrowserWindow | null) {
const taskRepo = createTaskRepository(db); // SQLite-backed implementation
const taskService = createTaskService(taskRepo); // Identical factory as server — from @zapvol/backend
registerTaskHandlers(taskService); // Thin IPC glue → shared service
// ... repeated for every domain
}
createTaskService is imported from @zapvol/backend — the exact same function the server uses. The only difference
is the repository passed in: PostgreSQL on the server, SQLite on the desktop. This is the Dependency Inversion Principle
in action.
Infrastructure Interfaces
@zapvol/backend defines a set of infrastructure interfaces (ports) that abstract platform-specific concerns. Each
platform provides its own implementation, injected at composition time. Two of them — ISandbox and BrowserBridge —
warrant a dedicated walkthrough because they shape what the agent can do; the rest are listed in the table at the end of
this section.
ISandbox
The ISandbox interface abstracts the agent’s execution environment. All filesystem tools and the shell tool dispatch
operations through it, making tool implementations completely agnostic to whether they’re running on a local filesystem,
a Daytona container, or an E2B cloud sandbox (only the Node adapter is implemented today; the Daytona / E2B config types are defined but their adapters are still placeholders):
export interface ISandbox {
readonly type: SandboxType; // "node" | "daytona" | "e2b"
readonly workspace: string; // Root working directory
readonly capabilities: SandboxCapabilities; // Feature flags for tool filtering
ensureReady(): Promise<ISandbox>; // Lifecycle: ensure sandbox is operational
// File operations — signatures mirror the agent tool parameters exactly
ls(options: LsOptions): Promise<LsResult>;
readFile(options: ReadFileOptions): Promise<ReadFileResult>;
writeFile(options: WriteFileOptions): Promise<WriteFileResult>;
editFile(options: EditFileOptions): Promise<EditFileResult>;
glob(options: GlobOptions): Promise<GlobResult>;
grep(options: GrepOptions): Promise<GrepResult>;
// Command execution with optional streaming callbacks
execute(options: {
command: string;
timeout?: number;
onStdout?: (line: string) => void;
onStderr?: (line: string) => void;
}): Promise<ExecutionResult>;
}
Sandbox selection uses a discriminated union config — the type field acts as the discriminant, and each variant
carries its platform-specific configuration:
type SandboxConfig = NodeSandboxConfig | DaytonaSandboxConfig | E2BSandboxConfig;
BrowserBridge
BrowserBridge is the backend-side port for BUA. The agent’s browser tool dispatches every action through
BrowserBridge.request(); the platform decides how the call reaches the user’s extension:
- Server — a Hono WebSocket endpoint pools per-user extension connections; the bridge instance for a given user resolves to that user’s live socket.
- Desktop — Electron main runs a loopback WebSocket server (
127.0.0.1:48123), authenticated by a per-machine pairing token; the bridge resolves to the single local connection.
Scope enforcement (domain blocklist, auto-session-on-first-action) lives on the extension side, so the backend
treats { ok: false, error: "domain_blocked" } results as authoritative — the agent never gets a chance to bypass the
user’s deny-list. See BUA → Session Model for the full lifecycle.
Other Infrastructure Ports
| Interface | Responsibility | Server Adapter | Desktop Adapter |
|---|---|---|---|
FileStorage | Persist uploaded files and compaction offload data | Cloudflare R2 (S3 API) | Local filesystem |
KeyEncryption | Encrypt sensitive data (API keys, MCP credentials) at rest | Plaintext (no-op adapter) | Electron SafeStorage |
OAuthTokenRefresher | Refresh expiring OAuth tokens for MCP server connections | Real OAuth provider calls | No-op (local mode) |
JobQueue | Background-job execution (title generation, compaction) | Persistent queue | In-process queue |
TaskLock | Prevent concurrent execution of the same task | Redis-backed | In-memory |
StreamBuffer | Buffer SSE events for resumable streams | Redis-backed | n/a (direct IPC) |
Key Design Decisions
| Decision | What we give up | What we gain |
|---|---|---|
| Factory functions over classes | instanceof checks, prototype-chain inheritance, familiar OOP patterns | Eliminates this-binding bugs in callbacks and destructuring; true closure privacy (not TypeScript’s compile-time-only private); better tree-shaking since unused methods aren’t on a shared prototype |
Repository interfaces in @zapvol/backend | One extra layer of indirection compared to direct DB queries | The same service code runs on PostgreSQL and SQLite without modification. Adding a third storage backend (e.g., LibSQL for edge deployment) requires writing one adapter — zero service changes |
| Client contracts via React Context | Requires a ClientProvider wrapper at the application root; slightly more ceremony than direct imports | Components become genuinely platform-agnostic. Testing replaces one Context provider instead of mocking N module imports. Adding a new platform means writing new adapters, not touching components |
Zod schemas in @zapvol/common | Zod becomes a runtime dependency for every package | Single source of truth for validation. The server validates request bodies with the same schema the client uses for form validation — no drift between client and server validation rules |
h() route wrapper with conditional types | Higher-order function adds a layer of indirection; may be unfamiliar to new contributors | Routes become declarative one-liners. Auth, body validation, and error handling are structurally guaranteed, not opt-in. The type system prevents accessing user without auth or body without validation |
Structural typing (no implements) | No runtime contract enforcement; contracts are compile-time-only | TypeScript’s structural type system catches interface mismatches at compile time. class Foo implements Bar adds no runtime safety — it’s erased during compilation. Structural typing achieves the same guarantee with less syntax |