Architecture

The deployable apps are adapters around one shared core

aibuddy currently ships three runnable app entry points: apps/web, apps/server, and apps/desktop. The architecture keeps Web and Desktop on the same product and agent runtime without pretending they have the same storage, auth, or process model.

Three rules carry that shape:

  1. Foundation packages do not depend back on product code — common, errors, and logger provide types, errors, and logging that browser and Node code can share.
  2. Agent capabilities own narrow boundaries — ai, tool, sandbox, memory, knowledge, skill, compaction, and workbench no longer live inside core; core orchestrates them.
  3. Apps compose adapters, not business logic — server binds the capabilities to PostgreSQL, Redis, R2, better-auth, and BullMQ; desktop binds core to SQLite, local files, Electron safeStorage, in-memory locks, and a fixed local-user.

The important dependency is directional: UI contracts depend on shared types, shared services depend on repository and infra interfaces, and deployable apps provide concrete implementations at the composition root.

Design decisions

The repo optimizes for shared product behavior, not for identical platform internals. Web and Desktop should feel like the same product, but they should not pretend that PostgreSQL and SQLite, JWT auth and local process identity, or remote workers and a local Electron main process are the same thing.

The package graph therefore separates reusable capabilities from concrete adapters. Eight capability packages own their mechanisms, @aibuddy/core orchestrates them, @aibuddy/http owns the shared Hono surface, and apps make those contracts real on each platform.

Layered Architecture Shared product and runtime layers, concrete platform adapters at the edges. Product UI @aibuddy/app — React pages, components, hooks, i18n, task and chat surfaces Client Contracts @aibuddy/app — TaskClient and domain clients hide HTTP, SSE, WebSocket, and IPC details Transport Adapters Web fetch modules; Desktop loopback HTTP, SSE, WebSocket, and host-local IPC HTTP Surface @aibuddy/http — createApp(), route factories, h() validation and auth wrapper Core Runtime @aibuddy/core — services, repositories, runTaskTurn, runAgentLoop, tools, memory, MCP, sandbox ports Shared Data Contracts @aibuddy/common — Zod schemas, types, tool definitions, model catalog, pure helpers Server Adapters PostgreSQL, Redis, BullMQ, R2, JWT auth, server MCP Desktop Adapters SQLite, local files, safeStorage, in-memory locks, local MCP Most application behavior sits above the adapter line; platform differences are composed at startup.

Fifteen packages form foundation, capability, and composition layers

aibuddy/
├── packages/
│   ├── common · errors · logger        types, errors, and logging
│   ├── ai · tool · sandbox             models, tools, and execution
│   ├── memory · knowledge · skill      cross-session information and on-demand capability
│   ├── compaction · workbench          context control and structured work
│   └── core · http · app · web-admin   orchestration, protocol, shared UI, and Web admin
└── apps/
    ├── web/        web                 Vite + React frontend on localhost:8000
    ├── server/     @aibuddy/server     Hono API server on localhost:8001 plus worker entry points
    └── desktop/    @aibuddy/desktop    Electron app, renderer on localhost:8002, loopback API on 8003

Dependency Graph Apps import adapters and shared packages; shared packages stay acyclic. apps/web Vite + React frontend apps/server Hono API + workers apps/desktop Electron host @aibuddy/app React UI + contracts @aibuddy/http Hono routes + h() @aibuddy/core Services + agent runtime @aibuddy/common Types, schemas, tools, model catalog Import direction stays one-way: apps compose concrete adapters; shared packages never import app code.

The package dependency graph is intentionally directional:

Package / appDepends onWhy
common, errors, loggerexternal runtime librariesStable foundations for browser and Node code.
Eight capability packagesfoundations and a few peer portsOwn model, tool, sandbox, memory, knowledge, Skill, compaction, and workbench mechanisms.
@aibuddy/corefoundations and capability packagesOrchestrates the agent loop, turn services, and domain services without owning every mechanism.
@aibuddy/httpcore and capability contractsRoutes call services without caring whether server or desktop hosts them.
@aibuddy/appbrowser-safe contractsReact sees client contracts and types, never the Node runtime.
apps/web@aibuddy/app, @aibuddy/common, @aibuddy/web-adminBrowser frontend and admin surface.
apps/server@aibuddy/core, @aibuddy/http, @aibuddy/commonProduction API, worker, repos, queues, auth, storage.
apps/desktop@aibuddy/app, @aibuddy/core, @aibuddy/http, @aibuddy/commonElectron host combines shared UI with local services.

React talks to contracts, not to transport

The shared UI package defines one client contract per business domain. For tasks, the contract is 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>;
  getMessages(id: string, options?: { limit?: number; afterSequence?: number }): Promise<TaskMessagesResult>;
  setMessageFeedback(id: string, messageId: string, feedback: MessageFeedback | null): Promise<void>;
  abort(id: string): Promise<void>;
  stream(id: string, content: string): Promise<{ status: string; sequenceNumber: number }>;
  readArtifact(id: string, path: string): Promise<ArtifactContent>;
}

packages/app/src/context/service-context.tsx exposes those clients through ClientProvider and hooks such as useTaskClient(). Components do not know whether a method is implemented by browser fetch, Electron IPC, or the desktop loopback API.

This is the boundary that lets a page, card, or tool renderer stay shared while each app chooses its own transport.

Web and Desktop provide different contract adapters

The Web frontend builds HTTP modules such as packages/app/src/api/modules/task.ts. Each method delegates to a shared request function:

export function createTaskModule(request: RequestFn) {
  return {
    list: (options) => request<TaskListPage>(`/api/tasks${query}`),
    get: (id) => request<TaskDetail>(`/api/tasks/${id}`),
    create: (data) => request<CreateTaskData>("/api/tasks", { method: "POST", body: JSON.stringify(data) }),
    abort: (id) => request<void>(`/api/tasks/${id}/abort`, { method: "POST" }),
  };
}

Desktop is now mixed by design:

Desktop pathUsed forWhy
Loopback HTTP on localhost:8003Shared Hono routes, task SSE, chat WebSocket, API keys, models, MCP, schedules, knowledgeIt lets the renderer consume the same HTTP/SSE/WS shape as Web while the main process serves SQLite-backed services.
Electron IPCLocal gaps such as task CRUD wrappers and deliverable reads that have not fully moved to loopbackIPC remains useful for host-local calls, but it is no longer the only desktop transport.

apps/desktop/src/main/local-api-server.ts starts the loopback server with a fixed local-user auth adapter. It calls the same createApp() from @aibuddy/http that the server uses.

createApp is the shared HTTP surface

packages/http/src/create-app.ts builds the transport-neutral Hono API. Both apps/server and apps/desktop pass in their own auth and service dependencies:

export function createApp(deps: AppDeps): Hono<AppEnv> {
  const h = makeH({ authenticate: deps.authenticate, getUserRoles: deps.getUserRoles });

  app.route("/api/projects", createProjectsRoute({ h, service: r.projectService }));
  app.route("/api/users", createUsersRoute({ h, userService: r.userService, preferenceService: r.preferenceService }));
  app.route("/api/api-keys", createApiKeysRoute({ h, service: r.apiKeyService }));
  app.route("/api/agents", createAgentsRoute({ h, service: r.agentService, promptService: r.agentPromptService }));

  if (r.mcpService && r.mcpClient) {
    app.route("/api/mcp-servers", createMcpServersRoute({ h, service: r.mcpService, mcpClient: r.mcpClient }));
  }
}

Optional route dependencies are capability gates. If a platform omits knowledgeService, scheduleService, or getTeamSnapshot, that route or sub-feature is not mounted. This keeps platform differences explicit without forking the route implementation.

The h() wrapper in packages/http/src/h.ts is also injected. Server passes JWT authentication and role lookup; desktop passes the fixed local-user and an admin role for its single-user loopback.

@aibuddy/core orchestrates capability packages without absorbing them

The task execution body is packages/core/src/services/agent/run-task-turn.ts. It owns the transport-neutral sequence: load task data, prepare sandbox, build RuntimeContext, assemble tool services, call runAgentLoop, convert the agent stream to UI chunks, and finalize the turn through finalizeTaskTurn.

Host files only adapt that core:

HostAdapter fileWhat it injects
Serverapps/server/src/services/task-runner.tsPostgreSQL repos, R2 file storage, Redis task lock and abort bus, BullMQ / inline JobQueue, credit checks, MCP, knowledge, workbench, schedules, WebSocket events.
Desktopapps/desktop/src/main/handlers/agent-stream.tsSQLite repos, local file storage, in-memory task lock, in-memory abort registry, inline JobQueue, fixed local-user, no credit gate.

Models, tools, sandboxes, memory, knowledge, Skills, and compaction now live in their respective capability packages. Turn services in @aibuddy/core obtain those ports and compose a run; tools receive ToolBuildDeps plus dependencies declared by each capability group. The split separates who owns a mechanism from who connects it into a task.

Platform adapters are concrete at the edges

Platform Abstraction Web and Desktop reuse UI, HTTP routes, and core runtime while swapping edge adapters. Web Desktop apps/web renderer @aibuddy/app + HTTP client modules apps/desktop renderer @aibuddy/app + loopback/IPC clients HTTP / SSE / WS loopback + IPC apps/server @aibuddy/http createApp() on port 8001 desktop main process @aibuddy/http createApp() on loopback port 8003 server task runner @aibuddy/core runTaskTurn() + full server deps desktop agent stream @aibuddy/core runTaskTurn() + local deps Server edges PostgreSQL repositories, Redis locks and stream buffer BullMQ or inline JobQueue, R2 storage, JWT auth Desktop edges SQLite repositories, local file storage, safeStorage In-memory lock and abort registry, local MCP manager @aibuddy/common types and schemas are shared by both paths.

ConcernServerDesktop
DatabasePostgreSQL + Drizzle repositories in apps/server/src/repositoriesSQLite + Drizzle-style repositories in apps/desktop/src/main/repositories
Authbetter-auth / JWT through server auth helpersFixed local-user in loopback + local desktop assumptions
Task streamSSE over HTTP, optionally backed by Redis StreamBuffer for cross-instance resumeSSE over loopback HTTP, backed by an in-memory StreamBuffer for reconnects within the main-process lifetime
Chat streamWebSocket plus Redis/cross-process fan-out where configuredLoopback WebSocket via DesktopWsHub
Job queueBullMQ when REDIS_URL exists, inline fallback otherwiseInline JobQueue
Task lockRedis lock + heartbeatIn-memory lock
File storageCloudflare R2 adapterLocal filesystem under the app data directory
Key encryptionPlaintext port todayElectron safeStorage
MCPServer-managed MCP OAuth / tool providerStdio-capable local MCP manager, able to spawn local servers

The table is the real “platform abstraction” in the current codebase. Adding another platform means writing these adapters and deciding which optional route capabilities it supplies.

Was this page helpful?