# Architecture
Source: https://dorkos.ai/docs/contributing/architecture

Architecture overview for DorkOS contributors



{/* Internal deep-dive (repo-only): contributing/architecture.md - keep in sync */}

# Architecture [#architecture]

DorkOS uses a hexagonal (ports & adapters) architecture that lets the same React client run in two modes:

1. **Standalone web**: Express server with HTTP/SSE communication
2. **Obsidian plugin**: In-process services, no server or network required

This guide explains how the pieces fit together.

## Monorepo Structure [#monorepo-structure]

DorkOS is a Turborepo monorepo. The apps and the packages that matter most for this guide:

<Files>
  <Folder name="dorkos">
    <Folder name="apps">
      <Folder name="client">
        <File name="React 19 + Vite 6 + Tailwind 4" />
      </Folder>

      <Folder name="server">
        <File name="Express + TypeScript" />
      </Folder>

      <Folder name="site">
        <File name="Next.js 16 + Fumadocs" />
      </Folder>

      <Folder name="obsidian-plugin">
        <File name="Vite lib build (CJS)" />
      </Folder>

      <Folder name="e2e">
        <File name="Playwright browser tests" />
      </Folder>
    </Folder>

    <Folder name="packages">
      <Folder name="cli">
        <File name="Publishable npm CLI" />
      </Folder>

      <Folder name="shared">
        <File name="Zod schemas, shared types, Transport interface" />
      </Folder>

      <Folder name="relay">
        <File name="Inter-agent messaging" />
      </Folder>

      <Folder name="mesh">
        <File name="Agent discovery & registry" />
      </Folder>

      <Folder name="test-utils">
        <File name="Mock factories, test helpers" />
      </Folder>
    </Folder>
  </Folder>
</Files>

## The Transport Interface [#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):

```typescript
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`](https://github.com/dork-labs/dorkos/blob/main/packages/shared/src/transport.ts) for the complete definition.

### Two Transport Implementations [#two-transport-implementations]

<Tabs items="[&#x22;HttpTransport (Standalone Web)&#x22;, &#x22;DirectTransport (Obsidian Plugin)&#x22;]">
  <Tab value="HttpTransport (Standalone Web)">
    `HttpTransport` communicates with the Express server over HTTP:

    * Uses `fetch()` for CRUD operations
    * Triggers turns via `postMessage()` (`POST /sessions/:id/messages`, `202` with the canonical session id)
    * Session events arrive separately on the durable SSE streams (`GET /sessions/:id/events`, `GET /events`) owned by `StreamManager`

    **Location:** `apps/client/src/layers/shared/lib/transport/http-transport.ts`
  </Tab>

  <Tab value="DirectTransport (Obsidian Plugin)">
    `DirectTransport` calls service instances directly in the same process:

    * No HTTP, no port binding, no network serialization
    * `StreamManager` iterates 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`
  </Tab>
</Tabs>

Both implementations expose the same interface, so the React client doesn't know which one it's using.

## Dependency Injection via React Context [#dependency-injection-via-react-context]

The Transport is injected into the React app via a context provider:

<Tabs items="[&#x22;Standalone Web&#x22;, &#x22;Obsidian Plugin&#x22;]">
  <Tab value="Standalone Web">
    ```typescript
    // main.tsx
    const transport = new HttpTransport({ baseUrl: '/api' })

    <TransportProvider transport={transport}>
      <App />
    </TransportProvider>
    ```
  </Tab>

  <Tab value="Obsidian Plugin">
    ```typescript
    // 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>
    ```
  </Tab>
</Tabs>

Components and hooks access the transport via `useTransport()`:

```typescript
import { useTransport } from '@/layers/shared/model/TransportContext';

function MyComponent() {
  const transport = useTransport();
  const sessions = await transport.listSessions();
}
```

## Server Architecture [#server-architecture]

The Express server (`apps/server/`) is organized into routes and services.

### Routes [#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):

<TypeTable
  type="{
  'sessions.ts': {
    type: 'route',
    description: 'Session listing, creation, message streaming, tool approval/denial',
  },
  'commands.ts': { type: 'route', description: 'Slash command discovery' },
  'capabilities.ts': {
    type: 'route',
    description: 'Runtime capability flags for all registered runtimes',
  },
  'models.ts': { type: 'route', description: 'Available Claude models via runtime' },
  'health.ts': { type: 'route', description: 'Health check and optional tunnel status' },
  'directory.ts': {
    type: 'route',
    description: 'Directory browsing for working directory selection',
  },
  'config.ts': { type: 'route', description: 'Configuration management' },
  'files.ts': { type: 'route', description: 'File operations' },
  'git.ts': { type: 'route', description: 'Git status and branch info' },
  'tasks.ts': { type: 'route', description: 'Task scheduler CRUD and run history' },
  'relay.ts': {
    type: 'route',
    description: 'Relay messaging, adapter catalog, and binding management',
  },
  'mesh.ts': { type: 'route', description: 'Mesh agent discovery and registry' },
  'agents.ts': { type: 'route', description: 'Agent identity CRUD' },
  'uploads.ts': { type: 'route', description: 'File uploads via multipart form data' },
  'mcp.ts': {
    type: 'route',
    description: 'External MCP server endpoint (Streamable HTTP, /mcp)',
  },
  'extensions.ts': {
    type: 'route',
    description: 'Extension discovery, enable/disable, bundle serving, and data storage',
  },
  'marketplace.ts': {
    type: 'route',
    description:
      'Marketplace sources, package browsing, permission preview, install/uninstall/update, and cache management',
  },
}"
/>

### Services [#services]

The services behind those routes, grouped by domain (also not exhaustive):

<TypeTable
  type="{
  'runtime-registry.ts': {
    type: 'core',
    description:
      'Registry of AgentRuntime instances keyed by type: default, per-session (first-write-wins), and per-agent resolution',
  },
  'claude-code-runtime.ts': {
    type: 'runtime',
    description: 'ClaudeCodeRuntime: the default AgentRuntime, encapsulates the Claude Agent SDK',
  },
  'codex/codex-runtime.ts': {
    type: 'runtime',
    description: 'CodexRuntime: maps DorkOS sessions to Codex SDK threads (@openai/codex-sdk)',
  },
  'opencode/opencode-runtime.ts': {
    type: 'runtime',
    description:
      'OpenCodeRuntime: drives a managed `opencode serve` sidecar via @opencode-ai/sdk',
  },
  'sessions/transcript-reader.ts': {
    type: 'runtime',
    description: 'Reads SDK JSONL transcript files: source of truth for Claude Code session data',
  },
  'session/session-list-broadcaster.ts': {
    type: 'core',
    description:
      'Fans every runtime session-list stream onto the global GET /api/events SSE feed',
  },
  'stream-adapter.ts': {
    type: 'core',
    description: 'SSE helpers for formatting StreamEvent objects as wire protocol',
  },
  'tooling/command-registry.ts': {
    type: 'runtime',
    description: 'Scans .claude/commands/ for slash commands',
  },
  'openapi-registry.ts': {
    type: 'core',
    description: 'Auto-generates OpenAPI spec from Zod schemas',
  },
  'config-manager.ts': {
    type: 'core',
    description: 'Persistent user config at ~/.dork/config.json',
  },
  'tunnel-manager.ts': { type: 'core', description: 'Optional ngrok tunnel lifecycle' },
  'upload-handler.ts': {
    type: 'core',
    description: 'File upload service with multer, MIME validation, and sanitized filenames',
  },
  'mcp-server.ts': {
    type: 'core',
    description:
      'External MCP server factory: Streamable HTTP transport, stateless, optional API key auth',
  },
  'marketplace-installer.ts': {
    type: 'marketplace',
    description:
      '8-stage install orchestrator: resolves package, previews permissions, runs per-kind flow, commits via atomic transaction engine (see ADR-0231)',
  },
  'marketplace-cache.ts': {
    type: 'marketplace',
    description: 'Content-addressable clone cache with TTL and prune support',
  },
  'marketplace-source-manager.ts': {
    type: 'marketplace',
    description: 'Source CRUD: reads/writes marketplaces.json on disk',
  },
  'ensure-core-extensions.ts': {
    type: 'core-extensions',
    description:
      'Stages every first-party core extension (Marketplace, Hello World, Linear Loop) at server startup',
  },
  'extension-manager.ts': {
    type: 'extensions',
    description:
      'Extension lifecycle management: load, enable/disable, compile, and hot-reload extensions',
  },
  'extension-compiler.ts': {
    type: 'extensions',
    description: 'Compiles TypeScript extensions into executable bundles via esbuild',
  },
  'extension-discovery.ts': {
    type: 'extensions',
    description: 'Discovers extensions from filesystem paths and workspace directories',
  },
  'ui-tools.ts': {
    type: 'mcp-tools',
    description:
      'MCP tools for agent UI control: control_ui dispatches UI commands, get_ui_state returns current UI state',
  },
  'extension-tools.ts': {
    type: 'mcp-tools',
    description:
      'MCP tools for agent-driven extension operations (install, query, manage extensions)',
  },
}"
/>

### Session Storage: Runtime-Owned [#session-storage-runtime-owned]

<Callout type="info">
  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.
</Callout>

* **Claude Code**: `TranscriptReader` scans SDK JSONL files at `~/.claude/projects/\{slug\}/*.jsonl`. All sessions are visible regardless of which client created them (CLI, DorkOS, or otherwise); `ClaudeCodeRuntime` calls SDK `query()` with `resume: sessionId` for continuity across clients.
* **Codex**: one DorkOS session per Codex SDK thread; the session-to-thread map lives in the `codex_threads` SQLite 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 [#client-architecture]

The React client (`apps/client/`) uses Feature-Sliced Design (FSD):

### FSD Layers [#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                                                              |

<Callout type="warn">
  Layer imports are strictly unidirectional: `shared` ← `entities` ← `features` ← `widgets` ← `app`.
  Cross-feature model/hook imports are forbidden.
</Callout>

### State Management [#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 [#data-flow-message-from-ui-to-claude-and-back]

<Tabs items="['Standalone Web (HttpTransport)', 'Obsidian Plugin (DirectTransport)']">
  <Tab value="Standalone Web (HttpTransport)">
    ```
    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 chunks
    ```
  </Tab>

  <Tab value="Obsidian Plugin (DirectTransport)">
    ```
    User 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 chunks
    ```
  </Tab>
</Tabs>

### SessionEvent Types [#sessionevent-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`:

<TypeTable
  type="{
  turn_start: {
    type: 'SessionEvent',
    description: 'Assistant turn begins (carries the triggering user message)',
  },
  text_delta: { type: 'SessionEvent', description: 'Incremental text for assistant messages' },
  thinking_delta: { type: 'SessionEvent', description: 'Incremental extended thinking content' },
  tool_call: { type: 'SessionEvent', description: 'Tool invocation begins or changes status' },
  tool_result: { type: 'SessionEvent', description: 'Tool execution result' },
  tool_progress: { type: 'SessionEvent', description: 'Live output from a running tool' },
  approval_required: {
    type: 'SessionEvent',
    description: 'Tool needs user approval (with resumable countdown)',
  },
  question_prompt: { type: 'SessionEvent', description: 'Claude is asking the user a question' },
  elicitation_prompt: { type: 'SessionEvent', description: 'MCP elicitation form request' },
  interaction_resolved: {
    type: 'SessionEvent',
    description: 'A pending interaction was approved/denied/answered/cancelled',
  },
  status_change: {
    type: 'SessionEvent',
    description: 'Partial status update (tokens, cost, lifecycle)',
  },
  todo_update: { type: 'SessionEvent', description: 'Todo list updated' },
  subagent_update: { type: 'SessionEvent', description: 'Background subagent lifecycle update' },
  hook_update: { type: 'SessionEvent', description: 'Hook lifecycle update' },
  memory_recall: { type: 'SessionEvent', description: 'Memories surfaced into the turn' },
  compact_boundary: {
    type: 'SessionEvent',
    description: 'Context-window compaction happened (tokens summarized)',
  },
  system_status: {
    type: 'SessionEvent',
    description: 'Transient operational status (e.g. compacting context, hook progress)',
  },
  turn_end: {
    type: 'SessionEvent',
    description: 'Assistant turn finished (with terminal reason)',
  },
}"
/>

## Session Event Stream [#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 [#testing]

Tests use Vitest with `vi.mock()` for Node modules. Client tests inject mock `Transport` objects via `TransportProvider`:

```typescript
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 [#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 `AgentRuntime` contract, proven by a shared conformance suite

## Next Steps [#next-steps]

<Cards>
  <Card title="Development Setup" href="/docs/contributing/development-setup">
    Get your local environment configured and running
  </Card>

  <Card title="Testing" href="/docs/contributing/testing">
    Testing patterns, mock Transport setup, and conventions
  </Card>

  <Card title="API Reference" href="/docs/api">
    REST and SSE endpoints with the interactive Scalar UI
  </Card>

  <Card title="SSE Protocol" href="/docs/integrations/sse-protocol">
    Deep dive into real-time streaming and session sync
  </Card>
</Cards>
