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:

  1. 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.
  2. 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.
  3. Acyclic dependency graph — Packages form a strict DAG: common → backend → server|desktop, common → app → web|desktop, and common → bua (BUA depends on common only — 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

Dependency Graph ↓ = depends on @zapvol/common Types + Zod schemas @zapvol/backend Services, agent, infra @zapvol/app UI, hooks, contracts server Hono + PG desktop Electron + SQLite web Vite + React Boundary Rules server must never import @zapvol/app web must never import @zapvol/backend marketing Completely independent

@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

Layered Architecture Shared layers (indigo) vs Platform-specific layers (teal) Shared (@zapvol packages) Platform-specific (apps) UI Layer @zapvol/app — React components, pages, layouts (components/ pages/) State Layer @zapvol/app — React Query (server state) + Zustand (client state) (hooks/ stores/) Contract Layer @zapvol/app — XxxService interfaces, platform-agnostic (contracts/) Transport Layer HTTP modules (web) / IPC wrappers (desktop) (api/modules/ ipc/) Route Layer Hono routes (server) / IPC handlers (desktop) (routes/ handlers/) Service Layer @zapvol/backend — Business logic, 100% shared across platforms (services/) Repository Layer PostgreSQL (server) / SQLite (desktop) — same interface, different backends (repositories/) Infrastructure Layer @zapvol/backend — FileStorage, KeyEncryption, Sandbox, OAuth interfaces (infra/) Components at the top are identical across platforms. Only transport + storage layers differ.

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

Platform Abstraction Same UI, same business logic — different transport and storage Web Browser (port 8000) React SPA — @zapvol/app components + pages api/modules/ — HTTP fetch via createApiClient() BrowserRouter — SPA routing HTTP / SSE Server (port 8001) Hono routes — HTTP + SSE endpoints @zapvol/backend — services + agent engine PostgreSQL — Drizzle ORM Desktop (Electron) Renderer Process React SPA — @zapvol/app components + pages ipc/modules/ — electron.invoke() wrappers HashRouter — file:// protocol routing IPC Main Process IPC handlers — async message handlers @zapvol/backend — services + agent engine SQLite — better-sqlite3 Highlighted rows use identical code from @zapvol/app and @zapvol/backend — only transport + storage differ

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

ConcernWeb (Server)Desktop (Electron)
DatabasePostgreSQL via Drizzle ORMSQLite via better-sqlite3
TransportHTTP / SSE (default) or WebSocketIPC (Electron async handlers)
Authenticationbetter-auth + JWT (HTTP-only cookies)Hard-coded local-user; no auth flow
File StorageCloudflare R2 (S3-compatible API)Local filesystem
Key EncryptionPlaintext (no-op KeyEncryption port)Electron SafeStorage (OS keychain)
SandboxNode (factory can target Daytona / E2B; still placeholders)Node (local filesystem only)
Stream RecoveryRedis-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

InterfaceResponsibilityServer AdapterDesktop Adapter
FileStoragePersist uploaded files and compaction offload dataCloudflare R2 (S3 API)Local filesystem
KeyEncryptionEncrypt sensitive data (API keys, MCP credentials) at restPlaintext (no-op adapter)Electron SafeStorage
OAuthTokenRefresherRefresh expiring OAuth tokens for MCP server connectionsReal OAuth provider callsNo-op (local mode)
JobQueueBackground-job execution (title generation, compaction)Persistent queueIn-process queue
TaskLockPrevent concurrent execution of the same taskRedis-backedIn-memory
StreamBufferBuffer SSE events for resumable streamsRedis-backedn/a (direct IPC)

Key Design Decisions

DecisionWhat we give upWhat we gain
Factory functions over classesinstanceof checks, prototype-chain inheritance, familiar OOP patternsEliminates 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/backendOne extra layer of indirection compared to direct DB queriesThe 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 ContextRequires a ClientProvider wrapper at the application root; slightly more ceremony than direct importsComponents 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/commonZod becomes a runtime dependency for every packageSingle 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 typesHigher-order function adds a layer of indirection; may be unfamiliar to new contributorsRoutes 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-onlyTypeScript’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
Was this page helpful?