Skip to content

Architecture

Control Center follows feature-first Clean Architecture with a ports and adapters pattern. The codebase is organized around business domains (features), with strict dependency rules enforced by an automated architecture test.

Control Center is a thin-client architecture. No client opens the database — a headless cc_server process owns the data (a small global.db plus one SQLite file per workspace, opened lazily) and serves it over WebSocket RPC. The desktop app, the web build and the phone companion are all renderers over that one RPC connection.

The desktop chooses how it reaches its server on first launch: local (it spawns a cc_server on this machine and talks over loopback — the default) or remote (it dials a cc_server elsewhere over a secure WebSocket). The web client is always remote. The boot resolver (bootstrap/server_backend.dart) picks the path before Riverpod exists, then overrides rpcClientProvider with the connected client — so every feature reads and writes through the server rather than a local database.

The RPC layer itself is stateless: a session is authenticated, but it is not bound to a workspace. Every workspace-scoped call carries its own workspace_id in its arguments and the dispatcher refuses such a call without one. That is what lets two clients on one server hold different active workspaces without a server-held “current workspace” to disagree about — and it means isolation is enforced per call, not per connection.

This is a single resolved-lockfile Dart workspace of twenty members — fifteen packages plus five apps — with the desktop+web app (control_center) as the workspace root. The five apps are the headless server binary (cc_server), the headless fleet executor (cc_worker), the phone PWA (cc_remote), the WebSocket relay broker (cc_signaling_server) and the Widgetbook gallery (cc_gallery). The fifteen packages are cc_ui (design system), cc_domain (pure-Dart shared kernel: entities, value objects, ports, events and every feature’s domain layer), cc_harness (the built-in agent kernel: a pure-Dart agent loop — messages, provider port, tools, compaction, steering, hooks, subagents — with no dart:io and no other cc_* dependency), cc_harness_runtime (the built-in harness runtime adapter: VM-only streaming providers, OAuth/PKCE credential brokering, credential stores and the generic tool set — runs agents with no external CLI installed), cc_rpc (client transports), cc_host (server kernel), cc_data (remote repositories), cc_persistence (server-side DB), cc_server_core (app-server composition + identity/presence/fleet/evals runtime), cc_infra (VM-only adapters), cc_mcp (MCP tools), cc_mcp_client (client for external MCP servers, bridging their tools into the local registry), cc_markdown (in-repo markdown engine), cc_natives (FFI leaf) and system_audio_capture. See Deployment and clients for the full client matrix and what runs where.

Presentation → Application/Providers → Domain ← Infrastructure
  • Domain layer: pure Dart entities, value objects, repository interfaces, ports and domain services. Zero infrastructure imports (no dio, drift, or network models).
  • Presentation layer: screens, widgets, notifiers. No direct drift/DAO/data-layer access; everything goes through Riverpod providers → repositories.
  • Infrastructure layer: concrete implementations of domain ports and repository interfaces. Depends on domain, never the other way around.

The full feature shape is:

feature_name/
├── data/ # Repository implementations, data sources, services, DTOs, mappers
├── domain/ # Entities, repository interfaces, ports, use cases
├── presentation/ # Screens, widgets, notifiers
└── providers/ # Riverpod providers

Very few features under lib/features/ actually carry all four and the gaps are informative rather than accidental. The shared kernel absorbed every feature’s domain/ into cc_domain and the thin client holds no repository implementations, so most features under lib/ are presentation/ plus providers/ and nothing else. A data/ or domain/ directory that survives in the client marks something genuinely client-side.

Two named exceptions are worth knowing:

  • mcp is providers-only. Its settings and status UI lives under settings/ and the tool surface itself is a package (cc_mcp), not a feature folder.
  • orchestration and plan_studio carry only presentation/ and providers/.

The guardrails feature puts its domain layer in cc_domain/features/guardrails/ rather than the root app, because the server enforces it: a rule maps an ActionClass (or a shell commandPrefix) to a decision — allow, prompt, or deny — at a scope, rules are stored per workspace and every governed agent action resolves through the policy chain before it runs. Resolution walks space > agent > workspace > mode preset > built-in default and the first scope with a matching rule decides; most-restrictive is only a tie-break within one scope and for combining the several classes one action declares. The operator-facing surface is Settings → Workspace → Agent permissions, at /workspaces/<id>/settings/workspace/permissions. See Guardrails for the model.

packages/cc_domain/lib/core/domain/ holds entities and repositories shared across 3+ features. It is pure Dart with zero infrastructure dependencies, which is what lets both the Flutter client and the Flutter-free server binary import the same definitions:

  • Core entities: Agent, AgentRunLog, Workspace, Repo, ReviewSpaceAssociation
  • Identity & multiplayer: User, Principal (sealed UserPrincipal | AgentPrincipal), WorkspaceMember, WorkspaceRole
  • Memory entities: MemoryFact, MemoryPolicy, AgentWorkingMemory, MemoryAccessGrant
  • Shared value objects: AgentCapabilities, AgentSkills, AgentRole, Mode, SandboxBackend, SandboxSpec, RunCost
  • Shared ports: SandboxPort, CredentialBrokerPort, WorkspaceFilesystemPort, GitRepoInspectorPort, EmbeddingPort
  • Domain services: MemoryAccessPolicy, ActivityLogger, MentionResolver, RunLivenessClassifier
  • DomainEventBus + event types (workspace/agent, PR/review, messaging, ticketing, pipeline, orchestration, memory, calendar/meetings, identity & membership, observability)

Control Center is multi-user: humans and agents are co-equal actors unified by a Principal. A workspace member is a User bound to a Workspace at a WorkspaceRole (owner/admin/member/viewer/guest). Membership is the access boundary — not a pairing key — and per-repo grants keep workspace membership from out-privileging the forge. The first user is the workspace admin; others join by invite or OIDC.

Real-time collaboration is authoritative-server + per-field last-writer-wins (LWW), not a CRDT. Presence (who’s here, where they are, cursors, typing, an agent’s live status and running cost) is a separate ephemeral lane that is never persisted; durable state rides an optimistic-mutation + server-rebase + LWW backbone. Humans and agents share one roster. With a single human operator the presence lane idles and no roster chrome appears — multiplayer costs the solo user nothing.

Ports are abstract interfaces in the domain layer. Adapters are concrete implementations in infrastructure. Because the headless server owns the data and must build Flutter-free, nearly every adapter is VM-only and lives in packages/cc_infra/lib/src/, organized by concern — the root app’s feature folders no longer carry data/adapters/:

Port Adapter location
SandboxPort cc_infra/lib/src/sandboxing/ (native_sandbox_adapter.dart)
CredentialBrokerPort cc_infra/lib/src/sandboxing/ (env_credential_broker.dart, task_scoped_credential_broker.dart)
GitRepoInspectorPort cc_infra/lib/src/git/ (git_repo_inspector.dart)
EmbeddingPort cc_infra/lib/src/embedding/ (embedding_service.dart)
NotificationPort lib/core/notifications/ (client-side: renders server events locally)
AgentBackend cc_infra/lib/src/dispatch/backends/ (acp_backend.dart, harness_backend.dart, cli_backends.dart)
TicketProviderPort cc_infra/lib/src/tickets/ (Linear, Jira, ClickUp, local)
PipelineEnginePort cc_domain features/pipelines/domain/services/ (a pure-Dart domain service — it needs no infrastructure)

The app composition root (di/providers.dart) binds client-side ports to implementations via Riverpod providers; the server composes its adapters in cc_server_core.

Riverpod for all state:

  • Notifier<T> and AsyncNotifier<T> for mutable state
  • FutureProvider<T> for async data
  • Database-backed state returns AsyncValue<List<T>> from Drift .watch() streams
  • MCP tools receive dependencies as typed constructor parameters, never Ref

Drift (SQLite), split into two databases, both in cc_persistence:

  • GlobalDatabase (<dataDir>/global.db) — a short list of genuinely server-wide tables: the workspace registry, users/preferences/paired devices, SSO connections, install-wide server settings, the per-user newsfeed, the fleet queue (workers/jobs/placement log), the pre-auth workspace_routes index and install metadata. Boot opens only this file, so startup cost stays flat no matter how much history the workspaces accumulate. Adding a table here is an isolation decision a ratchet test forces you to argue for.
  • WorkspaceDatabase (<dataDir>/<workspaceId>/workspace.db) — one directory per workspace, holding one SQLite file plus everything else that belongs only to that workspace, so a conversation’s worktrees and agent files sit beside the database and are deleted with it. Nearly everything lives here: agents, spaces, tickets, memory, pipelines, meetings, the code graph, reviews, repos. Files open lazily on first touch through WorkspaceDatabaseManager.of(workspaceId) and pay their own quick_check, FTS/trigger install and vector_init then.

Both halves started from a squashed v1 baseline — onCreate builds the current schema — with migration steps appended only when a deployed database has to be carried forward.

Because one workspace is one file, exporting a workspace is a single VACUUM INTO statement (workspace.export), not a table-by-table dump; workspace.import adopts such a file and uses the embedded workspace_meta to tell its own file re-adopted from one that came from another install.

Both databases are owned by cc_persistence and opened only by cc_server; no client opens a DB — every client reads/writes over RPC. Tables are defined in packages/cc_persistence/lib/database/tables/, DAOs in .../daos/; domain entities are pure Dart, separate from Drift table classes, with mapping in the data layers. FTS5 handles full-text search and sqlite_vector embeddings, both server-only; both databases run PRAGMA foreign_keys=ON in WAL journal mode.

go_router, with one fact that governs the rest: every in-app destination is workspace-prefixed as /workspaces/:workspaceId/…. The workspace id in the URL is the single source of truth for the active workspace — activeWorkspaceIdProvider is driven from the route, not the other way round — which is why reading workspace-scoped data outside a prefixed route has nowhere to get its id from.

  • Route builder functions in router/routes.dart, each taking the workspace id as its first argument, not string constants
  • Only three pre-context routes are unprefixed: /splash, /onboarding and /workspaces (the picker)
  • ShellRoute wraps the app shell; splash and onboarding render full-screen outside it
  • Auth guard redirects to /onboarding until GitHub auth and at least one workspace exist
  • Onboarding gate in features/auth/providers/

All external HTTP lives in the server. Clients never dial GitHub, Linear or Google directly — they call server RPC ops and even remote media is fetched through the server’s /proxy/media endpoint rather than from the upstream origin.

  • dio clients in packages/cc_infra/lib/src/: GitHubApiClient, GitHubPrClient, GitHubContentClient, GitHubGraphqlClient, LinearApiClient and the Google Calendar REST client
  • Auth token injection via interceptors, including a per-account Google OAuth interceptor that refreshes tokens on 401
  • All errors mapped through packages/cc_infra/lib/src/network/error_mapper.dart → typed AppException subclasses defined in cc_domain

The server holds the credentials that matter: GitHub and ticketing tokens for agent launches, LLM provider keys and OAuth tokens all live under the server data dir or its environment. What the client keychain still holds is its own connection material and the credentials it was configured with directly.

  • Secrets in flutter_secure_storage (macOS keychain, Windows credential manager, Linux libsecret), including the pre-shared key for a paired remote server
  • shared_preferences for non-sensitive preferences only (theme, font, layout)
  • SecureCredentialsRepository abstracts the store from providers

Architecture constraints are validated by test/core/architecture_constraints_test.dart, which fails if dependency rules are violated.