Architecture
Architecture overview for DorkOS contributors
Architecture
DorkOS uses a hexagonal (ports & adapters) architecture that lets the same React client run in two modes:
- Standalone web: Express server with HTTP/SSE communication
- Obsidian plugin: In-process services, no server or network required
This guide explains how the pieces fit together.
Monorepo Structure
DorkOS is a Turborepo monorepo. The apps and the packages that matter most for this guide:
The Transport Interface
The core abstraction is the Transport interface (packages/shared/src/transport.ts). It defines all client-server communication: session management, messaging, tool interaction, Tasks, Relay, Mesh, and agent identity.
A condensed view of the core methods (there is no separate createSession; posting a message with a fresh client-generated session id creates it on first send):
interface Transport {
listSessions(cwd?) → Promise<SessionListResponse>
getSession(id, cwd?) → Promise<Session>
getMessages(sessionId, cwd?) → Promise<{ messages: HistoryMessage[] }>
getSessionSnapshot(sessionId, cwd?) → Promise<SessionSnapshot>
subscribeSession(sessionId, sinceCursor?, cwd?, signal?) → AsyncIterable<SessionEvent>
subscribeSessionList() → AsyncIterable<SessionListEvent>
postMessage(sessionId, content, cwd?, options?) → Promise<{ sessionId: string }>
// trigger-only (202); resolves to the canonical session id, which may
// differ from the id passed in on session creation
stopTask(sessionId, taskId) → Promise<{ success: boolean; taskId: string }>
approveTool(sessionId, toolCallId, alwaysAllow?) → Promise<{ ok: boolean }>
denyTool(sessionId, toolCallId) → Promise<{ ok: boolean }>
getCommands(refresh?) → Promise<CommandRegistry>
getModels(opts?) → Promise<ModelOption[]>
getCapabilities() → Promise<{ capabilities, defaultRuntime }>
health() → Promise<HealthResponse>
}The interface extends further to cover Tasks schedules, Relay messaging, Mesh agents, bindings, and admin operations. See packages/shared/src/transport.ts for the complete definition.
Two Transport Implementations
HttpTransport communicates with the Express server over HTTP:
- Uses
fetch()for CRUD operations - Triggers turns via
postMessage()(POST /sessions/:id/messages,202with the canonical session id) - Session events arrive separately on the durable SSE streams (
GET /sessions/:id/events,GET /events) owned byStreamManager
Location: apps/client/src/layers/shared/lib/transport/http-transport.ts
DirectTransport calls service instances directly in the same process:
- No HTTP, no port binding, no network serialization
StreamManageriterates the runtime's snapshot + event streams in-process (getSessionSnapshot,subscribeSession,subscribeSessionList)- Lower latency, ideal for embedded contexts
Location: apps/client/src/layers/shared/lib/direct-transport.ts
Both implementations expose the same interface, so the React client doesn't know which one it's using.
Dependency Injection via React Context
The Transport is injected into the React app via a context provider:
// main.tsx
const transport = new HttpTransport({ baseUrl: '/api' })
<TransportProvider transport={transport}>
<App />
</TransportProvider>// CopilotView.tsx
const runtime = new ClaudeCodeRuntime(repoRoot)
const commandRegistry = new CommandRegistryService(repoRoot)
const transport = new DirectTransport({
runtime,
transcriptReader: runtime.getTranscriptReader(),
commandRegistry,
vaultRoot: repoRoot
})
<TransportProvider transport={transport}>
<ObsidianApp>
<App />
</ObsidianApp>
</TransportProvider>Components and hooks access the transport via useTransport():
import { useTransport } from '@/layers/shared/model/TransportContext';
function MyComponent() {
const transport = useTransport();
const sessions = await transport.listSessions();
}Server Architecture
The Express server (apps/server/) is organized into routes and services.
Routes
Routes are thin HTTP handlers. They obtain the active runtime via runtimeRegistry.getDefault(). The most load-bearing routes (not exhaustive; see apps/server/src/routes/ for the full list):
Prop
Type
Services
The services behind those routes, grouped by domain (also not exhaustive):
Prop
Type
Session Storage: Runtime-Owned
Sessions are not stored in a unified DorkOS database: each runtime owns its store, and GET /api/sessions aggregates across all registered runtimes, tagging every session with its runtime
type. A failing runtime degrades to partial results plus a warning, never a failed request.
- Claude Code:
TranscriptReaderscans SDK JSONL files at~/.claude/projects/\{slug\}/*.jsonl. All sessions are visible regardless of which client created them (CLI, DorkOS, or otherwise);ClaudeCodeRuntimecalls SDKquery()withresume: sessionIdfor continuity across clients. - Codex: one DorkOS session per Codex SDK thread; the session-to-thread map lives in the
codex_threadsSQLite table. - OpenCode: sessions live in the sidecar's own store, read via
@opencode-ai/sdk. - No delete endpoint: sessions persist in each runtime's storage.
- A session's runtime is fixed when it starts (persisted first-write-wins in
session_metadata).
Client Architecture
The React client (apps/client/) uses Feature-Sliced Design (FSD):
FSD Layers
| Layer | Purpose | Examples |
|---|---|---|
shared/ui/ | Reusable UI primitives | Badge, Dialog, Select, Tabs (shadcn) |
shared/model/ | Hooks, stores, context | TransportContext, app-store, useTheme |
shared/lib/ | Domain-agnostic utilities | cn(), font-config, celebrations |
entities/session/ | Session domain hooks | useSessionId, useSessions |
entities/runtime/ | Runtime capabilities | useRuntimeCapabilities |
entities/agent/ | Agent identity hooks | useCurrentAgent, useAgentVisual |
entities/tasks/ | Task scheduler hooks | useTasksEnabled, useSchedules, useRuns |
entities/relay/ | Relay messaging hooks | useRelayEnabled, useRelayMessages |
entities/mesh/ | Mesh discovery hooks | useMeshEnabled, useRegisteredAgents |
features/chat/ | Chat interface | ChatPanel, MessageList, ToolCallCard |
features/session-list/ | Session management | SessionSidebar, SessionItem |
features/settings/ | Settings UI | SettingsDialog |
features/tasks/ | Task scheduler UI | TasksPanel, CreateScheduleDialog |
features/relay/ | Relay messaging UI | RelayPanel, AdapterCard |
features/mesh/ | Mesh discovery UI | MeshPanel, TopologyGraph |
features/canvas/ | Agent-controlled UI panels | Canvas renderer, extension host |
entities/marketplace/ | Marketplace data hooks | useMarketplacePackages, useInstallPackage, usePermissionPreview |
features/marketplace/ | Marketplace browse and install UI | Marketplace, PackageCard, PackageDetailSheet, InstallConfirmationDialog |
widgets/marketplace/ | Marketplace pages | MarketplacePage (/marketplace), MarketplaceSourcesPage (/marketplace/sources) |
widgets/app-layout/ | App-level layout | PermissionBanner |
Layer imports are strictly unidirectional: shared ← entities ← features ← widgets ← app.
Cross-feature model/hook imports are forbidden.
State Management
- Zustand for UI state (sidebar, theme, etc.):
layers/shared/model/app-store.ts - TanStack Query for server state (sessions, messages, commands, schedules)
- URL Parameters (standalone mode):
?session=and?dir=persist state in the URL
Data Flow: Message from UI to Claude and Back
User types message
↓
ChatPanel → useChatSession.handleSubmit()
↓
transport.postMessage(sessionId, content, cwd) → POST /api/sessions/:id/messages
↓
Express route validates, acquires the write-lock, starts the turn → 202 { sessionId }
↓
Runtime yields StreamEvent objects → per-session projector assigns monotonic seq
↓
GET /api/sessions/:id/events (durable SSE, already open via StreamManager)
↓
StreamManager validates SessionEvent frames → session stream store applies them
↓
React state updates → UI re-renders with new message chunksUser types message
↓
ChatPanel → useChatSession.handleSubmit()
↓
transport.postMessage(sessionId, content, cwd)
↓
DirectTransport → runtime.sendMessage() → SDK query() (turn runs detached)
↓
StreamManager's in-process pump iterates runtime.subscribeSession()
↓
SessionEvents → session stream store applies them
↓
React state updates → UI re-renders with new message chunksSessionEvent Types
Runtimes yield internal StreamEvent objects; the per-session projector normalizes them into the runtime-neutral SessionEvent union (packages/shared/src/session-stream.ts), each carrying a monotonic seq:
Prop
Type
Session Event Stream
All session state is delivered on a durable per-session SSE stream: GET /api/sessions/:id/events. A cold connect receives a snapshot (history, in-progress turn, status, pending interactions, cursor), then live events. On reconnect, Last-Event-ID resumes from the last applied seq and the server replays the gap, or falls back to a fresh snapshot if it cannot. This one stream is also the multi-client sync mechanism: CLI writes and turns triggered by other clients appear in the DorkOS UI automatically.
The session list stays live via the global GET /api/events stream (session_upserted / session_removed / session_status), with no polling.
Testing
Tests use Vitest with vi.mock() for Node modules. Client tests inject mock Transport objects via TransportProvider:
import { createMockTransport } from '@dorkos/test-utils'
const mockTransport = createMockTransport({
listSessions: vi.fn().mockResolvedValue([]),
sendMessage: vi.fn(),
})
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<TransportProvider transport={mockTransport}>
{children}
</TransportProvider>
)
}
render(<MyComponent />, { wrapper: Wrapper })This pattern provides type safety and explicit test setup without global mocks.
Key Design Properties
- Same React app, two deployment modes: Transport abstraction enables full code reuse
- No network required for Obsidian: DirectTransport runs entirely in-process
- Type-safe end-to-end: Zod schemas generate both TypeScript types and OpenAPI specs
- Testable: Mock Transport objects make testing React hooks and components straightforward
- Real-time sync: durable, resumable SSE streams keep all clients in sync
- Runtime-owned storage: each runtime keeps its own session store; DorkOS aggregates instead of duplicating (no unified transcript database)
- Runtime-agnostic core: Claude Code, Codex, and OpenCode all implement the same
AgentRuntimecontract, proven by a shared conformance suite