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:
- Foundation packages do not depend back on product code —
common,errors, andloggerprovide types, errors, and logging that browser and Node code can share. - Agent capabilities own narrow boundaries —
ai,tool,sandbox,memory,knowledge,skill,compaction, andworkbenchno longer live insidecore;coreorchestrates them. - 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 fixedlocal-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.
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
The package dependency graph is intentionally directional:
| Package / app | Depends on | Why |
|---|---|---|
common, errors, logger | external runtime libraries | Stable foundations for browser and Node code. |
| Eight capability packages | foundations and a few peer ports | Own model, tool, sandbox, memory, knowledge, Skill, compaction, and workbench mechanisms. |
@aibuddy/core | foundations and capability packages | Orchestrates the agent loop, turn services, and domain services without owning every mechanism. |
@aibuddy/http | core and capability contracts | Routes call services without caring whether server or desktop hosts them. |
@aibuddy/app | browser-safe contracts | React sees client contracts and types, never the Node runtime. |
apps/web | @aibuddy/app, @aibuddy/common, @aibuddy/web-admin | Browser frontend and admin surface. |
apps/server | @aibuddy/core, @aibuddy/http, @aibuddy/common | Production API, worker, repos, queues, auth, storage. |
apps/desktop | @aibuddy/app, @aibuddy/core, @aibuddy/http, @aibuddy/common | Electron 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 path | Used for | Why |
|---|---|---|
Loopback HTTP on localhost:8003 | Shared Hono routes, task SSE, chat WebSocket, API keys, models, MCP, schedules, knowledge | It lets the renderer consume the same HTTP/SSE/WS shape as Web while the main process serves SQLite-backed services. |
| Electron IPC | Local gaps such as task CRUD wrappers and deliverable reads that have not fully moved to loopback | IPC 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:
| Host | Adapter file | What it injects |
|---|---|---|
| Server | apps/server/src/services/task-runner.ts | PostgreSQL repos, R2 file storage, Redis task lock and abort bus, BullMQ / inline JobQueue, credit checks, MCP, knowledge, workbench, schedules, WebSocket events. |
| Desktop | apps/desktop/src/main/handlers/agent-stream.ts | SQLite 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
| Concern | Server | Desktop |
|---|---|---|
| Database | PostgreSQL + Drizzle repositories in apps/server/src/repositories | SQLite + Drizzle-style repositories in apps/desktop/src/main/repositories |
| Auth | better-auth / JWT through server auth helpers | Fixed local-user in loopback + local desktop assumptions |
| Task stream | SSE over HTTP, optionally backed by Redis StreamBuffer for cross-instance resume | SSE over loopback HTTP, backed by an in-memory StreamBuffer for reconnects within the main-process lifetime |
| Chat stream | WebSocket plus Redis/cross-process fan-out where configured | Loopback WebSocket via DesktopWsHub |
| Job queue | BullMQ when REDIS_URL exists, inline fallback otherwise | Inline JobQueue |
| Task lock | Redis lock + heartbeat | In-memory lock |
| File storage | Cloudflare R2 adapter | Local filesystem under the app data directory |
| Key encryption | Plaintext port today | Electron safeStorage |
| MCP | Server-managed MCP OAuth / tool provider | Stdio-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.
Related reading
- Quick Start — the runnable Web and Desktop paths.
- Task Orchestration — how server and desktop host
runTaskTurn. - Agent Engine — the loop inside
@aibuddy/core. - Streaming Architecture — SSE, WebSocket, and resume behavior.
- Auth & Identity — server JWT vs desktop local identity.