Skip to content

Client-Server Architecture

AI coding agents need to mediate between user interfaces (terminal, web, IDE extension) and backend logic (LLM calls, file operations, session state). The fundamental question is whether this mediation happens inside a single process or across a network boundary.

A single-process monolith is simpler to deploy and debug, but it couples the rendering loop to the agent loop. A client-server split lets multiple frontends attach to one agent backend, enables remote access, and makes the TUI replaceable without touching the core. The trade-off is complexity: HTTP routing, serialization, event streaming, authentication, and process lifecycle management.

Of the three reference implementations, only OpenCode chose the client-server path. Aider and Codex are single-process architectures where the TUI (or CLI) runs in the same process as the agent. This page traces all three designs in depth.

For the dedicated SKILL.md subsystem that sits on top of these architectures (including OpenCode’s /skill routes), see Skills. For MCP lifecycle management routes and status events in the server layer, see MCP Integration. For Codex’s stdio MCP server mode, see MCP Server.


Pinned at: b9050e1d (aider)

Aider is a single-process Python application. There is no server, no HTTP, and no separation between the UI and agent logic.

The entry point is aider/main.py, which constructs a Coder instance and calls coder.run(). The run() method is a synchronous loop that reads input from prompt_toolkit, sends it to the LLM, processes the response, applies edits, and prints output — all in the same thread. The only concurrency is a background thread for chat history summarization (ChatSummary).

There is no mechanism for a second process to connect to a running Aider session. The --watch-files mode uses watchfiles to detect changes but still operates within the same process.

Aider’s design assumes one human, one terminal, one session. The prompt_toolkit input loop is inherently tied to a single terminal. There is no IDE extension, no web UI, and no remote access. The simplicity is intentional — Aider’s value proposition is “run it in your terminal and it edits your code.”

Aider demonstrates that a single-process model is viable for a terminal-only tool. The cost is that you cannot build a web UI, IDE extension, or remote access without rearchitecting the core.


Pinned at: 4ab44e2c (codex)

Codex is a single-process Rust application. The TUI and agent loop coexist in one tokio runtime within the same binary.

The entry point is codex-rs/tui/src/lib.rs:129 (run_main()). This function:

  1. Parses CLI flags and loads config via ConfigBuilder
  2. Constructs a codex_core::Config with sandbox mode, approval policy, and MCP settings
  3. Creates the App struct (the ratatui TUI application)
  4. Enters the ratatui event loop

The App struct owns both the rendering state and a handle to codex_core::Codex, the agent engine. The agent runs as a set of tokio tasks inside the same runtime. Communication between the TUI and agent is via async channels (async_channel::Sender<Event>), not HTTP.

┌─────────────────────────────────────────────┐
│ Single Process (tokio) │
│ │
│ ┌──────────┐ async_channel ┌────────┐ │
│ │ TUI │ ◄────────────────► │ Agent │ │
│ │ (ratatui)│ │ Loop │ │
│ └──────────┘ └────────┘ │
│ │ │ │
│ crossterm OpenAI API │
│ terminal I/O + tool calls │
└─────────────────────────────────────────────┘

Channel-based communication. The TUI subscribes to Event messages from the agent via async_channel. Events include ResponseItem (LLM output), TaskStarted, TaskComplete, McpStartupUpdate, and ApprovalRequest. The TUI does not poll; it receives events as they arrive.

No HTTP surface in normal mode. When you run codex interactively, there is no HTTP server. The binary is self-contained.

The MCP server exception. Codex can run as an MCP server via codex mcp-server, which is a separate binary entry point (codex-rs/mcp-server/src/lib.rs:51). In this mode, Codex reads JSON-RPC from stdin and writes to stdout — it becomes a stdio transport MCP server. This is NOT an HTTP server; it is designed to be spawned as a child process by an MCP client (like Claude Code or another agent). The architecture is covered in detail on the MCP Server page.

Headless mode. The codex exec subcommand runs the agent without a TUI, printing output to stdout. It reuses the same codex_core::Codex engine but skips the ratatui rendering. This is still a single process.

The codex-cli/ directory contains a Node.js wrapper that downloads the platform-appropriate Rust binary and spawns it as a child process. This is purely for npm distribution convenience — the Node process does not participate in the agent logic. It calls child_process.spawn() with the Rust binary and forwards stdin/stdout.

Codex was built by OpenAI for their own use case: a terminal tool that interacts with the Responses API. The single-process model keeps latency low (no serialization overhead), simplifies deployment (one binary), and avoids the complexity of HTTP routing and authentication. The codex mcp-server mode provides programmatic access without building a full HTTP API.


Pinned at: 7ed44997 (opencode)

OpenCode is the only reference implementation with a true client-server architecture. The server is a Hono HTTP application running on Bun, and the TUI is a separate process that connects via HTTP and SSE.

┌──────────────────────┐ ┌──────────────────────────────┐
│ TUI Client │ HTTP │ Server │
│ (OpenTUI + Solid.js)│ ◄─────► │ (Hono + Bun on :4096) │
│ │ SSE │ │
│ Uses SDK generated │ ◄────── │ Bus → SSE event stream │
│ from OpenAPI spec │ │ │
│ │ WS │ PTY sessions via WebSocket │
│ │ ◄─────► │ │
└──────────────────────┘ └──────────────────────────────┘
LLM APIs, MCP servers,
file I/O, git, LSP

Two separate processes:

  1. Server process — launched via opencode serve (packages/opencode/src/cli/cmd/serve.ts). Runs the Hono HTTP app on port 4096 (with fallback to OS-assigned port). Manages all business logic: sessions, LLM calls, tool execution, MCP connections, LSP clients, file operations, git.

  2. TUI client process — launched via opencode (the default command) or opencode attach <url>. Connects to the server via HTTP. Renders using OpenTUI (Zig rendering engine + Solid.js component model). The TUI does not run any agent logic — it is purely a frontend.

The server is defined in packages/opencode/src/server/server.ts. The App is a Hono instance with a layered middleware stack:

Middleware chain (applied in order, server.ts:62-212):

  1. Error handler (server.ts:62-79) — catches all exceptions, converts NamedError subclasses to appropriate HTTP status codes (404 for NotFoundError, 400 for model/worktree errors, 500 for unknown). Stack traces included in 500 responses.

  2. Basic auth (server.ts:80-88) — optional, activated by OPENCODE_SERVER_PASSWORD env var. Username defaults to opencode (configurable via OPENCODE_SERVER_USERNAME). Uses hono/basic-auth. CORS preflight (OPTIONS) bypasses auth.

  3. Request logging (server.ts:89-105) — logs method and path, measures request duration. Skips logging for POST /log (client-side log forwarding).

  4. CORS (server.ts:106-131) — whitelist approach:

    • http://localhost:* and http://127.0.0.1:* (any port)
    • tauri://localhost, http://tauri.localhost, https://tauri.localhost
    • *.opencode.ai (HTTPS only, regex validated)
    • Custom origins via opts.cors parameter
  5. Directory resolver (server.ts:195-212) — extracts working directory from query parameter (?directory=...), HTTP header (x-opencode-directory), or falls back to process.cwd(). Creates a per-directory Instance context via Instance.provide(). This is how one server serves multiple project directories simultaneously.

Route mounting (server.ts:227-237):

.route("/global", GlobalRoutes())
.route("/project", ProjectRoutes())
.route("/pty", PtyRoutes())
.route("/config", ConfigRoutes())
.route("/experimental", ExperimentalRoutes())
.route("/session", SessionRoutes())
.route("/permission", PermissionRoutes())
.route("/question", QuestionRoutes())
.route("/provider", ProviderRoutes())
.route("/", FileRoutes())
.route("/mcp", McpRoutes())
.route("/tui", TuiRoutes())

All route modules use lazy() for deferred instantiation — routes are only built when first accessed.

Catch-all proxy (server.ts:543-558) — any path not matched by the API routes is proxied to https://app.opencode.ai, which serves the web UI. This means the server doubles as a web frontend host, with a Content-Security-Policy header injected.

Server listen (server.ts:576-622):

export function listen(opts: { port, hostname, mdns?, mdnsDomain?, cors? }) {
const tryServe = (port) => Bun.serve({ hostname, idleTimeout: 0, fetch: App().fetch, websocket })
const server = opts.port === 0
? (tryServe(4096) ?? tryServe(0)) // Try 4096 first, then OS-assigned
: tryServe(opts.port)
}

Port negotiation: if port 0 is requested (automatic), it tries 4096 first for predictability, then falls back to any available port. idleTimeout: 0 disables connection idle timeouts (important for SSE streams). Optional mDNS publishing for LAN discovery is supported but only when hostname is not loopback.

OpenCode exposes 100+ HTTP endpoints across 12 route modules plus server-level routes. The full inventory:

Session Routes (routes/session.ts, ~900 lines, 19+ endpoints)

Section titled “Session Routes (routes/session.ts, ~900 lines, 19+ endpoints)”

The largest route module. Manages the full session lifecycle:

MethodPathPurpose
GET/session/List sessions (filters: directory, roots, start, search, limit)
POST/session/Create new session
GET/session/:sessionIDGet session details
DELETE/session/:sessionIDDelete session
PATCH/session/:sessionIDUpdate session (title, archive time)
GET/session/:sessionID/childrenList forked child sessions
GET/session/:sessionID/messageList messages (with limit)
POST/session/:sessionID/messageSend message to AI (streaming response)
POST/session/:sessionID/prompt_asyncSend message asynchronously (returns 204)
POST/session/:sessionID/abortCancel ongoing AI processing
POST/session/:sessionID/forkFork session at a message
POST/session/:sessionID/shareCreate shareable link
DELETE/session/:sessionID/shareRevoke share
POST/session/:sessionID/summarizeTrigger LLM compaction
GET/session/:sessionID/diffGet file diffs from a message
GET/session/statusAll active/idle/completed states

The critical endpoint is POST /session/:sessionID/message. It uses Hono’s stream() to hold the connection open while the LLM processes, then writes the complete message JSON at the end. This is NOT server-sent events — it is a single streamed JSON response. Real-time deltas arrive separately via the SSE event stream.

Permission Routes (routes/permission.ts, 69 lines)

Section titled “Permission Routes (routes/permission.ts, 69 lines)”
MethodPathPurpose
GET/permission/List pending permission requests
POST/permission/:requestID/replyApprove or deny (body: {reply, message?})

The permission flow is event-driven: the agent publishes a permission.requested bus event, the TUI receives it via SSE, displays the prompt, and posts the reply back via HTTP. The request ID is consumed on reply.

TUI Control Routes (routes/tui.ts, ~380 lines)

Section titled “TUI Control Routes (routes/tui.ts, ~380 lines)”

A bidirectional control channel for TUI-specific operations:

MethodPathPurpose
POST/tui/append-promptInject text into the TUI prompt
POST/tui/submit-promptSubmit current prompt
POST/tui/clear-promptClear prompt input
POST/tui/execute-commandExecute TUI command (agent_cycle, session_new, etc.)
POST/tui/show-toastDisplay toast notification
POST/tui/open-helpOpen help dialog
POST/tui/open-sessionsOpen session picker
POST/tui/open-themesOpen theme picker
POST/tui/open-modelsOpen model picker
POST/tui/select-sessionNavigate to a session
GET/tui/control/nextBlocking long-poll — get next TUI request
POST/tui/control/responseSubmit response to a TUI request
POST/tui/publishPublish arbitrary event

The control/next + control/response pair implements a request-response pattern over REST using an AsyncQueue. The server pushes a request to the queue, then GET /tui/control/next blocks until it arrives. The client processes it and posts the response to /tui/control/response.

MethodPathPurpose
GET/pty/List PTY sessions
POST/pty/Create PTY session
GET/pty/:ptyIDGet PTY details
PUT/pty/:ptyIDUpdate PTY
DELETE/pty/:ptyIDRemove PTY
GET (WS)/pty/:ptyID/connectWebSocket — real-time terminal I/O

The WebSocket connection at /pty/:ptyID/connect supports a cursor query parameter for replaying terminal history from a checkpoint. This is the only WebSocket endpoint in the entire server.

  • Global (routes/global.ts) — GET /global/health (health check with version), GET /global/event (cross-instance SSE stream)
  • File (routes/file.ts) — GET /find (ripgrep search), GET /find/file (file search), GET /file (directory listing), GET /file/content (read file), GET /file/status (git status)
  • Config (routes/config.ts) — GET /config/ (full config), PATCH /config/ (update config), GET /config/providers (provider list)
  • Project (routes/project.ts) — GET /project/ (list projects), GET /project/current, PATCH /project/:projectID
  • Provider (routes/provider.ts) — GET /provider/ (list providers), GET /provider/auth, POST /provider/:providerID/oauth/authorize, POST /provider/:providerID/oauth/callback
  • MCP (routes/mcp.ts) — covered in detail on the MCP Integration page
  • Question (routes/question.ts) — GET /question/ (list pending), POST /question/:requestID/reply, POST /question/:requestID/reject
  • Experimental (routes/experimental.ts) — GET /experimental/tool/ids, GET /experimental/tool (with schemas), POST /experimental/worktree

Server-Level Endpoints (in server.ts itself)

Section titled “Server-Level Endpoints (in server.ts itself)”
MethodPathPurpose
PUT/auth/:providerIDSet auth credentials
DELETE/auth/:providerIDRemove auth credentials
GET/docOpenAPI specification (auto-generated)
GET/commandList available commands
GET/agentList available agents
GET/skillList available skills
GET/lspLSP server status
GET/formatterFormatter status
GET/eventSSE event stream (instance-scoped)
GET/pathGet paths (home, state, config, worktree, directory)
GET/vcsVCS info (git branch)
POST/logClient log forwarding
POST/instance/disposeDispose instance and cleanup

OpenCode’s real-time communication uses a two-tier event bus:

Instance-scoped Bus (bus/index.ts) — per-directory event pub/sub. The Bus.publish() function dispatches to local subscribers and also forwards to GlobalBus. Subscribers can listen to specific event types or use Bus.subscribeAll() for a wildcard subscription. The bus is tied to Instance.state(), so each directory gets its own set of subscriptions.

GlobalBus (bus/global.ts) — cross-instance event aggregation. Events from all directories flow here. Used by GET /global/event for clients that need to watch multiple projects.

SSE stream (server.ts:486-541):

.get("/event", async (c) => {
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
stream.writeSSE({ data: JSON.stringify({ type: "server.connected", properties: {} }) })
const unsub = Bus.subscribeAll(async (event) => {
await stream.writeSSE({ data: JSON.stringify(event) })
if (event.type === Bus.InstanceDisposed.type) stream.close()
})
const heartbeat = setInterval(() => {
stream.writeSSE({ data: JSON.stringify({ type: "server.heartbeat", properties: {} }) })
}, 10_000)
await new Promise<void>((resolve) => {
stream.onAbort(() => { clearInterval(heartbeat); unsub(); resolve() })
})
})
})

Key details: the stream sends server.connected immediately on connection, heartbeats every 10 seconds (to prevent proxy/CDN timeouts), and auto-closes when the instance is disposed. X-Accel-Buffering: no disables Nginx buffering.

OpenCode generates a TypeScript SDK from its OpenAPI specification (packages/sdk/). The TUI client uses this SDK for all server communication:

packages/opencode/src/cli/cmd/tui/context/sdk.tsx
const sdk = createOpencodeClient({
baseUrl: props.url, // e.g., "http://localhost:4096"
signal: abort.signal,
directory: props.directory,
fetch: props.fetch, // Custom fetch with auth headers
headers: props.headers,
})

The SDK provides typed methods for every endpoint (sdk.session.get(), sdk.session.prompt(), sdk.event.subscribe(), etc.).

The opencode attach <url> command connects the TUI to an existing server:

packages/opencode/src/cli/cmd/tui/attach.ts
await tui({
url: args.url,
args: { continue: args.continue, sessionID: args.session, fork: args.fork },
directory,
headers: { Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` },
})

The TUI is purely a rendering layer — all state lives on the server. Multiple TUI clients can attach to the same server (there is no exclusive lock), though behavior with concurrent users on the same session is undefined.

The directory resolver middleware (server.ts:195-212) creates a scoped Instance context for each request based on the directory parameter. This means:

  • Each project directory has its own sessions, MCP connections, LSP servers, and bus subscriptions
  • One server process can serve multiple projects simultaneously
  • No cross-directory state leakage (enforced by Instance.provide())
  • No user/tenant isolation — directory is the only boundary

Claude Code is closed-source, so architecture is inferred from public documentation.

Claude Code uses a host-process model rather than a client-server split. The CLI binary (claude) is the canonical runtime. All other surfaces — VS Code extension, JetBrains plugin, Desktop app — wrap the CLI as a child process and communicate via its stdin/stdout or integration protocol.

┌───────────────────────────────────────────────────────────────────┐
│ User Surface │
│ │
│ ┌───────────┐ ┌───────────────┐ ┌────────────┐ ┌──────────┐ │
│ │ Terminal │ │ VS Code Ext │ │ JetBrains │ │ Desktop │ │
│ │ (claude) │ │ (hosts CLI) │ │ (hosts CLI)│ │ (Code │ │
│ │ │ │ │ │ │ │ tab) │ │
│ └─────┬─────┘ └──────┬────────┘ └─────┬──────┘ └────┬─────┘ │
│ │ │ │ │ │
│ └───────────────┼─────────────────┼───────────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Claude Code CLI │ │
│ │ (Agent Runtime) │ │
│ └────────┬─────────┘ │
│ │ │
│ Anthropic API │
│ + local tool calls │
└───────────────────────────────────────────────────────────────────┘

This is the inverse of OpenCode’s architecture: instead of a server that frontends connect to over HTTP, Claude Code is a process that host applications embed directly.

Both the VS Code extension and JetBrains plugin follow the same pattern:

  1. Extension/plugin provides GUI chrome (panel, diff viewer, diagnostic forwarding)
  2. Claude Code CLI runs as a child process underneath
  3. IDE-specific features (inline diff, selection context, @-mention resolution, diagnostic sharing) are communicated via integration protocol
  4. Configuration is shared via ~/.claude.json and ~/.claude/settings.json
  5. The /ide command connects an external CLI session to a running IDE instance

The VS Code extension also supports a terminal mode (useTerminal setting) where it falls back to the CLI interface inside a VS Code terminal panel.

The -p (print) flag transforms the CLI into a non-interactive execution engine:

Terminal window
claude -p "Summarize this project" --output-format json

Three output formats:

  • text (default): Plain text response
  • json: Structured JSON with result, session_id, structured_output (when --json-schema used), and usage metadata
  • stream-json: Newline-delimited JSON events for real-time streaming (combine with --verbose --include-partial-messages)

Session continuity works in programmatic mode via --continue (most recent) or --resume <session_id> (specific).

Python and TypeScript SDK packages extend this with native callbacks, structured outputs, and message objects.

Claude Code on the web (claude.ai/code) and Desktop remote sessions run on Anthropic-managed infrastructure:

┌──────────────────┐ ┌─────────────────────────────────────┐
│ User Surface │ │ Anthropic Cloud │
│ │ │ │
│ claude.ai/code ──┼──────┼──► ┌──────────────────────────────┐ │
│ Desktop (remote)─┼──────┼──► │ Isolated VM per session │ │
│ iOS app ─────────┼──────┼──► │ │ │
│ & prefix (CLI) ──┼──────┼──► │ ┌──────────┐ ┌──────────┐ │ │
│ │ │ │ │ Claude │ │ Git │ │ │
│ │ │ │ │ Code CLI │ │ Proxy │ │ │
│ │ │ │ └─────┬────┘ └────┬─────┘ │ │
│ │ │ │ │ │ │ │
│ │ │ │ ┌─────┴────────────┴─────┐ │ │
│ │ │ │ │ Security Proxy │ │ │
│ │ │ │ │ (domain allowlisting) │ │ │
│ │ │ │ └────────────────────────┘ │ │
│ │ │ └──────────────────────────────┘ │
│ │ └─────────────────────────────────────┘
└──────────────────┘

Key architectural properties:

  • VM isolation: Each session runs in its own VM with a universal dev image (Python, Node, Ruby, Go, Rust, Java, C++, PostgreSQL 16, Redis 7)
  • Git proxy: All git operations route through a dedicated proxy. Scoped credentials inside the sandbox; proxy translates to actual GitHub auth tokens. Push restricted to current working branch.
  • Security proxy: All HTTP/HTTPS outbound traffic through proxy for abuse prevention and rate limiting. Three access levels: No internet, Limited (domain allowlist), Full.
  • SessionStart hooks: Custom dependency installation via hooks (CLAUDE_CODE_REMOTE env var to scope remote-only)
  • Session persistence: Sessions continue even if the user disconnects. Accessible from web, Desktop, iOS, and CLI via /teleport.

Claude Code supports cross-surface session movement:

TransitionMechanismWhat Transfers
CLI -> Web& prefix or --remoteNew session with current context
Web -> CLI/teleport or --teleportBranch checkout + conversation history
CLI -> Desktop/desktopFull session handoff
CLI -> IDE/ideConnects to running IDE
Desktop -> Web”Continue in” menuPushes branch, generates summary
IDE <-> CLI--resumeShared conversation history (bidirectional)

Most transitions create new sessions with varying context transfer. Only IDE <-> CLI shares actual conversation history.

Claude Code provides a layered governance stack for enterprise deployment:

Server-Managed Settings (Public Beta)

Administrators configure Claude Code centrally via claude.ai Admin Settings web UI. Settings are delivered from Anthropic’s servers at authentication time and cached locally.

  • Supports all settings.json keys plus managed-only keys (disableBypassPermissionsMode)
  • Settings precedence: server-managed > endpoint-managed (MDM) > user/project
  • Startup + hourly polling; cached settings persist through network failures
  • Security dialogs for shell commands, custom env vars, hook configs (user must approve or Claude Code exits)
  • Non-interactive mode (-p) skips security dialogs
  • Not available on Bedrock/Vertex/Foundry/custom ANTHROPIC_BASE_URL
  • Audit logging via compliance API
  • Owner/Primary Owner roles only
  • Limitations: uniform for all org users (no per-group), no MCP server configs via this channel

Two managed settings approaches:

ApproachBest ForSecurity Model
Server-managedOrganizations without MDMDelivered from Anthropic servers at auth
Endpoint-managedOrganizations with MDMOS-protected file in system directory

OpenTelemetry Observability

Claude Code exports telemetry via standard OpenTelemetry protocols when CLAUDE_CODE_ENABLE_TELEMETRY=1:

  • Exporters: otlp, prometheus, console (metrics); otlp, console (logs/events)
  • 8 metrics: session.count, lines_of_code.count, pull_request.count, commit.count, cost.usage (USD), token.usage, code_edit_tool.decision, active_time.total
  • 5 events: user_prompt, tool_result, api_request, api_error, tool_decision
  • Standard attributes: session.id, organization.id, user.account_uuid, user.email, terminal.type
  • Event correlation via prompt.id (UUID linking all events from one user prompt)
  • Multi-team segmentation via OTEL_RESOURCE_ATTRIBUTES (department, cost_center)
  • Admin-configurable via managed settings (cannot be overridden by users)
  • Privacy: prompt content and MCP/skill names redacted by default

Deployment Options

Five deployment paths with different cost tracking and enterprise features:

OptionBillingAuthCost TrackingEnterprise Features
Teams/Enterprise$150/seat or customSSOUsage dashboardTeam mgmt, SSO, monitoring
ConsolePAYGAPI keyUsage dashboardNone
BedrockPAYG via AWSAWS credsAWS Cost ExplorerIAM, CloudTrail
VertexPAYG via GCPGCP credsGCP BillingIAM, Audit Logs
FoundryPAYG via AzureEntra IDAzure Cost MgmtRBAC, Azure Monitor

Enterprise proxy support: HTTPS_PROXY for corporate proxy, ANTHROPIC_BASE_URL/ANTHROPIC_BEDROCK_BASE_URL/ANTHROPIC_VERTEX_BASE_URL for LLM gateways. Model pinning via ANTHROPIC_DEFAULT_OPUS_MODEL/ANTHROPIC_DEFAULT_SONNET_MODEL/ANTHROPIC_DEFAULT_HAIKU_MODEL.

DimensionOpenCodeClaude Code
ArchitectureClient-server (Hono HTTP + SSE)Host-process (CLI embedded in surfaces)
Multi-frontendMultiple TUIs attach via HTTPIDE extensions host CLI as child process
Remote accessopencode attach <url>Cloud VMs + session mobility commands
Event streamingSSE on /event endpointIntegration protocol (IDE) or cloud sync (web)
API surface100+ REST endpoints, OpenAPI specCLI flags + Agent SDK (Python/TypeScript)
Multi-projectDirectory resolver middlewareSeparate sessions per project, multi-repo in cloud
AuthenticationOptional basic authOAuth (subscription) or API key, OIDC for cloud providers

Single-Process Is Good Enough for Most Cases

Section titled “Single-Process Is Good Enough for Most Cases”

Both Aider and Codex ship as single-process tools and serve millions of users. The client-server split adds significant complexity (HTTP routing, serialization, event streaming, process lifecycle, authentication) that is only justified if you need multiple frontends, remote access, or process isolation.

OpenCode’s POST /session/:sessionID/message uses Hono’s stream(), which holds the connection open and writes a single JSON response at the end. This is NOT SSE — there are no intermediate events on this endpoint. Real-time deltas arrive on a separate SSE stream (GET /event). This two-channel pattern (one for request-response, one for events) is common but means the client must correlate events with the original request.

OpenCode’s try-4096-then-fallback pattern is pragmatic but creates issues when multiple instances start simultaneously. There is no service discovery or port reservation mechanism. The mDNS support partially addresses this for LAN scenarios.

OpenCode’s optional basic auth (OPENCODE_SERVER_PASSWORD) is a deterrent, not a security boundary. There are no user roles, no session tokens, and no CSRF protection. The server trusts whatever directory the client claims. For a local development tool this is acceptable; for a multi-user deployment it would need a complete auth overhaul.

OpenCode’s catch-all route that proxies unmatched paths to https://app.opencode.ai means the server silently forwards unknown requests to an external service. This is convenient for serving the web UI but could leak request information if the user is unaware.

OpenCode’s Bus stores subscriptions in a Map<type, callback[]> with no subscription limits. A misbehaving client that opens many SSE connections without closing them will accumulate subscriptions. The onAbort cleanup depends on the client properly disconnecting.

Despite having a WebSocket-capable server (Bun), OpenCode only uses WebSocket for terminal I/O. All other real-time communication uses SSE. This is a reasonable choice — SSE is simpler, unidirectional, and sufficient for event streaming.

Claude Code’s approach of embedding the CLI in IDE extensions means the extension must manage the CLI process lifecycle (spawn, restart on crash, version updates). If the CLI changes its stdout format or integration protocol, all extensions break simultaneously. OpenCode’s HTTP API provides a more stable contract between frontend and backend. The trade-off is that the host-process model has zero latency overhead and no serialization cost.

Claude Code’s web/cloud execution is not just “run the CLI in a VM.” It requires VM orchestration, git proxy with credential scoping, network security proxy with domain allowlisting, session persistence, and cross-surface session mobility. This is a platform capability, not something a CLI tool can offer without significant infrastructure investment.

Claude Code’s session transfer between surfaces (CLI -> web, web -> CLI, CLI -> Desktop) creates new sessions in most cases. Only IDE <-> CLI shares actual conversation history. This means session state (tool approvals, file modification tracking, compaction state) is partially or fully lost during transitions.

Server-Managed Settings Are Client-Side Controls

Section titled “Server-Managed Settings Are Client-Side Controls”

Server-managed settings provide centralized policy but are enforced client-side. Users with admin/sudo access on unmanaged devices can modify the binary, cached settings file, or network config. The tampered cache reverts on next server fetch, but there’s a window of non-enforcement. For stronger guarantees, use endpoint-managed settings on MDM-enrolled devices.

Third-Party Providers Lose Governance Features

Section titled “Third-Party Providers Lose Governance Features”

When using Bedrock, Vertex, or Foundry, several governance features are unavailable: server-managed settings, Anthropic analytics dashboards, Claude Code Usage Report API, and Claude Code-specific workspace controls. Cost tracking falls back to the cloud provider’s native billing. Organizations need LiteLLM or similar proxy for per-key spend tracking.


Architecture Decision: Optional Client-Server

Section titled “Architecture Decision: Optional Client-Server”

OpenOxide will support both modes:

  1. Single-process mode (default) — TUI and agent run in one binary, communicating via async channels. This is the Codex model and will be the primary development target.

  2. Server mode (openoxide serve) — HTTP server that exposes the agent via REST + SSE. Enables web UI, IDE extensions, and remote access.

The key insight from the reference implementations: start single-process, add the server as a layer on top. The agent core should not know whether it is talking to a local TUI or a remote HTTP client.

openoxide-core — Agent loop, session management, tool dispatch
openoxide-tui — ratatui TUI, depends on openoxide-core
openoxide-server — HTTP server layer, depends on openoxide-core
openoxide-cli — CLI entry point, selects mode

openoxide-core communicates via a trait EventSink that the TUI and server both implement. The TUI calls openoxide-core directly; the server wraps it in HTTP handlers.

Use axum (tokio-native, tower middleware, proven in production Rust services). Route structure mirrors OpenCode’s but with Rust type safety:

openoxide-server/src/routes/mod.rs
pub fn router(state: AppState) -> Router {
Router::new()
.nest("/session", session::routes())
.nest("/permission", permission::routes())
.nest("/mcp", mcp::routes())
.nest("/event", event::routes())
.nest("/pty", pty::routes())
.layer(middleware::from_fn(directory_resolver))
.layer(middleware::from_fn(auth_layer))
.with_state(state)
}

Use axum::response::Sse with tokio::sync::broadcast for the event bus. Each directory gets its own broadcast channel. SSE heartbeat at 10-second intervals (matching OpenCode’s cadence).

Optional bearer token auth (not basic auth). Token set via OPENOXIDE_SERVER_TOKEN env var. No user/role system — same trust model as OpenCode but with a more standard auth header.

  1. Agent core is transport-agnostic. The EventSink trait abstracts whether events go to a local TUI or an HTTP SSE stream.
  2. No web UI proxy. OpenOxide will not proxy unknown paths to an external service. The web UI, if built, will be a separate static site.
  3. WebSocket for PTY only. Follow OpenCode’s pattern — SSE for events, WebSocket only for bidirectional terminal I/O.
  4. Directory isolation via tower middleware. Each request extracts a directory and creates an Instance scope, matching OpenCode’s pattern but using tower’s Extension layer.
  5. OpenAPI spec generation. Use utoipa for compile-time OpenAPI generation, enabling SDK codegen for TypeScript/Python clients.
  6. Agent core as library crate. Following Claude Code’s Agent SDK pattern, openoxide-core should be designed as a reusable library first, with the CLI and server as consumers. This enables future Python/TypeScript bindings and programmatic integration.
  7. IDE integration via server mode. Rather than requiring IDE extensions to host the binary as a child process, OpenOxide’s planned openoxide serve mode provides the integration surface. Extensions connect via HTTP/SSE (already designed above), getting a stable API contract. The /ide connection pattern can be supported as a convenience for external terminal sessions.
  8. Non-interactive mode with structured output. The CLI should support -p mode with --output-format text|json|stream-json and --json-schema for structured outputs. Session IDs in JSON output enable continuation. Exit codes follow Unix conventions (0 success, 1 error, 124 timeout).
  9. Container-deployable agent core. Design openoxide-core to run in containers without assuming local filesystem access patterns. This enables CI/CD integration (install binary, run -p) and potential cloud deployment. No hard dependency on local git credentials or shell profile.
  10. Layered governance stack. OpenOxide should support a four-level governance model: developer (model/cost config), team admin (workspace limits, analytics), org admin (managed settings, permission policies, OTel config), and IT/security (MDM-deployed settings, proxy/gateway). The managed settings file at the system level (/etc/openoxide/settings.toml on Linux, /Library/Application Support/OpenOxide/settings.toml on macOS) has highest precedence and cannot be overridden by user settings.
  11. OpenTelemetry as optional crate. Create openoxide-telemetry as an optional dependency that exports metrics and events via the OTel SDK. Metric names should follow the openoxide.* namespace but mirror Claude Code’s metric structure for compatibility with existing dashboards. Support otlp, prometheus, and console exporters.
CratePurpose
axumHTTP framework
tokioAsync runtime
towerMiddleware
tokio-tungsteniteWebSocket for PTY
utoipaOpenAPI generation
serde / serde_jsonSerialization