# Control Center — the complete manual > Everything on usectrl.dev in one file: product overview, FAQ, comparison, changelog and the full documentation. Generated from the same sources as the site. ## Overview Control Center is a free and open-source (MIT) developer operations deck, self-hosted first. - Platforms: native desktop apps for macOS (Apple Silicon, signed/notarized), Windows and Linux with auto-updates; web app; installable phone companion (remote.usectrl.dev). Every client is a thin renderer over one headless cc_server binary you run (macOS/Linux/Windows, plus Docker images on GHCR) — the server owns the database, external APIs and execution. - Agents: a built-in pure-Dart agent runtime (streaming, tool calls, compaction, steering, subagents) plus adapters for Claude Code, Codex, Pi, OpenCode, Gemini CLI, Goose and Cursor. Any OpenAI- or Anthropic-compatible endpoint joins as a custom provider; runners can be mixed in one fleet. - Isolation and safety: every space runs in its own copy-on-write Git worktree inside an OS-native sandbox (macOS Seatbelt, Linux bubblewrap); credentials are minted per launch, capability-gated and revoked on teardown. A unified guardrail store covers ~12 action classes with a per-space autonomy dial (propose-only / act-with-approval / act-freely); unattended approval prompts fail closed. - The operation around the fleet: PR review cockpit with AI reviewers (P0-P3 findings, ship/hold/block verdicts) across GitHub, GitLab and Bitbucket, merged into one inbox; vendor-agnostic tickets with bidirectional Linear sync (Jira and ClickUp scaffolded); DAG pipelines with manual, cron and domain-event triggers, resumable runs and approval gates; on-device meeting transcription with diarization and summaries; Google Calendar; per-user RSS/Atom newsfeed with ad blocking. - Knowledge: long-term memory with role-gated access policies, and a tree-sitter code graph (symbols, callers/callees, impact radius) with hybrid BM25 + vector semantic search computed by an on-device embedding model. - Multiplayer: humans and agents are co-equal members with workspace roles (owner/admin/member/viewer/guest), per-repo grants, presence, follow mode, steer/take-over/hand-back and live revocation. Agents collaborate over durable spaces (send_to_agent, ask_agent, delegate_task with budget inheritance and autonomy ceilings). - Tool surface: 100+ typed MCP tools over JSON-RPC, every workspace-scoped tool requiring its workspace_id; an MCP client bridges external MCP servers into the same registry. - Source: https://github.com/SamuelAlev/control-center ## Frequently asked questions ### What is Control Center? Control Center is a free and open-source developer operations deck: one native app where you dispatch AI coding agents across isolated Git worktrees, review and merge what they ship, and run the surrounding operation — tickets, pipelines, meetings, calendar, memory — side by side with them. It runs on macOS, Windows and Linux, in the browser and on your phone, all rendered from one server you own. ### How is Control Center different from running several Claude Code terminals? Terminals give you N agents and zero cross-agent visibility. Control Center gives each agent its own copy-on-write worktree, sandbox and budget, then adds everything the terminals cannot: a PR review cockpit with AI reviewers and merge actions, pipelines that trigger work on schedules or domain events, durable agent-to-agent messaging, long-term memory and a code graph every run starts from. You steer any run mid-flight, take it over, or hand it back. ### Which AI coding agents does Control Center support? Eight runners: a built-in pure-Dart agent runtime that needs no external CLI, plus adapters for Claude Code, Codex, Pi, OpenCode, Gemini CLI, Goose and Cursor. Any OpenAI- or Anthropic-compatible endpoint also joins as a custom provider. You can mix runners in the same fleet and the same pipeline. ### Does my code or data leave my machine? No, unless you decide to run it that way. Control Center is local-first: state lives in SQLite files you own, meeting transcription and speaker diarization run on-device, and semantic-search embeddings are computed by an on-device model. your code host and Linear are called only from your own server, over credentials stored in your OS keychain. ### Can an agent test in a real browser or run risky commands safely? Yes — that is what rigs are for. A rig is a disposable VM the agent drives in real time: a headless browser, a Linux desktop, an Android device, or the machine behind an enclosed terminal. It has its own kernel, a throwaway disk and a network that reaches only the hosts you allow; you watch it live and can take the controls at any moment. Dev servers started inside are forwarded to localhost, to the agent’s browser and to dev domains like https://myapp.test — and nothing inside a rig ever touches your machine. ### Is Control Center free and open source? Yes — MIT-licensed, source on GitHub, with signed builds for every platform. The desktop apps, the headless server and the phone companion are all free; you bring your own agent subscriptions or API keys, and the built-in runtime can use any provider you can reach. ### Which platforms does it run on? Native desktop apps ship together for macOS (Apple Silicon, signed and notarized), Windows and Linux, with auto-updates. The same server also serves a web app at app.usectrl.dev and a phone companion at remote.usectrl.dev, so the operation follows you across screens without a rewrite. ### Can my team use Control Center together? Yes. Humans and agents are co-equal members with workspace roles — owner, admin, member, viewer or guest — plus per-repo grants, invites and live revocation. Presence shows who is working on what (including watching an agent work), and every space carries an autonomy dial: propose-only, act-with-approval or act-freely. With one human, the multiplayer chrome simply idles. ### Which code hosts does Control Center work with? GitHub, GitLab and Bitbucket Cloud — and one workspace can hold repos from all three at once. Each repo is talked to through its own forge with its own credential, and the inbox merges pull requests from every one of them into a single stream. The full loop works everywhere: list and open PRs, read diffs, comment inline and at top level, review, merge, close and create. Forges genuinely differ beyond that (stacked PRs and synced viewed-state are GitHub-only, for instance), so the app hides what a host cannot do rather than showing a control that silently fails. ### How does Control Center work with Linear? Linear syncs tickets bidirectionally with projects, statuses, labels and assignees; Jira and ClickUp providers are scaffolded. Tickets themselves are vendor-neutral, so the same board works whichever tracker you sync. ## How Control Center compares Control Center is the only tool in this table that runs the whole developer operation — parallel agents, PR review and merge, tickets, pipelines, meetings, calendar, memory — in one app you self-host, with native desktop clients on all three platforms, a phone companion and multiplayer for humans and agents. Single-purpose tools do their slice better in isolation: Conductor for a turnkey Mac workforce, Superset for raw parallelism, Orca for a free cross-platform agent IDE with the most complete pull-request surface here, Paperclip for an agent org chart, Multica for PM-style task assignment, OpenClaw and Hermes Agent for a personal assistant rather than a coding fleet. Pick by the slice you actually need — or by whether you want the whole deck. Legend: ✓ yes, ≈ partial, — not offered. Checked against each product's public site, August 2026. Prices are each vendor’s own list price to use the tool. Every option here bills your model provider separately — you bring your own keys or subscription. | Tool | Starts at | Free & open source | Native desktop · mac / win / linux | Phone companion | Self-hosted headless server | Parallel worktree isolation | In-app PR review & merge | Pipelines & scheduled triggers | Meetings & calendar | Multi-user teams | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | Control Center (Developer operations deck) | Free · self-hosted | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Conductor (macOS workforce manager) | Free · Pro from $50/mo | — | ≈ | — | — | ✓ | ≈ | — | — | ≈ | | Superset (Parallel agent desktop) | Free · teams from $15/user | ≈ | ≈ | — | — | ✓ | ≈ | ≈ | — | — | | Orca (Agent development environment) | Free · open source | ✓ | ✓ | ✓ | ≈ | ✓ | ✓ | ≈ | — | — | | Paperclip (Agent org platform) | Free · open source | ✓ | — | — | ✓ | — | — | ≈ | — | ≈ | | Multica (PM for human + agent teams) | Free · cloud on request | ≈ | ✓ | — | ✓ | — | — | — | — | ✓ | | OpenClaw (Personal assistant agent) | Free · open source | ✓ | ≈ | ≈ | ✓ | — | — | ≈ | — | ≈ | | Hermes Agent (Self-improving personal agent) | Free · Plus from $20/mo | ✓ | ✓ | — | ✓ | — | — | ≈ | — | — | | Goose (Extensible single agent) | Free · open source | ✓ | ✓ | — | — | — | — | — | — | — | | Cursor (AI-first IDE) | Free · Pro from $20/mo | — | ✓ | ≈ | ≈ | ✓ | ≈ | ≈ | — | ≈ | | Plain terminals (The baseline) | Free · agents bill per token | — | ✓ | — | — | — | — | — | — | — | Notes on partial cells: - Conductor: macOS-only desktop, with no Windows or Linux build; per-task diff review with open-PR, stack and merge actions rather than a full review cockpit; Conductor Cloud runs workspaces in hosted Vercel sandboxes and self-hosting is announced but unshipped; multiplayer, the admin portal and SSO are paid tiers (Pro $50/mo, Teams $60/user/mo, Enterprise custom), and the mobile app is still listed as coming soon. - Superset: Source-available under the Elastic License 2.0 — the desktop app is free forever, but it is not OSI open source; macOS is the only tested platform (the Linux x64 AppImage is experimental, Windows is unreleased and the iOS app is “coming soon”); automations are scheduled agent sessions, not DAG pipelines; the $15/user/month team plan is a billing tier rather than shared workspaces. - Orca: Review is complete on GitHub (inline comments, checks, stacked PRs with atomic stack merge and merge-queue support) and thinner on GitLab; “self-hosted Orca servers” are remote compute targets agents run on, not a server your clients read state from; automations are scheduled runs, not DAG pipelines; no human multiplayer, tickets or meetings. - Paperclip: Fully self-hosted — one Node process, embedded Postgres, no Paperclip account — with heartbeat and event-driven wake-ups rather than DAG pipelines; a web UI rather than native desktop or phone clients; the team metaphor is an org of agents, not human multiplayer; no git worktrees or review surface. - Multica: The “Multica License” is Apache 2.0 plus conditions — no hosted service for third parties, no commercial embedding without a licence, and the branding has to stay — so source-available rather than OSI open source. Self-hostable by Docker Compose, single binary or Kubernetes (a hosted cloud exists but publishes no pricing); a PM surface, with no worktree isolation, review cockpit or phone client. - OpenClaw: Native desktop apps cover macOS and Windows; Linux installs through the CLI. The iOS and Android apps are pairing “nodes” that lend the gateway a camera, screen and voice rather than full clients — the everyday phone surface is whichever chat app you already use. Automation is cron and background tasks, not pipelines; one gateway can run as a shared team deployment; no git worktrees, review surface or calendar. - Hermes Agent: Self-hosted anywhere from a laptop to Docker, SSH, Singularity, Daytona or Modal, and free to run against your own keys — the Plus/Super/Ultra tiers ($20/$100/$200 a month) buy hosted model credits, not the software. No mobile app: a phone reaches it through the messaging gateway. The built-in cron takes natural-language schedules; “teams” means a roster of specialist bots in a group chat, not human multiplayer; no git worktrees or review surface. - Goose: Native desktop apps on all three platforms, and subagents parallelize sub-tasks inside one run — but it stays a single-agent surface, with no worktree-isolated fleet, review cockpit or server for clients to share. - Cursor: Worktrees are real and local, but cloud agents still branch in hosted Ubuntu VMs and self-hosted cloud agents are Enterprise-only; the phone app is iOS and iPadOS (Android is a browser PWA); Automations are cloud-run schedules and event triggers, not DAG pipelines; review spans the IDE, the Review bot and mobile; team features sit behind Teams ($40/user/mo) and Enterprise plans. - Plain terminals: The terminals are free but the agents bill per token; no isolation, no review surface, no shared state. When to pick each: - Control Center (https://usectrl.dev/, starts at Free · self-hosted): You want the agents and the operation around them — review, tickets, pipelines, meetings — on one deck you host yourself, on every screen you own. - Conductor (https://www.conductor.build/, starts at Free · Pro from $50/mo): You live on a Mac, want the smoothest turnkey parallel-agent experience, and are happy for the hosted half to run on someone else’s servers. - Superset (https://superset.sh/, starts at Free · teams from $15/user): You want maximum parallelism under a lightweight dashboard, and nothing else in the way. - Orca (https://www.onorca.dev/, starts at Free · open source): You want a free, cross-platform agent IDE with a phone companion, browser-per-task isolation and the most complete pull-request surface in this table — without tickets, meetings or an operation around it. - Paperclip (https://paperclip.ing/, starts at Free · open source): You want to model an organization of agents — budgets, goals, governance — more than a coding workflow. - Multica (https://www.multica.ai/, starts at Free · cloud on request): You want task assignment and progress tracking for agents inside a PM workflow you already understand. - OpenClaw (https://openclaw.ai/, starts at Free · open source): You want one always-on assistant for the whole of life — inbox, calendar, bookings, errands — driven from the chat app already open on your phone, rather than a coding fleet. - Hermes Agent (https://hermes-agent.nousresearch.com/, starts at Free · Plus from $20/mo): You want a personal agent that compounds — building and refining its own skills over time — and you are happy driving it from a terminal or a chat thread. - Goose (https://goose-docs.ai/, starts at Free · open source): You want one teachable agent and an MCP extension ecosystem, without fleet orchestration. - Cursor (https://cursor.com/, starts at Free · Pro from $20/mo): You want the best AI editor with parallel agent runs beside it, and are happy for the heavy lifting to happen in the cloud. - Plain terminals (https://code.claude.com/docs, starts at Free · agents bill per token): You run one agent at a time and already have the muscle memory. ## Changelog ### v0.0.1-rc.1 — Sep 10, 2026 — Control Center takes off The first release candidate ships the whole product at once: a native desktop app for running a fleet of AI coding agents across isolated Git worktrees, reviewing and merging their PRs and orchestrating the work as pipelines and tickets, with OS-native sandboxing, code-graph and hybrid semantic memory search, on-device meeting transcription, multiplayer for humans and agents and 100+ MCP tools, in 7 languages — on macOS, Windows and Linux, with web and phone companions over one self-hosted cc_server. Notes: macOS 13+ · Windows 10+ · Linux x86_64 · web and phone companions · headless cc_server on all three platforms plus Docker images on GHCR. - [new] Agent orchestration. Hire, configure and fire AI agents with custom roles, personas, skills, effort and monthly budgets; group them into teams; and run many concurrently in isolated sessions across chat, review and plan modes. One agent loop, eight runners: a built-in pure-Dart runtime (streaming, tool calls, compaction, steering, subagents) that needs no external CLI, plus adapters for Claude Code, Codex, Pi, OpenCode, Gemini CLI, Goose and Cursor; any OpenAI- or Anthropic-compatible endpoint joins as a custom provider. Claude routes through an in-app relay rather than metered claude -p. - [new] Enclosures (rigs). Agents can open a disposable VM to actually test in — a headless browser, a Linux desktop or an Android device — and drive it with computer_use, browser_use and mobile_use. You watch the machine live at full resolution sized to your panel while the agent works from its own cheap downscaled frames, and you can take the controls at any moment: the agent keeps observing but cannot type or click until you hand back, and every input event is recorded against whoever sent it. The space and PR terminals run inside one too, so shell work stops happening on your machine. A rig has a deny-by-default network, no stored credentials (git push asks the host for a short-lived token per operation), a disk that is discarded when it closes and a lifetime it cannot extend. The browser and terminal machines boot digest-pinned images fetched on first use and cached as pre-extracted packs, so repeat boots take seconds with the tooling already installed; a workspace can point either at its own image. The Android surface drives Google’s emulator, so it asks you to install their SDK and tells you which step is missing rather than offering a download that cannot exist. - [new] Ports out of the VM. Start a dev server inside an enclosed terminal and the plug icon lights up: every listening port is discovered with its process name and forwarded automatically — to localhost on your machine, to localhost inside the conversation’s Browser (VM) so an agent can test what you just started, optionally to your network at a deliberate random port, and optionally under a dev domain where https://myapp.test opens in the enclosed browser with a valid padlock, served by a certificate authority minted on your own server whose keys never enter any guest. - [new] OS-native sandboxing. Agents run under macOS Seatbelt or Linux bubblewrap with filesystem allow-lists and network egress controls and per-space capabilities (git push, GitHub API, ticketing, network) gate credential injection so a token is minted only when you explicitly enable it. Where neither sandbox backend is available, agents run unsandboxed. - [new] Workspace isolation. Workspaces are hard-boundary tenants that auto-seed a CEO agent plus specialists; registered repos provision copy-on-write worktrees per space via rift FFI (with git-worktree fallback) and every workspace-scoped query is filtered so one tenant's data never surfaces in another. - [new] Code graph and memory. Tree-sitter indexing extracts symbols and edges (calls, imports, extends, implements) incrementally by content hash, with ranked search and dependency traversal across callers, callees and transitive impact radius. Role-gated long-term memory stores facts (confidence, supersession, attribution) and governance policies across domains, with a read/write/none access matrix per role. Semantic search runs live: on-device ONNX embeddings (384-dim, nothing leaves the machine) drive hybrid BM25 + vector RRF over memory facts and code symbols alike. - [new] GitHub, GitLab and Bitbucket. A repo carries the forge it lives on, read from its origin remote, so one workspace can mix all three. The inbox and PR queue fan out across every connected host and merge the results into one stream, resolving your identity per forge — you are a different account on each. A host that is down or unconnected contributes nothing and leaves the others untouched. Where forges genuinely differ (stacked PRs, synced viewed-state, batched reviews) the app hides what a host cannot do rather than offering a control that silently fails. - [new] PR review cockpit. A dense diff viewer with syntax highlighting, commit-range selection, file-tree navigation and keyboard shortcuts, paired with a decision-lane PR list (ready / review / in-progress / attention / draft). Edit PR metadata in place, post forge-synced inline comments with suggestion blocks and merge via a squash / merge / rebase flyout, or open any PR straight into your editor of choice (Cursor, VS Code, Zed and more) on its worktree. - [new] AI review and reviewer swarms. Reviews produce P0-P3 findings with priority, confidence and ship / hold / block verdicts, filterable in accordions with batch dismiss and resolve and publish back to the forge as a single comment, request-changes or approve with anchored inline comments. Non-trivial PRs (≥200 LOC or ≥5 files) fan out specialist reviewers in parallel via the Swarm Protocol. - [new] Pipelines. A drag-and-drop canvas builds DAG templates from trigger, listen, join, router, forEach and terminal nodes with per-node retry, timeout and validation. Conditional routing uses predicate trees (fileExists, comparisons and/OR/NOT, switch), sub-pipelines nest with parent tracking and runs persist and resume across restarts. Triggers fire manually, on cron, or on domain events; approval gates, dry-run mode and per-run cost and token rollups round it out. - [new] Ticketing. Vendor-agnostic tickets with full local CRUD and bidirectional Linear sync, organized into color-coded projects with lifecycle status. Tickets carry hierarchy, relations (blocks / relates-to / duplicate-of), collaborators and a single-owner execution lock with stale recovery; assigning an agent auto-creates a space and dispatches. Jira and ClickUp providers are scaffolded for a future release. - [new] Messaging and focus mode. Spaces with @-mentions, threads and agent dispatch; agent-posed questions render inline as single-select, multi-select, or free-text forms whose answers route back to the blocked agent. Focus mode adds a standalone timer window with pause/resume and session goals, plus a floating compact pill. - [new] Meetings and calendar. Record meetings with on-device microphone and system-audio capture, live Whisper transcription with silence filtering and hallucination rejection, sherpa-onnx speaker diarization and echo cancellation (signal-level where the platform supports it, text-level everywhere); an AI summary pipeline produces enhanced notes, decisions and owner-assigned action items. Google Calendar sync adds multi-account support, RSVP to invitations, month/week/day/agenda views, meeting-starting-soon alerts and record-and-link. These features are desktop-only and not exposed over MCP. - [new] Dashboard, analytics and notifications. A live fleet dashboard matches real OS processes to running / blocked / failed state, while analytics surface scorecards, XP and levels, tiered achievements, streaks, leaderboards and workspace health across activity, throughput and review quality. Cost tracking enforces soft and hard budget thresholds at agent and workspace scope and desktop notifications add per-category controls, quiet hours, custom sounds and an in-app activity feed. - [new] Platform and MCP. 100+ typed MCP tools expose the agent, review, pipeline and ticketing surface to any external client over a JSON-RPC server, with workspace_id required on every workspace-scoped tool; an MCP client bridges external MCP servers into the same registry. A keyboard-centric shell adds customizable keybindings, a command palette and VS Code-style when-clauses; secure credentials live in the OS keychain; and a per-user newsfeed reads RSS/Atom with EasyList and uBlock Origin ad-blocking, with feeds, read state and bookmarks following you across workspaces and devices. - [new] Multiplayer identity and presence. Every actor is a Principal — a user or an agent — with workspace membership at owner, admin, member, viewer or guest, per-repo grants and invite-based onboarding. Presence is a separate ephemeral lane (status, locus, cursor, typing) that is never persisted. Follow mode — including watching an agent work from your seat — plus steer, interrupt, take over and hand back. Revocation is live: a removed member's sessions drop within seconds. - [new] Autonomy dial and unified guardrails. Every space carries a named autonomy profile — propose-only, act-with-approval or act-freely — over one policy store covering ~12 action classes (git push, PR create, network egress, secret access, package install, …). Resolution is most-specific-scope-wins (space > agent > workspace > mode preset) and a prompt with no approver connected is denied, fail-closed. - [new] Agent-to-agent collaboration. Agents talk over the same durable spaces you do — never a separate bus — with send_to_agent, ask_agent (request/reply with a mandatory timeout and pairwise cycle detection), delegate_task (child tickets guarded by depth cap, cycle detection, budget-envelope inheritance and an autonomy ceiling) and todo_read. Agent-to-agent spaces are muted by default and never bump your unread badge. - [new] Unified inbox and ⌘K omnibox. One inbox across every pillar — tickets waiting, PRs to review, agent questions, approvals, pipeline failures — with a command palette over it all. Nothing essential lives a level deep. - [new] Plan Studio. Editable DAG plans replace static proposals: orchestration graphs and plan documents carry revisions, approvals and execution state, and a plan node can delegate straight into a ticket. - [new] Headless server, thin clients. All state, database access, external APIs and execution live in one pure-Dart cc_server binary; desktop, web and phone are renderers. Each workspace's rows live in its own SQLite file (isolation is structural, not a WHERE clause), a workspace exports and imports as a single file, and backups run on a schedule. - [new] Fleet workers. A headless cc_worker binary pairs with your server, declares capabilities, heartbeats, pulls leased jobs and streams process events back — holding no durable state of its own. One authoritative server, N dumb limbs; a solo desktop stays byte-identical. - [new] Phone companion. cc_remote is an installable PWA at remote.usectrl.dev that remote-controls your operation over a sealed relay: check the fleet, answer an agent, approve a pipeline step — from anywhere. - [new] Skills with supply-chain scanning. Skills pass a fail-closed gate between fetch and write: no content reaches disk or an agent prompt without a verdict (pass / warn / quarantine). Static rules and the capability manifest are the mandatory layers, LLM review is additive, and trust tiers are provenance metadata — never a scan substitute. Bytes scanned are the bytes written, hash-locked. - [new] Evals and session review. Golden sessions, eval suites and eval runs put an agent's changes on rails: replay a session, score it against goldens and pin regressions before they ship. Session recordings capture what actually happened. - [new] Mermaid, natively. The in-repo markdown engine draws flowcharts, state, class, ER, sequence, pie and timeline diagrams itself — pure Dart layout and paint, no WebView, no JS. Unsupported dialects degrade to a code block; the engine never throws. - [new] Look and reach. Light and dark themes built on 80+ semantic design tokens, reduced-motion alternatives throughout and full localization in 7 languages: English, German, Spanish, French, Italian, Dutch and Portuguese. Signed and notarized desktop apps ship together for macOS, Windows and Linux with auto-updates, plus a web app and a phone companion over the same server. ## Documentation ### Introduction > Control Center is the cockpit for multi-agent software development. Source: https://usectrl.dev/manual/ Control Center gives you command over a fleet of AI coding agents. Spawn agents, watch them work in real time, review their PRs and manage repos, workspaces and integrations — from a native desktop app, a browser, or your phone. ## What you can do - **Message agents**: spaces are the primary way to drive the fleet. @-mention an agent and it dispatches into its own copy-on-write worktree, so several can code and review in parallel without touching your checkout - **Orchestrate the big asks**: give the orchestrator a goal, approve the whole-team plan it proposes once and a generated [pipeline](/manual/concepts/pipelines/) runs it - **Review and merge PRs** with a built-in diff viewer, inline comments and AI-powered review - **Hand agents disposable machines**: a [rig](/manual/guides/use-rigs/) is a throwaway VM — a browser, a desktop, a phone, or an enclosed terminal — the agent drives while you watch live and can take over; dev servers inside are forwarded to `localhost`, the agent's browser and `https://myapp.test` - **Automate workflows** with DAG-based pipelines triggered by events or schedules - **Manage knowledge** with workspace-scoped memory, facts and policies - **Track work** with vendor-agnostic tickets, projects and delegation - **Record meetings** with on-device capture, transcription and diarization, then summarized into notes, action items and decisions - **See your day** with Google Calendar sync, RSVP, meeting alerts and record-and-link - **Follow the fleet from your phone**: pair a device with your server and read messages, reply and triage tickets. The phone takes the best path it can reach — loopback, LAN, tailnet, or TLS — and falls back to an end-to-end-sealed relay that forwards frames it cannot read - **Drive agents from Slack**: bridge a workspace to a chat app and mention the bot, reply in a Slack thread, or file a ticket with a slash command — over an outbound connection, so no public endpoint is needed - **Integrate** with GitHub, Linear, Slack, Google Calendar and over 100 MCP tools ## You do not have to install it The same application runs in your browser at [app.usectrl.dev](https://app.usectrl.dev), with the same features. Every client — desktop, web and phone — is a thin renderer over a headless server that owns your data and does the work. The desktop can spawn that server for you; a browser tab cannot, so the web app connects to one you run. That server can sit on your own machine (`ws://localhost:9030`, no certificate needed) or on a remote host reachable over TLS, a VPN, or a tunnel. See [Install](/manual/install/#use-it-in-your-browser) for the browser route and [Deployment and clients](/manual/concepts/deployment/) for how the three clients relate. ## Next steps - [Install](/manual/install/): downloads, system requirements and where the server runs - [Quick start](/manual/quick-start/): the shortest complete path to an agent working on your repo - [Tutorials](/manual/tutorials/): longer lessons, in order, starting with [your first workspace](/manual/tutorials/first-workspace/) - [Guides](/manual/guides/): recipes for one task at a time — [add repos](/manual/guides/add-repos/), [configure guardrails](/manual/guides/configure-guardrails/), [pair your phone](/manual/guides/pair-a-device/) - [Concepts](/manual/concepts/): why the product works the way it does, starting with [deployment and clients](/manual/concepts/deployment/) - [Reference](/manual/reference/): complete tables — [routes](/manual/reference/route-map/), [MCP tools](/manual/reference/mcp-tools/), [keyboard shortcuts](/manual/reference/keyboard-shortcuts/), [domain events](/manual/reference/domain-events/) ### Concepts > How Control Center works and why it is built this way — the background reading behind the guides. Source: https://usectrl.dev/manual/concepts/ These pages explain **how Control Center works and why**. They are for understanding, not for doing: no page here is a procedure to follow. When you want to accomplish a specific task, go to the [how-to guides](/manual/guides/); when you want to look something up, go to the [reference](/manual/reference/). Read them in any order. If you are new, the core model is the place to start. ## Core model The four ideas everything else is built on. - [Workspaces and isolation](/manual/concepts/workspaces/) — the tenant boundary and why it is enforced by the database layout rather than by query discipline - [The agent model](/manual/concepts/agent-model/) — what an agent is made of: identity, role, skills, capabilities, adapter and model - [Agent dispatch lifecycle](/manual/concepts/dispatch-lifecycle/) — what happens between your message and the agent's first token - [Modes](/manual/concepts/modes/) — how `chat`, `plan`, `review` and `orchestrate` change what an agent may do - [Tool context and prompt caching](/manual/concepts/tool-context/) — why an agent sees a subset of its tools, how it reaches the rest and what keeps the provider cache warm ## Safety and control What an agent is allowed to touch and who decides. - [Sandbox and security](/manual/concepts/sandbox-security/) — what actually constrains an agent run today - [Guardrails](/manual/concepts/guardrails/) — one policy engine for every mutating action and how a rule is resolved - [Rigs and enclosures](/manual/concepts/rigs/) — why agents get disposable VMs instead of your machine, and what the boundary actually is ## Directing the work Turning intent into work that gets done. - [Tickets and delegation](/manual/concepts/tickets/) — the vendor-neutral unit of work the whole fleet shares - [Pipelines and automation](/manual/concepts/pipelines/) — DAG workflows, triggers and resumable runs - [Orchestration](/manual/concepts/orchestration/) — one goal, one approval, a generated pipeline - [AI review](/manual/concepts/ai-review/) — how a PR becomes a reviewed one: review spaces, peer consensus, axes and the Ship/Hold/Block verdict - [Memory and knowledge](/manual/concepts/memory-knowledge/) — facts, policies and what an agent is allowed to know - [Evals and replay](/manual/concepts/evals-replay/) — the regression-protection model ## People and reach Working with other humans and reaching the fleet from elsewhere. - [Multiplayer and presence](/manual/concepts/multiplayer/) — humans and agents as co-equal participants and what gates access - [Single sign-on](/manual/concepts/sso/) — SAML and OIDC logins - [Chat bridges](/manual/concepts/chat-bridges/) — why the Slack bridge dials out and what authorizes a message from chat - [Remote control and mobile](/manual/concepts/remote-control/) — how a phone reaches your server and the security model around it - [Meetings and transcription](/manual/concepts/meetings/) — on-device recording, transcription and summarization - [Calendar and scheduling](/manual/concepts/calendar/) — Google Calendar sync and meeting alerts ## Under the hood The shape of the system itself. - [Architecture](/manual/concepts/architecture/) — layering, the shared kernel, ports and adapters - [Deployment and clients](/manual/concepts/deployment/) — how the desktop, web and phone clients relate to one server - [Domain events](/manual/concepts/domain-events/) — the in-process bus features talk over ### The agent model > What an agent actually is in Control Center, which of its fields change behaviour and how the adapter it runs on decides what can be enforced. Source: https://usectrl.dev/manual/concepts/agent-model/ ## What is an agent? An agent is a named actor with a persona, a set of skills, an adapter to run on and a set of capabilities. Agents are principals in the same sense humans are: they author messages, own tickets, leave reviews and appear on the same roster. They are the things that do work. Each agent belongs to exactly one workspace. This is a hard invariant — `agent.workspaceId` is non-null, the agent's own workspace is what picks the database file its rows land in and creating a workspace-less agent is refused. An agent is also a directory. Its definition lives at `agents//AGENTS.md` under the workspace's data directory, with the skills it holds symlinked into `agents//.agents/skills`. The database row and the file are written together and a stale workspace is repaired by re-writing the files on the next launch. ## What an agent carries | Attribute | What it does | | ----------------------- | ------------------------------------------------------------------------------------- | | `name` | Short slug-like identifier (`architect`, `qa`). Also what `@mentions` resolve against | | `title` | Human-readable job title | | `persona` | Free-text personality and behavioural instructions, injected as a prompt section | | `systemPrompt` | Optional full system-prompt override | | `skills` | Case-insensitive set of skill names, mirrored as skill links on disk | | `adapterId` | Which runner the agent dispatches to. **Null falls back to the built-in loop** | | `modelId` | Which model within that adapter | | `effort` | Reasoning level, drawn from the selected model's own vocabulary | | `contextSize` | Context window override | | `capabilities` | Credential and egress flags, checked at launch | | `silenceTimeoutMinutes` | Per-agent override (1–240) for the silence watchdog | | `reportsTo` | Parent agent id — the reporting chain the org chart draws | | `role` | Enumerated specialty. See the caveat below | You edit all of these at Settings → Workspace → Agents. "Add agent" creates an unnamed agent that you then fill in; there is no hiring wizard and no role picker. :::caution[Fields that are stored but change nothing] Several persisted fields round-trip through the database and the wire without any code reading them. Do not plan around them: - **`strictMode`** (the "Strict identity check" toggle in the agent form) is written to the row and to the agent's `AGENTS.md` and is read by no dispatch, prompt, or policy code. - **Visibility** (workspace versus private) round-trips but filters nothing — no roster, no peer discovery — and there is no control that sets it. - **`runtimeProfileId`** is persisted and travels over the wire, but no dispatch code reads it. Runtime profiles do not affect how an agent launches. - **`maxConcurrentTasks`** is not a concurrency limit. Its only reader is the presence roster's workload model, where it is a capacity number. - **Lifecycle status** (`active` / `paused` / `archived`) is written — a budget hard stop flips an agent to `paused` — and it is rendered in the org chart, but no dispatch path consults it. A paused agent still runs. - **Per-agent budgets** (`monthlyBudgetCents`, a linked budget policy) _are_ read, by the post-run budget evaluator that records incidents and does the auto-pause above. But they have no field in the agent form, no MCP tool and no dedicated RPC operation — the only way to set one is a raw `agents.upsert` write — and the incidents they record have no screen either. ::: ## Roles `role` is an enumerated specialty: **CEO**, **coder**, **reviewer**, **QA**, **designer**, **security**, **devops**, **PM**, **general**. Where it is read, it does two things: it parameterizes the persona block in the assembled prompt and it is the key that memory access grants are written against — `propose_policy` refuses when the author's role lacks write permission on the target memory domain. :::caution[Role is not settable from the app] No screen writes `role` and neither the CEO seed nor the specialist seeds set one, so in practice every agent's role is null. The pre-built role templates in the codebase (coder, reviewer, QA, designer and so on, each with a persona and default skills) have no production caller at all — nothing surfaces them. Treat roles as a modelling concept that is only partly wired. ::: ## Skills Skills are case-insensitive names on the agent entity. Two separate things happen with them: - The names are listed in the assembled prompt under a "Skills" heading. That is all the entity's set does by itself — there is no per-skill prompt library keyed off it. - Each named skill's directory (`skills//SKILL.md` in the workspace) is linked into the agent's own folder. On the built-in harness, the linked skills' frontmatter is autoloaded into the system prompt and the agent reads a body on demand; typing `/skill:` in a conversation loads that body as a directive for the turn. Skills fetched from outside pass a fail-closed supply-chain scan between fetch and write — no skill content reaches disk or a prompt without a verdict. The outside is GitHub repositories you register yourself as skill sources: there is no curated store, so the repository's own metadata (its description, its stars, the skill's name) is untrusted display data and the scan verdict is the only safety signal. An install fetches the skill's whole directory, pins it to the commit it came from in `skills-lock.json`, and a later update re-passes the gate before replacing anything. The how-to lives in [install and manage skills](/manual/guides/manage-skills/). ### Repo-scoped skills A repo can ship its own skills, in any of the conventional places (`.agents/skills/`, `.claude/skills/`, `.opencode/skills/`). A space checks every linked repo out side by side under `repos//`, but an agent works in one repo at a time — so it carries **only that repo's** skills, and they swap when it moves. Which repo is active is inferred from the files the agent touches: writing a file under `repos//` makes that the active repo, while merely reading across repos does not, so chasing a symbol into a sibling service never swaps the set out from under a task. On a single-repo space the answer is known before the first turn. The scoping is a context-budget decision. The skill index sits in the prompt on every turn while the bodies do not, so listing every repo's skills at once costs tokens continuously — and a `testing` skill from one service actively misleads when the work is in another. The same reasoning is why an agent's index carries one repo but the composer is not scoped that way: naming a skill yourself is one explicit act, so `/skill::` reaches any repo in the space. Repo skills are loaded for **every** adapter, not just the built-in harness. The active repo's skills are materialized into the agent's working directory, which is the path Claude Code and OpenCode already scan; Codex has no notion of a skill at all, so the same index is composed into the `AGENTS.md` it reads. A repo's skills pass the same fail-closed scan an installed skill does before any of that happens — a checked-out repo is cloned content, and its frontmatter reaches a prompt. ## Capabilities Capabilities gate what a run is _handed_, not what its filesystem lets it touch. The credential broker checks them at launch: if a capability is off, the matching token is simply not injected. - **Push to repo** — gates injection of the GitHub token used for `git push`. - **GitHub API** — gates `GH_TOKEN` injection and egress to api.github.com. - **Ticketing** — gates the ticketing provider's API key and egress to its domains. - **Network egress** — arbitrary outbound requests. A run resolves them from the agent's own row. When the agent has none, a fixed conservative default applies: the three credential flags off, network egress on. The agent form's "Sandbox permissions" toggle chooses between that fallback and a custom set stored on the agent; the values it pre-fills come from a client-local preference, so the label "use workspace default" describes an intention rather than a workspace-scoped server setting. There is no per-conversation capability override in effect today. See [Sandbox and security](/manual/concepts/sandbox-security/) for the other half of the story, including what is and is not enforced at the filesystem level. ## The adapter decides what can be enforced The single most consequential field on an agent is its adapter, because the adapter's transport determines how much of Control Center's policy is actually enforceable. | Transport | Adapters | What Control Center enforces | | ---------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Built-in harness | Control Center (built-in) | Everything. The tool surface is _built_ from the mode, so a disallowed tool is invisible rather than merely denied; every call passes the approval gate and the action guardrails; the completion contract can nudge and then fail a run that ends without its deliverable | | Claude CLI | Claude Code | Only the `mcp__*` tools it is served. Claude's own read, write, edit and shell tools live in its process and run unseen — and it is launched with `--dangerously-skip-permissions`, because non-interactive `claude -p` would otherwise block forever on its own prompt | | Structured CLI | Pi | Prompt only. No flag tells a structured-JSON CLI it is in a read-only mode, so the mode reaches it as text; only its `mcp__*` calls pass a gate | | ACP | OpenCode, Gemini CLI, Goose, Cursor, Codex | The least. Control Center implements no `session/request_permission` handler, so there is no permission negotiation at all — `session/new` carries a working directory, a model and an MCP config path and nothing about mode | Concretely, **only the built-in harness supports subagents, mid-run steering, pause and resume (and therefore take-over) and structural mode enforcement.** The other transports get none of those. :::caution[The OS sandbox is a floor, not a mode enforcer and not every host has one] The matrix above is about what Control Center can _see and gate_. Underneath it, `cc_server` wraps the external CLI and ACP transports in the host's OS sandbox where one exists — Seatbelt on macOS, bubblewrap on Linux and WSL2 — so an unseen `rm` or an unseen write is still bounded by the OS. On **Windows there is no backend at all** and on Linux or WSL2 without `bwrap` and `socat` the probe reports none, so there those runs fall back to environment sanitization, brokered credentials, the guardrails on the calls Control Center can see and the conversation's own copy-on-write worktree. What the sandbox is not is a _gate_. It bounds where a process may read, write and connect — a read-only mode does reach it, as read-only bind mounts — but it cannot intercept a call, raise an approval, or tell you afterwards what the CLI tried. That, plus the four capabilities named just above, is still what the built-in harness has and the others do not. See [Sandbox and security](/manual/concepts/sandbox-security/). ::: An agent with **no** adapter falls back to **Control Center (built-in)** — the loop inside the server, so there is no CLI to install — and its run log records `cc-harness`. Every seeded agent is created without an adapter or a model, so a fresh workspace's five agents all run there, on Anthropic's default model, until you set an adapter and model per agent. The onboarding "default adapter" choice is a client-side preference the server never reads and it does not retro-apply to already-created agents. ## Where agents come from - **Seeded with the workspace.** Every workspace — not only the first — gets a CEO plus four specialists (`qa`, `architect`, `engineer`, `librarian`) reporting to it, created idempotently in the background when the workspace is created. See [Workspaces and isolation](/manual/concepts/workspaces/). - **Added by hand** at Settings → Workspace → Agents. :::caution[Hiring by chat is not wired] `hire_agent`, `fire_agent`, `propose_hire` and `update_agent` do not exist — the tool classes were deleted rather than wired up, so calling one returns "unknown tool". Asking the CEO to hire someone will not create an agent; use the agents UI. Likewise, importing agents by scanning `AGENTS.md` files found on disk exists as a server-side service (`AgentDiscoveryService`) with no RPC operation and no screen behind it. ::: ## Live state An agent has no stored status column. Its live state is derived from its most recent run logs each time it is read: | State | Meaning | | ---------- | -------------------------------------------------------------------- | | `neverRun` | No run log exists | | `running` | At least one run is currently in progress | | `failed` | The latest run errored, or its liveness is failed or dead | | `blocked` | The latest run's liveness is blocked, stalled, or looping | | `idle` | Everything else — the agent has run before and has nothing in flight | Two further states, `queued` and `succeeded`, exist for individual runs in the conversation run tree; the roster never produces them, because a finished run being green is a fact about the run, not about the agent. The presence roster shows a parallel, coarser picture synthesized server-side from live run events — thinking, running, blocked on approval, done — which is what makes agents appear beside humans. See [Multiplayer](/manual/concepts/multiplayer/). ## Related concepts - [Agent dispatch lifecycle](/manual/concepts/dispatch-lifecycle/): what happens when an agent is actually run - [Modes](/manual/concepts/modes/): the per-conversation setting that shapes an agent's tool surface - [Multiplayer](/manual/concepts/multiplayer/): agents as principals, presence and the autonomy dial - [Sandbox and security](/manual/concepts/sandbox-security/): what constrains a run today - [Agent configuration reference](/manual/reference/agent-configuration/): the full field list ### AI review > How a pull request becomes a reviewed one — review spaces, review nodes and peer consensus, cohorts and axes, the Ship/Hold/Block verdict, and why publishing stays a step you take. Source: https://usectrl.dev/manual/concepts/ai-review/ ## The shape of a review An AI review is one run of the seeded `pr_review` pipeline against one open pull request. The pipeline opens the PR's review space, fans out reviewer agents to read the diff in that space's isolated checkout, each files its findings as structured **review nodes**, a lead agent consolidates them into a walkthrough, and a finalizer computes one verdict: **Ship**, **Hold** or **Block**. What the pipeline never does is publish — posting to GitHub is a separate, deliberate step a human takes. The rest of this page explains the pieces and why they are shaped this way. ## One review space per PR A PR's review context is a messaging space — mode `review`, kind `pr` — linked to the pull request by a **review space association**. It is created idempotently: the PR page, the review pipeline and the chat surfaces all resolve the PR's _one_ backing space through the same code path, so a template that reviews a PR and a human who opens it can never end up in two different rooms looking at two different checkouts. The space's isolated worktree has the PR checked out at its head ref. One room also means one checkout: every reviewer works in the same space rather than each getting its own clone of the workspace. Each reviewer posts into its own named stream inside that one space, and findings are `review_node` messages rather than plain prose. The space-wide gather at the end is deliberate — a verdict computed from one thread's findings would silently be a verdict on one reviewer. The association carries a status ladder — `requested` → `in progress` → `awaiting approval` → `completed` — which is what the status bar on the PR's Review tab renders. ## Review levels How deep a review goes is a workspace policy with three levels, set at **Settings → Workspace → Workspace policy → Review level**: | Level | Reviewers that fan out | Reporting | | ----------- | --------------------------------------------- | --------------------------------------------- | | `light` | Engineer only | Only what materially matters | | `balanced` | Engineer, QA, architect (the default) | The standing pass | | `thorough` | All five, including security and performance | Nothing demoted | The level moves two dials at once: which reviewers fan out, enforced by the pipeline template's `runWhen` state gates rather than by prompt wording — prompt wording can be ignored, a gate cannot — and where sub-threshold findings are rendered. Neither dial ever deletes a finding: a level that reports less still files exactly as much and groups the remainder. The security and performance passes reuse the seeded architect and engineer agents rather than introducing new ones. The specialism is the brief the reviewer is dispatched with, not a separate agent per axis — an agent per axis would be five more rows in every workspace for no additional capability. Reviewer selection never inspects the diff for language or domain. ## Review nodes: findings as data, not prose A finding filed with `add_review_node` becomes a **review node** on the PR — which is what the Review tab renders, what the verdict is computed from, and what "Publish to GitHub" turns into an inline comment. A reviewer that only returns prose produces a pipeline run that looks successful and a review surface that is empty; filing is the load-bearing part. Each node carries: | Field | Values | | ----------------- | -------------------------------------------------------------------------- | | Kind | `bug`, `suggestion`, `recommendation`, `question`, `ticket` | | Priority | P0 (critical) → P3 (nit) | | Triage triple | category (`security`, `stability`, …), severity, effort | | Anchor | File path, line start and end | | Fix carriage | Suggested replacement lines, an optional diff, an optional fix prompt | | Confidence | A [0, 1] score | | Reasoning | The analysis, written before the finding | Two details are deliberate. A one-click GitHub suggestion is only offered when the reviewer supplied exact replacement lines — inferring them from a unified diff goes wrong quietly, and a one-click commit of a wrong inference is worse than no button at all. And the reasoning must be written before the finding, so the finding is conditioned on the analysis rather than rationalized after it. A node whose payload is malformed disappears rather than polluting the review. ## Peer consensus A node moves through `open` → `consensus-ready` → `resolved` or `dismissed`. The middle step is the heart of the model: **a finding only graduates when a peer reviewer confirms it, and an agent can never confirm its own finding.** A single reviewer's opinion never reaches GitHub on its own — publishing includes peer-confirmed findings only, by default. The rule is enforced in three independent places — the confirm tool, the finalizer and the publisher — so it survives a buggy caller in any one of them. Status changes all route through one service, which is what makes a status mean something: before that, two half-paths could disagree about whether the same finding was resolved. When reviewers disagree — same file and line, priorities two or more levels apart, or one calling it a `bug` while the other calls it a `suggestion` — the disagreement is surfaced above the findings list. That is where the reviewers' opinions diverge and a human's judgement is worth the most. Dismissing a finding also writes a soft suppression fact into the memory domain, at low confidence — one dismissal is a preference, not a rule. ## Cohorts: what the review is grouped around The review compute pass groups a PR's changed files into **cohorts** derived from the code graph: changed files map to the symbols they touch, symbols to connected components, and the cohorts are ranked by blast radius. A cohort carries a guided reading order, diagrams and insights rather than being a flat file list. Two honesty rules shape this. Cohort keys are content-derived and stable across pushes, so findings and review progress survive a rebase or force-push instead of orphaning. And when the repo has no code index, grouping falls back to plain paths and says so — the fallback never fakes semantic confidence. ## Review axes and gates Alongside the reviewers' findings, a review accumulates **axis results**: six axes, of two kinds. | Axis | Kind | Drawn from | | ----------------------------- | ------------- | ----------------------------------- | | `correctness`, `security` | Token-driven | The reviewers' own findings | | `testGap` | Token-driven | The reviewers' findings | | `performance`, `visual`, `apiContract` | Deterministic | Computed without spending tokens | An axis verdict is `pass`, `warn`, `fail`, `partial` or `unavailable` — and only `pass` or `warn` clears a gate. The distinction between `fail`, `partial` and `unavailable` is load-bearing: a gated axis that could not complete or could not run at all holds the overall verdict rather than passing it. **Absence of evidence never converts to a green gate.** Some axes are advisory on purpose. The test-gap axis derived from cohorts cannot see dependency-injection or factory coverage — an unindexed repo with a thorough suite and one with no tests at all look identical from there, so claiming either would be a lie; it surfaces in the dashboard without gating. The token axes are advisory because the finding priorities already drive the verdict, and re-gating on them would double-count. ## The verdict The finalizer computes the verdict over the findings with status `open` or `consensus-ready`: - any P0 finding with confidence ≥ 0.7 → **Block** - otherwise, any P0 (low-confidence) or any P1 → **Hold** - otherwise → **Ship** The verdict carries an aggregate confidence derived from the spread of the contributing confidences — agreement raises it, divergence lowers it. The lead (CEO) agent's consolidation is an editorial pass, not a re-listing: a hand-written second copy of the findings is exactly what drifts from the real one. Duplicate findings are demoted rather than repeated — several reviewers are pointed at one diff with deliberately overlapping remits, so the same defect reaching the finalizer three times is the system working; publishing it three times is the system leaking its own architecture into the reader's inbox. On a re-review, findings are fingerprinted and diffed against the previous round, so the delta is what surfaces. Finally the axes fold in, and they can only make the verdict **more** severe: a gated failing axis forces Block — it cannot be out-voted by findings — and a gated axis that could not complete forces at least Hold. ## Publishing stays with you The pipeline deliberately does not post to GitHub; publishing is user-gated so a review can never double-publish. When you press **Publish to GitHub**: - One GitHub review is submitted. Findings already resolved or dismissed are skipped, and by default only peer-confirmed findings are included — the precision-first wedge. - Findings anchored to a file and line become inline comments; everything else folds into the summary body, each carrying a "— Control Center AI review" footer so the source is unambiguous. - Anchors are re-verified against the PR's _current_ diff at publish time. A comment on code that has already been rewritten is the most trust-destroying thing a reviewer can leave; if GitHub rejects an inline anchor anyway, the whole review is re-sent with every finding folded into the body so nothing is silently dropped. - A **Block** verdict is submitted as `REQUEST_CHANGES`; anything else as `COMMENT`. The bot never approves on the author's behalf unless you explicitly opt in. - The review arrives under the account of the person who pressed the button. Only agent-triggered auto-publishes ride the server's own identity. Publishing declares the `prPublish` action class, so the guardrail chokepoint gates it like any mutating action — it prompts by default and fails closed when no approver is connected. See [Guardrails](/manual/concepts/guardrails/). ## What a reviewer may do Reviewer agents run in `review` mode: a read-only sandbox and a curated tool allowlist covering findings, commentary, tickets and peer review — but no mutations. A reviewer cannot fix the code it critiques; it can attach a suggested fix that a human applies. The mode's refusal message tells an agent that tries to act anyway to finalize the review and let the user act on the published summary. ## Related concepts - [Guardrails](/manual/concepts/guardrails/) — the policy engine that gates publishing - [Modes](/manual/concepts/modes/) — what `review` mode changes for an agent - [Pipelines and automation](/manual/concepts/pipelines/) — the engine the review runs on - [Agent dispatch lifecycle](/manual/concepts/dispatch-lifecycle/) — where the isolated worktree comes from ## Related guides - [Use AI-powered review](/manual/guides/ai-review/) — run a review and publish it - [Dispatch reviewer agents](/manual/guides/dispatch-reviewers/) — choose the reviewers yourself - [Review Studio](/manual/guides/review-studio/) — cohorts, axes and diffs, and why they have no viewer ### Architecture > How Control Center is structured with a thin-client server model, feature-first Clean Architecture and ports and adapters. Source: https://usectrl.dev/manual/concepts/architecture/ ## Overview 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. ## A thin client over a headless server 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](/manual/concepts/deployment/) for the full client matrix and what runs where. ## Dependency rule ``` 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. ## Feature structure 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//settings/workspace/permissions`. See [Guardrails](/manual/concepts/guardrails/) for the model. ## Shared kernel `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) ## Identity & multiplayer 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 and adapters 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`. ## State management **Riverpod** for all state: - `Notifier` and `AsyncNotifier` for mutable state - `FutureProvider` for async data - Database-backed state returns `AsyncValue>` from Drift `.watch()` streams - MCP tools receive dependencies as typed constructor parameters, never `Ref` ## Database **Drift** (SQLite), split into two databases, both in `cc_persistence`: - **`GlobalDatabase`** (`/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`** (`//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. ## Routing **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/` ## Networking (server-side) 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` ## Security 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 enforcement Architecture constraints are validated by `test/core/architecture_constraints_test.dart`, which fails if dependency rules are violated. ## Related concepts - [Workspaces and isolation](/manual/concepts/workspaces/): how isolation is enforced - [Agent dispatch lifecycle](/manual/concepts/dispatch-lifecycle/): the dispatch flow - [Domain events](/manual/concepts/domain-events/): cross-feature communication - [Deployment and clients](/manual/concepts/deployment/): the thin-client server model and the client matrix ### Authorization — who may do what, and how you prove it > The permission catalog behind Control Center's roles, the tamper-evident audit trail every authorization decision lands in, and the install-wide controls an operator can pin. Source: https://usectrl.dev/manual/concepts/authorization/ Control Center answers two authorization questions, and they are genuinely different. **"May this person call this operation?"** is about humans, roles and repositories. **"May this agent cause this effect?"** is about actions, arguments and blast radius — that one is [Guardrails](/manual/concepts/guardrails/). This page is the first question, plus the record both of them leave behind. ## One decision point Every human-side authorization decision resolves through a single function: ``` can(principal, permission, resource) → allow | deny + why ``` A **permission** is `:` — `tickets:write`, `members:administer`, `repos:read`. The catalog is **derived**, not hand-written: it is computed from the operation surface itself (the op's name prefix gives the domain, the role floor it already enforces gives the tier), so all ~550 operations carry a permission without anyone typing one. That derivation is the property worth protecting. It means a NEW operation is gated by default — the failure mode of a hand-annotated model is that somebody forgets, and the thing they forget is the gate. A ratchet test pins the resulting domain set, so adding an operation under a new prefix is a naming decision a human makes rather than an accident. The four tiers map exactly onto the built-in roles: | Tier | Role floor | Example | |---|---|---| | `read` | guest | `tickets:read` | | `write` | member | `tickets:write` | | `administer` | admin | `members:administer` | | `own` | owner | `workspace:own` | ## Roles Five built-in roles — **owner, admin, member, viewer, guest** — are presets over that catalog. Ownership is a single seat per workspace, handed over explicitly with **Transfer ownership** (an owner who leaves is otherwise unremovable: SCIM deprovisioning refuses to delete the last owner). **Custom roles are subtractive.** A custom role names a base preset and a set of permissions REMOVED from it — "admin, but cannot manage members". It can never grant more than its base, which is what makes them safe to introduce into an existing install: every existing check remains a sound upper bound. Members hold them as `custom:`, and a client too old to understand that value falls back to the lowest privilege rather than guessing. Underneath roles sit **per-repo grants** (`none` / `read` / `review` / `write`): workspace membership never silently out-privileges the forge. Every surface that exposes code checks them — including the ones that reach code sideways, like a terminal or an editor session opened on a space's worktree. ## Server authority Some operations are not about a workspace at all: install-wide settings, SSO configuration, provider credentials, model management, whole-install backups. Those declare **server authority** and are refused for everyone except the install's operator (the recorded server owner). There is deliberately one operator identity and one definition of it. ## The audit trail Every authorization decision — **allow and deny** — is appended to a per-workspace, hash-chained audit trail. A deny-only log answers "what did we block?" but not "what did this agent actually do, and who authorized it?", which is the question an incident review starts from. So each entry records the full attribution chain: the acting principal, the human it acted on behalf of, the delegation it arrived through, the action and its arguments (as a digest), the decision, and **the rule that decided**. Entries are chained: each one's hash covers the previous one's, so editing, deleting or reordering any row breaks every hash after it. **Verify chain** re-derives the whole thing and names the first link that does not hold. An audit log you cannot check is a log; one you can re-derive is evidence. Two consequences worth knowing: - The audit table is **exempt from age-based pruning**. A hash chain cannot be trimmed from the middle. Retention is export-then-truncate, and the truncation leaves a checkpoint carrying the removed segment's terminal hash, so what survives still verifies back to the beginning. - It sits **beside** the activity timeline, not on top of it. The timeline is what people did; this is what was authorized and refused. A shared correlation id reads one call across both. It exports as newline-delimited JSON and can stream to a SIEM (Splunk, Elastic, Sentinel — an HTTP endpoint with a bearer token). Streaming is best-effort by design: a SIEM being down must never block an agent's tool call, and the durable, verifiable copy is on disk either way. ## Sessions An install can set a **maximum session age** and an **idle timeout**. Both are enforced at the device-credential check every authenticated lane funnels through — a session bound a client could ignore answers a questionnaire, not an attacker. An install can also require SSO, which disables manual pairing for new devices (with a lockout guard: it refuses while no working SSO connection exists). ## What this is not - **Not per-object ACLs.** There is no per-ticket or per-message permission, deliberately. Repository-level separation is what teams actually ask for and it exists; per-object sharing is a combinatorial surface with no demonstrated demand here. - **Not a policy engine you configure in a DSL.** The vocabulary is closed on purpose, so the whole policy can be rendered, explained and enumerated. - **Not enforced anywhere but the server.** Client-side checks exist only to avoid offering a control that will be refused. The authoritative decision is always the server's. ### Calendar and scheduling > How Control Center syncs Google Calendar on the server, why sign-in is a device-code grant and what the read-only scope means for RSVP. Source: https://usectrl.dev/manual/concepts/calendar/ Control Center connects to Google Calendar so your day sits alongside your fleet. It syncs your events, shows them in month, week, day and agenda views, warns you before a meeting starts and turns any event into a recorded, summarized [meeting](/manual/concepts/meetings/). ## What it changes on Google Almost nothing. The integration pulls events in and renders them; it never creates, edits, or deletes calendar entries. The one write it can make is your own attendance response and even that is constrained by the scope the connect flow requests. Events are synced into the workspace's own database **on the server** (`//workspace.db`) and rendered by every client over RPC. No client holds a Google credential. :::caution[RSVP fails on accounts connected through the built-in flow] The RSVP path is wired end to end — the control appears on events you were invited to and the server PATCHes the response on its own token. But the shipped device-code OAuth scope is `calendar.readonly` and writing an RSVP needs `calendar.events`. Google answers **403**. Until you connect through your own Google client that grants the write scope, treat RSVP as unavailable. ::: ## Per-workspace accounts Calendar accounts are workspace-scoped, like everything else in Control Center. Each [workspace](/manual/concepts/workspaces/) connects its own Google account (or several) and one workspace's events and tokens are never visible from another. Switching workspaces switches which calendars you see. A workspace can connect **multiple** Google accounts, such as a work account and a personal one and their events are merged into the same views. ## Why sign-in is a device code The headless `cc_server` has no browser to catch an OAuth redirect. So it does not use one: it authorizes with the **device-code grant** (RFC 8628). The server shows a short user code and a Google URL, you approve on any device that has a browser and the server polls until you do. A headless install can connect an account precisely because there is no redirect endpoint to host. The consequence to know before you bring your own credentials: the client type must be Google's "TV and Limited Input devices". Google only grants an allow-listed set of scopes to that client type and a project where Calendar is not permitted for it fails with `invalid_scope` — you would need a loopback or web client instead. Tokens never leave the server. They are written to the server's `secrets.json` in its data directory, keyed `google_google::`, alongside the databases and under the same host-filesystem trust boundary. The server refreshes access automatically and, when Google permanently invalidates an account (a revoked or long-expired session), publishes `CalendarAuthExpired` and surfaces a **reconnect** banner instead of failing silently. A build from source ships **no** Google credentials at all — you supply your own client id and secret. Official release builds bake in a non-confidential device-code client so the connect flow works out of the box; the server stores only a marker referencing it, never a copy and a `--google-client-id` you supply always takes precedence. For running your own Google project, see [Connect a Google Calendar](/manual/guides/connect-calendar/). ## Sync The server pulls a rolling window of each connected account's events — every calendar on the account, not just the primary — **every two minutes**, plus on demand (start, connect, manual refresh). The short cadence is affordable because the sweep is incremental: each calendar carries its own sync token, so an unchanged calendar costs one near-empty response rather than re-fetching a five-month window. Recurring events are expanded server-side, so no client ever parses a recurrence rule. Navigating to a month outside the current window lazily loads that range. Deleted or moved-out-of-range events are reconciled away on the next sync. ## Views | View | What it shows | | ---------- | ----------------------------------------------------- | | **Month** | A full month grid (powered by the `kalender` package) | | **Week** | A week's timed events in a time-grid | | **Day** | A single day's timed events in a time-grid | | **Agenda** | A scrollable list of upcoming events | Open the calendar at `/calendar`; selecting an event opens its detail at `/calendar/:eventId`. The view you pick is stored in that client's own preferences, so it does not follow you to another device. ## Meeting alerts A per-minute scheduler scans for events starting within a configurable lead window and fires a **"meeting starting soon"** notification (a `MeetingStartingSoon` [domain event](/manual/concepts/domain-events/)). The notification carries the join URL when one is present and clicking it opens the event detail. Alerts are de-duplicated against the event's `alertedAt`, so the same meeting never alerts twice, even across restarts and an event whose start passed while the app was closed never replays. Two limits are easy to trip over. The scheduler runs **client-side on the desktop** and only for the workspace you currently have open — a workspace you are not in raises no alert. And the web build has no OS-notification pipeline at all, so it raises none either. ## Record and link From a calendar event you can start a [meeting recording](/manual/concepts/meetings/) seeded with the event's title. Control Center stores a one-to-one link between the recording and the event, so the meeting's notes and the calendar entry stay connected without either feature depending on the other. **Start recording & link** is desktop-only. On the web client the action reports that recording is unavailable — even though the meetings screen's own record flow does work in the browser. :::note[Agents cannot read your calendar] There are no calendar MCP tools. An agent cannot list events, read an event's details, or RSVP on your behalf; the `calendar.*` surface is RPC for human clients only. ::: ## See also - [Meetings and transcription](/manual/concepts/meetings/): what happens after you start a recording - [Workspaces and isolation](/manual/concepts/workspaces/): why calendar accounts are per-workspace - [Domain events](/manual/concepts/domain-events/): `MeetingStartingSoon` and `CalendarAuthExpired` - [Connect a Google Calendar](/manual/guides/connect-calendar/) - [Record and summarize a meeting](/manual/guides/record-meeting/) ### Chat bridges > Why the Slack integration dials out instead of listening, what actually authorizes a message that arrives from chat and how one bridge serves any chat product. Source: https://usectrl.dev/manual/concepts/chat-bridges/ A chat bridge makes an external chat product — Slack today — a first-class way into a Control Center workspace. Mention the bot and an agent wakes; reply in the Slack thread and the conversation continues; type a slash command and a ticket is filed. Underneath, the same spaces, transcripts, tickets and attribution the desktop app uses. Chat is a _surface_ on the workspace, not a copy of it. **Slack is the only provider that ships.** There is no Discord, Telegram, Teams, or Matrix implementation anywhere in the product. The feature is written provider-blind on purpose — adding a second product is one enum value, one adapter and one plugin — but today "chat" means Slack. Two surfaces cover it and they belong to different people. Connecting a workspace to Slack and customizing the bot are **admin** actions under Settings → Workspace → General. Linking your own chat account to your Control Center identity is a **member** action under Settings → You → Profile & identity. Three decisions shape the whole feature: **which direction the connection runs**, **what authorizes a message that arrives from outside** and **where the product-specific knowledge is allowed to live**. ## Outbound by design The bridge never listens for the provider. Slack is reached over **Socket Mode**: the server asks Slack for a socket URL and dials out, then receives mentions, DMs and slash commands over that connection. The consequence is the point. A laptop behind NAT, a home server with no domain, a machine that moves between networks — all of them can run the bridge with no inbound port, no tunnel, no TLS certificate and no public URL to rotate when it changes. There is no webhook endpoint to secure because there is no webhook endpoint. When Control Center composes the app definition it asserts Socket Mode and strips any request URL, because an app configured for both would be an app that half-works. The cost is a connection to keep alive, which is the server's job: reconnection is exponential backoff with jitter capped at 30 seconds, a provider-initiated socket rotation reconnects without escalating that backoff and a _rejected credential_ is terminal — retrying a revoked token forever would only earn a rate limit, so the bridge stops and waits for new tokens while reporting the provider's own error verbatim in settings. A workspace with no chat app costs nothing at all: no socket, no timers, no traffic. And the whole thing starts _after_ the server's ready banner, because a chat app that is slow to answer must never delay boot. ## Membership authorizes, not token possession The person who installed the app is not the person sending the message. So every inbound event resolves to a **Principal** before anything happens and it fails closed with an explanation: - **An unlinked chat account** is told how to link, in the Slack thread it wrote in. - **A linked account whose user is not a member** of this workspace is told to ask an admin for an invite. - **A member whose role is read-only** (`viewer`, `guest`) is told their role cannot start work. None of those proceed. A chat message is never attributed to somebody who did not send it — which is what keeps the audit trail, the per-principal rate limits and ticket reporters honest. See [Multiplayer — identity, membership and presence](/manual/concepts/multiplayer/) for the membership model this leans on. Linking is identity resolution, **not** an access grant. Membership is re-checked on every event, so a link belonging to someone who has since been removed resolves to a refusal rather than to their old privileges. Two paths establish a link: a short-lived single-use code the member types in chat, or an automatic match on the provider's _verified_ email — and the email path still requires that the matched user already be a member, so a stranger whose address happens to exist on the server links to nothing. The codes are deliberately kept in memory, with a 15-minute time to live. Persisting one would create a durable credential-shaped row for something meant to evaporate and a server restart invalidating every outstanding code is the correct behavior, not a regression — the member presses "link" again. A refusal is answered **in the Slack thread the message opened**, as a real reply. It used to ride the provider's _ephemeral_ channel — quieter, but an ephemeral message anchored to a thread nobody has opened raises no unread, so a mention the bridge could not serve was indistinguishable from a bot that was down. A threaded reply badges the message it answers, reaches the one person who needs it and still keeps "you are not linked" out of the channel itself. Posting a message is also the one capability every provider has, so the explanation never depends on a feature the app may not carry. ## Provenance and the echo problem A bridge that mirrors both ways will loop unless something tells the two directions apart. Every message the bridge brings in is stamped with `metadata['chat']` — provider, team, conversation, thread, message and sender. The outbound mirror skips any message carrying that stamp. That single rule is what makes bidirectional mirroring safe: a human message typed in Control Center is relayed out to the linked thread (prefixed with the author's name), while a message that came _from_ chat stops where it entered. What crosses outward is deliberately narrow. The agent's **answer** is relayed as ordinary chat text. A short thought can appear on the `Thinking…` row of the card. Tool output is a wall of diffs and stays in Control Center, which is also where a truncated reply points. ## A card reports the work; the text is still the message A relayed answer used to appear out of nowhere: nothing said the agent had started and a long turn looked like a bot that had stopped listening. So where the provider renders them, a turn also carries **one task card**, edited in place for as long as the turn runs and sitting above the answer it explains. The card is one grouped plan, not a mashed paragraph. Slack concatenates `details` and `output` on the same task, so a row's details are sent once (the thought on `Thinking…`) and `output` is never streamed. Setup is one row whose title is replaced (`Working on it…`, `Cloning …`). Reasoning adds a `Thinking…` row. Each tool is its own row (`Read lib/main.dart`). The answer is ordinary text under the card — Slack's plan view reads it from a later markdown append, not from a chunk mixed into the tasks. Nothing else: no tool output and only a tool argument from a short allow-list, because an unknown tool's arguments can be an entire file body and a row is one line. The card also covers the wait _before_ the turn. A first mention creates the conversation and creating it clones every repo the agent will work in — which on a real repository is minutes where the agent has not been asked anything yet and the thread looks like the bot ignored the request. So the card opens the moment the mention is accepted (`Working on it…`) and then that setup row updates in the same words the desktop shows — `Cloning acme/widgets…`, `Setting up Reviewer…`, `Starting the agent…` — then a `Thinking…` row as soon as the agent starts reasoning, then a row per tool. One card, one message, from "I heard you" through to the answer as ordinary text beneath. Setup is only reported where somebody in chat is waiting for it: a conversation being prepared in the app, or a stranded one resumed at server boot, provisions with no card at all. It carries the only thing chat cannot give the reader: a **View in Control Center** link to the conversation behind the reply. Chat products accept `http(s)` in a link and nothing else, so the button points at a small page this server serves, which bounces the browser to the desktop app's `control-center://` link. That page reads nothing and resolves nothing — the ids in its URL are echoed into a deep link and nowhere else — and a server with no reachable public URL simply produces a card with no button rather than a broken one. Cards are a capability like any other. A provider without them behaves exactly as before, text only and a provider that refuses one at call time keeps its stream: the reply always outranks the card that describes it. When streaming is off entirely, the finished card rides the single posted message instead. A filed ticket gets the same treatment — the `/cc ticket` confirmation is a completed card whose reason to exist is the link to the ticket just created. ## Capabilities are negotiated, then remembered Chat products differ in what they can render, so the bridge is written against the richest surface — a reply that grows live inside a thread, a transient "is thinking…" status, a titled thread, an ephemeral answer to a slash command — and degrades one capability at a time. A provider with none of it still works; replies simply arrive as one message. Advertising a capability is not promising it. Slack's streaming replies need a paid plan feature and Slack only refuses at call time. A refusal marked permanent is remembered for **the whole life of the connection**: the bridge stops asking and posts whole replies from then on and settings says so plainly, because "my replies stopped streaming" should be answerable without reading a log. Task cards are dropped the same way if Slack refuses one. Every limit belongs to the provider rather than to the relay: the message ceiling, the stream-chunk ceiling and whether a stream needs a thread anchor are all declared by the adapter and a long reply is split on a paragraph, line, or word boundary rather than mid-word. ## One bridge, one adapter per product Everything product-shaped lives behind a port. The generic core knows about markdown, chat conversations, chat threads and members; it knows nothing about Slack envelopes, `mrkdwn`, or gateway intents. Three pieces make that work: - **The adapter** normalizes one product: its transport, its API calls, its text codec and its envelope quirks. Slack's are instructive — a channel mention arrives twice (as `app_mention` _and_ as `message`) and most message subtypes are chrome rather than new requests. Both are resolved in the adapter, so the core never learns that Slack double-delivers. - **The descriptor** is the provider's public shape: which credentials it needs, what their formats are, which URLs help a user find them, what it can do and whether its app can be created or edited from here. The settings card, the connect dialog and argument validation are all _generated_ from it — the client posts back a provider plus a map keyed by field id. - **The plugin** is the server-side composition: how credentials become a verified connection, how an adapter is built and how the provider-side app is managed. So adding a product is one enum value, one adapter and one plugin. No new RPC op, no schema change, no client change — and the client stays honest, because a field it has never heard of still renders with the label the server supplied. Two details keep the abstraction from leaking upward. The app-management steps a provider has no API for (generate a token, install the app, reinstall after a permission change) are returned as **named steps with their own links**, so the finish-setup screen shows them without knowing which provider it is. And every instruction the bridge writes names the provider and the command the app actually uses, so a renamed command or a second provider reads correctly with no new string. :::caution[A permission change is inert until you reinstall] Editing the bot's name, description, or icon takes effect immediately. Editing its **permissions** does not: Slack applies the new manifest but keeps the _grant_ at whatever was installed, so the bridge keeps running on the old scopes indefinitely. The edit returns a remaining step naming the reinstall, with a link straight to Slack's "Reinstall to Workspace" page. Nothing changes until somebody follows it. ::: ## Where credentials live A workspace's chat tokens sit in that workspace's **own directory, beside its database** — as plain JSON at `//chat_credentials/.json`, not in the database and deliberately not in the OS keychain. Two consequences are easy to lose later: deleting a workspace unlinks the directory and its tokens go with it and exporting or backing up a workspace copies `workspace.db`, so a workspace handed to somebody else does not carry live bot tokens inside it. The file inherits the server host's filesystem trust boundary — anything with read access to the data directory can read a bot token, which is the same boundary the databases sit behind. One file per provider, written atomically and owner-only: a revoked app is deleted by unlinking one file and a corrupt file costs one provider rather than all of them. Credentials travel _in_ over the connect operation and never come back out — status reads answer with connection metadata only. ## What is deliberately not offered - **Structural app settings.** The transport, scopes and event subscriptions are asserted, not exposed. An app that can be configured into silence from a settings screen is a support ticket, not a feature. - **Interactive components.** Buttons and modals are not wired; the surface is conversation plus slash commands. - **A second source of truth.** Chat is not a place where work is stored. The Slack thread is a space, the reply is a transcript, the ticket is a ticket — all of them in the workspace, reachable from every client. ## See also - [Multiplayer — identity, membership and presence](/manual/concepts/multiplayer/): the principals and roles the bridge resolves through - [Workspaces and isolation](/manual/concepts/workspaces/): why links and credentials are per workspace - [The agent model](/manual/concepts/agent-model/): what wakes when a mention arrives - [Tickets and delegation](/manual/concepts/tickets/): what `/cc ticket` files - [Architecture](/manual/concepts/architecture/): where the bridge sits between server and clients - [Bridge Slack to your workspace](/manual/tutorials/first-chat-bridge/) - [Set up Slack integration](/manual/guides/slack-integration/) - [Link your Slack account](/manual/guides/link-chat-account/) - [Customize the chat bot](/manual/guides/customize-chat-bot/) - [PR conversations](/manual/concepts/pr-conversations/): the same three decisions, made for a forge ### Code intelligence > Why an agent needs the compiler, a structural matcher and a debugger — and what each one answers that the others cannot. Source: https://usectrl.dev/manual/concepts/code-intelligence/ Control Center gives an agent three ways to understand code beyond reading it. They answer genuinely different questions, and the reason there are three rather than one is that no single mechanism answers all of them. | Mechanism | Answers | Costs | | -------------------- | ------------------------------------------------ | -------------------------------------- | | **Code graph** | What is here, and what depends on it | An index pass; cheap to query | | **Language server** | Does this actually compile, and what is this type | A server process per project | | **Structural search**| Where does this SHAPE appear | A parse per file | | **Debugger** | What was true at this moment, in this frame | A stopped process | ## The gap the language server closes The [code graph](/manual/concepts/tool-context/) is built from tree-sitter: symbols, call edges, an impact radius. It is fast, it is cheap, and it knows nothing about **types**. It cannot tell you whether the call you just wrote compiles, whether that field exists on the object, or whether your rename missed a re-export. Without a language server, an agent finds out at test time — or never. It writes something plausible, moves on, and the failure surfaces three turns later attached to a different change. So the payoff is not really the `lsp` tool. It is that `write`, `edit` and `apply_patch` are wrapped: after every write, the file goes through the language server and **newly introduced** diagnostics are folded into the tool result. The agent learns it broke the build at the moment it broke it, whether or not it thought to ask. ### Only new diagnostics Repeating what the agent has already been told is worse than saying nothing: it fills the result with noise the agent learns to skim past, and the one new error goes with it. A per-run ledger tracks what has been reported, keyed on severity, code and message — **not** position, so a diagnostic that merely moved down four lines is not re-reported as new. ### Lazy, and honest about being absent No server starts at boot. One starts on the first request that needs it, which is almost always the first edit to a file it claims, and idle ones are swept. A host that never touches Dart never starts an analyzer. Detection is an **intersection**: a root marker must exist in the checkout *and* the binary must resolve. A marker alone says "this project is Python" and nothing about whether the server is installed; a resolvable binary alone says the host has a toolchain and nothing about this checkout. Only both together mean a query can succeed, and a tool that is offered and always fails is worse than one that is absent. ## Why structural search, when grep exists `grep` matches characters. That is the right tool for a name and the wrong one for a shape. A structural pattern is parsed by the **same grammar as the file**, so `dispose($X)` finds the call however it is spaced, wrapped or line-broken, and never matches the same characters inside a string literal or a comment. The property that makes it more than a regex with extra steps is that a metavariable repeated in one pattern must capture the same text: ``` if ($X != null) $X.dispose() ``` matches a guard that disposes what it tested, and does not match one that disposes something else. That is a bug class you cannot express textually at all. ### It runs on the tree-sitter already linked There is no second engine. `cc_natives` already loads tree-sitter and five grammars for the code graph, with a compiled-query cache and a language table. The matcher is pure Dart over trees that were being parsed anyway. A second structural engine would mean a second parser, a second grammar set to build and stage on every platform, and two answers to "what is a call expression". ### A fragment needs somewhere to live A grammar only defines what a whole *file* is. Parsed bare at Dart's top level, `dispose(x)` is a perfectly valid **function signature** — a declaration named `dispose` taking a parameter `x` — with no error anywhere to give it away. A matcher built on that parse looks for declarations, finds no calls, and reports "no matches" for a pattern that was never being read the way it looks. So a pattern is tried in a ladder of scaffolds — statement, expression, class member, top level — and the first that accepts it wins. A fragment no context accepts is reported as unparseable rather than searched for and quietly not found. ## Why a rewrite stages instead of writing A structural rewrite is the widest edit an agent can make: one pattern, forty files, and nobody reads the result line by line — not the model, and not the person who asked for it. Writing it and reporting "done" means the first honest look at the change is a `git diff` afterwards. So `ast_edit` reports what it *would* do — the count, the files, the matched sites — and the change lands only when `resolve` commits it. That is also what lets a diff and an Accept/Discard sit in front of a human before anything reaches disk. The commit is all-or-nothing and every file is checked against the content captured at staging time. Between staging and committing, the agent may have hand-edited one of those files, a diagnostics pass may have rewritten it, or a formatter may have touched it — committing anyway would discard that work and the diff would look intentional. Any mismatch refuses the whole change, because a partly-applied structural rewrite leaves a tree that compiles under neither shape. ## What a debugger answers that a print statement cannot The print-statement loop is: add a print, run the whole thing again, read the output, delete the print. It costs a full test run per question, and it only answers the question you thought to ask *before* running. A stopped frame answers every question about that moment at once — every local, every caller, and an expression evaluated in the frame's own scope. For a wedged process or a failure that reproduces once in ten runs, that is the difference between an afternoon and a minute. ### Bounded like an enclosure, and for the same reason A debug adapter owns a **stopped process** holding whatever that process holds — a port, a lock, a database connection. A session nobody is driving is a leak that outlives the turn that started it. So there is a hard TTL, exactly one session per conversation, and a second `launch` is refused rather than silently replacing the first: an agent that starts a second without ending the first has almost certainly lost track of the first. ### One transport, two protocols The Debug Adapter Protocol shares the Language Server Protocol's `Content-Length` framing and nothing above it — different correlation keys, a different failure shape, and a third message type (events) with no LSP equivalent. The framing lives in one place; the protocols sit on it separately. Writing the framing twice would mean two answers to "a message spans two chunks", "a chunk holds three messages" and "the server wrote a banner to stdout before its first frame", and only one of them would get the fix. ## Related - [Search code with the code graph](/manual/guides/code-search/) — the symbol and impact side - [Debug a failing test](/manual/guides/debug-a-failing-test/) — the debugger, end to end - [Refactor structurally](/manual/guides/structural-refactor/) — `ast_grep` and `ast_edit` - [Built-in agent tools](/manual/reference/agent-tools/) — every operation and argument ### Conversation history > How a run keeps, folds, branches and resumes its context — compaction strategies, the session tree, and what "resume" actually means. Source: https://usectrl.dev/manual/concepts/conversation-history/ A long conversation runs into a hard wall: the model's context window. Everything on this page exists because of that wall, and the interesting part is what gets thrown away, what gets kept, and what can be got back. ## Four ways to reclaim context They are not alternatives so much as a ladder, cheapest first. | Mechanism | Cost | Keeps | Loses | | -------------- | -------------- | ------------------------------------ | --------------------------------- | | **Elision** | Nothing | Every word of the narrative | Uneventful older tool results | | **Image shedding** | Nothing | The text around them | Old screenshots | | **Snapshots** | Nothing | Every word, as pixels | Legibility to a non-vision model | | **Summary** | One model call | A narrative | The details it did not pick | Elision and shedding run first because they are free and lossless in the way that matters: they drop bulk nobody was going to re-read. ### Why summarizing is the last resort A summary is the only one of the four that **spends a model call** and the only one that **decides for you** what mattered. Both are real costs. The call is the more interesting one, because of when it happens. Compaction usually runs *ahead* of the wall, on a threshold. But it also runs **reactively**, after the provider itself rejects a request as too large — the heuristic estimate under-counted what the provider actually measured. In that situation a summarizing compactor has to make a request to shrink the context, and the request it makes is the one that just overflowed. ### Snapshots Snapshot compaction renders discarded history onto dense pages of pixel glyphs and hands them back as images. The premise is a billing quirk: a vision model charges for an image at a flat or area-proportional rate that has nothing to do with how many characters are in it. Twelve thousand characters of discarded conversation cost twelve thousand characters' worth of text tokens. The same characters rendered onto one page cost one image. What makes it worth having rather than merely clever is that it needs **no model call, no key and no network**. It is the one strategy that is always available during overflow recovery. The reconstruction shape is verbatim head, imaged middle, verbatim tail. The head is the task and the constraints that everything downstream refers back to; the tail is where the work currently is. The middle is what gets re-read least and costs most. :::note[It degrades honestly] A model with no vision cannot read pages of pixels, and rendering text nothing can look at is not a degraded compaction — it is a deleted conversation. So the reader's capability decides, and a non-vision model always gets the summary. ::: :::caution[Page shape is priced for the API, not the weights] Gemini bills a flat rate per image, so every extra character on the page is free and its pages are the widest. OpenAI and Anthropic bill by area, so a bigger page buys nothing. Some processors downscale past a threshold, which silently destroys 5-pixel glyphs, so their pages stay under it. A Claude served through a gateway is billed by the gateway, so the shape follows the model id as the route names it. ::: Snapshots are re-rendered from **retained source text** on each pass, never from the previous pass's pixels. Imaging an image degrades the glyphs one generation per compaction until nothing can read them. ## What a run actually resumes from Historically every harness run started from an empty history and rebuilt continuity from a `` block in the prompt — a *summary* of the conversation rather than the conversation. Three things followed from that: - **Resume was a re-tell.** The model never saw its own earlier reasoning or the actual bytes its tools returned, only a description of them. - **Rewind died with the process.** A `checkpoint` is an index into the live message list, so a restart lost every label the model had set. - **Retry could not tell "nothing to retry" from "nothing loaded".** After a restart the failed turn is gone from live state either way. All three are fixed by persisting the transcript. It is saved at every **turn boundary** and never mid-turn: a history captured between a tool call and its result is one no provider will accept, so a crash at that instant restores the turn *before* rather than a half-turn that cannot be replayed. A resumed run is trimmed to its newest messages, cutting at a boundary that is not an orphaned tool result — a result whose call was trimmed away is a request providers reject outright, and a transcript that cannot be sent is worse than a short one. :::note[Where it lives] One JSON file per conversation-and-agent, under the workspace's own directory beside its database. Two agents in one conversation hold two histories: they saw different tool results and were given different system prompts, so merging them would hand each the other's reasoning as its own. ::: ## The session tree Every message records the one it continues from, and every conversation names its current **branch tip**. That one decision is the whole feature. **Branching writes nothing.** It moves the pointer. The path you left is still there, so switching back is another pointer move rather than a restore — and editing a prompt to re-run it stops costing you the answer you were comparing against. Without it, every form of going back has to hide or delete messages, and hiding is a lie the next reader cannot see through. ### Branch versus fork | | Branch | Fork | | -------------- | ------------------- | --------------------------- | | What happens | The pointer moves | The path is copied | | Where it goes | Same conversation | A new conversation | | Cost | Nothing | One copy of the branch | A fork copies with **fresh ids** rather than sharing rows. Two conversations sharing rows would show each other's later messages, which is the one thing a fork must not do. ### Rewind rides the same pointer The in-run `checkpoint` / `rewind` pair marks a clean turn boundary and returns to it after an exploration, keeping a digest. Because checkpoints are persisted alongside the transcript, a label the model set before a restart still lands where it was set. A checkpoint whose target did not survive a trim is **dropped**, not clamped — rewinding "to the label" and landing somewhere else is exactly the failure a label exists to prevent. ## Related - [Branch a conversation](/manual/guides/branch-a-conversation/) — the tree navigator, forking and export - [Tool context and prompt caching](/manual/concepts/tool-context/) — the other half of what fills a context window - [Slash commands](/manual/reference/slash-commands/) — `/compact`, `/shake`, `/context`, `/tree` ### Deployment and clients > How Control Center's thin-client architecture splits a headless server from its desktop, web and mobile clients. Source: https://usectrl.dev/manual/concepts/deployment/ Control Center is not one program. It is a **thin-client architecture**: a headless server that owns your data and a set of clients that render an interface over a single RPC connection to it. Understanding this split explains why some things run where they do and what each client can and cannot reach. ## The server owns the data A process called **`cc_server`** holds the databases — a small `global.db` for server-wide state plus one SQLite file per [workspace](/manual/concepts/workspaces/), opened lazily — and runs the long-lived work: pipeline engines, reconcilers, the MCP tool surface and the orchestration listener. Everything that needs to outlive a window, or that two clients should see identically, lives there. No client opens a database file itself. Instead, every client holds one **`RemoteRpcClient`** and issues repo operations and subscriptions over it. The server authenticates the connection against a paired-device pre-shared key — and then stops caring about the connection. **A session is not bound to a workspace.** The dispatcher is stateless: every workspace-scoped call carries its own `workspace_id` in its arguments and a call that omits one is refused. That is deliberate and it is what lets two clients on one server sit in different workspaces without a server-held "current workspace" for them to disagree about. Isolation is enforced per call instead, in four steps: 1. The op declares whether it is workspace-scoped; if it is and no `workspace_id` arrived, the call is rejected. 2. The named id must be a **registered** workspace, checked before anything opens a database — otherwise an unknown id would materialize an empty ghost `workspace.db` on every request. A miss is a not-found. 3. The caller's `WorkspaceRole` must meet the op's floor, derived from its kind: read needs guest, mutate needs member, destructive needs admin. 4. An op that exposes repo content additionally requires a per-repo grant at the level it declares — `none` / `read` / `write` — so workspace membership never silently out-privileges the forge. The sentence that matters: **holding a pairing key is not the access boundary; membership is.** ## Why split it this way Three pressures pushed toward a server: 1. **One source of truth.** A desktop and a phone, or a laptop and a desktop, should see the same fleet state. If each owned its own database, they would drift. A single server means every client reads the same live data. 2. **The web cannot self-serve.** A browser cannot spawn a subprocess or open a local file, so a web build can never own the database or run an agent. It must talk to a server that does. Rather than build a second, web-only data path, the desktop was brought onto the same path. 3. **Headless operation.** A server you can run on a small box — reachable from a phone on the train or a browser anywhere — is more useful than a GUI you must leave open. The trade-off is that the server is now a dependency: if it is not running, nothing works. The desktop's default answer to that is to **spawn one itself**. ## The clients Five clients reach the server, each with a different shape and trust profile. ### Desktop app The Flutter desktop application (macOS, Windows, Linux) is the full client. On first launch it asks how it should run, then remembers the choice: - **Local**: spawn and supervise a `cc_server` on this machine — the default, self-contained, single-user setup. The desktop launches the server, which owns the database under the app-support root and talks to it over loopback. - **Remote**: connect to a `cc_server` running elsewhere over a secure WebSocket. The data lives on that server; this desktop is purely a renderer. In both cases the resulting RPC client overrides the app's data provider, so the entire UI — every screen, every feature — reads and writes through the server. The desktop never touches the database file directly. ### Web client The web build is the same Flutter app compiled to run in a browser, hosted at [app.usectrl.dev](https://app.usectrl.dev) so there is nothing to install. A browser cannot spawn a subprocess, so the web client is **always** remote: it dials a `cc_server` over `wss://` and renders the full desktop interface. Once connected it shows the same screens against the same fleet, because it is the same code over the same RPC. The gaps are the handful of places that need a native capability a browser does not have — the meeting capture quality layer below is the main one and OS notifications are another. This is why "no download" and "no server" are different claims. The web client removes the install; it does not remove the server. What it dials can be a `cc_server` on the same laptop — `ws://localhost:9030`, which needs no certificate because browsers treat loopback as trustworthy — or one on another machine, which must be reachable over `wss://` with a certificate the browser trusts. `cc_server` refuses to bind a non-loopback address without TLS, so a remote server means either its own certificate, a TLS-terminating proxy, or a tunnel or VPN that supplies one. The bundle itself is hosted separately from the server it talks to. A standalone `cc_server` serves the RPC endpoint, the MCP surface and its proxies, but **not** a web bundle — the static-file path exists in the server but no web root is configured on the standalone binary, so it has nothing to serve at `/`. The published `cc-webapp` container image is what hosts the bundle and `scripts/build_web.sh` is what builds it. A server only accepts browser origins on its `--allowed-origins` list, which defaults to `https://app.usectrl.dev`, so self-hosting the bundle means adding its origin. Media (avatars, feed images, PR screenshots) routes through the server's proxy endpoint rather than hitting upstream hosts directly, so a browser never fetches an arbitrary origin. The deployed client stamps a per-request Content-Security Policy from a non-sensitive cookie so only the connected server's origin is allowed. ### Phone companion (cc_remote) The **Remote** app is a separate, lighter client — a Flutter web PWA at [remote.usectrl.dev](https://remote.usectrl.dev/). It is intentionally **not** the full app. It pairs over a brokered, end-to-end-sealed WebSocket relay and speaks a read-mostly slice of the tool surface: read tickets, messages and the newsfeed; send a reply or update a ticket. A phone is a **lower-privilege principal** than a local agent. A default-deny tool policy, a pre-shared key sealing every frame, a session capability that locks privileged ops (pairing, for instance) out of a companion client, the per-call role and repo-grant checks above and rate limiting keep an approved (or leaked) pairing from becoming full remote control. See [Remote control and mobile](/manual/concepts/remote-control/) for the full security model. ### MCP clients External tools — editors, other agents, CLIs — that speak the Model Context Protocol connect to the server's MCP surface and consume the typed tool registry. This is the programmatic integration path; it is not a GUI. See [Use the MCP server](/manual/guides/mcp-server/) and the [MCP tools reference](/manual/reference/mcp-tools/). ### Fleet executors (cc_worker) A **`cc_worker`** is a headless pure-Dart binary that pairs with a `cc_server`, declares its host capabilities and pulls leased jobs to execute — agent runs, pipeline steps, code indexing, golden renders, eval batches. It streams process events back over the same RPC and holds **no durable state**: no database, no auth, no approvals, no budgets — those never leave `cc_server`. A worker is a limb, not a second brain. One authoritative server, N dumb limbs; there is no consensus, no worker-to-worker traffic. What it advertises is the operating system, CPU architecture, core count, RAM, whether a Flutter SDK is reachable and which sandbox backends the host has (none on Windows). A dedicated worker also always advertises `alwaysOn` and `acceptsParallel`, which is what the pin → prefer → spill scheduler places against. There is no GPU or ML probe. A solo desktop notices none of this, because ordinary dispatch does not go through the fleet at all. There _is_ an implicit in-process worker registered for the server host, but it is a registration seam with no runners wired into it — a job actually placed on the local worker fails rather than executing. Today, only a real `cc_worker` executes leased jobs. ## What runs where | Concern | Server (`cc_server`) | Fleet executor (`cc_worker`) | Desktop client | Web / phone client | | ---------------------------------------------- | -------------------------- | ---------------------------- | -------------- | -------------------------------- | | Databases (Drift/SQLite) | owns them | — | — | — | | Auth, approvals, budgets, identity | owns it | — | — | — | | Pipelines, reconcilers, orchestration listener | runs it | — | — | — | | MCP tool surface | serves it | — | — | — | | Agent execution | runs it (or leases it out) | executes leased jobs | — | — | | UI rendering | — | — | full | full (web) / read-mostly (phone) | | Meeting capture (mic + meeting audio) | — | — | native capture | web only, via screenshare audio | | Calendar OAuth (Google) | stores tokens | — | — | — | The two tiers do not sandbox alike. `cc_server` wraps the runs it executes itself in the host's OS sandbox where a backend exists — Seatbelt on macOS, bubblewrap on Linux and WSL2 — and falls back to the other boundaries on Windows or without `bwrap` and `socat`. **`cc_worker` does not sandbox at all.** It advertises a `sandbox` capability so the server can place jobs by platform, but nothing in the worker applies one: a leased job is spawned directly, bounded by the lease's environment and the worktree it was given. Leasing work out therefore moves execution outside the sandbox even on a host that has one. See [Sandbox and security](/manual/concepts/sandbox-security/) for what bounds a run in each case. Meeting capture happens on a client because it needs a microphone and the meeting's audio — a server in a closet cannot record your call. The recording is then summarized by the server's pipeline and the notes are stored where every client can read them. The web client records too, which the table's shape can obscure: it captures the microphone plus a screenshare audio track in the browser and streams 16 kHz PCM to the server. It _requires_ that audio track, so it fails explicitly where the browser will not provide one. What is genuinely desktop-only is the quality layer around capture — signal-level echo cancellation through the native processor, the input-level meter and dead-mic warning, re-running a summary, cancelling processing and the calendar's record-and-link action. ## Pairing and trust Every client proves who it is with a **pre-shared key** provisioned once: - The desktop in local mode generates a fresh key each boot and hands it to the spawned server via environment, so nothing secret is persisted on the desktop. - Remote clients (desktop-remote, web, phone) use a key minted by the server's `pair` command and stored in the OS keychain (or, on the phone, locally). Revocation is **live**, not lazy. The server watches its paired-device table and drops any open session whose device has left the active set, within seconds — on the direct WebSocket path and the relay path alike. You do not have to wait for the device to reconnect to cut it off. The key never travels through a URL a static host can see. When it arrives by link — on the phone or in a browser — it rides in the **URL fragment**, the part after `#`, which the browser never sends to the server hosting the page. That is the invariant worth remembering and it applies to both. The web client has two other entry points: the connect form, where you paste a server and key directly and an invite code, which it redeems for a credential minted for that browser alone. ## Related concepts - [Workspaces and isolation](/manual/concepts/workspaces/): why every RPC call carries its own workspace id and how the database split makes isolation structural - [Architecture](/manual/concepts/architecture/): how the codebase is structured around the server and its clients - [Remote control and mobile](/manual/concepts/remote-control/): the phone pairing and security model ## Related guides - [Run a headless server](/manual/guides/run-headless-server/): start `cc_server` and pair a client - [Connect to a remote server](/manual/guides/connect-remote-server/): point the desktop or web client at a server elsewhere - [Pair a device](/manual/guides/pair-a-device/): add a phone, a second browser, or another desktop - [Run a fleet worker](/manual/guides/run-fleet-worker/): add a `cc_worker` that pulls leased jobs ### Agent dispatch lifecycle > What happens between a message arriving and a run log closing — prompt assembly, worktrees, identity, what bounds a run and how its outcome is recorded honestly. Source: https://usectrl.dev/manual/concepts/dispatch-lifecycle/ ## What a dispatch is A dispatch is one run of one agent, in one conversation, on one adapter. Everything about it happens on `cc_server`: the client sends a message and then watches events stream back. There is no dispatch path in any client. Agents are woken for six reasons, which the run carries as its **wake context** so the agent knows _why_ it is awake: | Wake reason | Trigger | | ---------------- | ------------------------------------------------------------ | | `userMessage` | Somebody sent a message in a space, or mentioned the agent | | `assignment` | A ticket was assigned to the agent | | `pipelineStep` | A pipeline step dispatched it | | `childCompleted` | A child ticket finished | | `followUp` | A scheduled follow-up heartbeat | | `recovery` | The recovery path re-dispatched after a failure | The wake context carries the ids that go with the reason — ticket, space, message, pipeline run — and they are injected into the agent's environment and prompt. ## Prompt assembly The prompt is built in layers, each one a labelled section: - **Identity** — the agent's own id, name and workspace id, with an explicit instruction to pass those exact UUIDs to workspace-scoped tools. The workspace is intrinsic to the agent, so it is always present. - **Tool catalogue and resource protocols**, then the **workspace layout**. - **System prompt override**, if the agent has one, then **persona** (parameterized by role) and the **team brief**. - **Skills** — the names on the agent. - **Execution contract and procedure** for the current mode. - **Wake context** and **mention context**: who summoned the agent and the roster around it. - **Mode block** — generated from the mode's capability profile _and its materialized tool list_, so a prompt can never name a tool the run does not actually have. - **Memory context** — facts retrieved for the task. - **Conversation context** — recent space history, capped at 50,000 characters of verbatim window regardless of the agent's configured context size. Older history is not truncated away: the agent retrieves it on demand with `get_messages` and semantically relevant older messages still surface. :::note[Slash commands are parsed from the raw user text] The layered prompt starts with a context block, so it does not begin with a slash. Commands are therefore parsed from the user's message verbatim and the command is then spliced out of the tail of the layered prompt so every context layer survives. Parsing the built prompt instead is exactly the bug that once made `/plan`, `/goal`, `/loop` and `/skill:` silently inert on the space path. ::: Commands are split across two places. `/todo` and `/compact` are intercepted in the composer and deliberately never persisted as messages or sent to the agent — persisting `/compact` would push the compaction cut back a turn and pollute the transcript. `/plan` is handled in both: the composer flips the space's mode, which is what makes the tool surface, guard preset and prompt agree for this turn and every turn after it. `/goal`, `/loop` and `/skill:` are handled inside the run itself and only on the built-in harness — `/goal` and `/loop` start autonomous work with a cost budget, and a `skill:`-prefixed name is looked up as a skill whose `SKILL.md` body becomes the turn's directive. The namespace is what keeps the two vocabularies apart: resolving a bare name against skills meant a skill called `plan` or `compact` was permanently shadowed by the builtin. A bare non-builtin name still resolves, for messages written before the namespace existed. An unrecognized name simply falls through as plain text. ## Worktree provisioning Before anything runs, the conversation gets its own working root, with an isolated copy-on-write worktree per repo underneath it. By default that is every repo linked to the workspace; a PR space — or a space created with an explicit selection — provisions only the allow-listed ones and a PR-review conversation pins its one repo to the pull request's head ref while the others sit on their default branch. The source checkout is never mutated and the whole conversation directory is reclaimed when the space is deleted. Copy-on-write is the **only** implementation, backed by the bundled `rift` native. Plain `git worktree` is a second _backend_, not a degraded mode and it is reached in exactly two situations, both environmental: a filesystem that cannot do copy-on-write (not APFS or reflink-capable, or source and destination on different volumes) and Windows, where there is no copy-on-write backend to build. A missing `rift` on a platform that ships it is a broken install — the server refuses to boot rather than quietly falling back, because a silent fallback hides the breakage behind a slower path forever. ## Identity and credentials Two identities are resolved per run, before any process launches. **Git authorship.** Commits an agent makes are authored _as the agent_ — ` (agent) ` in both `GIT_AUTHOR_*` and `GIT_COMMITTER_*` — never impersonating a human. The requesting human is credited with a `Co-Authored-By:` trailer, carried in the environment and instructed in the system prompt. An explicit caller-set identity always wins over both. **Forge identity.** If the requesting member stored their own GitHub token, it _overrides_ the credential broker's token in the merged environment. A member-requested run therefore acts under that member's GitHub identity, not the server owner's — which is what you want for attribution and worth knowing when a run's permissions differ from what the server itself can do. **Model provider credentials** resolve in a fixed order and the order matters: 1. a per-adapter environment override, 2. the caller's environment, 3. the server's credential store (refreshing an OAuth token if it is near expiry), 4. the process environment. Because the per-adapter override comes first, it silently beats a provider account connected in the app. If a run is billing to the wrong account, that override is the first place to look. Provider credentials are host-global — shared across every workspace on the install. Capability-gated tokens (GitHub, ticketing) come from the credential broker, which injects only what the run's capabilities allow. See [The agent model](/manual/concepts/agent-model/). ## Launch The run is spawned with a sanitized environment, its worktree as the working directory and — for external CLIs — a generated MCP config so the agent can reach Control Center's tool surface. :::note[The OS sandbox wraps a run only where the host has a backend] `cc_server` probes the host once at boot and, when a backend is available, wraps agent processes in it — Seatbelt on macOS, bubblewrap on Linux and WSL2. Both dispatch seams are wrapped, because the transports do not share one: the external CLIs (`pi`, `claude`, `codex`) go through the sandbox port and the ACP transport plus the built-in harness's `bash` tool go through the sandbox manager. It is also what applies the run's protected paths: your registered repo checkouts are deny-write inside the sandbox, in every mode. Where there is no backend, the run is **not** sandboxed and nothing else changes to compensate. That means Windows always and Linux or WSL2 without `bwrap` and `socat` on the `PATH`. The server names which state it is in on its startup log line and `--sandbox off` opts out deliberately. Two gaps survive the sandbox even where it is on. The harness's in-process file tools (read, write, edit, apply patch, search) run inside the server process, so no profile constrains them — their bound is the tools' own path confinement plus the guardrails. And Claude Code is launched with `--dangerously-skip-permissions`, so its own file and shell calls are not gated by Control Center; the OS sandbox is the boundary around them. The remaining bounds hold on every host, sandbox or not: the run's own copy-on-write worktree, environment sanitization, capability-gated credentials brokered per launch and revoked at teardown, the unified action guardrails (fail-closed — a `prompt` decision with no approver connected is denied) and the tool surface the conversation's mode allows. ::: ## Streaming The run streams a typed event sequence back, which the stream processor persists as message deltas, folds into transcript segments and turns into messaging events: | Event | What it carries | | ------------------ | ---------------------------------------------------------------------------------------------------------------- | | `thinking` | Reasoning output, rendered inline | | `text` | Visible output | | `toolCall` | A tool invocation, with its id and structured arguments | | `toolResult` | The matching result, flagged when it errored or is a partial stream | | `usage` | Token counts and cost for a measurement window | | `error` | An error, with the adapter's structured code when it reported one | | `sandboxViolation` | The OS denied a syscall. Only ever emitted where a sandbox is actually wrapping the process — see the note above | | `debug` | Runtime diagnostics (launch markers, guard decisions, contract nudges) | | `done` | The run finished | ## What bounds a run There is deliberately **no turn ceiling anywhere**. A hard iteration count kills legitimate long work (migrations, fan-out cleanup) and catches nothing the guards below catch earlier. A run ends when: - **the model stops** on its own; - **a human stops it**, or takes over the conversation (which pauses the built-in harness at its next clean turn boundary — external CLI runs, which have no safe boundary, are stopped instead); - **the silence watchdog fires.** No output for the threshold and the session is terminated with a `silentRun` failure. The default is 15 minutes, dropping to 10 in the read-mostly modes (review and plan); an agent may override it between 1 and 240 minutes. The watchdog is armed when a child process reports its PID, so it covers the external CLI and ACP transports — **not** the built-in harness, which spawns nothing; - **a cost cap bites** — but only for autonomous commands. The doom-loop guard is not a terminator: three identical tool calls in a row and the loop _steers_ the agent to change approach, which is the bound that replaced a turn ceiling. :::note[A normal turn has no cost cap] The in-session cost guard is wired only for `/goal` and `/loop`; every other run passes no budget at all. For those commands, one unattended segment may burn 500 cents ($5.00) by default, with a soft steer at 80% asking the agent to wrap up cleanly before the hard stop and a durable goal budget of 5000 cents ($50.00) across segments. An explicit `/goal --budget` is never overshot by a whole segment. ::: Put together, an ordinary interactive turn on the built-in harness has no turn ceiling, no cost cap and no silence watchdog. It ends when the model stops or when you stop it. That is a deliberate trade — the alternatives all kill legitimate long work — but it does mean an unattended interactive run is bounded by nothing but the model's own judgement. Mid-run, a message sent to a running agent is queued as **steering** and drained at the next turn boundary, so you can nudge a run without killing it. Steering, pausing, subagents and the completion contract are built-in-harness features; external CLI and ACP transports have none of them. ## How a run is recorded Every dispatch writes an `AgentRunLog`: start and end time, summary, token counts and estimated cost (including cost rolled up from subagents), adapter and model, process id and log path, the conversation and any ticket or pipeline run it belongs to, plus two classification fields that carry most of the truth. - **Status** is coarse: `pending`, `running`, `completed`, `error`. - **Liveness** is the finer read: `alive`, `productive`, `completed`, `blocked`, `empty`, `looping`, `failed`, `stalled`, `dead`. - **Error family** groups failures usefully: `transientUpstream`, `sandboxInfrastructure`, `budgetExceeded`, `processLost`, `silentRun`, `unknown`. :::note[`completed` does not mean "delivered"] When a run ends without the deliverable its mode required — a plan-mode run that never called `submit_plan`, an orchestrate run that never proposed — the status stays `completed`, because the process did exit cleanly. The honest signal rides on liveness (`empty`), error family (`silentRun`) and a summary saying exactly what was not produced. That summary is protected: finalization will not overwrite it with the model's last chatter. This is deliberate. Flipping the status would move ticket and pipeline state machines that have nothing to do with the missing artifact. It also means "status: completed" alone is never enough to conclude a run succeeded — read the summary. ::: Closing the run publishes `AgentRunCompleted` and a lot hangs off it: the notification frame and its durable feed entry, the live forwarder that pushes it to connected clients, pipeline cost roll-up, resuming a pipeline step that was waiting on the run, completing the backing ticket, mapping an orchestration to a terminal state, the goal supervisor's continue-or-stop decision, the checker role and the post-run budget evaluation. The presence roster follows separately, by watching the recent run logs rather than the event. ## Budgets, after the fact Budget enforcement is a _reaction_, not a gate. When a run completes, the evaluator compares the agent's month-to-date spend against its effective ceiling (a linked budget policy if there is one, otherwise the agent's own monthly figure; zero means unlimited). Crossing the soft threshold records a warning incident; exhausting the budget records a hard incident and flips the agent's lifecycle status to `paused`. :::caution[Both halves of that are hard to use today] No dispatch path checks lifecycle status, so a `paused` agent still runs — the pause is visible in the org chart and nowhere else. The pre-dispatch budget block that _would_ stop a run exists but is called only by the orphan-run reaper and the stranded-ticket reconciler when they consider scheduling recovery. And budget incidents have no RPC operation and no screen, so a hard stop's only visible symptoms are the status change and a `BudgetThresholdCrossed` pipeline trigger. ::: ## Retry lineage Every run log has room for retry lineage — a parent run id, an attempt counter and a continuation summary that would let a follow-up run resume with context instead of starting cold. The conversation run tree renders the attempt number when it is non-zero. :::caution[Nothing currently advances it] No production code increments the attempt counter or writes a continuation summary. The orphan-run reaper checks the attempt count and a budget block, then only logs that a run is "eligible for recovery" — it does not re-dispatch. A "try again" from chat is a plain new dispatch with a fresh run log and no lineage. Treat these fields as schema, not behaviour. ::: ## Related concepts - [The agent model](/manual/concepts/agent-model/): what the adapter can and cannot enforce - [Modes](/manual/concepts/modes/): where the tool surface and the completion contract come from - [Guardrails](/manual/concepts/guardrails/): the effect-class policy every gated call passes - [Sandbox and security](/manual/concepts/sandbox-security/): the isolation story in full - [Multiplayer](/manual/concepts/multiplayer/): steering, take-over and the autonomy dial - [Domain events](/manual/concepts/domain-events/): what `AgentRunCompleted` sets off ### Domain events > Why features talk through an in-process event bus instead of importing each other, what the bus is and is not the source of truth for and which events can actually drive automation. Source: https://usectrl.dev/manual/concepts/domain-events/ ## What a domain event is Domain events are the decoupling mechanism that lets features communicate without direct dependencies. Instead of feature A calling feature B, feature A publishes an event and feature B subscribes to it. The `DomainEventBus` is an in-process broadcast publish/subscribe bus. Publishers call `publish(event)`; subscribers consume a typed `on()` stream. Every event implements `DomainEvent` and carries `occurredAt`. **The bus runs inside `cc_server`.** Every publisher and every live subscriber lives there. No client sees this bus. What a client sees is a curated subset the server re-emits as `notifications/*` JSON-RPC frames. ## Why a bus at all Without events, every cross-feature reaction is an import. The notification path would have to import the agents feature, the pull-request feature, ticketing, and meetings just to know when to raise a toast — and each of those would then have to know that notifications exist. Ticketing would import pipelines so a completed ticket could advance a run. The dependency graph closes into a knot, and the shared kernel stops being shared. Publishing an event breaks that. The notification path subscribes to eleven event classes and imports none of the features that raise them. The pipeline trigger dispatcher subscribes to the whole bus and knows nothing about pull requests, tickets, or meetings. Observability aggregates run outcomes without reaching into dispatch. The cost is the usual one: a publisher cannot tell whether anyone is listening, and a listener that is never constructed is silently inert. Several event classes in the catalog are published today with no subscriber at all. ## What the bus is not It is worth being precise about one thing the bus does _not_ own. **The audit trail is not a projection of the event stream.** Every mutating RPC operation is audited by default — `RepoOp.audited` defaults to true and an operation opts out only by declaring `audited: false`, a deliberate act visible on the operation itself. The dispatcher appends the "who did what, from where" row directly after a successful call. A `DomainEventAuditBridge` exists in the shared kernel, but it is never constructed, so nothing bridges events into that trail. The event bus _is_ the transport for notifications and for automation. It is not the bookkeeping system of record. ## What the bus does drive - **Notifications.** Eleven event classes are turned into `notifications/*` wire frames by the server, recorded into a durable per-workspace feed and broadcast to connected sessions. The workspace filter is applied client-side; only the durable feed is structurally workspace-scoped. - **Event-driven automation.** Pipeline triggers subscribe to the bus and auto-start matching pipelines (see below). - **Lifecycle reactions.** Creating a workspace seeds its CEO and specialist agents plus the built-in pipeline templates. A completed agent run resumes a suspended pipeline step, closes a task and feeds the goal supervisor. A merged PR or a deleted space triggers worktree garbage collection. - **Cross-vendor sync.** Local ticket changes are pushed out to configured vendors by a coordinator listening for five ticket events. ## Event categories Events span the whole product surface. At a high level: - **Workspaces, agents and repos** — workspace creation, run completion, repo registration - **Pull requests** — publishing, status changes, merges, review requests, mentions, externally detected PRs - **Messaging** — messages, spaces, conversations and space provisioning progress - **Tickets** — the full lifecycle plus external webhook intake - **Tasks** — sequenced lifecycle frames for one dispatched run, so clients can order and de-duplicate them - **Pipelines** — run and step start / finish / fail, the backbone of run tracking - **Orchestration and plan documents** — proposals, revisions, approvals, execution - **Artifacts** — work products published and revised by runs - **Memory** — facts, beliefs and conflicts as the memory system consolidates - **Approvals** — escalations routed to the people who can resolve them - **Observability** — audit entries, worktree merges, budget thresholds - **Identity and membership** — users, members, roles, invites and device revocation - **Calendar and meetings** — syncs, auth expiry, meetings starting and recordings finishing For the complete catalog — all 75 concrete event classes, their payloads and the 19 subscribers that consume them — see the [Domain events reference](/manual/reference/domain-events/). That page is the inventory; this one is the model. ## Live access control rides two different mechanisms Membership and device events look symmetrical and are not, which matters if you are reasoning about how fast a revocation takes effect. **Revoking a device does terminate its sessions.** The server watches the paired device table and drops any live session whose device left the active set, within seconds. **Removing a member does not close the socket.** `WorkspaceMemberRemoved` is published but has no subscriber. What denies the removed member is the role gate, which re-resolves membership on **every** call — so their next request is refused with "Not a member of this workspace". The effect is immediate for anything they try to do; it just is not achieved by hanging up on them. ## Pipeline triggers Pipeline triggers subscribe to domain events and auto-start matching pipelines. `PipelineTriggerDispatcher` listens to the whole bus, then: 1. Short-circuits unless the event type is one it knows how to map to a payload 2. Looks up enabled triggers matching that event type, across every workspace 3. Filters each to its own workspace and applies its payload match filter 4. Starts a run for each trigger that survives The important constraint is step 1. The bus carries 53 event types; the add-trigger picker offers **16** of them (`EventPayloadMapper.knownEventTypes`): `ExternalPrDetected`, `PullRequestPublished`, `PullRequestStatusChanged`, `PrMerged`, `MessageReceived`, `TicketCreated`, `TicketAssigned`, `TicketCompleted`, `TicketFailed`, `TicketCancelled`, `TicketStatusChanged`, `BudgetThresholdCrossed`, `RepoAdded`, `MeetingRecordingStopped`, `SkillUpdated` and `SpaceDeleted`. Nothing else can start a pipeline. :::caution[Two of those can never fire] The picker's list and the payload mapper's branches are two separate lists, and `TicketCreated` and `TicketStatusChanged` are on the first but not the second — so **14 of the 16 actually carry a payload**. The dispatcher bails before it ever looks up triggers for the other two. A trigger on either is selectable, saveable and permanently inert. ::: ## How a client sees any of this `RemoteEventForwarder` translates the notification-class events into wire frames and pushes them over RPC to connected sessions, where a frame mapper turns them into in-app notifications. A thin client therefore sees a projection of the server's event stream without owning any execution — which is the whole point of the [thin-client split](/manual/concepts/architecture/). Not every pushed frame is a notification, though. The task-lifecycle stream and ticket reassignment are forwarded live to drive UI, but they are not recorded into the notification feed and raise no toast. ## See also - [Pipelines and automation](/manual/concepts/pipelines/): what a trigger starts - [Tickets and delegation](/manual/concepts/tickets/): why `TicketAssigned` dispatches nothing - [Architecture](/manual/concepts/architecture/): how events fit the dependency rule - [Domain events reference](/manual/reference/domain-events/): the complete catalog and its subscribers - [Set up pipeline triggers](/manual/guides/pipeline-triggers/) - [Configure notifications](/manual/guides/notifications/) ### Evals, replay and regression protection > The designed model for recording, replaying and grading agent runs — and an honest account of how little of it runs today. Source: https://usectrl.dev/manual/concepts/evals-replay/ :::caution[Not available yet] Most of what this page describes is designed and implemented in the domain layer, but is not wired into a running server. Specifically: - **Eval suites cannot be executed.** The runtime builds the `evals.*` ops with no task executor, so `evals.runSuite` returns _"Eval execution is not available on this server (no dispatch-backed task executor wired)."_ The per-suite **Run** button in Observability → Quality is enabled and always fails; the UI reports a generic "eval run failed" toast rather than the server's reason. - **Nothing writes a session recording.** No dispatch path creates one, so `evals.recordings` is always empty, there is nothing for `evals.blessGolden` to bless and replay has no input. - **Nothing records a config version.** The only writer is the canary gate, which has no call site, so `evals.configVersions` is empty and `evals.reliability` always returns a zero-evidence score — which resolves to the _observe only_ recommendation for every agent. - **Replay, drift detection and the canary gate have no callers.** All three are unit-tested domain services with no production entry point. What does work today: **eval suites can be created, listed, edited and deleted** over the `evals.*` RPC ops and in Observability → Quality and five starter suites are seeded into each new workspace. Everything downstream of running one is inert. The model below is worth understanding because it is what the feature is being built toward. Read the present tense as _designed_, not _available_. ::: ## Why replay is the primitive Agents are nondeterministic: the same prompt can produce different behavior depending on the model, the tools in play and the state of the repo. That makes "it worked yesterday" an unverifiable claim — until a run becomes an artifact that can be re-executed, compared and graded. The design rests on one idea: **replay is the primitive**. Record real runs, replay them deterministically to test the harness, re-execute them live to test the agent and distill the results into scorecards that gate configuration changes and inform how much autonomy an agent has earned. Every other piece here — goldens, suites, the canary gate, drift detection — is a consumer of that one primitive. ## Session recordings: runs as artifacts A `SessionRecording` is designed to capture one complete harness run so it can be replayed later. Each recording bundles four things, stored as content-addressed refs alongside the run log it captures: - **The event stream** (`eventsRef`): every event the harness emitted, serialized as JSONL under the server data dir. - **The cassette bundle** (`cassetteRef`): the recorded LLM turns and tool results, so every external call can be stubbed back exactly as it happened. - **The fixture** (`fixtureRef`): a git bundle plus pinned SHA (or worktree snapshot) of the repo state, so the world the run saw can be reconstructed. - **The config hash** (`configHash` + `hashVersion`): the fingerprint of the exact agent configuration that produced the run. What a recording would prove: _this is precisely what happened_ — the inputs, the externals and the emitted stream, pinned to one configuration. The schema, repository and DAO all exist. No code path constructs one. ## The config hash: pinning a run to an exact configuration A recording is only comparable if you know which configuration produced it. `AgentConfigSnapshot` computes a SHA-256 over a canonical JSON document: sorted keys, a fixed field list covering the assembled system prompt, the sorted mode prompts, sorted tool `(name, schemaHash)` pairs, the resolved model id, sorted memory-policy `id@version` refs and the routing-table hash. Two consequences follow from hashing rather than versioning by hand: - **Drift detection.** Two runs with the same hash ran under an identical effective configuration; a different hash means something changed, even if nobody "edited the agent". A memory policy edit or a tool schema change moves the hash. - **Deliberate re-keying.** The field list carries a `hashVersion`. Adding a hashed field later re-keys every config _on purpose_ — old hashes stay comparable within their version instead of silently colliding with new ones. The hashing itself is pure and tested. What is missing is the step that records the result: no run currently persists its config version, which is why the whole chain below it has nothing to stand on. ## Two kinds of replay, honestly labeled Replay would answer two different questions and the modes are kept distinct because conflating them produces false confidence. **Deterministic replay tests the harness.** The replay driver re-runs a recording with every external stubbed from the cassette: the model hands back the recorded turns in order and every tool pops the recorded result without executing anything. There is no network, no token cost and no side effect. The driver asserts the harness produces a byte-identical event stream, compared via a canonical, timestamp-free signature of each event. If a harness change reorders, drops, or reshapes events, the replay reports the mismatch — and if the loop makes one more model call than was recorded, the provider throws, because an over-call is itself a harness regression. **Live replay tests the agent.** Re-executing a task against the real model — dispatched into a throwaway worktree — measures the agent as it behaves today. Because a live run is sampled behavior, the unit of truth is never a single run but a batch: a scorecard over N repetitions. The distinction matters for gating: deterministic goldens are exact and free, so they can gate; live goldens are advisory, because a flaky model response should not block a save on its own. ## Golden sessions: the regression baseline A `GoldenSession` is a blessed recording pinned per agent — an operator's declaration that _this is what good looks like_. In the design, an enabled golden gates saves and CI in its mode and each golden tracks its last run status and scorecard plus who blessed it and when. No save path or CI hook consults a golden anywhere in the product today and since nothing writes recordings there is nothing to bless in the first place. The `evals.goldens`, `evals.blessGolden` and `evals.deleteGolden` ops exist and work; they operate over an empty set. ## Suites, runs and scorecards An `EvalSuite` is a workspace-scoped evaluation definition: a task spec (prompt, target agent and mode, setup), an optional fixture with a pinned SHA for fixture-drift measurement and a list of graders. Graders are deterministic first — free and exact — with an LLM-rubric judge reserved for what deterministic checks cannot express. A default batch size of N repetitions exists to expose variance rather than hide it. Suites are the one part of this page you can actually use. Five starter suites ship with Control Center, covering the product's own workflows — orchestration proposals materializing, review finding a seeded bug, plan mode staying read-only, plan mode producing a plan and a ticket CLI round-trip. :::note[Starter suites are seeded on workspace creation only] Seeding runs on the `WorkspaceCreated` event, which is published when a workspace is created through `workspace.upsert`. It skips any suite whose name already exists and there is no backfill — a workspace that predates the feature has none. Check Observability → Quality to see what your workspace has. ::: An `EvalRun` would be one batch execution of a suite against a config hash, with a status lifecycle (`queued` → `running` → `done`/`failed`/`cancelled`), metered cost and a record of what triggered it (`manual`, `canary`, `golden`, or `ci`). Large batches can fan out to workers as a fleet job. The result would be an `EvalScorecard`: pass rate with its standard deviation, average cost and cost variance, average turns and duration and a per-grader breakdown, where a repetition passes only when _every_ grader passes. What a scorecard proves is statistical: _this configuration, measured over a batch, achieves this quality at this cost_ — never a single-run verdict. Execution is the missing seam. Until a dispatch-backed task executor is wired, running a suite returns an error rather than a fake pass, which is the right failure: a green result you cannot trust is worse than no result. ## The canary lifecycle: promoting config changes on evidence Every run would record its effective config as an `AgentConfigVersion` (idempotent by hash), so "what changed?" always has an answer. Each version carries a lifecycle status: - **live**: the configuration currently in effect. - **canary**: a proposed change under evaluation. The old config stays live while the new one runs its goldens and suite. - **retired**: a previously-live version superseded by a promotion. The canary gate promotes a canary only when its scorecard is green (a 0.9 pass-rate bar by default), or via an explicit override that is recorded as such. Promotion retires the previously-live version and a config hash that is already live or retired is never silently demoted back to canary. What the gate would prove: _this change was promoted on measured evidence, or a human overrode the evidence and left a paper trail._ The gate service is implemented and unit-tested. It is also the only writer of config versions and it has no call site — so no configuration is currently tracked through this lifecycle at all. ## Reliability scores and the autonomy dial Scorecards are designed to accumulate into a track record. `ReliabilityScore` blends eval pass rate and production success rate weighted by volume, then applies bounded penalties for sandbox violations, doom-loop incidents and negative human feedback. From the score it derives a recommended autonomy ceiling: - **observe only**: not enough evidence, or reliability below the approval bar. - **act with approval**: reliable enough to act, with human approval on effects (at least 20 runs and an 0.85 score by default). - **act freely**: demonstrated reliability high enough to act without approval (at least 100 runs, a 0.95 score and zero sandbox violations by default). Requiring _both_ a high score and a minimum evidence volume is the point: an agent with no history is never recommended for act-freely and the recommendation always carries human-readable rationale lines explaining why. Two caveats, one by design and one by omission. By design, the link from reliability to autonomy is a recommendation, not a clamp — the domain provides a gate predicate, but nothing in the server enforces a [dial setting](/manual/concepts/guardrails/#the-autonomy-dial-is-a-profile-not-a-parallel-system) against it. By omission, the score is computed from config-version scorecards, and since no config versions are written, `evals.reliability` returns zero evidence for every agent — which is exactly the input that yields _observe only_. Do not read that recommendation as a judgement about your agent. ## Catching drift that recording cannot One failure mode survives every artifact above: behavior shifting _without_ a config change — an upstream model update, a tool that starts behaving differently. The drift detector addresses it by comparing a rolling window of recent runs (cost, turn counts, failure-family mix) against a baseline window and raising an alarm with per-dimension evidence when they diverge. Together with recordings, goldens and scorecards, it is meant to close the loop: regressions from your changes get gated and regressions from the world's changes get detected. The detector is implemented and unit-tested and like the rest of the chain it is waiting on the executor that would give it data. ## Where this surfaces The eval UI is the **Quality** tab of Observability (`/workspaces//observability`), alongside the scored benchmark. It lists each suite with its recent batch runs and a per-suite Run action — the action that currently fails. ## Related concepts - [The agent model](/manual/concepts/agent-model/): the configuration a config hash pins - [Guardrails](/manual/concepts/guardrails/): the autonomy dial a reliability score would inform - [Manage costs and budgets](/manual/guides/manage-costs/): the cost side of what a scorecard measures ### Guardrails — one policy for every mutating action > Why Control Center routes every agent side effect through a single allow/prompt/deny engine, how the ActionClass taxonomy stays small on purpose and what the autonomy dial really changes. Source: https://usectrl.dev/manual/concepts/guardrails/ Agents in Control Center can delete files, push branches, publish pull requests and call external APIs. Each of those is a different _kind_ of risk, and for a long time only one of them — shell commands — had a policy. The guardrails exist to answer one question uniformly, for every mutating action an agent can take, regardless of which runtime or tool proposes it: **is this allowed, does it need an approval, or is it denied?** ## One engine, every chokepoint There is exactly one policy engine. It is consulted at every point where an action can leave the system: the built-in harness's tool registry, the server-side MCP dispatcher gate and repo-op mutations. A rule written once therefore applies to the built-in runtime and to external agent CLIs alike — there is no second, quieter path around it. The older bash-only command policy still works: a rule can match a command prefix instead of an action class and the two forms coexist in the same store. The engine is not, however, a substitute for the OS sandbox and the sandbox is not a substitute for it. They answer different questions: the guardrails decide whether a call Control Center can see is allowed to happen at all, while the sandbox bounds where the resulting process may reach. `cc_server` applies the sandbox where the host offers a backend — but [Windows offers none and Linux needs `bwrap` and `socat`](/manual/concepts/sandbox-security/). On those hosts the guardrails are the outermost enforcement layer rather than a policy sitting on top of a floor, which is worth knowing when you decide how permissive to be. ## ActionClass: a deliberately closed taxonomy Every mutating tool declares which **ActionClasses** it belongs to, from a closed set of thirteen effect classes. Five of them prompt out of the box; the rest allow and a rule is what changes that. | Class | Built-in default | What it covers | | -------------------------- | ---------------- | ---------------------------------------------------------------- | | `fileDelete` | **prompt** | Deleting a file (a delete-shaped write, `rm`) | | `gitPush` | **prompt** | Pushing to a git remote | | `prCreate` | **prompt** | Creating a pull request | | `prPublish` | **prompt** | Publishing a review, merging a PR | | `vendorSyncWrite` | **prompt** | Writing to an external ticket vendor (Linear, Jira, GitHub sync) | | `fileWriteOutsideWorktree` | allow | Writing a file outside the isolated worktree | | `gitCommit` | allow | Creating a git commit | | `networkEgress` | allow | Network egress (web fetch, arbitrary HTTP) | | `secretAccess` | allow | Reading a secret or credential | | `packageInstall` | allow | Installing a package (npm, pip, pub, brew) | | `processSpawn` | allow | Spawning a process (shell and anything shelling out) | | `workspaceMutation` | allow | Mutating workspace structure (repos, spaces, agents) | | `enclosureControl` | allow | Driving an enclosure (rig): booting a VM, sending it input | The eight allow-by-default classes were chosen on the assumption that the sandbox floor applies beneath them. It does where the host offers a backend, and it does not on Windows or on a Linux box missing `bwrap` and `socat` — so if you want `fileWriteOutsideWorktree` or `packageInstall` gated there, that is a rule you have to write. The set is closed on purpose. A new mutating tool that declares no class fails the ratchet test, so the taxonomy can only grow by an explicit, argued decision — "taxonomy sprawl is the death of this feature." Thirteen classes is enough to express the policies people actually write (never push without asking, never delete outside a worktree, always allow reads) and few enough that the settings UI stays comprehensible. ## Rules and scope resolution A rule maps `(scope, ActionClass | command prefix) → allow | prompt | deny`. Scopes nest from most to least specific: **space > agent > workspace > mode preset > built-in default** Resolution is **specificity first**: the first scope with a matching rule decides and resolution stops there. Most-restrictive is only a tie-break — between two equally specific rules within one scope and when combining the several classes a single action declares. Within a scope, a command rule matches on the longest command prefix. This replaced an earlier flat precedence (`allow > deny > prompt`), which let a broad allow silently defeat a narrow deny — the opposite of what a safety system should do. Allow no longer beats deny anywhere. Two decisions carry hard guarantees: - **Fail-closed prompts.** A `prompt` with no approver connected resolves to _denied_. An unanswered question is never a yes. - **Most-restrictive combination.** An action spanning several classes combines them most-restrictively; when several classes prompt, one confirmation lists them all. There is deliberately no per-turn latch. Every tool call re-resolves the policy from scratch, so repeated prompting for the same action is bounded by the operator's own "remember this decision" rather than by anything turn-scoped. ## Argument-level rules A rule can constrain the ARGUMENTS of an action, not just its verb. "May push" and "may push to `feature/*`" are different claims, and only the second is a control — a capability gate that authorizes the verb alone is the shape behind most published agent incidents. A constraint may name: - **paths** — glob patterns (`repos/**`, `!**/.env`) - **refs** — git refs, with `!` negation (`['**', '!main']` reads as "any branch except main") - **hosts** — network destinations, with subdomain wildcards (`*.internal`) - **commands** — command prefixes, matched on a word boundary - **ceilings** — `maxCount` / `maxCents`, so "delete up to 50 files" is expressible Three properties fall out of keeping the grammar closed and loop-free, and all three are the point: every rule renders as a sentence you can read back, every denial can name the constraint that matched, and the whole policy can be enumerated — you can see what an agent may do *before* it runs. Two rules that matter: - A **constrained rule is more specific** than an unconstrained one at the same scope, so "deny push to `main`" beats "allow push" without anyone thinking about ordering. - A constraint that names a facet does **not** match a request that carries no values for it. A tool whose arguments are not yet extracted therefore falls through to the unconstrained rule that was already in force — never to a silent allow. ## Standing approvals Answering a prompt with "remember this" writes a real policy rule: scoped (space / agent / workspace), **argument-constrained** (approving a push to `feature/login` grants `feature/**`, not `main`) and **self-revoking** on an explicit TTL. Saying yes for the next few hours is not the same as quietly rewriting your permanent policy, and the rules it writes appear in the matrix marked `remembered`, with their expiry, where they can be revoked early. ## Enforcement levels Each rule carries one: - **advisory** — allow, but record that the rule matched. This is the adoption path: roll a strict rule out in advisory mode, read the audit trail for a week to see exactly what it *would* have blocked, then promote it. Without this tier, strict policies never get turned on. - **soft** — deny, overridable by someone holding the override permission, with a recorded justification. The override is itself an audited event. - **hard** (the default) — deny. Nothing overrides it. ## The managed tier An install operator can pin policy that no workspace admin can loosen. Managed rules live server-side and are merged **most-restrictive** with each workspace's own chain — they are not a scope at the head of it, because a head-of-chain managed `allow` would let the install override a workspace's deny, the opposite of what a clamp is for. A managed rule can therefore only ever tighten. Setting `CC_SERVER_MANAGED_POLICY` to a JSON file outranks the stored rules entirely, which is what lets an operator pin a posture that no admin UI can flip — the answer to "can I stop my developers from disabling the safety controls?". ## The autonomy dial is a profile, not a parallel system The per-space autonomy dial is a named profile over this same policy store, not a second mechanism. Its three settings are stored and sent as `proposeOnly`, `actWithApproval` and `actFreely` (the hyphenated forms are UI prose only and `autonomy.setForSpace` rejects anything else). Leaving it unset behaves as `actWithApproval`. What each one does to a resolved decision: - **`proposeOnly`** denies every gated tool outright. The agent can still reason and reply; it just cannot act and its message says why. - **`actWithApproval`** (and unset) is the fail-closed approval gate described above. - **`actFreely`** pre-approves anything that did not resolve to a hard `deny`. That last one deserves to be stated plainly rather than softened: under `actFreely` a `prompt` decision is **not** escalated to an approver — it is allowed and no one is asked. Only an explicit `deny` rule survives the dial. So `actFreely` is a deliberate grant of autonomy, not a convenience setting, and the fail-closed rule protects exactly the case where it matters (`actWithApproval`, the default). Delegation between agents is guarded at the `delegate_task` chokepoint by all four guards: a depth cap (default 3), cycle detection, an **autonomy ceiling** (a delegate can never act with more autonomy than its delegator — privilege cannot be laundered by handing work to a freer agent) and a **budget envelope** (delegation bills the delegator's remaining budget and cannot mint more). Each is refused loudly, with the guard's reason returned to the agent verbatim. ## The adapter honesty matrix Not every runtime lets Control Center intercept every action. External agent CLIs run some tools natively, in-process, where no gate can sit. Rather than pretend otherwise, each adapter carries an **honesty matrix**: per adapter, whether Control Center filters the tool surface, intercepts tool calls, observes the completion contract, can see the runner's native tools and whether in-process tools are covered by a sandbox profile. Two entries are worth reading before you trust a configuration. The built-in harness declares that its in-process file tools are **not** sandboxed — the tool surface and this policy engine are the only filesystem boundary it has. Claude Code declares that its own tool calls are **not** interceptable, because it is launched with `--dangerously-skip-permissions`; Control Center sees only its `mcp__*` calls and its own read, write, edit and shell tools run unseen. The settings screen shows this matrix, because the one unforgivable failure for a guardrail system is claiming coverage that does not exist. ## Probing policy before it bites The guardrails live at **Settings → Workspace → Agent permissions** (`/workspaces//settings/workspace/permissions`). The screen has three sections: the policy matrix where rules are written, a what-if probe and the adapter honesty matrix. The probe takes an action class (or a command) and a scope and shows which rule decided and why. Because resolution is pure and deterministic — same action, same scope, same decision — the probe's answer is the answer the agent will get. ## Related concepts - [Sandbox and security](/manual/concepts/sandbox-security/): what actually constrains an agent run and how far the OS floor beneath this engine reaches - [Multiplayer — identity, membership and presence](/manual/concepts/multiplayer/): spaces, principals and where the autonomy dial lives - [Configure guardrails](/manual/guides/configure-guardrails/): writing and testing rules in practice ### Meetings and transcription > How Control Center records a call, transcribes and diarizes it on the server and where the one step that leaves the host is. Source: https://usectrl.dev/manual/concepts/meetings/ Control Center can record a call and hand you back a clean writeup — a summary, action items and decisions — with speech recognition and speaker separation running on your own hardware. Think Granola, but self-hosted and wired into the same workspace your agents live in. :::caution[Recording needs a voice model and a server restart] Recording and dictation are unavailable until a speech (ASR) model is installed **and** the server has been restarted. The server resolves its voice-model paths exactly once at boot, so a model installed at runtime does not register the `meeting.*` recording operations until the next boot — the client fails with "Could not start recording on the host" until then. Pick a model at Settings → Server → Voice & meeting models, let it download, then restart `cc_server`. ::: ## What a meeting is A **meeting** is a recorded, transcribed session. It is _not_ the same thing as a [calendar event](/manual/concepts/calendar/): an event is a scheduled commitment synced from Google, while a meeting is something you actually recorded. The two can be linked, since you can start a recording from an event, but they stay distinct records. Every meeting is workspace-scoped, like everything else in Control Center. Meetings recorded in one workspace never surface in another. ## Where the work happens "On-device" is the right instinct but the wrong noun. The client captures audio and streams 16 kHz PCM16 frames to the **machine running `cc_server`**. That host does everything else: it writes the per-channel WAVs under `/meetings//`, transcribes in a worker isolate, filters echo and diarizes after the fact. On a standalone desktop that host is the same machine. Pointed at a remote server, it is not — so "nothing leaves my laptop" is only true when the server is your laptop. There is one further exception and it is the important one. :::caution[The summary leaves the host] The transcript and your notes are sent to whatever model provider the summarizing agent is configured against. For any cloud provider that means the full transcript goes to that provider's API. If a transcript must not leave the host, point the summarizing agent at a local endpoint (Ollama, LM Studio, or another local server). ::: Everything before the summary — capture, transcription, echo cancellation, diarization, voiceprint matching — runs on the server with no network call. ## Which models and which are optional The **embedding and diarization** models are force-installed on first boot, so speaker separation works out of the box. The **speech (ASR) model is opt-in**: it is selectable and several hundred megabytes, so you choose one. Four ship in the picker, differing in size, language coverage and accuracy: - Parakeet TDT 0.6B v3 — multilingual, the recommended default (~600 MB) - Parakeet TDT 0.6B v2 — English (~480 MB) - Whisper large-v3-turbo — multilingual (~626 MB) - Whisper base.en — English-only, the small historical default (~198 MB) Models are the only artifacts the server fetches at runtime; every native library ships in the bundle. ## How capture works A meeting records **two audio channels at once**: - **You ("me")**: your microphone. - **Them ("them")**: the meeting's audio output, captured by a driver-free loopback — Core Audio process/device taps on macOS, WASAPI loopback on Windows, a PipeWire / PulseAudio monitor on Linux. Because the two channels are captured separately, the transcript is speaker-attributed from the start: your words are tagged `me`, everyone on the call is tagged `them`. Your own voice bleeds from the system output back into the mic and would otherwise be transcribed twice. Two filters stop that. Signal-level **AEC3 runs on every remote-mode recording and is required** — if the native AEC library cannot load, the recording refuses to start rather than silently streaming an echo-polluted mic. (In-person mode has no far-end reference to cancel against, so AEC is an identity passthrough there.) On top of it, a host-side text-level echo filter runs on every platform. All OS-level microphone processing is deliberately disabled. Enabling the platform's own echo cancellation or auto-gain on macOS switches the mic to Voice Processing I/O, which in this build killed both the mic _and_ the system-audio tap. ## Transcription and diarization While you record, audio is decoded in **rolling windows** — cut on a short trailing silence, or at a maximum window length — off the UI thread. Each window becomes a speaker-tagged transcript segment with millisecond offsets. Windows that never rose above an RMS threshold are skipped **without being decoded at all**, because a speech model renders silence as hallucinated non-speech tokens and the decode is wasted CPU. The practical consequence: a consistently very quiet speaker produces no transcript. If someone comes back empty, check their input level before blaming the model. After you stop, **diarization** runs offline over the recording and splits the remote channel into individual speakers (`Person 1`, `Person 2`, …) that you can rename. The number of speakers is inferred from a clustering threshold, not from a count you set — so it is a threshold artifact and a quiet or overlapping speaker can be merged into a neighbour. The transcript is rendered as `[mm:ss] SPEAKER: text` lines. ## Voice profiles Renaming a speaker does more than label one transcript. Control Center can save the speaker's voiceprint as a **voice profile**, so it recognizes them automatically in future meetings. When you name a diarized speaker, you're asked whether to save the voiceprint; if you do, it's blended into a running centroid for that name and matched against new speakers by cosine similarity, so the same person shows up with their name next time without you relabeling them. Voice profiles are workspace-scoped and never cross the boundary. Renaming a speaker who was previously saved un-enrolls the old name and offers to enroll the new one; deleting a profile removes the stored voiceprint but leaves any names already applied to past meetings intact. ## The summary pipeline When a recording stops, the server publishes a `MeetingRecordingStopped` [domain event](/manual/concepts/domain-events/). The built-in **`meeting_summary`** pipeline template is triggered by that event. The recorder doesn't wait on it; the meeting simply transitions through its status lifecycle: ``` recording → processing → done ``` The summary agent receives the title, your rough live notes and the transcript, and returns **structured JSON**. The pipeline's persist steps then write that JSON to discrete rows: - `enhancedNotes` and `summary` → the meeting's notes - each action item → a `MeetingActionItem` row (content, owner, optional ticket link) - each decision → a `MeetingDecision` row Action items and decisions are **never** parsed out of free-form markdown, only from the agent's structured arrays. If a run produces no structured output, the persist steps are skipped and the raw transcript is kept as a fallback, so you never lose the record. Nothing in the pipeline marks the meeting `done` — a single reconciler does that from the run's terminal event, so a sibling step failing cannot strand a half-written meeting that already looks finished. :::note[Agents cannot read your meetings] There are no meeting or calendar MCP tools. An agent connected over [MCP](/manual/guides/mcp-server/) cannot list meetings, read a transcript, or see action items and decisions — that data reaches human clients over RPC only. Decisions do reach agents indirectly, as memory facts in the `decisions` domain. ::: ## Where meetings show up | Surface | What it shows | | ---------------------- | ------------------------------------------------------------------------------------- | | `/meetings` | The list of meetings, with action-item and decision counts | | `/meetings/record` | The live recording HUD: your notes on one side, the streaming transcript on the other | | `/meetings/:meetingId` | A meeting's detail: Notes, Transcript, Action items and Decisions tabs | ## Platform support Capture is driver-free on all three desktop platforms (Core Audio taps on macOS, WASAPI on Windows, PipeWire on Linux). **The web client records too**, which the rest of this page's desktop framing can hide: the browser takes your mic through `getUserMedia` and the meeting audio through `getDisplayMedia` (screenshare), downsamples to 16 kHz PCM16 and streams to the same host. Three caveats apply there: - The shared surface **must** carry an audio track. Safari, Firefox and macOS full-screen sharing yield none and the recording refuses to start. - There is no signal-level AEC and no input level meter; only the host-side text echo filter applies. - "Re-run summary" and "cancel processing" are inert on web. The calendar's **Start recording & link** action is desktop-only and reports that plainly on web, even though the meetings screen's own record flow works there. :::caution[Deleting a meeting leaves its audio behind] Deleting a meeting removes its database rows only. The retained per-channel audio directory `/meetings//` is never cleaned up — there is no retention policy, size cap, or GC sweep for it. Reclaiming that disk is a manual job on the server host. ::: ## See also - [Calendar and scheduling](/manual/concepts/calendar/): record a meeting straight from a calendar event - [Pipelines and automation](/manual/concepts/pipelines/): the engine behind `meeting_summary` - [Tickets and delegation](/manual/concepts/tickets/): link a meeting's action items to tickets - [Domain events](/manual/concepts/domain-events/): `MeetingRecordingStopped` and `MeetingStartingSoon` - [Record and summarize a meeting](/manual/guides/record-meeting/) ### Memory and knowledge > How Control Center stores agent knowledge, what actually reaches an agent's prompt and why memory reads are fail-open. Source: https://usectrl.dev/manual/concepts/memory-knowledge/ ## What memory is for Without memory, every agent conversation starts from zero: the same convention has to be re-explained, the same decision re-litigated, the same dead end re-explored. Memory is the workspace-scoped store that survives runs and it exists so that knowledge produced by one agent is findable by another later. It has three layers and they answer different questions: 1. **Facts** — what is true (a convention, an outcome, a decision). 2. **Policies** — what agents must do about it (a normative rule). 3. **Working memory** — what one agent was in the middle of. The distinction matters because they reach the agent by different routes. Facts are searched. Policies are pushed. Working memory is private. ## Facts A `MemoryFact` is a knowledge unit scoped to a workspace and a domain. | Attribute | What it is | | ------------------------------------------- | ------------------------------------------------------------------------- | | `domain` | The knowledge domain ("conventions", "decisions", "codebase") | | `topic` | A short topic label | | `content` | The knowledge text | | `confidence` | 0.0–1.0, seeded from provenance and raised by re-mention | | `memoryType` | The fact's kind — drives its temporal decay curve | | `veracity` | How it came to be known — seeds confidence and sizes each re-mention bump | | `sourceObservationIds` | Which observations led to it | | `supersededBy` | The newer fact that replaced it, if any | | `validUntil`, `recallCount`, `mentionCount` | Expiry and usage signals used at recall | The last two rows are the ones people miss and they are what decide _which_ fact surfaces. `memoryType` carries per-type Weibull decay parameters, so a time-boxed commitment fades quickly while a stable profile fact does not. `veracity` records provenance — stated by a human (weight 1.0), inferred by an agent (0.7), produced by a tool (0.5), imported (0.6) — and that weight seeds the fact's starting confidence. Facts are never edited in place when they are contradicted. Writing a fact runs conflict detection against same-domain, same-topic facts and supersedes the loser, recording a conflict row. The audit trail is the point: you can always see what was believed before and what replaced it. ## Policies A `MemoryPolicy` is a normative rule derived from facts, scoped to a workspace and domain, with an active flag and an optional `requiredRole`. Every **active** policy in the workspace is injected into **every** dispatch, unfiltered — no filtering by domain, role, task, or space. This is worth knowing before you write your twentieth policy, because policies compete for the same context window as the task. It is also why `requiredRole` on a policy is advisory: it describes who the rule is aimed at, it does not stop the rule reaching everyone else. ## Domains A `MemoryDomain` groups facts and policies within a workspace. Domains give knowledge organizational structure and they are the unit access grants are written against — but see the caution below before treating a domain as a confidentiality boundary. ## Access control is fail-open for reads `MemoryAccessGrant` maps an agent role and a domain to one of three permission levels: | Permission | Meaning | | ---------- | --------------------------------------------------- | | `none` | No access | | `read` | Can search and retrieve facts | | `write` | Can create, update and supersede facts and policies | :::caution[A missing grant means read, not deny] When there is no grant row for a `(role, domain)` pair, the effective permission is **`read`**. Reads are fail-open by design; only writes are hard enforced and only writes throw. Putting sensitive content in a memory domain does not restrict who can read it unless you have explicitly written a `none` grant for every role that should not see it. There is also no UI for grants anywhere in the product today. The memory screen has Facts, Policies and Graph tabs and nothing else; grants are reachable only over the `memory_access_grant.*` RPC ops. ::: The asymmetry — soft on reads, hard on writes — is a deliberate trade. Memory is most useful when it circulates and a read denial produces an agent that silently knows less rather than one that reports a problem. A write denial, by contrast, is loud and correctable, so that is where the enforcement sits. ## Working memory Each agent has an `AgentWorkingMemory`: a free-text scratchpad keyed by workspace and agent. It persists across runs, is private to that agent and is injected into its prompt at every dispatch. It is where "I was halfway through refactoring auth" lives — the state that is worth carrying to the next run but not worth promoting to a durable fact. ## What actually reaches an agent's prompt At dispatch, the memory preamble is assembled from exactly three things: - **Every active workspace policy**, as `- [domain] rule` lines. - **The agent's own working memory**, verbatim. - **A keyword-matched shortlist of facts** for this task — at most five, within a ~1,200-character budget and only when the task description is at least 20 characters long. Shorter tasks get no facts at all. The shortlist is FTS/keyword-only and it is capped on purpose. Embedding a query is slow and the dispatch path is the one place where latency is immediately visible, so the slow semantic path is deliberately kept off it. The preamble closes by telling the agent to verify load-bearing items with `search_memory`, which is where the full recall model lives. No access grants are consulted on this path. Whatever the shortlist finds is what the agent sees. ## Recall: one query, four voices The agent-facing `search_memory` tool has three modes and they are genuinely different searches: - **`keyword`** — FTS5 only. - **`semantic`** — BM25 and vector results fused by reciprocal rank fusion. - **`hybrid`** (the default) — polyphonic recall: four ranked voices fused together, then diversified. The four voices are lexical (FTS), semantic (vector KNN), temporal (recent active facts) and graph — a two-hop walk over episodic edges from the strongest lexical and semantic seeds. The graph voice is the reason episodic edges exist at all: it surfaces the fact that is _connected to_ your answer without matching any of your words. The voices are weighted by a classified query intent (semantic carries the most weight by default, then graph and lexical, then recency), every candidate's score is scaled by its Weibull decay, and the result set is MMR-diversified so five restatements of one fact do not crowd out four different ones. :::note[Semantic recall is silent until the model is installed] The vector voice only contributes once the on-device embedding model has been downloaded. Until then, hybrid recall runs with three voices and there is no retroactive backfill — facts written before the model was installed never gain vectors. The vector path degrades further, to an in-Dart scan over the packed embeddings, if the SQLite vector extension itself fails to load; recall still answers, just more slowly. ::: The desktop and web memory search is a separate, simpler path: the `memory_fact.search` RPC op is **FTS5-only**, because a thin client cannot ship a query embedding and the hybrid path stays host-internal. So the ranking you see in the memory browser is not the ranking an agent gets from `search_memory`. That is expected, not a bug, but it does mean "I searched and did not find it" in the UI is not evidence the agent will not find it. ## Where memory lives and who writes it The memory browser is at **Settings → Workspace → Memory** (`/workspaces//settings/memory`), with Facts, Policies and Graph tabs. There is an asymmetry there that surprises people: **you can edit and delete memory, but you cannot create it.** Neither the Facts tab nor the Policies tab has an add affordance. Authoring is agent-side, through `propose_fact`, `propose_policy` and `remember`. The browser is a review and correction surface, not an authoring one — so if you want a fact in memory, the way to put it there is to tell an agent. Two maintenance operations are likewise agent-invoked rather than scheduled: `consolidate_memory` merges and prunes accumulated facts and `harmonize_memory` reconciles beliefs that diverged across agents. `list_memory_conflicts` shows what conflict detection has already recorded. Nothing runs them for you on a timer. ## Related concepts - [The agent model](/manual/concepts/agent-model/): roles are what grants are written against - [Workspaces and isolation](/manual/concepts/workspaces/): memory is workspace-scoped and lives in that workspace's own database file - [Manage workspace memory](/manual/guides/manage-memory/): working with memory in practice ### Modes > Why a space, not an agent, carries a mode; what each of the four modes guarantees; and where that guarantee is structural rather than merely prompted. Source: https://usectrl.dev/manual/concepts/modes/ ## What a mode is A mode is a property of a **space**, stored as one column on the space row. Every conversation inside a space shares it, threads included — a thread inherits its space's mode like any other conversation. The same agent can therefore be in different modes in different spaces at the same time and switching a space's mode changes what happens on the next turn without touching the agent. There are exactly four: `chat`, `review`, `plan`, `orchestrate`. The enum itself carries no policy. Everything a mode guarantees is declared once, in a **capability profile**, which every enforcement layer projects from: 1. **Tool surface** — which tools exist at all in that mode, expressed as a maximum approval tier, an allow-list, a deny-list and a set of refused effect classes. 2. **Required output verb** — what the run must call to have delivered anything. 3. **Denied effect classes** — what the action guard refuses outright. 4. **Worktree posture** — whether the worktree may be written. This is enforced by stripping the mutating tools out of the surface, which is what makes it hold on every host. Where the host has an OS sandbox, the same posture also carves the filesystem write rules — a read-only mode mounts the worktree read-only — but that is a second copy of the guarantee, not the one doing the work. 5. **System prompt block** — a _generated_ capability preamble derived from the actual materialized tool list, plus hand-written guidance. That single declaration exists because these five used to be asserted independently and drifted. Plan mode's prompt once instructed the agent to write plan files into a directory whose write tools had been removed, so the instructed deliverable was impossible and the run reported success anyway. Orchestrate mode's guard preset once denied the exact effect class its only output verb declares, so the mode could not produce its own deliverable. Both bugs are the same bug: a fact with more than one writable home. ## The four modes ### Chat — displayed as "Agent" The default and the only mode with no extra constraints: the full tool surface up to the execution tier, a writable worktree, no denied effect classes and no completion contract. Use it for implementation, bug fixes, questions about the codebase and anything ad hoc. Note the label: in the composer the mode is shown as **"Agent"**, not "Chat". ### Plan — read-only, one deliverable Plan mode makes the worktree read-only and strips the write tools from the surface entirely, so the restriction is structural rather than instructed. The agent reads, searches and researches and delivers exactly one thing: a `submit_plan` call carrying a typed dependency graph — a goal plus nodes with keys, titles, descriptions and the node keys each depends on. The plan renders as a compact row in the conversation and opens Plan Studio in a tab beside it. Approving it compiles the graph into an orchestration, runs the generated work and flips the authoring space back out of plan mode — the two halves of "leaving plan mode" are linked deliberately, because they used to be separate and the plan's own room stayed read-only after approval. If a plan-mode run ends without ever calling `submit_plan`, the loop first injects a nudge saying the plan does not exist until that call succeeds. The nudge explicitly authorizes an honest "no plan is needed here" exit, because plan mode is a space setting and a plain question asked inside it should not force a bogus plan. :::note[An unmet contract is not a failed run] A run that ends without its plan is still recorded with status `completed` — the process exited cleanly. The truth rides on the run's liveness (`empty`), error family (`silentRun`) and a summary that names what was not produced. See [Agent dispatch lifecycle](/manual/concepts/dispatch-lifecycle/). ::: There is a second, separate way out: `exit_plan_mode`. It is a hard approval gate, not an output verb — an agent that calls it instead of `submit_plan` has delivered nothing. The first call opens a durable `plan_exit` approval and returns `pending` and the space stays read-only; repeat calls stay `pending` rather than opening a second gate. Only once the approval is granted does a subsequent call flip the space to chat. A rejection lets the agent open a fresh request for a revised plan. :::caution[There is no screen for deciding a plan-exit approval] The durable approval entity has no human user interface. It can be decided over the `decide_approval` MCP tool — that is, by another agent — but nothing in the app lists pending approvals of this kind. The Inbox's "agent approval" rows are a different thing: per-turn confirmation prompts from a running agent. In practice, take a space out of plan mode from the composer. ::: ### Review — read-only, no contract Review mode is for PR review. It makes the worktree read-only, caps the tool surface below the execution tier and applies a curated allow-list of review, ticketing and communication tools. A space opened for a pull request is created in review mode automatically. The agent's output is structured findings — **review nodes** — and, when a review is finalized, a **verdict**. A node carries a kind (`bug`, `suggestion`, `recommendation`, `question`, or `ticket`), a priority from P0 (blocks release) to P3 (nice-to-have), a self-assessed confidence, an optional anchor (file plus a line range; repository-wide notes have none) and a lifecycle status that moves from `open` to `consensusReady` once a peer — never the author — confirms it. A finding whose priority or confidence is missing or out of range is dropped at the read boundary rather than polluting the verdict. The verdict is ship, hold, or block and it is only ever made _more_ severe by the per-axis results folded into it: a gated axis that failed forces `block` and a gated axis that could not run forces at least `hold`. Absence of evidence never greens a gate. Unlike plan and orchestrate, review carries **no completion contract**. Findings accumulate through `add_review_node` and are published by `finalize_review` and both may legitimately be called zero times — a pipeline agent running in review mode may be there to complete a ticket instead. ### Orchestrate — read-only, proposes a team Orchestrate mode turns one goal into a whole-team plan. Like plan mode it is read-only and cannot write files; on top of the research surface it has a single privileged verb, `propose_orchestration`, which emits a structured proposal: roles, a sub-ticket tree, a synthesis step and a budget. Its prompt also names the hiring, delegation and ticket lifecycle verbs as unavailable — not to enforce anything (they are already absent from the surface) but so a weak model does not burn turns narrating work it has no tool for. Nothing in the proposal executes until you approve it. On approval a deterministic materializer hires the agents, creates the tickets and a project and runs a generated pipeline. Approving is operator-only: there is no MCP tool for it, so an agent can propose but never approve its own plan. Agent-initiated re-proposals are rate-limited by a two-minute cooldown, so consecutive replans are refused and told to coalesce. See [Orchestration](/manual/concepts/orchestration/). ## Two rules worth knowing **A mode's own output verb can never be denied.** `submit_plan` and `propose_orchestration` are pinned into the tool surface and pre-approved past the tool filter, the approval gate and the action guard alike. This is what fixed orchestrate mode being structurally unable to emit its only deliverable and it also means the per-space autonomy dial cannot silence a mode's deliverable. The harness's `ask_user` is pinned for the same reason: it _is_ how a run reaches the operator, so gating it would deadlock a run that needs to ask something. It is read-tier, so every read-only mode keeps it, and it is exempt from the approval wrapper — a prompt in front of a prompt fails closed with no approver connected, which would deny an agent the act of asking. **Read-only modes do not deny network access or secrets.** The effect classes review, plan and orchestrate refuse are the ones that change the world: file deletion, writes outside the worktree, commits, pushes, PR creation and publication, vendor sync writes, package installs, process spawning and workspace mutation. Network egress and secret access stay at their defaults on purpose — research is the entire point of a read-only mode. ## Choosing and changing a mode The composer's mode dropdown offers **three** options: Agent (chat), Plan and Orchestrate. `review` is **not user-selectable** — it is set by the system when a PR review space is created and by pipeline steps. A pipeline node can pin its step's mode with an `extras['mode']` key. The key is exact-match: any other spelling is silently ignored and the step gets the body's default, which is how generated orchestration steps once ended up read-only by accident. Resolution itself is simple. The dispatcher reads the space's `mode` column; a dispatch with no space — a one-shot, a CLI invocation, or a space id from another workspace, which is simply not in this workspace's database file — resolves to `chat`. Changing the mode takes effect from the next turn. ## Where the guarantee actually holds A mode's tool surface is built and enforced by the **built-in harness**. It is the only transport that can make a tool invisible rather than merely denied, gate every call before it runs and observe a completion contract. :::caution[External CLIs get much less] On the Claude Code transport a non-chat mode is passed as `--permission-mode plan`, which Claude honours by convention rather than by our enforcement and its own read, write, edit and shell tools run unseen. On a structured-JSON CLI the mode reaches the agent as prompt text only. Over ACP nothing about mode is negotiated at all. For those transports the mode is guidance, not a boundary — see [The agent model](/manual/concepts/agent-model/). The allow-lists are not an inventory of what exists. They constrain only write-tier-and-above tools, so the built-in harness's read tier reaches tools no list names; and on the external-CLI path, where the allow-list is the whole enforcement, a tool it omits is refused whether or not the server has it. ::: ## Related concepts - [Agent dispatch lifecycle](/manual/concepts/dispatch-lifecycle/): where the mode is resolved and projected - [The agent model](/manual/concepts/agent-model/): which transports can enforce a mode - [Guardrails](/manual/concepts/guardrails/): the effect-class policy a mode's preset feeds into - [Orchestration](/manual/concepts/orchestration/): what an approved orchestrate proposal does - [Use plan mode](/manual/guides/plan-mode/): the practical walkthrough ### Multiplayer — identity, membership and presence > How Control Center models humans and agents as co-equal participants, what actually gates access to a workspace and why live awareness and shared records travel on two different lanes. Source: https://usectrl.dev/manual/concepts/multiplayer/ Control Center works for a single operator, but the model underneath it is multi-actor from the start. Humans and agents message in the same spaces, edit the same tickets and appear on the same roster. Making that safe and comprehensible comes down to three decisions: **who counts as an actor**, **what gates access** and **which state is shared durably versus merely broadcast live**. ## Principals: users and agents are co-equal actors Everything that records "who did this" — messages, tickets, reviews, plans, run logs, audit entries — resolves through a **Principal**, a sealed union of `UserPrincipal` and `AgentPrincipal` with a compact wire form (`user:` / `agent:`). Attribution never collapses N humans (or N agents) into one sentinel account, so a review left by an agent and a review left by a person are distinguishable everywhere they surface. Because the principal is the unit of identity, it is also the unit of containment: - **Rate limits are per principal, not per session.** The flood guard on remote tool calls budgets by user, not by connection: all of a member's devices draw from one shared sliding window (120 calls a minute, of which at most 30 may mutate), so opening three devices doesn't triple anyone's allowance and one noisy member can't starve the others. - **Undo is per principal.** Each client's action journal records only the mutations _its_ principal performed. You can ⌘Z your own ticket edit; you can't undo a teammate's — by the time you see it, the durable lane has already resolved it (see below). ## Membership is the access boundary — not the pairing key Two separate credentials answer two separate questions. The **pairing key** (see [Remote control and mobile](/manual/concepts/remote-control/)) authenticates a _device_ to the server. **Membership** authorizes that device's user to act inside a _workspace_. A paired device whose user holds no membership in a workspace can neither read nor mutate it. Membership is one row per (workspace, user), stored in **that workspace's own database file** — so "who is in this workspace?" is an ordinary scoped read, while the inverse question, "which workspaces am I in?", necessarily spans files and is one of the few sanctioned cross-workspace fan-outs. It is what the workspace picker runs before any workspace has been chosen. Each row carries a role from a five-level, rank-ordered ladder: | Role | May | | -------- | ---------------------------------------------------------------------------------------------------------- | | `owner` | Full control, including workspace deletion; exactly one per workspace | | `admin` | Manage members, invites, settings, destructive operations | | `member` | Day-to-day read-write: messages, tickets, agent runs | | `viewer` | Read-only across the workspace | | `guest` | Read-only like viewer, plus secret-exclusion globs and per-repo grant checks on every code-bearing surface | Every access decision reduces to "is the caller's role at least X". The floor is derived from the operation's kind unless it declares one explicitly: a read needs `guest`, a mutation needs `member`, a destructive operation needs `admin`. Per-object ACLs beyond the per-repo grants below are deliberately out of scope. **Invites** are how membership is granted, from Settings → Workspace → Members & roles. An admin mints a single-use code (as a link or QR) that expires after seven days by default; the server stores only the code's SHA-256 hash and the code itself is shown exactly once. Redeeming it JIT-provisions the user, records membership at the invite's role (never `owner` — ownership is transferred explicitly, never granted by invite) and hands off to device pairing. Device credentials minted in the app are themselves time-boxed to 30 days, so a leaked QR is not a permanent key. **Repo grants** sit beneath membership. Because the server holds full checkouts, workspace membership alone must never out-privilege the forge: every code-bearing surface — files, diffs, PR review, code search — checks the member's grant on _that repo_. Grants are per (workspace, user, repo) and ranked `none` → `read` → `review` → `write`, where `write` means driving agent runs that write to the repo. Absence of a row means `none`; owners and admins implicitly hold `write` on every linked repo and carry no rows at all. ## Two lanes of collaboration state Control Center splits shared state into two lanes with opposite durability rules, because the two kinds of state fail in opposite ways. ### The ephemeral presence lane Presence — who is online, where they are, what they're typing, what an agent is doing right now — is worthless the moment it is stale. If it were persisted, every reconnect would have to reconcile "the database says Sam is editing this ticket" against reality. So presence is **never written to the database**. It lives in an in-memory **presence hub** on the server: clients publish with `presence.update`, peers subscribe with `presence.watch` and entries expire automatically after three missed heartbeats (heartbeats run every 10 seconds; the hub sweeps every 5). There is nothing to reconcile — only to let lapse. Two properties keep the lane trustworthy: - **Identity comes from the session, never from client arguments.** The server maps the connection's authenticated device to a user id; a client can only ever update its own presence, so nobody can spoof another participant's status or locus. An explicit `offline` update (disconnect or the invisible toggle) removes the entry immediately. - **The roster is server-hubbed.** Peers never exchange presence directly, so every participant sees the same roster regardless of topology. Agents have no client, so the server _synthesizes_ their entries from live run state — thinking, running, blocked on an approval gate, done — with a single representative run deciding an agent's state and locus so concurrent runs don't make the roster flap. Fan-out is tiered per consumer: desktop and web ride a full tier (at most one roster emission per 100 ms), the phone a summary tier (per 500 ms), so cursor-cadence updates can't melt the PWA. A participant's **locus** — where they currently are, down to the artifact — is a sealed union: `Space`, `File` (repo + path + optional line), `Pr`, `Ticket`, `PlanNode`. Not every locus has somewhere to jump to: there is no file-viewer route, so a `File` locus renders as a label rather than a destination. The lane also carries **soft claims** ("Sam is editing this ticket", "an agent holds this worktree"): conflict _visibility_, not locks. Unknown locus kinds parse to null, so an old client simply doesn't render what it doesn't understand. ### The durable shared lane Records that must survive — tickets, the shared space Notes doc, messages — take the opposite path: authoritative, ordered and persisted. Concurrent edits resolve by **last-writer-wins in server receipt order**, never by client clocks. The resolution is deliberately fine-grained: ticket edits are _per-column_, so two people editing different fields of the same ticket both land and neither clobbers the other (workflow transitions keep optimistic locking instead); the space Notes doc is one column, so the whole doc is LWW with soft claims on the presence lane making concurrent editing visible instead of locked. It is deliberately not a CRDT — the burden of proof is on the CRDT and receipt-order LWW plus visible claims covers the actual collaboration patterns. "Last writer" means **server receipt order**, never a client clock — a laptop with a skewed clock cannot win an edit it lost. Behind the durable lane sits the **sync feed**: SQLite triggers append every mutation to a per-workspace change log _inside the writing transaction_, allocating a monotonic per-workspace sequence number in the same transaction as the mutation itself. A server service tails that log into versioned delta frames in sequence order. Clients evolve from full snapshots to deltas; a gap in the sequence drops that store back to snapshot mode rather than misapplying frames. ## Follow, steer, take over, hand back The presence lane isn't just a roster — it drives the collaboration verbs: - **Follow mode.** Click a participant and your client rides their locus, navigating as they navigate. Follow is "until you act": the moment you navigate under your own steam, follow detaches. A dismissible banner announces when another participant is spotlighting (presenting) a space you're in. - **Steer.** A message to a running agent is queued as steering; the agent re-reads it before resuming its next turn, so you can nudge a run mid-flight without killing it. - **Take over.** Assuming control of a conversation pauses every active run at its next clean turn boundary (external CLI runs, which have no safe boundary, are stopped) behind a per-turn git snapshot, so the take-over is reversible. A durable marker blocks new dispatches into the space and survives a server restart — the server never auto-resumes into a human's half-finished edit. One live editor per worktree is a soft claim: a second take-over is visible and refused, not silently merged. - **Hand back.** Returning control posts a structured diff summary of the worktree to the space, queues it as steering for the paused runs, resumes them and clears the marker. ## The autonomy dial Steering and take-over are interventions after the fact. The **autonomy dial** is the standing setting: per space, per agent, set from the space header, it decides what happens when an agent reaches a gated action. | Level | Wire value | Effect on a gated tool call | | ----------------- | ----------------- | ------------------------------------------------------------------------------------------------------------- | | Propose only | `proposeOnly` | Denied outright, with a reason the agent is told to relay — it can describe the action but not take it | | Act with approval | `actWithApproval` | The fail-closed approval gate: one confirmation surfaces to a connected operator and no approver means denied | | Act freely | `actFreely` | Pre-approved, except where a hard `deny` guardrail rule applies — a deny rule always wins | | (unset) | `null` | Same as act-with-approval: the server's default | Two things sit outside the dial in both directions. A mode's own output verb is pinned and can never be denied, whatever the dial says — orchestrate mode was once structurally unable to emit its only deliverable because a preset denied the effect class that verb declares. And a hard `deny` from the action guardrails is not overridable by `actFreely`. :::note[The dial gates the built-in harness only] It is consulted by the harness loop's approval callback. An agent running on an external CLI or ACP adapter makes its own tool calls inside its own process, where Control Center has nothing to gate — see [The agent model](/manual/concepts/agent-model/). ::: ## Solo zero-regression All of this is additive. A solo operator's workflow is unchanged: with one human and no agents the presence hub has nothing to broadcast, so the lane emits its initial snapshot and then idles at zero traffic; the LWW and sync machinery is the same single-writer path it always was; and the pairing key remains the device credential it has always been — membership only starts governing access the moment a second principal joins. ## Related concepts - [Workspaces and isolation](/manual/concepts/workspaces/): the boundary membership enforces - [Remote control and mobile](/manual/concepts/remote-control/): the pairing key and device credential side of identity - [The agent model](/manual/concepts/agent-model/): the agent half of the principal union - [Agent dispatch lifecycle](/manual/concepts/dispatch-lifecycle/): the runs that steering and take-over pause and resume - [Guardrails](/manual/concepts/guardrails/): the effect-class policy the autonomy dial composes with - [Domain events](/manual/concepts/domain-events/): the bus that live updates ride ### Orchestration > How one "big ask" becomes a whole-team plan you approve once, then a generated pipeline that runs it. Source: https://usectrl.dev/manual/concepts/orchestration/ A single agent is good at one thing. Some asks are bigger than that: "research the market for X and write a positioning doc", "ship this feature end to end", "audit this module and fix what you find". An **orchestration** is how Control Center handles those: an orchestrator (usually your CEO agent) proposes a whole-team plan, you approve it **once** and a deterministic materializer turns it into a pipeline that does the work. It sits between the two existing ways of getting work done: | When you want to… | Use | | ------------------------------------------------- | --------------------------------------------------------- | | Hand one task to one agent | A [message in a space](/manual/concepts/dispatch-lifecycle/) | | Encode a process you already know, by hand | A [pipeline template](/manual/concepts/pipelines/) | | Say the goal and let the team figure out the plan | **An orchestration** | ## The shape of a proposal When you give the orchestrator a goal, it returns a structured **`OrchestrationProposal`**: a typed plan the app can validate and execute, rather than free-form prose. A proposal has: - **Goal**: your high-level ask, restated. - **Roles**: who does the work. Each role is either an **existing agent** you pick, or a **new hire** the orchestration creates on approval. - **Sub-tickets**: the work, broken into a DAG of [tickets](/manual/concepts/tickets/). - **Research** _(optional)_: a research phase that runs first and feeds the rest. - **Discussion** _(optional)_: a bounded round where each role posts a structured position before work begins. - **Synthesis**: the final step that produces the deliverable. - **Budget**: a declared cost ceiling. :::caution[The budget is advisory] The proposal's cost ceiling is compared against the plan's estimated total and flags an over-budget plan in the approval bar _before_ you approve. Nothing reads it once the run has started, so it does not stop or fail an orchestration that overruns. Per-agent and per-workspace budget policies are a separate mechanism and are what actually pause spend. ::: ## Where you review it Two surfaces show the same orchestration. The **proposal card** lives in the conversation that produced the plan. It is one message that re-renders through the whole lifecycle — proposed, executing, completed — with the approve and cancel actions on it. **Plan Studio** is the editor: a DAG canvas, a node inspector, a revision and diff drawer and the approval bar carrying the estimate. It opens as an editor tab straight from that card. There is deliberately no "Plans" item in the sidebar; `/plans` and `/plans/:kind/:id` exist as deep links for sharing. Approving is **operator-only**. There is no MCP tool for it: an agent can propose (`propose_orchestration`), submit a plan (`submit_plan`), or instantiate a playbook (`run_playbook`), but only a human approves. Agent-initiated revisions are additionally rate-limited by a two-minute cooldown, so a replanning loop coalesces instead of flooding your feed. What you can change before approving is what the node inspector exposes: retitle or rewrite a sub-ticket, change which **role** owns it, rewire its dependencies, edit its output schema, or delete the node outright. Structural nodes (research, discussion, synthesis) and nodes that have already executed are read-only. You cannot pin a role to a specific named agent and you cannot edit the budget, from the editor. Two editors on one plan is resolved by optimistic concurrency, not merging. Every edit carries the `base_revision` it was made against; an edit based on a stale revision is refused with "the plan moved on" rather than silently clobbering someone else's. Rewinding is not a delete either — it appends the older proposal as a new revision, so the revision timeline only ever grows. ## Honest estimates, or none The estimate on the approval bar is built from real completed-run history for each role, as an interquartile (p25–p75) band and the plan total is the critical path through the DAG rather than the sum (parallel branches overlap). A node whose role has **no completed-run history** reports `sampleSize: 0` and null ranges. That renders as exactly that — unknown — never as a fabricated point value, because an estimate you cannot trust is worse than no estimate. When some nodes have history and some do not, the total is marked partial. Blast radius appears only when a node declared `symbol` or `file` provenance refs; a node without them stays "unknown" rather than inferring from prose. ## One approval, then deterministic execution This is the core idea: **the agent plans; a deterministic function executes.** When you approve, the `OrchestrationMaterializer`, a pure function with no LLM and no I/O, converts the proposal into a real [pipeline](/manual/concepts/pipelines/). Given the same proposal and the same resolved roles, it always produces the same DAG. That matters because the generated pipeline inherits everything the engine already does for free: suspension and resume across restarts, crash recovery, per-run cost and token rollups and the run-detail UI. Approval also creates the scaffolding the work needs: - **hires** any roles marked as new (and records them so they can be cleaned up) - a **team** grouping the roles - a **project** holding the sub-tickets - the **pipeline template + run** that drives it all ## The lifecycle ``` proposed → approved → executing → synthesizing → completed ↘ ↘ failed ↘ ↘ cancelled ``` | Status | Meaning | | -------------- | -------------------------------------------------------------------------------- | | `proposed` | The plan is ready and waiting for your one upfront approval | | `approved` | You approved it; materialization is in progress (hires, team, project, pipeline) | | `executing` | The generated pipeline is running: sub-tickets, discussion, work | | `synthesizing` | The sub-tickets are done; the synthesis step is producing the deliverable | | `completed` | Done: the deliverable landed on the parent ticket | | `failed` | A hard error, or every sub-ticket failed | | `cancelled` | You cancelled it | A monotonic **revision** number tracks the proposal as the orchestrator revises it; the app records which revision you approved, so a mid-flight replan can't silently change what you signed off on. An orchestration always opens against a **parent ticket** and shares one discussion **space**, so the whole effort is traceable to a single work item and the team talks in one room. ## What happens when it finishes A kept-alive listener watches the generated pipeline and maps its terminal state back onto the orchestration and the parent ticket, so completion and failure surface in the places you already watch. The orchestration feature does not need its own execution engine. You can cancel an orchestration at any time; the cancellation tears down the in-flight work. ::::note Orchestration builds on [pipelines](/manual/concepts/pipelines/) and [tickets](/manual/concepts/tickets/). It is a planning and approval layer over the top of them rather than a separate runtime. :::: ## See also - [Pipelines and automation](/manual/concepts/pipelines/): the engine a materialized orchestration runs on - [Tickets and delegation](/manual/concepts/tickets/): the sub-tickets and parent ticket an orchestration creates - [The agent model](/manual/concepts/agent-model/): the roles an orchestration fills or hires - [Run an orchestration](/manual/guides/run-orchestration/) - [Work in Plan Studio](/manual/guides/plan-studio/) ### Pipelines and automation > Why pipelines separate scheduling from work, what actually starts one and which of the built-in templates are live out of the box. Source: https://usectrl.dev/manual/concepts/pipelines/ Pipelines are the deterministic half of Control Center. An agent decides what to say; a pipeline decides what happens next, the same way every time. A pipeline is a DAG of steps that can dispatch agents, run scripts, branch on a condition, fan out over a collection and pass data between steps — persisted, resumable, and costed. They exist for three reasons: to chain a repetitive multi-step process, to coordinate several agents in sequence or in parallel and to react to something that happened without a human being there. ## Templates and runs A **template** (`PipelineDefinition`) is the declarative DAG: steps, declared inputs and configuration. It lives in the workspace's own database. A **run** (`PipelineRun`) is one execution of that template, carrying a mutable state bag, cost and token totals and per-step status. One template has many runs. Runs survive a restart: the engine reloads in-flight runs and picks them up where they stopped. Two backstops deliberately fail the whole run rather than resume a step, because resuming would be worse than stopping: - A step that has been `suspended` longer than `suspendedStepTimeout` (24 hours by default) is waiting on tickets that will never reach a terminal state, so it is failed to free the run. Approval gates are exempt — waiting days for a human is their job. - A step declared `extras: {'idempotent': false}` that was mid-flight when the server stopped may already have applied its side effect (a squash merge, a worktree cleanup). Re-running it could double-apply, so the engine fails it and leaves the retry to you. ## Kind schedules, body works A step has two orthogonal parts and keeping them apart is what lets the engine stay small. The **kind** (`StepKind`) tells the engine _when_ a step becomes runnable relative to its upstream steps — that is the only thing the scheduler needs to know. There are six: `trigger`, `listen`, `join`, `router`, `forEach` and `terminal`. The **body** (`bodyKey`) is the _work_ the step performs once it is scheduled. Bodies are closures registered by key at server startup, so many steps of different kinds can share one body: several `listen` steps all running `conversation.promptAgent`, for instance. Because scheduling semantics live in six kinds, the catalog of behaviours can grow without anyone touching the scheduler. See [Pipeline steps](/manual/reference/pipeline-steps/) for the complete catalog of kinds, bodies and their configuration. Two consequences of the registry design are worth knowing before you author a template by hand: - A step whose `bodyKey` is not registered produces a **warning at template load** and then never executes. Nothing fails loudly at runtime. (`trigger` and `terminal` steps are exempt — they are scheduler sentinels and carry no body.) - Keys in a node's `extras` map are **exact-match and silently ignored otherwise**. Only `extras['channelId']` (key unchanged) puts a step's work in an existing conversation and only `extras['mode']` sets that conversation's mode. Any other spelling gets a hidden conversation and the body's default mode. ## State and data flow Steps do not call each other. They read from and write to a shared state bag on the run: a step reads its inputs by key, executes and writes its output back under its own `outputKey`. A `StateReducer` merges concurrent writes when parallel branches target the same key and a `TemplateRenderer` substitutes `{{...}}` placeholders in prompts and scripts from that same state. This is why a pipeline is inspectable after the fact — the state bag is the whole story of the run. ## What starts a run `PipelineTrigger` rows declare when a template auto-starts. There are four kinds: | Trigger | Starts a run when | | ---------------- | ------------------------------------------------------------------------------------------------------------------------- | | **Domain event** | A matching [domain event](/manual/concepts/domain-events/) fires — for example `PullRequestPublished` or `TicketAssigned` | | **Schedule** | A cron/interval expression comes due | | **Manual** | You run it by hand; the presence of an enabled manual trigger is what puts the template in the run picker | | **Webhook** | An external system POSTs to `/webhooks/` on the server's HTTP port | Event triggers are a closed set: 14 event types are known to the dispatcher and offered in the picker and nothing else can start a pipeline. Two of those 14 — `TicketCreated` and `TicketStatusChanged` — have no payload mapping behind them yet, so a trigger on either sits enabled and never fires. See [Domain events](/manual/concepts/domain-events/) for the full list. The webhook token doubles as the HMAC secret: the request's `X-Hub-Signature-256: sha256=` header is verified against `HMAC-SHA256(body, token)`, unverified deliveries are rejected and not replayable and duplicates are dropped by `dedupe_key`. Because trigger rows are unique on (workspace, event type, template), a template can hold at most one webhook trigger and one trigger per domain event type. The built-in templates seed their **manual and event triggers enabled**; **scheduled triggers seed disabled**, so a recurring sweep is always opt-in. ## The templates that ship disabled Thirteen pipeline templates are seeded into every workspace and **eight of them ship disabled**: `external_pr_welcome`, `cross_review`, `ticket_to_pr`, `pr_triage`, `pre_merge_gate`, `release_notes`, `dep_audit` and `pr_digest`. This is the part that surprises people. Their triggers are seeded _enabled_, but `PipelineEngine.start` refuses a disabled template and returns null — so the trigger fires into nothing and no run appears. **The template, not the trigger, is the switch.** Enable it at Settings → Workspace → Pipelines before expecting any of those eight to do anything. Re-seeding a workspace preserves whatever you chose: your `isEnabled` setting and your existing trigger rows survive. There is exactly one deliberate exception — `pr_merged_cleanup`'s daily schedule is force-enabled even if it was previously off, because it is garbage collection. Worktree rows, code-graph partitions and copy-on-write copies accumulate without it and that was never meant to be a user choice. One more asymmetry worth knowing: the `pr_review` built-in deliberately no longer posts to GitHub. Publishing a review is a separate, user-gated step and a pipeline that posted directly would double-publish. `cross_review`, `ticket_to_pr` and `pr_triage` do still post a comment straight to GitHub when they run. ## What the engine knows `PipelineEngine` creates the run record, schedules the trigger step and then walks the DAG: as steps complete it evaluates downstream triggers, resolves router branches, waits on joins, applies retry policies and continue-on-fail, and persists state, cost and errors at every step. What it does _not_ know is anything about ticketing, dispatch, GitHub, or messaging. Its only extension seam is the `PipelineBodyRegistry`. The bodies registered there are what hold `AgentDispatchPort`, `MessagingPort` and their siblings — which is why adding a capability to pipelines never means changing the scheduler. ## Error handling A failing step records its error message and moves to `failed`. If the step declares `continueOnFail`, downstream steps proceed and the error is stashed in the run state; otherwise the run fails. A step with a `StepRetryPolicy` is retried first, with linear or exponential backoff, before either of those applies. ## See also - [Domain events](/manual/concepts/domain-events/): what an event trigger listens to - [Orchestration](/manual/concepts/orchestration/): a plan you approve, compiled into one of these pipelines - [Tickets and delegation](/manual/concepts/tickets/): what pipeline steps create and complete - [Pipeline steps](/manual/reference/pipeline-steps/): the complete catalog of kinds, bodies and config - [Build your first pipeline](/manual/tutorials/first-pipeline/) - [Create a pipeline template](/manual/guides/create-pipeline/) - [Run a pipeline manually](/manual/guides/run-pipeline/) - [Monitor pipeline runs](/manual/guides/monitor-pipelines/) - [Set up pipeline triggers](/manual/guides/pipeline-triggers/) ### PR conversations > How a GitHub PR comment becomes an agent turn with no webhook, what authorizes the author, and who each side of the conversation is attributed to. Source: https://usectrl.dev/manual/concepts/pr-conversations/ A pull request is already where the conversation about code happens. PR conversations make it a surface on a Control Center workspace, the same way a [chat bridge](/manual/concepts/chat-bridges/) makes Slack one: @mention the server's GitHub App bot on a PR, reply inside its review threads, or add the `ai-review` label, and the work happens in the PR's review space — under the same membership, attribution and guardrails as anything typed in the app. The bot's answers come back on GitHub, in the lane the question was asked in. Three decisions shape the feature, and each is the same decision the chat bridge made for chat, restated for a forge: **which direction the connection runs**, **what authorizes a comment that arrives from outside** and **who each side is attributed to**. ## Polling is the transport GitHub's only push channel for an app is a webhook, and a webhook needs an inbound URL — the one thing this server deliberately never requires. So discovery is a sweep: every minute or so, one aliased search per app installation asks GitHub for the open PRs that mention the bot or carry the review label, PRs that already have a review space get their comment threads read, and everything is diffed by comment id against what was seen last sweep. The design consequences are worth naming: - **Latency is a sweep interval, not an event.** The 👀 acknowledgement lands within about a minute. Nothing urgent rides on it. - **Exactly once, per comment.** Comment ids are unique across GitHub; each is recorded the moment it is classified as eligible, before anything acts on it. A crash can lose a comment's handling, never double-fire it. - **Offline time is caught up, not replayed.** The seen-set is persisted, so a restart delivers what arrived while it was down. The one exception is the first sweep ever: with no memory to diff against, everything outstanding is recorded silently — an operator who labeled twenty PRs before this server existed must not come back to twenty running reviews. - **The label is a set, not an event.** Search reports label _presence_, so a labeled PR triggers once, ever. Re-adding a label after removing it is invisible to the sweep; a mention is the re-run path. The search runs once per installation account on that owner's own client, because an installation token only sees its own installation's repos — a single server-wide credential would quietly miss every PR under the app's other installations. ## Membership authorizes, not the comment A PR comment arrives carrying little more than an author login — an unauthenticated external identity until proven. The bot resolves it against a reverse index of the workspace's members and their connected GitHub accounts, rebuilt on a TTL because people sign in and get invited while the server runs. Failing closed looks like a ladder, and every rung answers in the thread it refused in: - **An unlinked login** is told how to link - **A linked non-member** is told membership is the boundary - **A read-only member** is told their role cannot start work None of them proceed. A comment is never attributed to somebody who did not send it, and a stranger's words never enter a workspace they cannot see. ## Two identities, on purpose Each side of the conversation carries a different identity, and the difference is the feature: - **On GitHub, the bot is the server.** Every comment, reaction and reply the bridge posts rides the app's installation token. Nobody clicked anything in a client, so nothing borrows a person's account — the same rule that keeps webhooks and polling off human credentials. - **In the space, the question is yours.** The message is attributed to the member the author resolved to, and the agent run executes on their behalf: commits co-author them, their stored credential backs the run, and the space's autonomy dial and action guardrails apply exactly as they do to a message typed in the app. Publishing review findings back to GitHub remains a person's decision, made in Control Center. A mention can _start_ a review; only a human publishes one. ## The loop guard, and the short name Everything authored by a bot account is dropped before any gate runs — ours specifically, and every `[bot]` suffix, so another app's review bot cannot wake ours either. Without this the bot's own answers would arrive as new comments and re-trigger it forever. The `[bot]` suffix is also why the short form exists. GitHub fixes an app account's login at `[bot]`, offers no autocomplete for it, and never resolves `@slug` to the app — so accepting the bare slug is _our_ matcher's decision, and discovering it needs a raw comment-**text** search lane rather than GitHub's mention index. The cost is stated rather than smoothed: a human user who shares the app's slug addresses the bot with every short-form mention of themselves. Distinctive app names make the collision hypothetical. ## What is deliberately not offered - **No auto-publish.** Findings land in the review space; publishing to GitHub is a separate, human action - **No fuzzy identity.** The author resolves to exactly one member or is refused — there is no best-guess attribution - **No inbound network.** No webhook endpoint exists to secure, because no webhook endpoint exists ## See also - [Talk to Control Center from a GitHub PR](/manual/guides/github-pr-conversations/): the how-to for all of this - [Chat bridges](/manual/concepts/chat-bridges/): the same three decisions, made for chat - [Create the GitHub App](/manual/guides/github-app/): where the bot identity comes from - [Multiplayer — identity, membership and presence](/manual/concepts/multiplayer/): the principals and roles a login resolves through - [Guardrails](/manual/concepts/guardrails/): what the answering agent is allowed to do - [Use AI-powered review](/manual/guides/ai-review/): the pipeline a mention or label starts ### Remote control and mobile > How the phone reaches your server over a brokered, end-to-end-sealed relay rather than a peer-to-peer link and the layered access model that keeps that reach narrow. Source: https://usectrl.dev/manual/concepts/remote-control/ Control Center is a desktop app, but the work it tracks doesn't stop when you step away from your desk. **Remote control** pairs your phone with your `cc_server`, using **[Remote](https://remote.usectrl.dev/)**, the companion app at remote.usectrl.dev, so you can follow the fleet from there: read messages and tickets, reply to an agent, triage your newsfeed, without the audio, transcript, source code, or credentials that live on the server ever leaving it. Think of it as a read-mostly companion to the deck. It is not meant to replace the full desktop client. :::note[Two different remote paths] The phone companion pairs with a `cc_server` over a **brokered WebSocket relay** that carries end-to-end-sealed JSON-RPC frames. The full **web build** (and a desktop pointed at a remote server) is the other remote path; it dials `cc_server` over WebSocket and renders the complete app. See [Deployment and clients](/manual/concepts/deployment/) for how the two differ. ::: ## This is not WebRTC It is worth saying plainly, because the shape of the thing looks like it should be. There is **no peer-to-peer data channel**. The server owns one invite-gated room on a WebSocket signaling broker and relays sealed frames through it, or the client connects directly over loopback, LAN, tailnet, or `wss://`. That is the whole topology. `flutter_webrtc` is still listed in the root `pubspec.yaml` and the signaling broker can mint short-lived TURN credentials — but no Dart file imports the former and nothing consumes the latter. Both are provisioned against a possible future WebRTC transport. Neither is an active NAT-traversal mechanism today; the brokered relay is what guarantees reachability. ## What the phone can do The paired phone reaches a small, intentional slice of the server's surface: - **Read** tickets, agents, spaces and their messages and your newsfeed - **Reply**: send a message in a space, update or assign a ticket, mark an article read or saved - **Run a playbook**: instantiate a saved plan template with its parameters. This only _proposes_ a plan; nothing executes or spends until an operator approves it in Plan Studio on a full client - **Switch workspaces**, from the phone's own picker That is close to all of it. A phone is a **lower-privilege principal** than a full client. It cannot spawn processes, spend LLM budget, push to GitHub, hire or fire agents, or create workspaces. See [the access model](#the-access-model) for how that is enforced rather than merely intended. ## How the link is built The connection rides a **brokered relay**: the server joins one invite-gated room on a `wss://` broker as its owner and every paired client — desktop, web, phone — joins the same room with a time-boxed admission token. Three pieces come together: | Piece | Role | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Remote (phone app)** | The companion web app, hosted at `remote.usectrl.dev` by default, or self-hosted. Loads in a browser, holds the pairing record in IndexedDB. | | **Relay broker** | A stateless `wss://` relay (`cc_signaling_server`) hosting N-way rooms. It carries **only opaque frames**: every payload is sealed end-to-end between the phone and the server, so the broker never sees app data or the pairing secret and never interprets what it forwards. | | **`cc_server`** | The room's owner. It answers the phone's calls through the same dispatcher every other client uses. | A pairing offer carries a **connection descriptor** listing every path to the server (LAN, tailnet, public `wss://`, relay) plus its identity fingerprint. The phone's resolver picks the best reachable path and pins the fingerprint on first connect (TOFU); the relay guarantees connectivity even when no direct path exists, without ever being able to read the traffic. ## What the pairing payload is Pairing is minted from the app and delivered as a deep link; see [Pair a device](/manual/guides/pair-a-device/) for the procedure. The anatomy is what matters here. ``` https:///# ``` The payload rides in the URL **fragment**, the part after `#` that browsers never send to the server, so the PWA's HTTPS host never sees it. It carries everything the phone needs to reach the server and prove who it is: the connection descriptor (every path plus the identity fingerprint), a freshly minted device id, a 32-byte pre-shared key and an expiry stamp. The phone decodes it, stores a pairing record locally and strips the fragment so the secret leaves the URL. There are two payload shapes and they are **not interchangeable**. The phone QR minted from the in-app pairing panel is the v2 `PairingPayload` above, pointed at the Remote PWA host. `cc_server pair --client-url` emits a different, older v1 link (`{s, i, k}` — server URL, device id, key) intended for the **web** client only. They also differ in lifetime, which is easy to get wrong: | Minted by | Credential expiry | | ------------------------------------- | ----------------------------------------- | | In-app pairing panel (`pairing.mint`) | 30 days, always | | `cc_server pair` on the command line | **None** — the credential does not expire | ## The access model A paired device is **authenticated but untrusted**. Several independent gates keep an approved (or leaked) pairing from becoming full control of the server. ### 1. Membership, not pairing, is the boundary A valid pairing key proves _which device_ you are. What you may read or change is decided by your **workspace role**, re-resolved on every single call and by per-repo grants on anything that exposes repo content. A device holding a perfectly good key but belonging to a user who is not a member of the named workspace is refused with "Not a member of this workspace". The role floor is derived from the operation's kind unless it declares its own: reads need `guest`, mutations need `member`, destructive operations need `admin`. ### 2. A phone is a lower session tier Beyond the role check, a phone session carries a lower capability than a desktop or web session. Seventeen operations are declared full-client-only and are refused for a phone before the handler runs, regardless of how privileged the user behind it is: minting, listing, renaming and revoking device pairings; reading and setting tunnel connectivity; server backup and workspace export/import; process detection and kill; server settings; and taking over or handing back a running agent. ### 3. A default-deny tool allow-list For the MCP tool surface specifically (`tools/call`), a default-deny allow-list governs which tools the phone may invoke. Anything not listed is rejected before it runs. The list is read-and-observe heavy plus a handful of **local-only** writes — a message, a ticket update, an article flag — that spend no LLM budget, spawn no process and touch no external system. Denied, for example: consulting an agent, starting an AI review, killing an agent, publishing a review to GitHub, hiring or firing agents, creating a workspace. ### 4. Per-call workspace scoping The server holds **no** "current workspace". It is stateless by design: every workspace-scoped call must name its target `workspace_id` in its arguments and the dispatcher refuses the call outright when that is missing or names a workspace the server does not have registered — before anything opens a database. A phone can therefore never inherit or drift into another client's scope and naming a foreign workspace does not help either, because the membership check in gate 1 still applies. This is the same [workspace isolation](/manual/concepts/workspaces/) invariant the rest of the product enforces. ### 5. End-to-end-sealed frames keyed by the pre-shared key Every frame between phone and server is sealed with the pairing key; the admission token only gets a client into the room, it decrypts nothing. The broker relays ciphertext and never sees the key. A device that is revoked finds its credential already gone, so it **fails closed** on reconnect. An app-minted credential is time-boxed to 30 days; past that the connect gate fails it and the phone must re-pair. Revocation is live: the server watches the device table and drops a revoked device's open sessions within seconds rather than waiting for its next connect. ### 6. Rate limiting per principal A sliding-window limiter caps remote tool traffic at 120 calls a minute, with a tighter 30-a-minute sub-cap on the mutating verbs. The budget belongs to the **principal, not the session**: three of your devices draw from one allowance, so a hijacked client cannot buy headroom by opening more sessions and one noisy member cannot starve the others. ### 7. A broker that learns nothing The relay broker is deliberately dumb: it hosts rooms, forwards sealed frames, and holds no accounts, no app data and no keys. Rooms are invite-gated and transient. The app data, audio, transcript, source and credentials all stay on the server. ## Live updates Once paired, the phone receives **live updates** as notification frames — new agent messages, tickets assigned to you, status changes — mirroring the desktop's own notifications. The server broadcasts those frames to every connected session and the workspace filter is applied client-side, so what you see is scoped to the workspace you are looking at. ## Where it lives | Surface | What it shows | | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | **Settings → You → Your devices** (`/workspaces/:workspaceId/settings/you/devices`) | Paired clients with live status, plus **Pair a new client** (web browser, desktop app, or phone) and revoke | The same panel mints pairings for a second browser or desktop app, not just phones — the flow is identical, only the deep link differs. ::::note Remote control is server-side and is **not** exposed over MCP. The phone speaks to the server's own dispatcher over the relay; it is not an MCP client of an external server. :::: ## See also - [Workspaces and isolation](/manual/concepts/workspaces/): the per-call scoping this leans on - [Multiplayer — identity, membership and presence](/manual/concepts/multiplayer/): roles, membership and repo grants - [Deployment and clients](/manual/concepts/deployment/): where remote control sits among the client tiers - [Architecture](/manual/concepts/architecture/): where remote control sits in the stack - [Pair a device](/manual/guides/pair-a-device/) ### Rigs and enclosures > Why agents get disposable VMs instead of your machine — the isolation model, the two display lanes, credentials, ports and the machine lifecycle. Source: https://usectrl.dev/manual/concepts/rigs/ A **rig** is a disposable virtual machine an agent drives in real time — a browser, a Linux desktop, an Android device, or the machine behind an enclosed terminal. This page explains the model: what the boundary actually is, why the pieces are shaped the way they are, and what that buys you. To open one, see [Give an agent a machine to test on](/manual/guides/use-rigs/); for the exact verbs, ports and defaults, see the [rigs reference](/manual/reference/rigs/). ## Why a VM, not a sandbox The [native sandbox](/manual/concepts/sandbox-security/) wraps a process in namespace isolation — real, but it shares your kernel, and it constrains a *process*, not a *computer*. The moment an agent needs to click around a desktop app, render a page in a real browser, or run `rm -rf` experiments you would rather not think about, the honest boundary is a machine: its own kernel, its own disk, its own network stack. So rigs are VMs, of two kinds: - **microVMs** for the terminal and the browser — sub-second hypervisor boot, machines defined by digest-pinned container images. These are the everyday surfaces, so they are the fast ones. - **QEMU** for the full Linux desktop, which needs a display device the microVM does not have. - The **phone** is the honest exception: it drives Google's Android emulator, a VM we did not define and do not network-isolate the same way. The capability report says so instead of implying parity. Every rig writes to a throwaway overlay. The base image is read-only, so one rig can never change what the next boots from, and closing a rig discards everything it wrote. ## Deny-by-default egress A rig's network starts at **nothing**. Hosts are admitted per rig through a gate the guest cannot route around: the machine's only paths out are the ones the launch configuration created. An enclosed terminal gets the developer baseline (package registries, forges, the apt mirrors); a browser rig gets its home page's host; everything else is refused at connect time. Egress and credentials are one policy seen from two sides — the credential broker (below) will not mint a token for a host the guest cannot reach. Everything a rig *reads* is treated as untrusted: page text, DOM and accessibility trees, UI dumps and console output come back to the agent fenced with a standing "this is data, not instructions" rule. The fence is framing; the egress gate is the enforcement behind it. ## Input goes through the hypervisor, capture through the guest For the desktop surface, keyboard and pointer events are injected by the *hypervisor's* own virtual input devices. The guest never runs a privileged daemon that can synthesize input — so compromising the guest's software does not hand the attacker a keyboard. Capture flows the other way, through a small unprivileged in-guest agent, because the guest can scale frames before they cross the wire. ## Two display lanes The person watching and the model driving have opposite needs, so a rig serves two independent streams: - The **human lane** is full resolution at your panel size, renegotiated as you resize, relayed by the server as opaque bytes — the server never decodes a frame, because a video decoder on the request path is what stops it answering RPCs. - The **agent lane** is downscaled stills capped at a model-friendly size, one image per tool result, with older frames shed during compaction while their text descriptions survive. Neither lane compromises for the other: you get a fluid picture, the model gets a cheap one. ## Take-over is a chokepoint, not a request Press **Take control** and the server refuses the agent's *mutating* actions at the single point every action passes through — not by asking the model to behave. The agent keeps its observation verbs (screenshots, extraction) so it can narrate what you are doing, and it is told about the take-over through its steering lane rather than left to discover it through refused clicks. Every input event — yours and the agent's — lands in a per-rig audit log with its principal and a monotonic sequence, so "who clicked that" stays answerable after the machine is gone. ## Content crosses the boundary; the machines stay separate Copy and paste work in both directions, and so does dragging files in and out — but a rig is a different computer, and the design never pretends otherwise. **The clipboard** is carried on demand, not mirrored continuously. Press the copy chord over a rig and the guest is told to copy, then whatever it put on its clipboard — text, an image, a list of files — is fetched and placed on yours. Paste sends the same content the other way and then presses paste inside the guest. Nothing is synchronised in the background: a password you copy on your own machine does not silently arrive inside a VM because a timer fired. On macOS the crossing chord is ⌘C/⌘V, which is unambiguous — ⌃C keeps reaching a guest shell as the interrupt it is. On Windows and Linux the two chords are the same keystroke, so the guest receives it unchanged and your clipboard is only overwritten when the guest's actually changed. An interrupt stays an interrupt. **Files dragged in** are copied into the machine's drop folder and then offered to it. What "offered" means depends on what can honestly be done: a browser page receives a *real* drop event at the point you let go, so an upload zone behaves exactly as it would for a local file; a desktop gets the files in `~/Drops` with their paths on its clipboard, because no host can synthesize a drag into an arbitrary Linux toolkit without running a privileged daemon inside the guest — which is precisely what this design refuses to have. The result says which happened, so a CSV dropped on an upload zone is never reported as uploaded when it is merely a file in a folder. Dropping onto an enclosed **terminal** copies the file in and types its guest path at the prompt, which is what a shell can actually use. **Files dragged out** work on the desktop surface, where X11's drag protocol makes the payload readable while a drag is in flight. Start a drag inside the guest, carry it out of the canvas, and the files are fetched to your machine and handed to the OS as a real drag. Copying files in the guest's file manager and pasting on the host works the same way. The browser surface deliberately does *not* offer this: a headless browser has no drag selection to read, and the nearest available signal — the page's text selection — is not evidence of a drag at all, so acting on it would hijack ordinary clicks. Everything read out of a guest stays untrusted content: clipboard text reaches a model inside the same fence a page extraction does, and the audit log records that a paste happened and how much of it there was — never what it said. ## Credentials never live in the guest `git push` works from an enclosed terminal, yet the guest stores no forge token. It holds one per-VM secret whose only power is to *ask* the host's credential broker — over a tunnel the host controls — for a short-lived, repo-scoped token, per operation, rate-limited, bounded by the same host allowlist as egress, and revoked the moment the rig closes. A machine built to be thrown away must contain nothing worth stealing. ## The worktree in a rig is a satellite When a rig carries your repository, the host worktree stays authoritative. The copy is *synced in* at boot (with secrets like `.env` files excluded), and commits come back only when you ask — fetched into `refs/rigs//*`, never pushed, never checked out. Nothing inside a VM can move your branch or touch your working tree; merging what a rig produced is always a human decision made on the host. ## Ports without holes A dev server started inside the terminal VM becomes reachable — on your machine, in the Browser (VM), optionally on your LAN, optionally under a dev domain with HTTPS — without ever opening the machine up. The hypervisor's port map is fixed at boot, so all later forwarding rides two narrow, host-controlled channels: one pre-created inbound multiplexer that refuses to dial anything the guest is not actually listening on, and reverse tunnels the host holds open for guest-initiated connections. Dev-domain HTTPS terminates at the server using a locally minted authority whose keys never enter any guest. The mechanics are in [Forward ports from an enclosed terminal](/manual/guides/vm-ports/). ## Machines are bounded by construction A rig cannot become a pet: - a **hard TTL** the guest cannot extend; - an **idle ladder** — park (vCPUs stopped, RAM resident) after the idle window, close after twice it — where an open terminal or a watching viewer counts as "in use"; - a **memory budget** counted in *resident megabytes*, not sessions, because a parked VM frees CPU and keeps every byte of RAM; the least-recently-used machine is evicted first, and never one a person is driving or watching; - teardown on server shutdown, plus an ownership-marked orphan sweep at the next start, so a crashed server never strands a hypervisor that answers to nobody. First boots pull the machine's pinned image; the server then caches a pre-extracted copy in the background, so every later machine of that kind boots in seconds and an enclosed terminal comes up with its tooling already installed. ## Three browsers, not one "It works on my machine" is a claim about one rendering engine. So a browser rig is not "the browser" — it is **Chromium, Firefox or WebKit**, chosen when you open it, and a rig runs the one it was opened with for its whole life. One conversation can hold one of each: opening *Firefox (VM)* beside *Chromium (VM)* gives you two machines, and pointing both at the same page is the comparison. WebKit is as close to Safari as anything that runs on Linux — same engine lineage, so it catches the gaps Safari users hit. The verbs are identical across all three; the protocols underneath are not, and the differences are stated rather than smoothed over. Chromium speaks the DevTools Protocol and *pushes* frames, so its live view costs nothing while a page is still. Firefox speaks WebDriver BiDi and WebKit speaks classic WebDriver, neither of which has a screencast, so their live views poll — a few frames a second, which is right for watching a page and not for watching an animation. Where an engine genuinely cannot do something (Firefox has no cache-bypassing reload; WebKit cannot see console output logged before a page finishes loading), the answer says so instead of quietly doing something else. ## Where rigs live in the product A rig is deliberately **not** a global destination. The live view is a tab in the space or PR where the work is happening, scoped to that conversation — so your tab and the agent's `browser_use` calls address *one* machine (one per engine, on the browser surface). Whether rigs can boot at all is a host property, so capabilities, images, per-workspace custom images and the running-machine list live under **Settings → Server → Enclosures**. Driving an enclosure is its own [guardrail action class](/manual/concepts/guardrails/), so a workspace, agent or space can allow it, require approval, or deny it outright — read-only modes deny it wholesale. ### Sandbox and security > What actually constrains an agent in Control Center today, how far the OS sandbox reaches and which boundaries hold on a host that has no backend. Source: https://usectrl.dev/manual/concepts/sandbox-security/ :::note[Where the OS sandbox does and does not reach] `cc_server` probes the host at boot and wraps agent command execution in the OS-native sandbox when one is available — Seatbelt on macOS, bubblewrap on Linux and WSL2 (which needs `bwrap` and `socat` on the `PATH`). Where no backend exists — Windows, or a Linux box missing those tools — runs fall back to the other boundaries below and the server says so in its startup log. `--sandbox off` turns it off deliberately. Two gaps survive the sandbox and are worth holding in mind: - The built-in harness's **in-process file tools** (`read`, `write`, `edit`, `apply_patch`, search) run inside the server process, so only `bash` is wrapped. What bounds those is path confinement in the tools themselves plus the action guardrails. - **Claude Code is launched with `--dangerously-skip-permissions`**, because a non-interactive `claude -p` would otherwise block forever on its own prompt. Its file and shell calls are therefore not gated by Control Center's guardrails — the OS sandbox is the boundary around them. ::: ## Why isolation matters here An agent reads files, runs shell commands and calls external APIs on your behalf. The failure you care about is rarely malice — it is an agent that misreads a path and rewrites your working checkout, or one that pushes a branch you had not finished reviewing. So the question the product has to answer is not "is the agent trustworthy" but "what is the blast radius when it is wrong". Control Center answers that in layers and the layers are independent on purpose. If one is missing, the others still hold. That matters, because the outermost layer — the OS sandbox — is the only conditional one: it depends on the host offering a backend and on Windows no host does. ## What actually constrains an agent Five things bound an agent run on every host, sandbox or no sandbox. None of them is a kernel boundary; all of them are real. Where the host offers a backend the OS sandbox sits above them and "What the sandbox adds and where it stops" below covers what that changes. **Its own copy-on-write worktree.** Every conversation provisions its own checkout of each linked repo, cloned copy-on-write through the bundled `rift` native rather than copied byte by byte, so isolation is cheap enough to be the default. The registered repository you added to the workspace is not the directory the agent works in — two conversations touching the same repo cannot see each other's edits and neither is editing your checkout. The grain is worth being precise about: **the worktree is per conversation, not per agent.** Two agents in the same conversation get distinct overlay working directories that _share_ that conversation's `repos/` — which is the point, since they are collaborating on one change — while their private agent directories stay separate. On a filesystem with no copy-on-write support and on Windows where there is no copy-on-write backend at all, this degrades to a plain `git worktree`: slower to create, isolating the same way. **Path confinement in the built-in harness's file tools.** The harness's `read`, `write`, `edit`, `apply_patch` and search tools resolve every path against the run's working directory plus one shared root — the conversation's `repos/` directory, where the worktrees live — and refuse anything that lands outside. Absolute paths are accepted only if they were already inside. A symlink inside the worktree pointing out of it is resolved and refused too. This is why an agent cannot `write` to `~/.ssh` even with no sandbox in the picture. **The mode's tool surface.** A conversation's [mode](/manual/concepts/modes/) decides which tools are materialized for the run at all. In the read-only modes (`plan`, `review`, `orchestrate`) the mutating tools are simply not in the registry and the shell command policy for those modes denies every mutating command outright — `git commit`, `git push`, `npm install`, `docker` and the rest — rather than prompting for them. A tool that is not present cannot be called, which is a stronger guarantee than a tool that is present and gated. **The unified action guardrails.** Every mutating tool declares the effect classes it can produce and the policy engine resolves each one to allow, prompt, or deny before the call runs. A `prompt` with no approver connected resolves to _denied_ — a question nobody can answer is never a yes. See [Guardrails](/manual/concepts/guardrails/) for the resolution model. **Capability-gated credentials and a sanitized environment.** Credentials are minted per launch from the conversation's capability flags and revoked when the run ends and the environment handed to any spawned process is stripped of the dynamic-linker and interpreter injection vectors (`LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`, `NODE_OPTIONS`, `GIT_SSH_COMMAND` and their kin) before the process starts. ### Where those bounds thin out The harness's file tools run _in the server process_, not in a child process, so no sandbox profile could constrain them even if one were attached — only `bash` goes through the sandboxed command runner. Their confinement is the path check described above and that check is therefore load-bearing rather than a second line of defence. The `bash` tool is the other place to be precise. Its commands are evaluated against the mode's command policy and then spawned with a sanitized environment and the worktree as its working directory. Where a backend exists that spawn is wrapped, so the sandbox is what stops a shell command from writing outside the worktree; where none does — Windows, or Linux without `bwrap` and `socat` — the command policy is the only thing between the command and the rest of the disk. In `chat` mode the policy hard-denies a fixed list (privilege escalation, system and disk destruction, container and namespace escape) and prompts on remote-state mutations (`git push`, `gh pr create`, package publishes and installs). Anything on neither list runs. Pipelines, chains and subshells are parsed and each sub-command evaluated separately, with the most restrictive decision winning. Claude Code, when used as an adapter, is the case where the sandbox carries the most weight. Control Center launches it with `--dangerously-skip-permissions`, because a non-interactive `claude -p` would otherwise block forever on its own approval prompt. Its read, write, edit and shell calls happen inside its own process and Control Center never sees them; the only calls it makes that reach a gate are the `mcp__*` tools Control Center itself serves. Where a backend exists, those unseen calls still happen inside the sandbox wrap, so the OS is the boundary around them. Where none does, nothing is. Each adapter states this in its enforcement matrix — see [the adapter honesty matrix](/manual/concepts/guardrails/#the-adapter-honesty-matrix). ## Capability gating is enforcement by absence A conversation carries four capability flags: push to the repo, call the GitHub API, call the ticketing provider and access the network. They are not checked when the agent acts. They are checked once, at launch, by the credential broker — and a capability that is off means the matching token is simply never put in the environment. That is the whole mechanism and it is worth stating plainly because the failure mode is not what people expect. Turning off "GitHub API" does not produce a "capability denied" message. It produces an ordinary `401` inside the agent, because there is no `GH_TOKEN` to send. The agent may well narrate that as a credentials problem. Enforcement by absence is robust — there is no check to bypass — but it is silent and the agent does not know why it failed. The network flag behaves the same way. With it off, the harness's `web_fetch` and `web_search` tools refuse every call and no proxy endpoint is exposed to a spawned process. Credentials are revoked when the run tears down, so a handle does not outlive the conversation turn that minted it. ## What the sandbox adds and where it stops Two backends are implemented — `native` (Seatbelt on macOS, bubblewrap on Linux and WSL2) and `none`. Understanding their shape explains what you gain where one is attached and what they were never going to give you. `cc_server` probes the host once at boot and where a backend answers it feeds both of the seams the dispatch path has, because the transports do not share one. The first wraps the CLI transports — Pi and Claude Code. The second wraps the ACP adapters (OpenCode, Gemini CLI, Goose, Cursor, Codex) and the built-in harness's `bash` tool. Feeding only one would leave the other transport spawning unwrapped, which is what used to happen to all of them. On a host with no backend, or under `--sandbox off`, they all spawn unwrapped and the server says which case you are in on its startup line. One surface deliberately opts out: the server-hosted terminal accepts a backend argument per session and `cc_server` constructs it defaulting to the host shell, so a terminal you open from the app runs unsandboxed unless a client names `native` explicitly. That is a real shell on the server host, not an agent run. On Linux, `bubblewrap` builds a mount namespace, so the agent genuinely sees only what was bound into it. On macOS, `sandbox-exec` applies a Seatbelt profile that is **permissive by default** — a fully deny-by-default profile makes an interactive shell unusable, because the number of dyld, XPC and Mach calls to enumerate is impractical. The macOS profile therefore starts from `(allow default)` and carves out denies: reads of secret paths, a write policy reset to deny with explicit allows on top, unlink-blocking on every denied path and its ancestors so a rename cannot bypass a write-deny, exec denies for always-dangerous binaries and for anything copied into a writable directory and network restricted to the loopback proxy ports plus DNS. The practical consequence: on Linux the agent's view of the filesystem is narrowed; on macOS the agent can still _read_ most of the host and your registered checkouts are protected by explicit write and rename denials rather than by invisibility. Both are namespace-level isolation, not a kernel security boundary — the backends say so in their own capability notes. The bind-mount set is where the cross-agent boundary is drawn. Three mounts, resolved per dispatch: the agent's overlay working directory read-write, the agent's config directory read-only and the conversation's shared `repos/` directory read-write when it exists. A sibling agent's directory is never mounted. This is the same shape the harness's in-process path check enforces in software, so the two agree — but only the path check covers the harness's own file tools, which never reach a mount namespace at all. Your registered checkouts join the deny-write set on top of that, in every mode and on all three of the wrapped paths and each rule is emitted in both its literal and its symlink-resolved spelling. The second spelling is not a nicety: the kernel matches a deny rule against the resolved path and macOS makes resolution the common case rather than the exotic one — `/tmp` is really `/private/tmp`, `/var/folders/…` is really `/private/var/folders/…` — so a checkout reached through either would have been writable while the profile appeared to forbid it. Network, under a sandbox, is a per-session **domain** allowlist enforced by in-process loopback proxies. Egress is default-deny: with the network capability off, no proxy is exposed at all. With it on, the resolver builds the allowlist from a curated baseline — LLM API providers, git hosting, package registries, the model catalogue — plus the GitHub domains an agent's GitHub or push capability earns it and matches every connection's target host against it. Wildcards work (`*.example.com` matches subdomains, not the apex). A separate always-denied list wins over the allowlist and covers cloud metadata endpoints and telemetry sinks. What is _not_ configurable is the list itself: the spec carries an `egressAllowlist` field for extra domains and a ticketing domain hook, but nothing in the dispatch path populates either, so in practice every run gets the baseline plus its capability-derived domains and nothing more. Denials, on macOS, are observable: a monitor taps the system log for Sandbox denials, filters known noise, deduplicates retry storms and surfaces each as a `SandboxViolation` naming what was attempted and which capability would unblock it. Those arrive on the run's own event stream, so a denied syscall shows up in the transcript rather than only in a log. The telemetry only exists while a native sandbox is actually wrapping the process — on a host with no backend there is nothing to observe. It is also unrelated to capability gating: a violation comes from a denied syscall, never from a capability lookup. ## Confirmations come from the guardrails, not the sandbox The sandbox has no approval path. It launches, executes, emits events and tears down; there is nowhere in it for a human to answer a question. When you see a confirmation in a space — approve this push, approve this delete — it was raised by the guardrail engine (or the harness's command policy) _before_ the action reached the runtime at all. This ordering is deliberate. An approval that happened after the syscall would be an audit record, not a gate. It is also why the fail-closed rule lives in the guardrails: with no approver connected, the decision is made before anything runs and it is a denial. ## Diagnostics A diagnostic service exists that checks the sandbox backend, database access, CLI tool availability, disk space and network connectivity. It has no reachable entry point today: there is no doctor command in the client and no `doctor` MCP tool — that tool class was deleted, so nothing appears in `tools/list`. [Run agent diagnostics](/manual/guides/agent-diagnostics/) is the single place that states what can be run. ## Related concepts - [Rigs and enclosures](/manual/concepts/rigs/): the step above the sandbox — when the work needs a whole disposable machine with a kernel boundary, not a wrapped process - [Guardrails](/manual/concepts/guardrails/): the policy layer above the OS floor and where approvals actually come from - [Agent dispatch lifecycle](/manual/concepts/dispatch-lifecycle/): where isolation fits in the dispatch flow - [Modes](/manual/concepts/modes/): how a mode narrows the tool surface and the command policy - [Sandbox backends reference](/manual/reference/sandbox-backends/): the backends and their configuration ## Related guides - [Configure sandbox policies](/manual/guides/sandbox-policies/): capability flags and filesystem posture - [Run agent diagnostics](/manual/guides/agent-diagnostics/): what the diagnostic checks cover ### Single sign-on > How SAML and OIDC logins work, what they verify and how accounts stay safe. Source: https://usectrl.dev/manual/concepts/sso/ Single sign-on lets an external identity provider (IdP) decide who may sign in to your Control Center server. Two protocols are supported — SAML 2.0 and OpenID Connect — and both land in the same place: a freshly minted device credential that is indistinguishable from an invite or QR pairing everywhere downstream. ## The shape of a login SAML logins are **browser-mediated**. The IdP never opens a connection to your server; the user's browser carries everything: ``` browser ──> /saml/login ──> IdP ──POST SAMLResponse──> /saml/acs │ verify ▼ provision user (maybe) │ ▼ browser handoff: device PSK in the URL fragment (never a query parameter) ``` Because the browser is the courier, **a server behind NAT works fine** for logins: localhost, LAN, Tailscale, VPN, or a tunnel all serve — only the user's browser must reach the ACS URL. The WebSocket relay used by phone remote control cannot carry the HTTP round-trip, which is why SSO entry points always target the server's HTTP origin. OpenID Connect follows the same shape with an authorization-code + PKCE round-trip; claims are read from the issuer's token endpoint over TLS, so the browser never supplies identity data at all. ## The adapters Control Center splits the responsibility deliberately: | Piece | Where | Owns | | ----------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `cc_saml` native | `packages/cc_natives/native/saml/` | XML-DSig verification, canonicalization, SAML profile validation — delegated to the pinned pure-Rust `saml` crate behind a stateless C ABI | | `SamlService` / `OidcService` | `cc_server_core` | login state (pending request trackers, replay dedupe), validation policy knobs, the shared provisioning call | | `SsoProvisioner` | `cc_server_core` | find-or-create the user, pin the provider subject, grant memberships under the auto-member policy | | `ScimService` | `cc_server_core` | the SCIM 2.0 push surface (provision/deprovision lifecycle) | | `SsoSettingsService` | `cc_server_core` | the saved connection rows, env seeding, live re-apply, the SCIM token | There is deliberately **no pure-Dart SAML crypto fallback**: hand-rolled XML-DSig/canonicalization is exactly where signature-wrapping vulnerabilities live, so a missing `cc_saml` library refuses both SAML login and server boot. The native is stateless by design — cross-request state lives in the Dart services, single-use and short-lived. ## What a login verifies Inside the verified native and the service, in order: 1. the assertion's **signature** (always required; the response envelope's is optional per configuration) — structurally resistant to signature-wrapping because the validated payload is bound to the verified element, 2. the **issuer** matches the configured IdP, 3. the **audience** names this service provider, 4. the **destination** is our ACS URL, 5. `InResponseTo` matches a pending AuthnRequest we issued (unsolicited, IdP-initiated responses are off by default), 6. the **lifetime** windows hold within the clock skew (default 90s) and 7. the **assertion id** was never seen before (replay cache). Failures come back as typed codes — signature, expired, audience, destination, request-match — and the endpoints answer with generic errors, so an attacker learns nothing about why a forgery failed. ## Accounts and subject pinning The first SSO login pins the provider's immutable subject (`issuer` + NameID, or SCIM externalId) onto the account. From then on, the subject — not the email — identifies the login. If someone else later presents the same email from the same or another IdP, the pinned account refuses them. This is what stops a recycled email at a provider from becoming an account takeover. ## Provisioning and deprovisioning JIT provisioning (at login) and SCIM pushes share the same provisioner, so behavior is identical. Deprovisioning — SCIM `active: false` or `DELETE` — revokes every device credential first (sessions drop within seconds through the device watch), removes every workspace membership and stamps the account deactivated. The user row survives: attribution is permanent. Reactivation restores login but never re-grants memberships. ## What stays out - **Group→role mapping happens at login** from the SAML attributes / OIDC claims — pushed SCIM groups are not stored. - **The owner role is unreachable from SSO** — neither as a default role nor through a group mapping. - The WebSocket relay is never an SSO channel; phones that reach the server only through the relay cannot complete a browser round-trip against it. ### Tickets and delegation > Why a ticket tracks work without dispatching it, how delegation is guarded and what the vendor mirror does and does not own. Source: https://usectrl.dev/manual/concepts/tickets/ A ticket is the unit of work Control Center tracks. It is deliberately a _dumb_ artifact: a title, a body, a status, an owner and a place in a tree. It is not the thing agents execute against — agent work lives in conversations and the structured output of a run lives on the run log. Tickets replaced the former "tasks" feature. `Ticket` is now the single unit-of-work aggregate. ## Assignment records ownership; it dispatches nothing This is the single most important thing to get right about tickets, because it used to work the other way. Setting `assignedAgentId` writes a field and publishes a `TicketAssigned` [domain event](/manual/concepts/domain-events/). That event is an audit and notification signal and a pipeline trigger. **It starts no agent run.** There is no ticket dispatcher in the product; nothing watches for assignment and wakes an agent. If you want an agent to work on a ticket, you ask it in a conversation, or a pipeline step dispatches it, or another agent delegates to it. Assigning a ticket to an agent and waiting is the one thing that will never produce work. ## Mirror and overlay A ticket can be purely local or backed by an external tracker and it carries both on one row. The **mirror** — provider, external key, URL, title, description, priority, labels, raw and normalized status, timestamps — is a cache when the ticket is remote. The remote is the source of truth there and a refresh rewrites the mirror wholesale. The **overlay** — assignment, delegation, space link, parent, project, linked PRs — is Control Center-only. The tracker knows nothing about it and a refresh never touches it. That split is what makes it safe to sync at all: a vendor pull can be destructive to the mirror without ever losing the orchestration metadata layered on top. ## The lifecycle graph binds agents, not you Statuses run `backlog → open → inProgress → inReview → done`, with `blocked` as a side loop and `failed` / `cancelled` as the other terminal states. The transition graph is enforced **only on the agent and automation path** — MCP tools and reconcilers. An illegal transition there is logged and ignored, so a confused agent cannot walk a ticket backwards out of a terminal state. Every user-driven change in the UI passes `force: true` and bypasses the graph entirely. A human may move a ticket to any status, including reopening a terminal one. The graph is a guard rail for automation, not a rule about how work is allowed to go. `TicketWorkflowService` owns every mutation through a single chokepoint that loads the row, asserts it belongs to the caller's workspace and writes with an expected `version` so two concurrent edits cannot silently overwrite each other. For the full status table, transitions, field list and relation kinds, see [Ticket lifecycle](/manual/reference/ticket-lifecycle/). ## Provenance: how a ticket came to exist Every ticket records `originKind`: `manual` for one a human filed, `pipelineStep` for one a pipeline produced, `agentDelegation` for a delegated child, `externalSync` for a mirrored vendor issue and `recovery` for one a reconciler rebuilt. That is where you look when a ticket appears that nobody remembers creating. ## Guarded delegation Delegation is where an agent creates work for another agent and it is the place privilege could leak, so the guards live server-side at a chokepoint rather than in prompt instructions. `delegate_task` routes through `TicketWorkflowService.delegateGuarded`, which computes the child's depth and root from the parent chain and refuses two things: a hop that would exceed a **depth cap of 3** and a hop back to an agent already in the chain (**cycle detection**). A refusal is thrown as a `DelegationRefusedException` whose message reaches the delegating agent verbatim — a loud denial, never a silent no-op. `delegate_ticket` is the older, **unguarded** sibling: it calls `delegate` directly and skips both checks. Prefer `delegate_task`. :::caution[Two guards are declared but not enforced] `DelegationGuards` also defines an autonomy ceiling and a budget-envelope check. Neither is wired into the ticket chokepoint yet, so today only the depth cap and cycle detection are enforced. ::: Do not confuse the delegation depth cap (3, on `delegate_task` chains) with the built-in harness's subagent nesting cap (2). They are different limits on different mechanisms. ## Hierarchy, links and projects Parent and sub-issue relationships live on `tickets.parent_ticket_id`, owned by `setParent` / `clearParent`. They are **not** `ticket_links` rows, so anything reading a ticket's relations has to merge both sources. `ticket_links` holds the directional edges between otherwise unrelated tickets: | Link type | Meaning | | ------------- | --------------------------------------- | | `blocks` | The source blocks the target | | `relatesTo` | The two tickets are related (symmetric) | | `duplicateOf` | The source is a duplicate of the target | A **project** is a workspace-scoped grouping of tickets toward a shared goal. Projects are Control Center-only and never sync to an external provider. ## Collaborators `TicketCollaborator` rows are how more than one principal is attached to a ticket, at a role of `assignee`, `collaborator`, or `reviewer`. Humans and agents are co-equal rows — a `principalId` plus a `collaboratorType` — with no sentinel value for "the user" and no foreign key to either table. ## External sync Control Center's ticket is primary; a vendor issue is mirrored into it. The current path is `TicketSyncEngine` with a `MultiVendorTicketSyncCoordinator` pushing local changes out on domain events. Four adapters ship: Linear, GitHub Issues, Jira and ClickUp. Vendor pulls write without emitting domain events, so a mirrored change can never loop back out. :::caution[Vendor sync has no in-app configuration] Nothing in the product creates a `TicketSyncConfig` row — the thin client's config repository refuses the write outright and no RPC op creates one. The engine, the adapters and the inbound webhook path are code-complete, but a connection row must be inserted out of band before any of it runs. Adapter credentials come from the server's environment, not from settings. ::: Deleting a ticket is **local only**. No provider supports remote deletion, so a vendor-backed ticket you delete is not deleted on the tracker and may reappear on the next sync. Collaborators and child tickets cascade. ## Tickets and pipelines A pipeline step can create, complete and cancel tickets and a pipeline trigger can fire on `TicketAssigned`, `TicketCompleted`, `TicketFailed`, or `TicketCancelled`. A ticket a pipeline created is identifiable by `originKind: pipelineStep`; there is no `pipelineRunId` column on the ticket. ## See also - [Pipelines and automation](/manual/concepts/pipelines/): pipelines create and complete tickets - [Orchestration](/manual/concepts/orchestration/): where a DAG of sub-tickets comes from - [The agent model](/manual/concepts/agent-model/): who a ticket is assigned to - [Domain events](/manual/concepts/domain-events/): the ticket lifecycle events - [Ticket lifecycle](/manual/reference/ticket-lifecycle/): statuses, fields, transitions, relations - [Create and manage tickets](/manual/guides/manage-tickets/) - [Delegate work to agents](/manual/guides/delegate-tickets/) - [Organize work with projects](/manual/guides/projects/) ### Tool context and prompt caching > Why an agent is shown a small set of tools rather than all of them, how it reaches the rest, and the rules that keep the provider's prompt cache warm across a run. Source: https://usectrl.dev/manual/concepts/tool-context/ Two things are true of every request the built-in harness makes: the tool definitions are the largest fixed cost in it, and almost none of them will be used. This page explains what Control Center does about that, and why the answer is not simply "send fewer tools". ## The problem is attention, not bytes Control Center registers about 126 tools. Sent in full, their definitions cost roughly **23,000 tokens on every request** — a cost no compaction can touch, because tool definitions travel in a separate part of the request from the conversation. The token bill is the obvious complaint, and with prompt caching it is also the smaller one. The real cost is **selection accuracy**. Published evaluations of tool-calling agree that models pick the right tool reliably up to somewhere around 30–50 tools in context, and get progressively worse beyond that. The named cause is not the token count but **semantic overlap**: once a catalogue contains several tools whose descriptions read alike, the model is choosing between look-alikes rather than recalling a capability. At 126 tools, an agent is well past that band on every single turn — including the many turns where it only needed to read a file. ## The two-tier surface A run is therefore shown two things: - a **resident** set — roughly two dozen tools, sent with full schemas on every request; - a **name index** of everything else, grouped under headings like "Tickets and projects" or "Memory and knowledge", costing a few hundred tokens for the whole catalogue. Measured on the current catalogue, that takes the tool block from **~23.1k tokens to ~5.0k, about 78% smaller**, and puts the always-visible set inside the band where selection is reliable. The resident set is the tools a run uses in *most* runs: the file and shell vocabulary (`read`, `write`, `edit`, `search`, `bash`, …), the discovery tools, the task checklist, memory search, and whatever verb the current [mode](/manual/concepts/modes) must call to deliver its output. A mode's own output verb is always resident — a run must never have to go looking for the one call that produces its deliverable. ### Deferred does not mean unavailable This is the part that makes the design safe rather than merely cheap. **A deferred tool is callable from the first turn.** Only its schema is withheld. The agent sees the name in its prompt. If it calls that name directly, the harness loads the schema and runs the tool in the same step — no error, no round trip. The model does not need to know that the tool was ever "unloaded". When the agent knows the task but not the name, `search_tools` takes a plain-language description ("assign a ticket to someone"), ranks the run's whole surface, and returns the matches *with their schemas already loaded*, so the very next turn can call one. If a search finds nothing, it says so plainly and suggests different wording or a wider search — deliberately, because an agent that reads an empty search as "this capability does not exist" will invent a substitute instead of looking again. ### What deferral never does Deferral is applied **after** the mode's tool filter, never instead of it. It hides a schema; it cannot reveal a tool the mode denied. An activated tool still passes the approval prompt and the [action guardrails](/manual/concepts/guardrails) exactly as a resident one would, and a tool cannot load a name that was not on the run's surface to begin with. The MCP surface external clients see is **not** affected. `tools/list` still returns the complete catalogue. An earlier attempt to trim it broke agents outright: MCP clients validate tool names against their own cached list and refuse anything unlisted without ever asking the server, so a "hidden but callable" tool was simply unreachable. Deferral lives in the harness, where loading a tool mid-run is something the harness can actually do. ### Turning it off `--tool-deferral=off` (or `CC_SERVER_TOOL_DEFERRAL=off`) makes every admitted tool resident again, producing exactly the requests the server made before deferral existed. See the [`cc_server` CLI reference](/manual/reference/cc-server-cli). ## Prompt caching Agent workloads are lopsided: they send enormous prompts and receive small answers, turn after turn, with the same prefix each time. Providers cache that prefix, and a cache read costs about a tenth of a fresh one. Cache hit rate is therefore a first-class property of the harness rather than an optimisation — and on Anthropic it also buys throughput, because cached tokens do not count against the per-minute input limit. Caching works on an exact prefix match, which yields one governing rule. ### Never rewrite what was already sent Any byte that changes invalidates the cache from that point onward. So the harness only ever **appends**: new messages at the end, and newly activated tool schemas *after* the cached tool block rather than merged into it. That ordering is the reason activation is cheap — the cached prefix stays identical and the run pays only for the schemas it just loaded. The same rule reshaped an existing behaviour. The loop trims stale tool output from older turns to reclaim context; it used to do that on *every* tool-bearing turn, editing the middle of the conversation each time. That reclaimed a few dozen tokens and invalidated the cache for everything after the edit, permanently. It now waits until there is enough to reclaim to be worth the rewrite, making it a rare deep trim instead of a continuous leak. ### Where the breakpoints go A provider allows a limited number of cache markers, and Control Center spends them deliberately: | Marker | Covers | Lifetime | | ---------------------------- | ---------------------------------- | -------- | | Last **resident** tool | The tool block | 1 hour | | Last system block | Tools + system prompt | 1 hour | | Previous turn's tail | History up to the last request | 5 min | | Current turn's tail | The full history | 5 min | The last two are a rolling pair, and the reason for the pair is subtle: a cache lookup searches backwards a bounded number of blocks for something an earlier request stored. A single turn that runs many tools in parallel can emit more blocks than that window, at which point one tail marker quietly misses and the request pays full price with nothing in the response to say so. Marking the exact spot the previous request stopped makes the hit structural instead of a race. The tools-and-system prefix takes the long lifetime because it is constant for the whole run, is shared by every run and subagent with the same shape, and because the clock starts when a request *begins* — a turn that streams for several minutes has already spent most of a short window before the next request forms. ### Subagents share one prefix Subagents of the same type produce byte-identical prompt prefixes, so they should read one cache entry rather than each writing their own. But an entry only becomes readable once the request that wrote it starts responding, so a fan-out launched all at once has every child paying the premium for a prefix they all share. The harness lets the first child of a given shape start, then releases its siblings as soon as it begins streaming. The wait is bounded: a slow first child costs a cache hit, never the fan-out itself. For the same reason, every subagent gets the same resident tool set regardless of its profile — varying it per profile would fragment the shared prefix for no gain, since the profile has already decided what the child may call. ## Watching it work Both halves are observable rather than assumed: - The **context explorer** on a conversation reports the resident tools, the deferred index and what each withheld schema *would* cost, built from the same code path a real run uses. - Each run records which deferred tools it loaded and what triggered it, so a search that finds nothing is visible as a retrieval miss rather than disappearing into an agent quietly doing something adjacent. - Observability reports cache effectiveness as cache reads over all prompt tokens processed, cache writes included — a run that keeps rebuilding its prefix scores low, instead of scoring like one that never needed a cache. ### Workspaces and isolation > Why a workspace is a hard tenant boundary, how the database split makes that boundary structural and what deliberately crosses it. Source: https://usectrl.dev/manual/concepts/workspaces/ ## What is a workspace? A workspace is the top-level isolation tenant in Control Center. It groups agents, repositories, spaces, tickets, memory, pipelines and members into a bounded context. Everything that happens inside a workspace stays inside it. A typical workspace maps to one project or one codebase. You might have separate workspaces for "api-service", "frontend-v2" and "infrastructure". ## Workspace-scoped data A workspace's rows live in that workspace's own SQLite file, at `//workspace.db`. Each workspace gets a directory rather than a bare file, because a workspace accumulates more than a database — per-conversation worktrees, the agent and skill files, chat bot credentials — and giving it a folder means all of that lives and is deleted, together. | Entity | Scope | | --------------------------------------------------- | -------------------------------------------- | | Agents | Belong to exactly one workspace | | Repositories | Registered into one workspace (see below) | | Spaces, conversations and messages | Workspace-scoped | | Tickets, projects, plans, orchestrations | Workspace-scoped | | Memory facts, policies, domains, access grants | Workspace-scoped | | Pipeline runs, templates, triggers | Workspace-scoped | | Pull request state, review spaces, review cohorts | Workspace-scoped | | Agent run logs and working memory | Workspace-scoped | | Members, invites, per-repo grants | Workspace-scoped | | Code graph symbols and edges | Scoped by `repoId` within the workspace file | Most of these rows still carry a `workspaceId` column, but that column is no longer what keeps workspaces apart — see the isolation invariant below. ## Repos are workspace-scoped; identity across workspaces is by path A repo row lives in its workspace's own database file — the file _is_ the scope, so the table has no `workspaceId` column and no join table. The old server-global `repos` table and its `workspace_repos` many-to-many collapsed into one table when the database was split, because within a single workspace a repo is linked exactly zero or one times and the join row carried no information. There is therefore no "link an existing global repo" step. You add a repo _into_ a workspace, at Settings → Workspace → Repositories. Registering the same checkout in two workspaces creates two independent rows with two ids; repo identity _across_ workspaces is by filesystem path (or GitHub owner/name), never by id. Registration is strict: the path must be inside a git work tree, it must have an `origin` remote and that remote must point at a supported forge (github.com, gitlab.com or bitbucket.org). The forge is read from the remote and stored on the repo, so one workspace can hold repos from all three. Anything else is rejected. The folder browser you pick from lists the **server host's** filesystem, confined to the server's `--repo-roots` allow-list (which defaults to the server user's home directory). Removing a repository from a workspace deletes the row outright — there is no shared global repo left to unlink from — and cascades its code symbols, edges, files, index checkpoints and per-member grants. The checkout on disk is untouched. The code graph lives in the same file, keyed by `repoId`, because different workspaces may have the same repository on different branches with different code. ## The isolation invariant Cross-workspace data leaks are treated as bugs. Isolation used to be a convention — every query had to remember `WHERE workspace_id = ?`, policed by tests that could only pattern-match SQL. It is now **structural**, enforced at several levels: - **Database layer.** Each workspace has its own SQLite file, handed out by `WorkspaceDatabaseManager.of(workspaceId)`. A workspace database does not declare `users`, `workspaces`, or any other workspace's tables, so a cross-workspace read is not a bug you can write — it does not compile. Repositories resolve their DAO per call rather than caching one and because every repository method takes a required `workspaceId`, the workspace can never be inferred or defaulted. - **The workspace id is a path segment.** Because the id becomes a directory name, it must match a safe single-segment pattern (alphanumeric start, then alphanumerics, dots, underscores, hyphens; no `..`). Ids are UUIDs everywhere in the product. Anything else throws rather than being sanitized — that is the path-traversal guard and it fails loudly rather than writing a workspace's data to a path someone else chose. - **Defense in depth.** The `workspaceId` columns are still there and still written. Redundant within a file, they keep the sync-feed triggers, FTS indexes and existing row shapes unchanged and they make a file self-describing when it is inspected on its own — which is what export and import rely on. - **Domain layer.** Services that mutate entities by id validate `entity.workspaceId == workspaceId` at a single chokepoint before proceeding and throw `WorkspaceMismatchException` on mismatch — the loud failure for an id that arrived from the wrong context. Denying loudly matters: a silent no-op hides the bug and proceeding leaks. - **RPC layer.** The repo-RPC dispatcher is the chokepoint. Every workspace-scoped op must carry `workspace_id` in its own args — there is no per-session "current workspace" to leak and an unknown id is refused as not-found _before_ anything opens a database, so a bad id cannot materialize a ghost `workspace.db`. Genuinely global ops (the newsfeed, the fleet queue) declare themselves unscoped as an explicit, reviewed decision. MCP tools follow the same rule: any tool touching workspace-scoped data requires `workspace_id`. - **Cross-workspace fan-out is enumerable.** Answering a question about several workspaces means opening several databases. That fan-out is confined to one helper, `CrossWorkspaceQueries`, so the complete list of things that legitimately cross the boundary lives in one file's call sites instead of diffusing behind doc comments. Those call sites are: server-wide aggregation, startup reconcilers, retention and GC sweeps, backup, event routers — and the membership lookup below. - **ID-only access is not sufficient.** Looking up an entity by its UUID does not prove it belongs to the caller's workspace. The file split makes the wrong file unreachable; the domain-layer validation catches a wrong id that arrives through a legitimate file. ## Membership, not the pairing key, is what grants access Holding a device credential authenticates a _device_. It does not authorize anything inside a workspace. Every workspace-scoped op resolves the caller's membership role and enforces a floor derived from the op's kind — read needs `guest`, a mutation needs `member`, a destructive operation needs `admin` — and ops that expose repo content additionally check the caller's per-repo grant. A valid pairing key with no membership gets `Not a member of this workspace`. The consequence for isolation is neat: membership rows live in the _workspace's own_ file, so "who is in this workspace?" is an ordinary scoped read. The inverse question — "which workspaces am I in?" — is by definition spread across files, so it is one of the few sanctioned `CrossWorkspaceQueries` fan-outs and it is what the workspace picker runs before any workspace has been chosen. See [Multiplayer](/manual/concepts/multiplayer/) for the role ladder and repo grants. ## The global registry Not everything is workspace-scoped. A second database, `/global.db`, holds what is genuinely server-wide: - the **workspace registry**, so the switcher can list every workspace without opening a single workspace file — and so a workspace's name, logo, owner, manual order and soft-delete marker live outside the file they describe; - **identity**: users, user preferences and paired devices, because one human is one user across every workspace and a paired device survives a workspace being deleted; - the **fleet queue** (workers, jobs, placement log), whose scheduler matches the whole queue against every worker on each tick; - the **newsfeed**, which is server-wide by design — adding a feed in one workspace adds it everywhere; - **`workspace_routes`**, the pre-auth index that answers "which workspace owns this key?" for entry points that arrive with nothing but a secret or an opaque id (an invite hash, a webhook token, a deep link) and no workspace. A miss there is a not-found; there is deliberately no scan fallback; - **install-wide settings and identity**: the install id, host-level settings and SSO connections. Boot opens only this file. Workspace files open lazily on first touch, so startup cost stays flat no matter how much history the workspaces accumulate and the per-file `quick_check` is paid on first use rather than on the path to the ready banner. A workspace file carries only a single-row `workspace_meta` for self-identification (its id, the install that created it, the schema version it was created with, when). Everything descriptive about a workspace is in the registry. ## Creating a workspace Creating a workspace does more than insert a registry row. In one operation the server stamps the creating user as `ownerUserId`, records that user's owner-role membership and publishes a `WorkspaceCreated` domain event. Listeners on that event then seed, idempotently and in the background: - a **CEO agent** plus four specialists — `qa`, `architect`, `engineer`, `librarian` — with the specialists reporting to the CEO; - the **built-in pipeline templates** and their triggers (most of which ship disabled); - the **starter eval suites**. This happens for _every_ workspace, not only the first. The seeded agents are created with no adapter, so they run on **Control Center (built-in)** with Anthropic's default model until you set an adapter and model per agent at Settings → Workspace → Agents. See [The agent model](/manual/concepts/agent-model/). The owner-membership write matters more than it looks: without it the freshly created workspace would have no members and every workspace-scoped call the creator makes next would be denied. :::note[Agents cannot create a workspace] There is no `create_workspace` MCP tool. It used to exist unregistered — inserting a bare registry row that published no `WorkspaceCreated` event, so nothing was seeded and no owner or membership was stamped — and was deleted rather than wired up. `workspace.upsert`, which does all of the above, is the only path. ::: ## Deleting, exporting, importing Deleting a workspace is a **soft delete**: the registry row is marked and the workspace disappears from every list and lookup, but its directory and database file stay on disk and maintenance sweeps (backup, retention) still visit them via the "including deleted" id list. Nothing reclaims that space automatically. Because one workspace is one file, handing a workspace around is a file operation rather than a table-by-table dump: - `workspace.export` is a single `VACUUM INTO` — a consistent, defragmented snapshot taken under a read transaction while the server keeps serving. - `workspace.import` adopts such a file, replacing whatever the target workspace currently holds. The embedded `workspace_meta` lets an import distinguish "my own file, re-adopted" from "a file from another install" (the latter is allowed, but logged rather than invisible). - `server.backupNow` snapshots the whole install into a timestamped directory that mirrors the live data dir — `manifest.json`, `global.db` and one `/workspace.db` per workspace — so restoring is copying it back. - `server.listBackups` reads that directory back, newest first, with the size and workspace list of each snapshot. A snapshot whose manifest is missing or names files that are not there is still reported, flagged incomplete — hiding it is how an operator comes to believe they have a backup they do not. All four are on **Settings → Server → Backup & restore**: "Back up now" takes an install snapshot, the list underneath says which snapshots exist and whether each one is whole, and every workspace carries its own export, import and delete. Restoring one workspace out of a snapshot _is_ `workspace.import`, pointed at that snapshot's `/workspace.db`, so the two cannot drift into different treatments of the same file. Restoring a whole install is still a copy-back of the snapshot directory with the server stopped — the layout mirrors the live data directory precisely so that nothing else is needed for it. What none of them carry is the rest of the workspace directory. A workspace accumulates more than a database — pasted images, skill and agent files, chat credentials, worktrees — and export and import move the **file**, so everything beside it stays where it is. That is deliberate for credentials and disposable for worktrees; for the rest it is a limit to know about rather than a feature. The [backup reference](/manual/reference/backup/#what-a-backup-does-not-contain) has the full table. ## Moving a backup between the server and your device Every op above speaks in paths on the **server**, which is a complete answer only when the server is your own machine. Three signed HTTP routes — [`/backup/workspace`, `/backup/snapshot` and `/backup/restore`](/manual/reference/backup/#transfer-routes) — carry the bytes for every other topology, on the same authenticated lane the media proxy and the image store already use. On the page this is a **Download** button beside each export and each snapshot (you pick where it lands), and **Choose a file and upload** to restore from a file sitting on the machine you are using. The upload is the one that works when the server is not your machine: `workspace.import` needs the file to already be on the host, and this is how it gets there. Three properties are worth knowing: - **A download mints a fresh copy and the server keeps none of it.** Downloading is not "fetch the file the export button made" — it exports again, streams that, and deletes it. The response is `no-store` with `Accept-Ranges: none` for the same reason: a resumed range would splice two different exports into one file that looks valid and is not. - **An upload is streamed to disk, never buffered**, on both ends, and the staged copy is deleted on every path out — including a refusal. A workspace database is not a screenshot. - **A relayed connection has no HTTP origin**, so the transfer controls are disabled there with a note saying why. Everything that speaks in server-side paths keeps working over the relay; only the byte lanes need a direct connection. :::note[Which paths are whose] The paths the page *shows* — the snapshot directory, the file an export wrote, the source an import reads — are on the machine running `cc_server`. The path a **download** reports is on the machine you are sitting at. That distinction is why both exist: the first is what a maintenance script or an ssh session needs, the second is what a person collecting a backup needs. ::: ## Multiple workspaces You can run multiple workspaces side by side. Each operates independently: agents in one workspace cannot message agents in another, cannot read another workspace's memory and cannot see its tickets. Recipient resolution for agent-to-agent messaging is exact and never crosses a workspace. There is no cross-workspace dashboard. The analytics surface at `/observability` subscribes to a bounded global feed of recent runs and immediately narrows it to the active workspace, so what you see there is one workspace at a time. The only genuinely multi-workspace surfaces a person touches are the workspace picker and the switcher. One practical limit worth knowing: open workspace databases are cached and never evicted. Past 32 open at once the server warns rather than refusing, because each open file holds a background isolate and a page cache. ## Related concepts - [Multiplayer](/manual/concepts/multiplayer/): membership, roles and repo grants — the access half of isolation - [The agent model](/manual/concepts/agent-model/): agents belong to exactly one workspace - [Memory and knowledge](/manual/concepts/memory-knowledge/): memory is workspace-scoped - [Architecture](/manual/concepts/architecture/): where the databases sit in the client/server split - [Back up and restore](/manual/guides/back-up-and-restore/): taking a snapshot, moving a workspace, keeping copies elsewhere - [Backup, export and import](/manual/reference/backup/): every operation, route, manifest field and limit - [Add repositories](/manual/guides/add-repos/): registering a checkout into a workspace ### How-to guides > Task-oriented recipes — pick the goal you have and follow the steps. Source: https://usectrl.dev/manual/guides/ Each guide solves **one problem for someone who already knows their way around**. They assume you have Control Center installed and a workspace set up, they state a goal in the title and they stop when the goal is met. If you are still learning the product, start with the [tutorials](/manual/tutorials/). If you want to understand _why_ something behaves the way it does, read the [concepts](/manual/concepts/). ## Agents - [Create and configure an agent](/manual/guides/create-agent/) - [Run agents in parallel](/manual/guides/parallel-agents/) - [Build an agent team](/manual/guides/build-team/) - [Manage costs](/manual/guides/manage-costs/) - [Diagnose an agent](/manual/guides/agent-diagnostics/) - [Tune an agent's tool context](/manual/guides/tune-tool-context/) ## Workspaces - [Add repos to a workspace](/manual/guides/add-repos/) - [Run scripts when a space's worktree is created or destroyed](/manual/guides/repo-scripts/) - [Manage workspace memory](/manual/guides/manage-memory/) - [Install and manage skills](/manual/guides/manage-skills/) - [Search code with the code graph](/manual/guides/code-search/) - [Give an agent a machine to test on](/manual/guides/use-rigs/) - [Forward ports from an enclosed terminal](/manual/guides/vm-ports/) ## Pull requests - [Review and merge a PR](/manual/guides/review-merge-pr/) - [Use AI-powered review](/manual/guides/ai-review/) - [Dispatch reviewer agents](/manual/guides/dispatch-reviewers/) - [Review Studio (cohorts and axes)](/manual/guides/review-studio/) ## Messaging - [Chat with an agent](/manual/guides/chat-with-agent/) - [Use spaces](/manual/guides/spaces/) - [@-mention agents](/manual/guides/mention-agents/) - [Use plan mode](/manual/guides/plan-mode/) - [Triage your inbox](/manual/guides/triage-inbox/) ## Pipelines and plans - [Create a pipeline template](/manual/guides/create-pipeline/) - [Run a pipeline manually](/manual/guides/run-pipeline/) - [Set up pipeline triggers](/manual/guides/pipeline-triggers/) - [Monitor pipeline runs](/manual/guides/monitor-pipelines/) - [Run an orchestration](/manual/guides/run-orchestration/) - [Work in Plan Studio](/manual/guides/plan-studio/) ## Ticketing - [Create and manage tickets](/manual/guides/manage-tickets/) - [Delegate work to agents](/manual/guides/delegate-tickets/) - [Organize work with projects](/manual/guides/projects/) ## Meetings and calendar - [Record and summarize a meeting](/manual/guides/record-meeting/) - [Connect a Google Calendar](/manual/guides/connect-calendar/) ## Server and deployment - [Run a headless server](/manual/guides/run-headless-server/) - [Connect to a remote server](/manual/guides/connect-remote-server/) - [Run a fleet worker](/manual/guides/run-fleet-worker/) - [Back up and restore](/manual/guides/back-up-and-restore/) - [Pair a device](/manual/guides/pair-a-device/) - [Connect an OpenID Connect provider](/manual/guides/sso-oidc/) - [Provision users with SCIM](/manual/guides/sso-scim/) ## Integrations - [Connect a code host](/manual/guides/connect-forges/) - [Set up GitHub integration](/manual/guides/github-integration/) - [Create the GitHub App](/manual/guides/github-app/) - [Set up Linear integration](/manual/guides/linear-integration/) - [Set up Slack integration](/manual/guides/slack-integration/) - [Link your Slack account](/manual/guides/link-chat-account/) - [Customize the chat bot](/manual/guides/customize-chat-bot/) - [Use the MCP server](/manual/guides/mcp-server/) - [Curate your newsfeed](/manual/guides/newsfeed/) ## Your environment - [Configure notifications](/manual/guides/notifications/) - [Use focus mode and soundscapes](/manual/guides/focus-mode/) - [Manage adapters and models](/manual/guides/adapters/) - [Configure sandbox policies](/manual/guides/sandbox-policies/) - [Configure guardrails](/manual/guides/configure-guardrails/) - [Manage API keys](/manual/guides/api-keys/) ### Manage adapters and models > Connect model providers, pick the model an agent runs on and check which agent runner CLIs the server host has. Source: https://usectrl.dev/manual/guides/adapters/ This guide shows you how to connect model providers and choose what an agent runs on. Everything here lives on one page: **Settings → Server → Model providers**. The sub-sidebar entry reads "Model providers"; the page heading reads "Adapters". ## What an adapter is An adapter is the _runner_ that executes an agent turn. There are eight and the list is fixed — you cannot add a ninth. | Adapter | CLI | Transport | | ------------------------- | -------------- | --------------------------------------------------- | | Control Center (built-in) | none | In-process agent loop, talks to providers over HTTP | | Pi | `pi` | Structured JSON CLI | | Claude Code | `claude` | `claude -p --output-format stream-json` | | OpenCode | `opencode` | Agent Client Protocol (`opencode acp`) | | Gemini CLI | `gemini` | Agent Client Protocol (`gemini --acp`) | | Goose | `goose` | Agent Client Protocol (`goose acp`) | | Cursor | `cursor-agent` | Agent Client Protocol (`cursor-agent --acp`) | | Codex | `codex` | Agent Client Protocol, via the `acpx` bridge | What you _can_ add is a **provider** for the built-in adapter — any OpenAI- or Anthropic-compatible endpoint. That is the next section. :::note[Detection probes the server, not your machine] The **Detected runners** card lists what `cc_server` found on the _server host's_ `PATH`. On a thin or web client that is a different machine from the one you are looking at. Installing a CLI on your laptop does nothing for a remote server. ::: ## Connect a model provider The built-in adapter needs at least one connected provider before it can run. Eleven ship in the catalogue. 1. Go to **Settings → Server → Model providers** 2. Scroll to **Providers & models** 3. Find your provider and connect it: | Provider | How to connect | | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | Anthropic, OpenAI | Browser OAuth login (PKCE with a loopback callback), or an API key | | Kimi Code | Browser OAuth only — a device-code flow. It is a flat-rate plan, so it has no per-token price | | OpenRouter, Groq, Google Gemini, DeepSeek, Mistral, xAI, z.ai, Moonshot (Kimi API) | API key | Checkpoint: the row's badge changes to connected and expanding it lists the models that provider serves, each with its price per million tokens and context window. OAuth tokens land in the server's own credential store and are refreshed before they expire. Provider credentials are **host-global** — they are shared by every workspace on the install, not scoped to one. ### Add a custom provider Use this for Ollama, LM Studio, vLLM, or a private deployment. 1. In **Providers & models**, under **Custom providers**, press **Add provider** 2. Fill in a **name**, pick the **API type** (OpenAI-compatible or Anthropic-compatible) and give the **base URL** (for example `http://localhost:11434/v1`) 3. Add an **API key** if the endpoint needs one, then press **Add provider** A custom provider appears in the same list, with a delete button its built-in siblings do not have. ## Pick a model for an agent 1. Go to **Settings → Workspace → Agents** and select the agent 2. On the **Settings** tab, choose the **adapter** 3. Choose the **model** from the dropdown beside it For the built-in adapter the dropdown is assembled live from every connected provider's own catalogue. A provider with no stored credential contributes zero models, which is why an empty dropdown almost always means "nothing connected yet" rather than "nothing available". :::note[models.dev is enrichment, never a source of ids] The bundled [models.dev](https://models.dev) catalogue supplies price and context window for a model that is already in the live list. It never adds a model id. If a model you expect is missing, the provider's endpoint is not returning it. ::: Model ids for the built-in adapter are qualified `provider/model` and may carry a `|`-separated fallback chain. Under a fallback, cost is priced against the model that actually served the turn, not the one you asked for. :::note[An agent with no adapter runs on the built-in loop] Every workspace seeds five agents — a CEO plus `qa`, `architect`, `engineer` and `librarian` — with no adapter and no model. Dispatch falls back to **Control Center (built-in)**, not to the **Default runners** setting on this page, which is a client preference the dispatch path never reads. The run log records `cc-harness` for those runs, so the transcript names what actually ran. With no model either, the built-in loop asks Anthropic for its default model. An agent left entirely unset therefore runs if you have connected an Anthropic account, and otherwise fails with `No credential for provider "anthropic"`. The one exception: leaving onboarding's adapter step back-patches any agent named `ceo` whose adapter or model is unset, so the first workspace's CEO gets one. The four specialists never do and every workspace created later has all five unset. Set the adapter and model explicitly on every agent you intend to use. ::: ## Install a runner CLI The built-in adapter needs no installation. For any other adapter, install its CLI **on the server host** with that tool's own package manager, for example: ```bash npm install -g @anthropic-ai/claude-code npm install -g @openai/codex ``` Then press **Refresh** at the top of the page. Each detected row shows the resolved version and path, plus chips for whether it supports JSON mode and model selection. An external CLI keeps its own credentials, in its own configuration, on the server host. Control Center does not manage them. :::caution[The per-adapter Arguments and Environment variables fields do not reach the server] Each adapter row offers an **Arguments** field and an **Environment variables** dialog. The server does read per-adapter argv and env overrides at launch — but it reads them from its own settings store and the client writes them to your device instead. Nothing bridges the two and there is no client call that writes the server's copy. Do not put an API key there and expect a run to see it. ::: ## Restrict which providers a workspace may use Provider governance is per workspace and is evaluated last-match-wins. 1. In **Providers & models**, flip the **allow** switch on a provider row off That writes a `deny` statement for that provider in the active workspace and its models drop out of the catalogue for that workspace. Turning the switch back on removes the statement. ## Troubleshooting ### A runner shows as not found - Check the CLI is on the **server host's** `PATH`, not just yours - A GUI-launched desktop app spawns its server with a minimal `PATH`; a CLI installed under a shell-only path may be invisible to it - Press **Refresh** after installing ### The model dropdown is empty - The adapter is the built-in one and no provider is connected — connect one in **Providers & models** - Or the provider is connected but denied by this workspace's governance switch ### A model runs but the price shows as unknown The live model list carries the id; price and context come from models.dev. A brand-new model, or one from a custom provider, has no catalogue entry yet. ## Related guides - [Create and configure an agent](/manual/guides/create-agent/) - [Manage API keys](/manual/guides/api-keys/) - [Diagnose an agent](/manual/guides/agent-diagnostics/) - [Manage costs](/manual/guides/manage-costs/) ## Related concepts - [The agent model](/manual/concepts/agent-model/) - [Agent dispatch lifecycle](/manual/concepts/dispatch-lifecycle/) ### Add repos to a workspace > Register Git checkouts into a workspace so agents can work on them. Source: https://usectrl.dev/manual/guides/add-repos/ This guide shows you how to register a Git checkout into a workspace, re-index it and remove it again. Repos are **workspace-scoped**. There is no global registry and no linking step: adding a repo and putting it in a workspace are the same act. The same checkout registered in two workspaces becomes two independent rows with two different ids — across workspaces, a repo's identity is its filesystem path, never its id. ## Before you start A folder can only be registered if all three of these hold: 1. It is inside a **git work tree**. 2. That work tree has an **`origin` remote**. 3. The `origin` remote points at a **supported forge**: github.com, gitlab.com or bitbucket.org. Anything else is refused at registration time with an explicit message: "Folder is not inside a git work tree", "No `origin` remote configured for this repository", or "The `origin` remote is not hosted on a supported forge". There is no local-only mode. The forge is read from the remote and stored on the repo — you never pick it. A workspace can hold repos from all three at once, and each is talked to through its own API with its own credential. GitLab's nested namespaces are preserved, so `gitlab.com/group/subgroup/project` registers as owner `group/subgroup`, name `project`. The folder must also live on the machine hosting `cc_server`, inside one of that server's `--repo-roots` directories. That allow-list defaults to the server user's home directory and browsing above a root is refused. ## Register a checkout 1. Open **Settings → Workspace → Repositories** (`/settings/workspace/repositories`). 2. Click **Add repository**. The dialog is a navigable browser over the **server's** filesystem, not yours. It is the same flow on desktop, web and phone, because a native folder picker would pick a folder on the wrong machine. 3. Navigate within the server's configured roots. Folders that are git checkouts are flagged. 4. Tick any number of them. The selection persists as you navigate, so you can collect checkouts from several folders. 5. Register them in one batch. One failed pick never drops the rest of the batch — you get the ids that were created plus a per-path error for each that was refused. Re-registering a path already present in this workspace returns the existing repo rather than minting a duplicate. Each registered repo appears in the list with its `owner/name`, a badge naming its forge, and its path on the server. GitHub repos also show the owner's avatar; the other forges have no guessable owner-avatar URL, so they fall back to initials. The forge badge matters in a mixed workspace: two repos can both be called `acme/web` on different forges, and they behave differently — what a review can do, and where a link goes. ## What happens next Registration fires a `RepoAdded` event scoped to this workspace, which starts the `index_code` pipeline for that checkout. That is what populates the code graph, so search and impact analysis have something to answer with. Only Dart, JavaScript, TypeScript, TSX and PHP files are indexed. See [Search code with the code graph](/manual/guides/code-search/). ## Re-index a repo Each row carries an **Index code** button. Click it to re-run the indexer for that checkout; while it runs the button becomes a progress readout showing files done over total, with a cancel control. When it finishes it settles into a check with the symbol count, or a warning if the run failed. ## Reorder repos Drag the handle on a row. The order you set is the order every repo list in the app reads back. ## Fix the GitHub link The link icon on a row opens an editor for the repo's GitHub owner and repository name — useful when the remote was renamed or transferred. ## Remove a repo 1. Open **Settings → Workspace → Repositories**. 2. Click the trash icon on the row. 3. Confirm "Remove repository from workspace?" The dialog says what it does: "_name_ will be removed from this workspace. The local files on disk are not touched." Removal deletes the repo row from this workspace and cascades away its code-graph rows — symbols, edges, files and index checkpoints — plus every per-member repo grant on it. There is no separate unlink, because there is no shared global repo left to unlink from. The same checkout registered in another workspace is an independent row and is unaffected. ## Related guides - [Search code with the code graph](/manual/guides/code-search/) - [Run agents in parallel](/manual/guides/parallel-agents/) - [Set up GitHub integration](/manual/guides/github-integration/) - [Your first workspace](/manual/tutorials/first-workspace/) ## Related concepts - [Workspaces and isolation](/manual/concepts/workspaces/) ### Diagnose an agent > Work through the run log, adapter probe, server status and host diagnostics when an agent stops producing runs. Source: https://usectrl.dev/manual/guides/agent-diagnostics/ This guide shows you how to find out why an agent is not running, using the surfaces that exist in the app today. :::caution[There is no agent doctor] Earlier documentation described an "Agent doctor" run from an agent's context menu. There is no such menu item, no diagnostics screen for an agent and no `doctor` MCP tool — the tool class was deleted, so an agent calling it gets "unknown tool". Nothing in the product produces a per-agent diagnostic report. Work through the checks below instead. ::: Work top down: the agent's own runs first, then its backend, then the server, then the host. ## 1. Check the agent's run history **Settings → Workspace → Agents** → select the agent → **Logs**. Each run shows a status badge, a duration ("Running…" while in flight), its start and completion times, its PID and the adapter it used. **View** opens that run's raw event stream. **Stop** kills a running process and marks the run "Killed by user"; **Kill running** in the header does the same for every in-flight run of that agent. Read it like this: - **No runs at all** — the dispatch never happened. Check that you actually mentioned the agent by an exact name match (mention parsing matches `@` plus word characters only, so a name with a hyphen, dot or space can never be mentioned), then go to step 2. - **A run that ended immediately in error** — open **View**. A failure to launch the backend shows up here as a process error. - **A run stuck at "Running…"** — stop it, then check the agent's **Silence timeout** in the Settings tab. An unset timeout uses the per-mode default. - **A completed run with no useful output** — the run log's status stays `completed` for a run that ended without its declared deliverable; the honest signal is the summary text, not the badge. The Logs tab shows no cost. For spend, use the per-agent table in **Observability → Insights**. ## 2. Check the agent's backend **Settings → Server → Model providers** (`/settings/server/providers`). The **Detected runners** list shows every adapter with its probe result — an installed version, "Checking…", or "Not found" — plus the resolved path and the capability chips for a found CLI. Click **Refresh** in the page header to re-probe. The probe runs on the machine hosting `cc_server`, not on the machine you are looking at. If you are on the web client or a paired device, "Not found" means the CLI is missing on the server host. Two things to confirm: - **The agent has an adapter set at all.** An agent with an empty adapter is dispatched to **Control Center (built-in)** and the run log records `cc-harness`, so the transcript names what ran. With no model either, it asks Anthropic for its default — which fails with `No credential for provider "anthropic"` unless you have connected an Anthropic account. The seeded specialists ship with no adapter. - **The built-in adapter has a model.** Its model list comes live from the providers connected further down this same page, so a provider with no stored credential contributes zero models. The adapter catalogue itself is fixed — there is no way to register a new adapter. What you can change per adapter here is its launch arguments and environment variables. Note that a per-adapter environment override silently beats a connected provider account when credentials are resolved. ## 3. Check the server **Settings → Server → Connection & status** (`/settings/server/connection`) shows which server this client is talking to and what it reports. For a headless server, `GET /healthz` is the same information without a client. It is unauthenticated and CORS-open by design: ``` curl http://localhost:9030/healthz ``` It returns the server's status, uptime in seconds, live connection count, bound port, build version and git SHA, workspace schema version, RPC catalog version, server id, name and identity fingerprint, whether the bind is insecure and a `codeGraph` block with `watching` / `indexing` / `pending` counts. Two things worth knowing when a change "works in tests but not in the app": - The desktop launches a **prebuilt** `cc_server` binary in preference to source. A new RPC op or MCP tool does not appear until the binary is rebuilt and boot logs a warning when the child's git SHA differs from the app's. - The desktop kills a spawned server that has not printed its ready banner within 20 seconds and drops to an error screen. ## 4. Check the host **Settings → Server → Diagnostics & privacy** (`/settings/server/diagnostics`) is the host-level page. It carries: - **Sandboxing** — the master toggle, the detected backend, an install hint, the default capability toggles and a reset. Only the detected backend comes from the server; see the caution below. - **Semantic search** — the on-device embedding model's lifecycle: installed, downloading with a percentage, not installed, or an install error. Until it is installed, memory and code search stay keyword-only. - **System behaviour** — keep the computer awake while agents run. - **Sync engine** — per-store kill switches for tickets, messaging and notes. These are emergency escape hatches back to full-snapshot subscriptions; a change only affects new subscriptions, so it takes full effect after a reload. - **Log level** — raise it before reproducing a failure. - **Privacy** — whether raw diff content may be forwarded to the agent's model and whether crash reports are sent. :::caution[This section reports the host and its controls are client-side] Detection and enforcement now agree: where the detector reports a native backend, agent runs on that host really are wrapped in it. But the detector is the only live thing in this section. The master toggle, the backend picker and the default capability toggles are preferences stored on your device and the server reads none of them — the server's own switch is `--sandbox` (`on` by default, `off` to opt out) and for any agent left on "Use workspace default" the server falls back to its conservative default: no git push, no GitHub API, no ticketing key, network on. So a warning here is worth acting on, but it is not usually why an agent is failing. To confirm which state the server is in, read its startup log: it prints `agent sandbox ON` with the backend note, `no OS-native agent sandbox on this host` with an install hint, or `agent sandbox DISABLED by --sandbox off`. `GET /healthz` carries no sandbox field. Where there is no backend — Windows always, Linux or WSL2 without `bwrap` and `socat` — runs are **not** sandboxed and what bounds them is the conversation's own checkout, the capabilities that gate which credentials are minted into it, the action guardrails, environment sanitization and the tool surface its mode allows. ::: ## Common failures ### The run never starts and the adapter shows "Not found" Install the CLI **on the server host**, then click **Refresh** on the Model providers page. Or switch the agent to the built-in adapter, which needs no CLI and connect a provider on the same page. ### The agent cannot push or open a pull request Its capabilities are the gate. Open **Settings → Workspace → Agents** → the agent → **Sandbox permissions**, turn the switch on and enable **Allow git push** and **Allow GitHub API calls**. Leaving the switch on "Use workspace default" gives the agent none of them. ### Semantic search returns nothing useful Check **Settings → Server → Diagnostics & privacy → Semantic search**. Until the embedding model is installed, search is keyword-only. Rows written before the model was installed are never backfilled, so they stay keyword-only even afterwards. ### The server will not boot at all Native libraries are required and there is no degraded mode. A missing dylib makes `cc_server` refuse to start and name the offender in the error. Stage the natives with `scripts/natives/build_natives.sh` and rebuild the server binary. ### An action is silently denied A `prompt` guardrail decision with no approver connected is denied, by design. Check the workspace's rules under **Settings → Workspace → Agent permissions**. ## Related guides - [Create and configure an agent](/manual/guides/create-agent/) - [Manage adapters and models](/manual/guides/adapters/) - [Configure guardrails](/manual/guides/configure-guardrails/) - [Run a headless server](/manual/guides/run-headless-server/) ## Related concepts - [Sandbox and security](/manual/concepts/sandbox-security/) - [Agent dispatch lifecycle](/manual/concepts/dispatch-lifecycle/) ### Use AI-powered review > Run the pr_review pipeline on a PR, read the findings and verdict, then publish the review to GitHub. Source: https://usectrl.dev/manual/guides/ai-review/ This guide shows you how to have reviewer agents review a pull request and how to publish the result to GitHub. ## Start an AI review 1. Open the PR and go to its **Overview** tab 2. Open the ⋯ overflow menu in the action row and choose **Ask AI** The item only appears while the PR is open — it is a pre-merge action. The same button is offered as **Ask AI** on the **Review** tab's empty state, before any review exists. Control Center starts the seeded `pr_review` pipeline and navigates you to its run page so you can watch it. The pipeline: 1. Opens the PR's own review space, whose isolated worktree has the PR checked out at its head 2. Fans out the reviewers for the workspace's review level — three by default (the seeded **qa**, **architect** and **engineer** agents, in parallel). There is no language or domain detection 3. Has the lead (CEO) agent consolidate their findings into a walkthrough 4. Finalizes: sorts the findings into consensus buckets and computes the verdict, and the status bar on the Review tab flips to **Awaiting your approval** How deep the review goes is the workspace's **review level** — `light` (one reviewer), `balanced` (three, the default) or `thorough` (all five, adding the security and performance specialists) — set at **Settings → Workspace → Workspace policy → Review level**. A lighter level files just as many findings; it reports fewer of them front and centre. All the reviewers post into the PR's single review space, which is what makes their findings comparable. See [AI review](/manual/concepts/ai-review/) for how the pieces fit together. `pr_review` ships with a manual trigger only. To have it fire on every published PR, add a trigger yourself — see [Dispatch reviewer agents](/manual/guides/dispatch-reviewers/). You can also start one from GitHub itself, no pipeline trigger needed: mention the server's bot on the PR or add the `ai-review` label — see [Talk to Control Center from a GitHub PR](/manual/guides/github-pr-conversations/). If the toast says the run is a duplicate, a review is already in flight for this PR; open **Pipelines** to find it. ## Read the findings Reviewers record findings as **review nodes** in the review space. Each carries: | Field | Description | | -------------- | ------------------------------------------------------------- | | Kind | `bug`, `suggestion`, `recommendation`, `question` or `ticket` | | Priority | P0 (critical) → P3 (nit) | | Axis | Which review axis the finding belongs to | | File and line | Where the finding is anchored | | Original code | The code the finding is about | | Suggested code | The proposed fix | | Confidence | How confident the reviewer is | | Status | `open` → `consensus-ready` → `resolved` or `dismissed` | A finding only reaches **consensus-ready** once a _peer_ reviewer confirms it. An agent cannot confirm its own finding, so a single reviewer's opinion never graduates on its own. The **Review** tab shows the findings list, filterable by kind and status, with a toggle for dismissed findings, beside the artifact the consolidating agent published. The deterministic view — cohorts, the multi-axis dashboard, contract and visual diffs — is computed server-side but has no surface in the app; see [Review Studio](/manual/guides/review-studio/) for what exists and where. ## Check the disagreements panel When two reviewers anchor findings to the same file and line but rate them very differently — priorities two or more levels apart, or one calls it a `bug` while the other calls it a `suggestion` — a disagreement panel appears above the findings list. Read those first: they are where the reviewers' opinions diverge and your judgement is worth the most. ## Read the verdict When the lead agent finalizes, it computes a **verdict**: | Verdict | Meaning | | --------- | -------------------------------------------- | | **Ship** | Nothing blocking found | | **Hold** | Issues that should be addressed before merge | | **Block** | Critical issues that must be fixed | The verdict carries an aggregate confidence score and per-priority finding counts. The review axes then fold into it and they can only make it **more** severe. A gated axis that fails forces **Block**; a gated axis that could not complete (`partial`) or could not run at all (`unavailable`) forces at least **Hold**. Absence of evidence never clears a gate. ## Publish the review to GitHub Publishing is a separate, deliberately user-gated step. The `pr_review` pipeline does **not** post to GitHub — the old "post PR comment" step was removed so a review cannot double-publish. 1. On the **Review** tab, wait for the status bar to read **Awaiting your approval** 2. Press **Publish to GitHub** Control Center sends **one** GitHub review. Findings already resolved or dismissed are skipped and only findings a peer confirmed are included. Those that carry a file and line anchor become inline comments; everything else folds into the summary body, each with a "— Control Center AI review" footer so the source is unambiguous. A **Block** verdict is submitted as `REQUEST_CHANGES`; anything else is submitted as `COMMENT` — the bot never approves on your behalf. The status bar then flips to **Published**. If GitHub rejects an inline anchor because the line is not part of the diff, the whole review is re-sent with every finding folded into the body, so nothing is silently dropped. :::caution[Publishing is guardrail-gated] `pr_review.publishReview` declares the `prPublish` action class and runs through the guardrail chokepoint. If the resolved decision is `prompt` and no approver is connected, the publish is denied — guardrails fail closed. Set an explicit rule at Settings → Workspace → Agent permissions if you want it to run unattended. ::: Other built-in pipelines do post to GitHub directly: `cross_review`, `pr_triage`, `ticket_to_pr` and `pre_merge_gate` each end in a comment step. Only `pr_review` holds its output back for you. ## Dispatch reviewers yourself For control over which roles review the PR and which files each one looks at, use the `dispatch_reviewers` MCP tool inside an existing review space, or add reviewer nodes to a pipeline. Both paths, with the tool's exact signature, are in [Dispatch reviewer agents](/manual/guides/dispatch-reviewers/). ## Related guides - [Review and merge a PR](/manual/guides/review-merge-pr/) - [Dispatch reviewer agents](/manual/guides/dispatch-reviewers/) - [Review Studio (cohorts and axes)](/manual/guides/review-studio/) ## Related concepts - [AI review](/manual/concepts/ai-review/) - [Guardrails](/manual/concepts/guardrails/) ### Manage API keys > Where each credential lives — your user account on the server, the server itself, or an external CLI — and how to set, rotate and remove it. Source: https://usectrl.dev/manual/guides/api-keys/ This guide shows you where each credential Control Center uses is stored and how to change it. **Nothing is stored on the machine you are typing on.** Every credential lives on the server; which one answers a given request depends on whether there is a human behind it. | Credential | Stored | Who uses it | | ------------------------------------ | --------------------------------------------------------- | ---------------------------------------------------------------- | | Your forge / ticketing credential | The server's 0600 secrets file, keyed by your user id | Anything you ask for: the PR surface, your agent runs, your images | | The server's app identity | The same file, plus `server_settings` for the non-secrets | Work no human asked for: webhooks, PR polling, ticket sync | | Model provider keys and OAuth tokens | The server's own credential store | The built-in adapter, for every workspace on the install | | Slack bot tokens | `//chat_credentials/` on the server | The chat bridge for that workspace | | External CLI credentials | Whatever that CLI uses | Claude Code and friends, on the server host | ## Two GitHub credentials, and they answer different questions - **Yours**, from signing in (device flow) or pasting a token. It is what every request made *on your behalf* uses, so a comment posted for you carries your name. Another member cannot read it, and they connect their own - **The server's**, from the GitHub App the operator registered. It answers everything with no caller — so forge access does not disappear when one person leaves or rotates a token Yours is set in the **Code hosting** card at **Settings → You → Profile & identity**; the server's in **Provider apps** at **Settings → Server → Single sign-on**. See [Set up GitHub integration](/manual/guides/github-integration/) and [Create the GitHub App](/manual/guides/github-app/). :::note[There is also a URL-only screen] `/workspaces//api-keys` renders the same two cards as onboarding's first step. It has **no navigation entry anywhere in the UI**; it is reachable only by typing the URL. Use Settings → You → Profile & identity instead. ::: ## Set model provider keys The built-in adapter authenticates providers itself and those credentials live on the server. 1. Go to **Settings → Server → Model providers** 2. Scroll to **Providers & models** 3. Connect the provider — browser OAuth for Anthropic, OpenAI and Kimi Code; an API key for the rest See [Manage adapters and models](/manual/guides/adapters/) for the full list and for adding a custom endpoint. These credentials are **host-global**: one key serves every workspace on the install. OAuth tokens are refreshed before they expire. External adapter CLIs keep their own credentials, in their own configuration, on the server host — Control Center does not manage them. Authenticate each CLI the way its own documentation says, on the machine `cc_server` runs on. The per-adapter **Environment variables** dialog looks like a place to put a key for one of them, but it is not: it writes to your device, while the server reads its own copy and nothing bridges the two. See [Manage adapters and models](/manual/guides/adapters/). ## Connect a ticketing vendor The **Ticketing** card holds both halves of one decision: which vendor your tickets live in, and the credential for it. Choosing **Local** hides the credential row — local tickets live in this server's own database and have nothing to authenticate to. Your key is stored on the server against your user id, like the forge one. The server keeps its own Linear key separately (**Provider apps**), which is what ticket sync runs on when no human asked. A key an agent receives is only injected when the agent has the **Allow ticketing API calls** capability. See [Set up Linear integration](/manual/guides/linear-integration/) for what is and is not wired. ## Rotate or revoke - **Your GitHub credential**: press **Disconnect**, then sign in again or paste the new token. Revoke the old one on GitHub - **The server's app**: generate a new private key or client secret on the app's GitHub page, paste it in **Provider apps** and press **Test** - **A model provider**: on its row, sign out (OAuth) or remove the key, then connect again - **Slack**: re-paste the tokens in the connect dialog. See [Set up Slack integration](/manual/guides/slack-integration/) ## Troubleshooting ### The PR list is empty but my token works in `curl` Check the **Code hosting** row's subtitle. If it names another source ("via this server's app", "from the environment"), the credential you are testing is not the one answering — yours was not saved. ### A token I just pasted is not working The token is probed as it is stored, so the row names the account behind it — but only the account, not the scopes. A token with too few scopes connects and then fails individual requests. Check the scopes and the expiry: ```bash curl -H "Authorization: token YOUR_TOKEN" https://api.github.com/user ``` ## Related guides - [Set up GitHub integration](/manual/guides/github-integration/) - [Create the GitHub App](/manual/guides/github-app/) - [Manage adapters and models](/manual/guides/adapters/) - [Set up Slack integration](/manual/guides/slack-integration/) - [Set up Linear integration](/manual/guides/linear-integration/) ### Back up and restore > Take a snapshot of the install, move a workspace between machines, restore one workspace or all of them, and keep copies somewhere else. Source: https://usectrl.dev/manual/guides/back-up-and-restore/ This guide covers the five things people actually do with backups: take one, collect it, move a workspace, put one back, and keep copies off the machine. For the exact shape of every artifact and operation, see the [backup reference](/manual/reference/backup/). Everything here lives at **Settings → Server → Backup & restore**. ## Prerequisites - A **full client** — the desktop or web app. A paired phone can trigger none of this, by design. - For per-workspace actions: **admin** of that workspace to export it, **owner** to import or delete it. Downloading a whole install snapshot needs to be the install's **owner**. - For downloads and uploads: a **direct** connection to the server. The brokered relay carries RPC frames, not file transfers, and the transfer controls disable themselves with a note when you are on one. :::caution[Nothing is scheduled] A snapshot is taken only when somebody asks for one. Nothing runs on a timer, nothing rotates old snapshots and there is no `cc_server backup` subcommand. If you want unattended backups, set them up yourself — see [Copy backups off the machine](#copy-backups-off-the-machine). ::: ## What a backup covers Both artifacts are **databases only**: - An **install snapshot** is a timestamped directory: `manifest.json`, `global.db` and one `/workspace.db` per workspace. - A **workspace export** is that single `workspace.db` file. Neither carries the rest of the data directory: `secrets.json` (pairing keys, provider credentials, SSO), a workspace's `blobs/` (images pasted into messages), `skills/`, `agents/`, `chat_credentials/`, recorded meeting audio or rig images. Some of that is disposable and some of it is not — the [full table is in the reference](/manual/reference/backup/#what-a-backup-does-not-contain), and covering it is what the [off-machine section](#copy-backups-off-the-machine) is for. ## Take a snapshot of the whole install 1. Open **Settings → Server → Backup & restore**. 2. Under **Install snapshots**, press **Back up now**. 3. The toast names the directory that was written, on the server host. The new snapshot appears at the top of the list. The server keeps running throughout. Each file is written with `VACUUM INTO`, which is consistent against a live database, so there is no maintenance window and no need to stop agents. Each row in the list shows the timestamp, the size on disk, how many workspaces were captured and whether the snapshot is **Complete** or **Incomplete**. Incomplete means the manifest is missing or names files that are not there — a backup interrupted by a crash or a full disk. Those are still listed on purpose: a snapshot you cannot see is one you believe you have. An incomplete snapshot cannot restore a whole install, but the workspace files it does hold can still be adopted one at a time. A row may also say **_n_ workspaces not captured**. That is a workspace registered but never written to (there is no file yet), or one whose copy failed — check the server log. ## Collect a copy on your own machine The paths the page shows are on the machine running `cc_server`. When that is not the machine you are sitting at, use the **Download** buttons: - Beside each **workspace** under **Workspace data** → **Download**, for one `.db` file. - Beside each **snapshot** under **Install snapshots** → **Download**, for the whole snapshot as a `.zip`. On desktop you pick where the file lands and the toast names it; on web it goes to the browser's downloads. Either way both report progress in bytes, because these are the two payloads that run to gigabytes. Both mint a fresh copy at the moment you press the button, stream it, and delete the server's copy afterwards — a download is not "fetch the file the export button made earlier". Unzipping a snapshot archive reproduces the snapshot layout exactly, which is what makes restoring it a copy back. ## Move a workspace to another install One workspace is one database file, so handing it over is a file operation. 1. On the **source** server, **Settings → Server → Backup & restore → Workspace data**, expand the workspace and press **Download**. 2. On the **destination** server, create (or pick) the workspace that will receive it. 3. Expand that workspace, and under **Restore from this device** press **Choose a file and upload**. Pick the `.db` you downloaded. If both servers are the same machine you are sitting at, **Save on server** plus **Import** → **Choose file** does the same thing without the round trip; the import dialog's paths resolve on the server host. :::caution[Import replaces everything] Importing overwrites everything the target workspace currently holds, and it cannot be undone. Export the target first if it holds anything you might want back. ::: Two things are allowed and logged rather than refused: importing a file whose recorded workspace id differs from the target (this is how you duplicate a workspace or restore it under a new id), and importing a file from a **different install**. In that second case the file's paired devices and user ids belong to the install it came from and will not resolve on this one — the workspace's own data is intact, but re-check membership after adopting it. Chat bot tokens live beside the database, not inside it, so a workspace you hand to somebody else carries no live credentials. The receiving install reconnects its own. ## Restore one workspace from a snapshot 1. Under **Install snapshots**, expand the snapshot you want. 2. Find the workspace's row and press **Restore**. 3. Confirm. Everything that workspace has done since the snapshot was taken is lost. This is the same operation as an import, pointed at the snapshot's copy of that workspace's file — deliberately, so there is no second mechanism that could treat the same file differently. A row may say **Not on this server any more**, with **Restore** disabled: the snapshot holds a workspace this install no longer has. Bring it back by hand — create a workspace, then under **Workspace data** import into it from `//workspace.db`. The expanded snapshot shows its own **Path on the server** in a copy field, and the row's title is the old workspace id, so you have both halves of that path in front of you. ## Restore a whole install There is no button for this, and there should not be: it replaces the databases the running server is holding open. 1. **Stop `cc_server`.** If you run the desktop app with its embedded server, quit the app. 2. Copy the snapshot's contents over the data directory. The snapshot layout mirrors the live one, so this is a plain copy: ```bash # macOS default; see the CLI reference for other platforms DATA_DIR="$HOME/Library/Application Support/control-center" SNAPSHOT="$DATA_DIR/backups/2026-07-28T09-12-33-000Z" cp "$SNAPSHOT/global.db" "$DATA_DIR/global.db" for d in "$SNAPSHOT"/*/; do id=$(basename "$d") mkdir -p "$DATA_DIR/$id" cp "$d/workspace.db" "$DATA_DIR/$id/workspace.db" done ``` 3. Delete any `-wal` and `-shm` files sitting next to the databases you replaced. They belong to the old files and a stale one alongside a restored database is a corruption risk. 4. Start the server again. The snapshot holds no `secrets.json`, so an install restored onto a **fresh** host keeps its data but not its credentials: paired devices, provider tokens and SSO settings have to be re-established, or restored from wherever you kept that file. Restoring onto the same host, where `secrets.json` was never lost, keeps everything. ## Copy backups off the machine A snapshot on the same disk as the database it copied protects you from a bad import, not from the disk. Nothing in Control Center ships a copy anywhere, so this part is yours. ### Sync the snapshots Snapshots are immutable once written and named by timestamp, so any sync tool handles them well. Exclude `transfer/`, which is a workbench for in-flight downloads and uploads: ```bash DATA_DIR="$HOME/Library/Application Support/control-center" # To another host over ssh rsync -a --delete --exclude 'transfer/' \ "$DATA_DIR/backups/" backup-host:/srv/control-center-backups/ # To object storage aws s3 sync --exclude 'transfer/*' \ "$DATA_DIR/backups/" s3://my-bucket/control-center/backups/ # Or with deduplication, encryption and its own retention restic backup --exclude 'transfer' "$DATA_DIR/backups" ``` `--delete` mirrors your local pruning to the remote. Drop it if you want the remote to keep snapshots longer than the host does. :::danger[Never sync the live databases directly] Do not point rsync, `aws s3 sync` or a file-level backup agent at `global.db` or `/workspace.db` while the server is running. A live SQLite database has a `-wal` sidecar, and a file-by-file copy captures the two at different instants — the result is a file that often opens and is silently missing recent writes, or does not open at all. Copy the **snapshot**, which was written under a read transaction, or take one with `VACUUM INTO` as below. ::: ### Include what a snapshot leaves out To be able to rebuild the install rather than just its data, copy these too. They are ordinary files, safe to copy while the server runs: ```bash rsync -a \ "$DATA_DIR/secrets.json" \ backup-host:/srv/control-center-backups/config/ rsync -a --relative \ "$DATA_DIR"/./*/chat_credentials \ "$DATA_DIR"/./*/blobs \ "$DATA_DIR"/./*/skills \ "$DATA_DIR"/./*/agents \ backup-host:/srv/control-center-backups/workspace-files/ ``` `secrets.json` is `0600` and holds every pairing key, provider credential and SSO setting on the install. Wherever it lands must be at least as protected as the server host — encrypted at rest, and not a bucket anyone else can read. Skip `spaces/` (copy-on-write worktrees, re-provisioned from your repos), `pr_clones/` (a cache), `models/` (re-downloaded), `media_cache/` and `rigs/` (machine state, and large). Recorded meeting audio under `meetings/` is worth including if you keep meetings. ### Snapshot on a schedule, without the UI There is no CLI subcommand and the RPC ops need a paired full client, so the practical way to automate this on the server host is to do what the server does — `VACUUM INTO`, which is safe against the live databases: ```bash #!/usr/bin/env bash set -euo pipefail DATA_DIR="${CC_SERVER_DATA_DIR:-$HOME/.local/share/control-center}" DEST="/srv/control-center-snapshots/$(date -u +%Y-%m-%dT%H-%M-%SZ)" mkdir -p "$DEST" sqlite3 "$DATA_DIR/global.db" "VACUUM INTO '$DEST/global.db'" for db in "$DATA_DIR"/*/workspace.db; do id=$(basename "$(dirname "$db")") mkdir -p "$DEST/$id" sqlite3 "$db" "VACUUM INTO '$DEST/$id/workspace.db'" done ``` Run it as the same user that owns the data directory, from cron or a systemd timer, and sync `DEST` off the host afterwards. `VACUUM INTO` needs SQLite 3.27 or newer. Write it **outside** `/backups/`. A hand-made directory there has no `manifest.json`, so the app would list it as an incomplete snapshot and the list would stop being an honest record of what the server itself produced. ### Prune old snapshots Nothing rotates them, and each one is roughly the size of your databases. ```bash find "$DATA_DIR/backups" -mindepth 1 -maxdepth 1 -type d \ ! -name exports ! -name transfer \ -mtime +30 -exec rm -rf {} + ``` Check the output of a `-print` run before adding `-exec`. Keep more than one: the failure you are protecting against is often "the last backup also has the problem". ## Check that a backup is good A backup nobody has restored is a hypothesis. Cheap check — the file opens and its pages are intact: ```bash sqlite3 "$SNAPSHOT/global.db" 'PRAGMA integrity_check;' # expects: ok ``` Real check — adopt it. Create a scratch workspace on a non-production install, import a workspace file into it, and look at whether the spaces, tickets and messages you expect are there. That exercises the same path a real restore takes, including the FTS rebuild, and it is the only check that tells you the backup is restorable rather than merely readable. Do this once when you set backups up, and again whenever you change where they are stored. ## Troubleshooting | Symptom | Cause and fix | | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | | Download and upload controls are disabled with a note about a relay | You are connected through the brokered relay, which carries no file transfers. Connect directly | | "The server refused" | Role. Downloading a workspace needs admin, restoring one needs owner, a whole snapshot needs the install's operator | | "This server has no backup surface" | The host has no backup port wired — a demo server. The operations are absent rather than refused | | "The file is larger than the server accepts" | Uploads are capped at 8 GiB. Put the file on the server host and use **Import** → **Choose file** instead | | Import refused: not a Control Center workspace database | The file has no `workspace_meta` row — it is not a workspace export, or it is truncated. Nothing was replaced | | A snapshot is listed **Incomplete** | Its manifest is missing or names absent files. Take a fresh one; adopt individual workspace files from it in the meantime | | A snapshot says workspaces were not captured | Those workspaces have never been written to, or their copy failed. Check the server log | | A restored install has no forge or SSO credentials | `secrets.json` is not in a snapshot. Restore it from wherever you kept it, or reconnect the providers | | A restored workspace is missing pasted images | Blobs live beside the database, not in it. Copy `/blobs/` across as well | ## Related guides - [Run a headless server](/manual/guides/run-headless-server/): where the data directory comes from - [Connect to a remote server](/manual/guides/connect-remote-server/): direct versus relayed connections - [Pair a device](/manual/guides/pair-a-device/): the credential the transfer routes sign with ## Related concepts and reference - [Backup, export and import](/manual/reference/backup/): every operation, route, field and limit - [Workspaces and isolation](/manual/concepts/workspaces/): why one workspace is one file - [`cc_server` CLI](/manual/reference/cc-server-cli/): the data directory layout ### Branch a conversation > Try a different approach from an earlier point without losing the one you already have, then fork or export what you want to keep. Source: https://usectrl.dev/manual/guides/branch-a-conversation/ This guide shows you how to go back to an earlier point in a conversation and take it somewhere else — while keeping the path you left. ## The one thing worth knowing **Branching writes nothing.** A conversation is a tree of messages with a pointer at the tip, and going back moves the pointer. Nothing is deleted, nothing is hidden, and switching back is another pointer move. That is why editing a prompt and re-running is safe here: the answer you were comparing against is still there. ## See the branches ``` /tree ``` The navigator lists every message, indented at each fork, with the current path marked **On this branch**. A conversation that never forked renders as the flat list it always was — the navigator costs nothing to look at until there is something to navigate. Each row offers two actions. | Action | Effect | | ------------------------------- | --------------------------------------------------------- | | **Continue from here** | Moves the pointer. Your next message continues from that message. | | **Fork into a new conversation** | Copies the path up to that message into a new conversation. | ## Branch: try a different approach 1. `/tree` 2. Find the message you want to start again from — usually the last one you were happy with. 3. **Continue from here.** 4. Type the new prompt. The old continuation is still in the tree; `/tree` will now show two paths from that point, and you can switch between them freely. :::note[The transcript follows the pointer] The conversation shows the branch you are on. The other one is not gone — it is one `/tree` away. ::: ## Fork: take it somewhere else Forking copies the path into a **new conversation** in the same space. Use it when the two directions are going to keep diverging and you want them side by side rather than switching. The copy has fresh message ids. Two conversations sharing rows would show each other's later messages, which is exactly what a fork is supposed to prevent. ## Revert versus branch They are different tools and the difference matters. | | Revert | Branch | | ------------------- | -------------------------------------------------- | ------------------------------- | | The later messages | Hidden from the transcript | Still visible in `/tree` | | The **worktree** | Rolled back to that turn's snapshot | Untouched | | Undo | `unrevert` restores the batch | Move the pointer back | Revert is what you want when the agent changed *files* you want back. Branch is what you want when the conversation went the wrong way but the files are fine. Reverting also moves the branch tip, so your next message continues from the right place rather than from a tail nobody can see any more. ## Take it out of the app ### `/export` Writes a **self-contained HTML file**: the whole conversation with collapsible tool cards showing what each call was given and what it returned. No stylesheet, no script, no image host — it works from a USB stick and it works in five years. - **Desktop:** `Documents/ControlCenter/exports/`, timestamped so a second export does not overwrite the first. - **Web:** a browser download. ### `/dump` Copies the transcript to the clipboard as markdown, with tool calls in collapsible blocks. This is the one to paste into an issue. Reasoning is deliberately left out: it is the model's scratch space, and a bug report is read by a person. ## Related - [Conversation history](/manual/concepts/conversation-history/) — why the tree is a pointer move, and what resume actually restores - [Slash commands](/manual/reference/slash-commands/) — `/tree`, `/export`, `/dump` ### Build an agent team > Group agents under a leader so assigned work routes through one coordinator. Source: https://usectrl.dev/manual/guides/build-team/ This guide shows you how to create a team and give it a leader and what you can currently do with one. A team is a named group of agents inside one workspace, with one agent designated as its **leader**. Assigning a ticket to the team wakes the leader, which breaks the work down and delegates to members; when a member's run finishes the leader is woken again to decide what is next. :::caution[Nothing in the app dispatches to a team] Teams can be created, edited and given a leader entirely from the UI and leader routing works once a ticket carries a team assignment. But the ticket assignee picker offers only "Unassigned", yourself and individual agents — there is no team option — and a team's id is never displayed anywhere in the app. The two paths that do set a team assignment (an agent calling the `assign_ticket` tool with a `team_id`, or a pipeline **Team dispatch** node) both need that id and there is no `list_teams` tool to obtain it. Treat team dispatch as designed but not yet reachable. ::: ## Create a team 1. Open **Settings → Workspace → Teams** (`/settings/teams`). 2. Click **Add team**. 3. Enter a **name** (for example "backend" or "review squad") and an optional **description**, then click **Create**. The team is created and selected and its editor opens in the right-hand pane. ## Set the leader This is the step the whole feature depends on. In the team's editor: 1. Under **Leader**, pick an agent. The help text describes it exactly: "The coordinator that receives team-assigned work and delegates to the best-suited member." 2. Optionally fill in **Operating instructions** — free text handed to the leader with every routed ticket. Promoting an agent to leader also makes it a member if it was not one already. A team left on **No leader** is inert: nothing is dispatched when work is routed to it and removing the current leader from the members list leaves the team leaderless again. ## Add members In the same editor, use the members card to add agents from the workspace and to remove them. Members are the roster the leader delegates across. Teams are workspace-scoped: only agents in this workspace can join. ## Give the team a space There is no automatic team space. If you want one, create an ordinary space from **Spaces** and add the team's agents to it. A `Team` carries a name, description, leader, instructions and members — no space association. ## Reporting lines are a separate thing Each agent also has a **Reports to** field, set in **Settings → Workspace → Agents**. That builds the workspace's reporting tree, which you can see with the **Org chart** button on the agents page and it is what a leader consults when it picks a delegate. It does not drive anything else. Memory access grants are keyed on an agent's role, not on the reporting chain and no analytics surface aggregates by team or hierarchy. Delegation guards cap depth and detect cycles, but they never require the target to be a subordinate. ## What a routed ticket does When a ticket does carry a team assignment: 1. The team's leader is dispatched with the ticket, its operating instructions and the member roster. 2. The leader delegates to members and records its evaluation with the `record_team_activity` tool — `action` (delegated), `no_action` (nothing more to do, which stops further re-triggers) or `failed`. 3. Each member run that completes re-wakes the leader, bounded by the no-action guard. The pipeline **Team dispatch** node is the other entry point. It takes a team id, a prompt and a dispatch mode: - `allParallel` — one task per member into a shared hidden conversation, suspending until every run completes, with each member's output collected under the node's output key - `manager` — dispatch only the leader, which coordinates in the conversation ## Related guides - [Create and configure an agent](/manual/guides/create-agent/) - [Run agents in parallel](/manual/guides/parallel-agents/) - [Delegate work to agents](/manual/guides/delegate-tickets/) ## Related concepts - [The agent model](/manual/concepts/agent-model/) - [Tickets and delegation](/manual/concepts/tickets/) ### Chat with an agent > Create a space with an agent, send it work, steer it mid-run and stop it. Source: https://usectrl.dev/manual/guides/chat-with-agent/ This guide shows you how to work with an agent in a space. ## Create a space 1. Navigate to **Spaces** in the sidebar 2. Click the **+** button beside the Spaces heading 3. Type a **name** — this is required. Leaving it empty cancels the dialog silently 4. Add one or more agents from the workspace 5. If the workspace has more than one repo, a repo picker appears. It starts with every repo selected; narrow it to the repos this space should provision worktrees for 6. Confirm Control Center opens the space and starts provisioning a copy-on-write worktree per selected repo in the background. ## Send the first message Type your message in the composer and press Enter. The agent dispatches and starts streaming its response. If the space is still provisioning, your message is parked rather than lost — the composer holds it and sends it automatically once the worktrees are ready. As the agent works you see its turn stream in: reasoning segments, tool calls (file reads, writes, shell commands) and answer text, all inside one agent-turn message. ## Steer a running agent While an agent is working, plain conversational text does **not** queue a new turn. It is injected into the live run at the next turn boundary and you get a "Sent to the running agent as steering." toast. Three things change that behaviour: - A message containing an **@mention** is always sent as a message, never as steering - A message starting with a **slash command** is always handled as that command - If every live run has already finished, the text falls through and is sent as a normal message :::note[Steering needs the built-in harness] Only the built-in Control Center harness has a steering lane. On an external CLI or ACP adapter the injection is not delivered and your text is sent as an ordinary message instead. ::: ## Approve a blocked action When an agent hits an action its guardrails gate — a destructive command, a privileged tool — the server **blocks the run** and publishes an approval request to every connected client. An always-on-top panel appears in the shell listing what is waiting; approve or deny it inline and the agent continues. There is no timeout: a blocked agent waits until someone decides. The same request also appears in the inbox's attention strip and on the phone client, so you can unblock it from wherever you are. ## Answer an agent's question An agent can also just ask you something. The `ask_user` tool renders a form directly in the conversation — a question with options, free text, or both — and the run blocks on your answer. It is a separate mechanism from the approval panel above: an approval is "may I do this?", a question is "which of these did you mean?". It is available on the built-in harness in every mode, including the read-only ones (asking mutates nothing), and it is deliberately not wrapped in an approval prompt — a dialog in front of a dialog would fail closed with no approver connected, denying the agent the act of asking. A run with no conversation to render into simply does not get the tool. ## Stop an agent While runs are live, the composer's send button becomes a **stop** button. Clear the composer and press it — this stops every agent running in that conversation. There is no stop control in the space header and no keyboard shortcut for it. To stop just one run when several are live, open that run's activity tab — its header carries a run-scoped pause/resume and stop (the stop asks you to confirm). **Observability → Live** lists every running agent in the workspace with the same control. A stopped run is marked as stopped. Send a new message to dispatch again. ## Use slash commands Type `/` in the composer to see the command list. The ones that change how the conversation behaves: | Command | What it does | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `/plan` | Switches the space into plan mode. A bare `/plan` just arms the mode; `/plan ` arms it and sends the rest as your message | | `/goal ` | Records the conversation's working goal | | `/loop ` | Iterates on a task until it is complete | | `/compact` | Folds older history into an anchored summary and continues on the compacted context | | `/todo …` | Views and edits this conversation's persisted todo list | Skills appear in the same list under their own `skill:` namespace, so they never collide with the commands above — a skill named `plan` or `compact` stays reachable: | Command | What it runs | | --------------------------- | ------------------------------------------------------------------ | | `/skill:` | A skill installed in the workspace, or attached to the agent | | `/skill::` | A skill shipped by one of the repos checked out in this space | Repo skills are badged with the repo they came from, because a name alone does not say which service's conventions it encodes and two repos may each ship a `testing`. You do not have to type the prefix to find one: typing the bare skill name matches the namespaced entries too. An agent only ever carries the skills of the repo it is currently working in — see [Repo-scoped skills](/manual/concepts/agent-model/#repo-scoped-skills). The composer is not scoped that way: you can invoke any repo's skill by naming it. `/compact` and `/todo` are intercepted by the client and never persisted as messages — a `/compact` line in the transcript would push the next compaction's cut one turn back. `/plan` flips the space's stored mode rather than travelling as text. So none of the three is "a message you send the agent"; do not expect to see them in the history. ## Ask for a plan first Before a complex change, switch the space to plan mode with `/plan`, the mode dropdown on the composer toolbar, or Shift+Tab. The agent then researches read-only and delivers a typed plan you approve before any work starts. See [Use plan mode](/manual/guides/plan-mode/). ## Reach an agent without a dedicated space You do not need a space per agent. In any space: ``` @alice can you look at the failing tests in src/auth_test.dart? ``` The mentioned agent is added to the space and dispatched with that space's context. See [@-mention agents](/manual/guides/mention-agents/) for how names are matched. ## Related guides - [Use spaces](/manual/guides/spaces/) - [@-mention agents](/manual/guides/mention-agents/) - [Use plan mode](/manual/guides/plan-mode/) - [Set up Slack integration](/manual/guides/slack-integration/) to do the same from Slack ## Related concepts - [Modes](/manual/concepts/modes/) ### Search code with the code graph > Index a repo and use the code-graph tools to find symbols and see what a change would break. Source: https://usectrl.dev/manual/guides/code-search/ This guide shows you how to get a repo into the code graph and then use it to answer the question you actually have before a refactor: what depends on this? The code graph is a symbol-and-edge database built by parsing your registered checkouts with tree-sitter. Its tools are **agent-facing** — you reach them by asking an agent, not from a code-graph screen. There is no human-facing view of the graph at all today: the blast-radius pane that was one went with Review Studio. ## Before you start :::caution[Five languages only] The graph indexes **Dart, JavaScript, TypeScript, TSX and PHP** and nothing else. Files in any other language are never parsed, so they will never appear in `search_code`, `code_symbol`, `code_callers`, `code_callees` or `code_impact`. This is the shipped grammar set, not a configuration. ::: Two more things shape what gets indexed: - In a git work tree, enumeration defers to `git ls-files --cached --others --exclude-standard`, so every `.gitignore`, nested ignore file, `.git/info/exclude` and the global excludes file is honoured. That is what keeps `node_modules` and build output out of the graph. - Generated Dart is dropped regardless. Generated files are usually committed, so git alone would not exclude them and they churn on every codegen run. ## Get a repo indexed Registering a checkout fires `RepoAdded`, which starts the `index_code` pipeline for it automatically. See [Add repos to a workspace](/manual/guides/add-repos/). To re-index by hand: 1. Open **Settings → Workspace → Repositories**. 2. Click the **Index code** button on the repo's row. 3. While it runs the button shows files done over total, with a cancel control. When it finishes it settles into a check with the symbol count. The graph is partitioned by workspace and repo, so each workspace-plus-repo pair has its own graph. Two throttles are worth knowing about, because they explain a wait that looks like a hang: - Only **one** index run may be in flight at a time across all workspaces. A second repo queues. - Worktrees belonging to **dormant spaces** are neither watched nor indexed. The judgement is re-evaluated every five minutes, so a space that becomes active again gets picked up. :::caution[A missing grammar stops the server, not the search] Tree-sitter and its five grammars are required natives. If one is missing, `cc_server` refuses to boot and names the missing dylib — the symptom is a server that will not start, never a degraded search. Stage them with `scripts/natives/build_natives.sh` and rebuild the server binary. ::: ## Find a symbol Ask an agent to search. The tool is `search_code` and it needs both a workspace and a repo: ``` search_code(workspace_id: "...", repo_id: "...", query: "authentication middleware") ``` Repo ids come from `list_repos`. The optional `mode` argument is `keyword`, `semantic` or `hybrid` and it defaults to hybrid — BM25 over names, signatures and doc comments, fused with vector similarity. Each result carries the symbol's id, name, qualified name, kind, file path, start and end line and its signature when it has one. The **id** is the handle you feed to every other tool on this page. When you know the exact name, look it up directly instead: ``` code_symbol(workspace_id: "...", repo_id: "...", name: "MyClass") ``` ## Trace calls Both take a `symbol_id` from a search result, plus an optional `limit` (default 50): ``` code_callers(workspace_id: "...", symbol_id: "...") code_callees(workspace_id: "...", symbol_id: "...") ``` `code_callers` returns the symbols with an incoming edge to this one; `code_callees` returns the ones it depends on. ## Check what a change would break This is the tool the whole page exists for: ``` code_impact(workspace_id: "...", symbol_id: "...", depth: 3) ``` It computes the **reverse**-dependency radius: everything that directly or transitively depends on the symbol. `depth` is the maximum number of hops, between 1 and 6, defaulting to 2. The result is three fields: - `root` — the symbol you asked about - `impacted` — every dependent symbol, each carrying its `depth` from the root - `edgeCount` — how many edges the traversal crossed, as a count rather than the edges themselves Run it before you rename or change a signature and give the agent the impacted list as its scope. ## Results are checked against the caller's own working copy For an agent caller, the conversation id is injected automatically and results are verified against **that conversation's** checkout. A symbol whose file no longer exists in the caller's tree is omitted and the response carries a `staleOmitted` count plus a note saying the index is stale for those symbols. This is why you and an agent can get different answers to the same query and why an agent in one space can get a different answer from an agent in another. Verification fails open: a stale answer is preferred to an empty one. ## See the graph as a human You cannot, today. The blast-radius pane lived in Review Studio, and that surface was folded into the PR's review artifact tab — the graph view went with it. `review_studio.blastRadius` still answers over RPC and the cohort grouping still falls back to plain paths on an unindexed repo, but nothing in the app draws either. See [Review Studio](/manual/guides/review-studio/). ## Semantic search needs the embedding model Symbols and facts are embedded at write time and only when the on-device embedding model is installed. Until then, `mode: "semantic"` and the semantic half of hybrid have nothing to match against and search is effectively keyword-only. :::caution[Rows written before the model was installed stay keyword-only] The embedding backfill job is a stub that always returns zero, so there is no retroactive pass — and re-running the index does not help either, because a file whose content hash has not moved is skipped before it reaches the ingest that would embed it. Only a symbol whose file actually changes gains a vector. Install the model first — **Settings → Server → Diagnostics & privacy → Semantic search** — and index afterwards. ::: Note also that any rebuild of the server binary re-stages the grammar dylibs and changes their mtimes, which invalidates every index checkpoint and forces one full re-walk of every checkout on the next run. It re-hashes, but it does not re-extract unchanged files. ## What is in the graph **Symbol kinds:** function, method, class, field, enum, constructor, getter, setter, typedef, extension, mixin, variable. **Edge kinds:** calls, imports, extends, implements, mixes in, references. The canonical signatures for these tools live in [MCP tools](/manual/reference/mcp-tools/). ## Related guides - [Add repos to a workspace](/manual/guides/add-repos/) - [Review Studio (cohorts and axes)](/manual/guides/review-studio/) - [Use the MCP server](/manual/guides/mcp-server/) ## Related concepts - [Workspaces and isolation](/manual/concepts/workspaces/) ### Configure guardrails > Set allow, ask-first and deny rules for agent actions, test them with the what-if probe and tune per-space autonomy. Source: https://usectrl.dev/manual/guides/configure-guardrails/ This guide shows you how to configure guardrails: the rules that decide whether an agent's action runs, asks for approval first, or is refused. Guardrails cover the thirteen effect classes every mutating tool declares. They are enforced server-side at the point the tool is called, never by an instruction in a prompt. ## Set a decision for an action class 1. Go to **Settings → Workspace → Agent permissions** 2. Pick a scope with the segmented control: **Workspace**, **Agent** (then choose the agent), or **Space** (then choose the space) 3. On the row for the action class, pick **Allow**, **Ask first** or **Deny** 4. To drop a rule you set, press the reset button that appears at the end of that row (**Clear to inherited**) — the decision falls back to the wider scope Each row shows the effective decision as a status tag and a badge for where it came from: **This scope**, **Inherited** or **Built-in default**. The matrix resolves at Agent (`chat`) mode, so **Mode preset** never appears here — use the what-if probe with a read-only mode selected to see that layer. Writing a rule needs the `admin` role. Reading the matrix does not. Checkpoint: set **Push to a remote** to **Deny** at the workspace scope, then run the what-if probe below on `git push origin main`. The result reads `deny` with source `workspace`. ## Understand how a decision is resolved Resolution is **specificity first, then restrictiveness**: 1. **Space** rules, then **agent**, then **workspace**. The first scope with a matching rule decides and resolution stops 2. If no rule matches, the **mode preset** applies — a read-only mode (plan, review, orchestrate) refuses eleven of the thirteen classes 3. If the mode has nothing to say, the **built-in default** applies Within a single scope, a command rule resolves by longest matching prefix and an equally specific conflict resolves **most restrictive** — deny beats ask first beats allow. An action that declares several classes resolves each one independently and then combines them most-restrictive. :::caution[Allow does not beat deny] The old flat `allow > deny > prompt` precedence is gone. A narrower rule wins on specificity and a tie is broken by restrictiveness — so an **Allow** at the workspace scope no longer overrides a **Deny** anywhere. ::: An **Ask first** decision surfaces one approval prompt covering every class that resolved to `prompt`. **With no approver connected it is denied**, for both shell commands and harness tool calls. That is deliberate: an ungated run is worse than a refused one. ### Action classes and built-in defaults | Action class | Built-in default | | ---------------------------- | ---------------- | | Delete a file | Ask first | | Write outside the worktree | Allow | | Create a commit | Allow | | Push to a remote | Ask first | | Open a pull request | Ask first | | Publish a review or merge | Ask first | | Write to an external tracker | Ask first | | Access the network | Allow | | Read a secret | Allow | | Install a package | Allow | | Run a process | Allow | | Change workspace structure | Allow | The taxonomy is closed. A new tool that ships without declaring its classes fails a ratchet test in CI, so nothing arrives with a silently empty effect set. ## Test a rule with the what-if probe Before changing a rule, check how the current policy already resolves it. 1. On the same page, scroll to **What if?** 2. Pick an **Action** (one of the thirteen classes) 3. Optionally fill in **Command** (for example `git push origin main`), **Agent**, **Space** and **Mode** 4. Read the result: the decision, the reason and **Source:** The probe runs the same resolver the enforcement path uses, against your live rules, so the answer is exact. If it surprises you, change a rule in the matrix above and probe again. ## Set a space's autonomy dial The dial is per agent per space and it composes with the rules rather than replacing them. 1. Open the space 2. Open **Manage participants** from the space header 3. Under **Autonomy** on the agent's row, pick a level: | Level | Effect | | --------------------- | --------------------------------------------------------------------------------------------------- | | **Default** | No space setting — behaves as **Act with approval** | | **Propose only** | Every gated tool is denied outright, with a message telling the agent to propose the action instead | | **Act with approval** | Risky actions hit the fail-closed approval gate | | **Act freely** | Pre-approves anything that did not resolve to a hard deny | :::caution[Act freely swallows every prompt] Under **Act freely** an **Ask first** decision is granted without asking you. Only a `deny` — from a rule, from the mode preset, or from a built-in default — still blocks. Reach for it when you are watching the space, not as a default. ::: The dial only reaches the built-in harness. An external CLI adapter runs its own tools outside this gate. ## Know where guardrails do not reach The **Where rules are enforced** table at the bottom of the same page is the in-app version of this: per effect class, it says whether each runner catches it at the policy gate, leaves it to the sandbox, or cannot intercept it at all. - **External CLI and ACP adapters** are not gated on their own tool calls. Claude Code is launched with `--dangerously-skip-permissions`, so its read, write, edit and bash calls never pass through the guard. What is gated for those adapters is the MCP tool surface they call back into and the shell command Control Center spawns to launch them. - **A mode's own output verb** (`submit_plan`, `propose_orchestration`) is pinned and can never be denied — otherwise a mode would be structurally unable to produce its only deliverable. - **Whether the sandbox backs these rules up depends on the host.** Where `cc_server` finds a backend it wraps the external CLI and ACP transports and the harness `bash` tool, so a rule you did not write still has an OS floor under it. On **Windows there is none** and on Linux or WSL2 without `bwrap` and `socat` there is none either — there, guardrails plus the mode's tool surface are the whole enforcement, not a second line of it. Check which case you are in with [Configure sandbox policies](/manual/guides/sandbox-policies/). ## Related guides - [Configure sandbox policies](/manual/guides/sandbox-policies/) - [Create and configure an agent](/manual/guides/create-agent/) - [Manage adapters and models](/manual/guides/adapters/) ## Related concepts - [Guardrails — one policy for every mutating action](/manual/concepts/guardrails/) - [Modes](/manual/concepts/modes/) - [Sandbox and security](/manual/concepts/sandbox-security/) ### Connect a Google Calendar > Connect Google Calendar to a workspace, manage multiple accounts and reconnect when a token expires. Source: https://usectrl.dev/manual/guides/connect-calendar/ This guide shows you how to connect Google Calendar to a workspace. It's per-workspace: it syncs that workspace's events into Control Center, read-only. See [Calendar and scheduling](/manual/concepts/calendar/) for the full picture. ## Connect an account 1. Open **Calendar** in the sidebar. 2. Click **Add calendar account** in the calendar sidebar. (The same section lives at **Settings → You → Profile & identity**, under **Google Calendar**.) 3. Choose how to authorize: - **Use Control Center's Google app** — nothing to set up. This is the default when your build ships with one. - **Use my own Google Cloud client** — paste a client id and secret from your own project (see [Bring your own Google client](#bring-your-own-google-client-forks-and-self-hosting)). 4. Click **Connect Google Calendar**. The dialog shows a short code and a link to Google's device page. 5. Open that page on any device, enter the code and approve the requested access. The dialog closes on its own and your events begin syncing. The server, not your device, runs this exchange and keeps the resulting refresh token — which is why the code is approved on "any device" rather than in this app specifically. The account is connected **to the active workspace only**. Switch workspaces and you'll see that workspace's calendars instead. :::caution[RSVP does not work for accounts connected this way] Control Center requests `calendar.readonly` plus your email, so it reads events and nothing more. The event panel shows a **Going?** control with **Yes** / **No** / **Maybe**, but for an account connected through the built-in device-code flow Google rejects the write with a 403 and the app reports "Couldn't update your response". RSVP needs the `calendar.events` write scope, which this flow does not request. To make it work you must bring your own Google client and add that scope — see below. ::: ## How syncing works The **server** owns the schedule: it sweeps every workspace's connected accounts every two minutes and writes the events into the workspace's database. Clients never talk to Google — they read what the server wrote. The sweep is incremental. The first pass per calendar fetches a rolling window (60 days back, 90 days ahead) and captures Google's sync token; later passes present that token and receive only what changed, which is what makes a two-minute cadence affordable. The window is re-anchored with a full sync every 24 hours and whenever Google invalidates the token. Refresh tokens live server-side in `secrets.json` under the server's data directory, written owner-only (`0600`) — not in the OS keychain and never on a client device. ## Connect multiple accounts A workspace can connect several Google accounts (for example work and personal). Repeat the connect step for each; their events merge into the same month / week / agenda views. Each account syncs and refreshes independently and each can be disconnected on its own. ## Reconnect an expired account If Google permanently invalidates an account's token (a revoked or long-expired session), syncing stops and Control Center shows a **Reconnect** banner on the calendar, plus a desktop notification. Click **Reconnect** and approve a new code; the banner clears on the next successful sync. ## Disconnect an account From the calendar sidebar (or **Settings → You → Profile & identity**), click **Disconnect** on the account. This deletes its synced events and clears its stored tokens on the server. ## Connect from a headless server A server with no GUI attached connects the same account from its own terminal: ```bash cc_server calendar connect --data-dir --workspace \ --google-client-id --google-client-secret ``` It prints a code and a URL, waits for approval, then stores the tokens and syncs on the next sweep. A build from source ships **no** Google client, so the id and secret are required — pass them as flags, or set `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`. Without them the command exits with a missing-client error. ## Bring your own Google client (forks and self-hosting) Released builds ship with a Google client, so most people never touch this. Bring your own if you build from source, run your own server, want the consent screen to name your own project, or need RSVP to work. 1. In the [Google Cloud Console](https://console.cloud.google.com/apis/credentials), enable the **Google Calendar API** and configure the **OAuth consent screen**, adding the `calendar.readonly`, `openid` and `email` scopes. Add `https://www.googleapis.com/auth/calendar.events` as well if you want RSVP writes to succeed. While the app is unverified, add yourself as a **test user**. 2. Create an **OAuth client ID** of application type **TVs and limited input devices** and copy both the client id and the client secret. 3. Supply them either per-connect (choose **Use my own Google Cloud client** in the dialog) or server-wide with `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET` (equivalently `--google-client-id` / `--google-client-secret`). A server-wide pair replaces the built-in one everywhere, including behind the "use Control Center's Google app" option — so on your own server, that option is your app. The client secret never leaves the server: no RPC response carries it and an account connected through the server's own client stores a marker rather than a copy of the pair, so rotating it is a matter of restarting with a new value. ### Troubleshooting | Symptom | Likely cause | | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | The dialog offers no built-in option | This build ships no Google client (a source build), or the server was started with only one half of the pair. Both the id and the secret are required | | `invalid_scope` when starting the flow | Calendar is not permitted for a device-code client in that Cloud project. Use a loopback or web client project instead | | `invalid_client` from Google | The client is not a **TVs and limited input devices** client, or the id and secret are from different clients | | Approval succeeds but sync is empty | Your account is not a **test user** on the consent screen, or `calendar.readonly` was not granted | | The code expires before you approve it | Device codes are short-lived. Start the connect again for a fresh one | | "Couldn't update your response" on an RSVP | The account was connected without the `calendar.events` scope — expected for the built-in flow | | Sync stops with a refresh warning in the server log | The account was connected with a client that is no longer configured. Reconnect the account, or restore that client id and secret | ## Related guides - [Record and summarize a meeting](/manual/guides/record-meeting/): turn an event into a recording - [Run a headless server](/manual/guides/run-headless-server/): where the tokens and the sync schedule live - [Set up GitHub integration](/manual/guides/github-integration/) ## Related concepts - [Calendar and scheduling](/manual/concepts/calendar/) ### Connect a code host > Connect GitHub, GitLab or Bitbucket so the PR surface, repo registration and agent runs work — one workspace can use all three. Source: https://usectrl.dev/manual/guides/connect-forges/ Control Center works with three code hosts, called **forges** throughout the app: **GitHub**, **GitLab** and **Bitbucket Cloud**. None of them is the default. Connect one and everything works; connect several and a single workspace can hold repos from all of them at once. The forge is a property of each **repo**, read from its `origin` remote when you register it. You never pick one, and there is no per-workspace setting to get wrong. ## Connect a forge 1. Open **Settings → You → Profile & identity** (`/settings/profile`). 2. Find the **Code hosting** card. Every supported forge is listed, connected or not. 3. Press **Sign in with …** if the row offers it, or **Add token** and paste one. **Sign in** appears for a forge your server has an app registered for — today that is GitHub, over a device flow (see [Create the GitHub App](/manual/guides/github-app/)). Everything else is a pasted token, which is the honest affordance rather than a button that cannot complete. Either way the credential is **yours and lives on the server**, keyed by your user id: no token is stored on the machine you typed it into, every device you sign in from uses the same one, and no other member can read it. It takes effect on the **next request** — there is nothing to restart, and disconnecting stops it being sent immediately. Each row tells you where its credential came from, because that is the difference between "my token isn't working" and "a different token is answering": | Subtitle | Meaning | | -------------------------------- | ------------------------------------------------------------------ | | `Signed in as octocat` | Your own credential is being used | | `Signed in as octocat · via this server's app` | The server's app identity is answering — you have not signed in | | `Signed in as octocat · from the environment` | A server environment variable is answering, not your credential | | `Not connected` | You have no credential for this forge | ### What token each forge wants | Forge | Token | Where to create it | | ------------- | --------------------------------------------------------- | --------------------------------------------------------- | | **GitHub** | A personal access token with `repo` and `read:org` | github.com → Settings → Developer settings → Tokens | | **GitLab** | A personal access token with `api` | gitlab.com → User settings → Access tokens | | **Bitbucket** | An Atlassian API token, plus your account email | id.atlassian.com → Security → API tokens | Bitbucket authenticates with your **account email as the username** and the token as the password. Set `BITBUCKET_EMAIL` in the server environment so it knows which email to pair with the token. ### Or use the environment For a headless or CI install, the server reads these: - **GitHub** — `GITHUB_TOKEN`, then `GH_TOKEN` - **GitLab** — `GITLAB_TOKEN`, then `CI_JOB_TOKEN` - **Bitbucket** — `BITBUCKET_API_TOKEN`, then `BITBUCKET_TOKEN` (with `BITBUCKET_EMAIL`) These are the **server's** credential, not yours, and the distinction is load-bearing: a request made on your behalf uses your credential and nothing else, so the environment never quietly stands in for you. Work with no human behind it — webhooks, PR polling, ticket sync — resolves the server's app identity first, then the server owner's credential, then the environment. The order is fixed so that "I signed in and nothing changed" is never a supported outcome. ## You are a different person on each forge The same human is `octocat` on GitHub and something else entirely on GitLab. Control Center resolves your identity **per forge** and uses the right one for each repo, which is what makes "needs my review", "created by me" and "you approved this" correct in a mixed workspace. If a forge's identity has not resolved, its repos contribute nothing to the inbox rather than matching everything. The inbox says so instead of claiming you are all caught up. ## One inbox, every forge The inbox and the PR queue fan out across every forge represented in the workspace and merge the results into one stream — a GitHub pull request and a Bitbucket pull request sit next to each other, ordered by time rather than grouped by vendor. Failures are isolated. A forge that is down, rate-limited or unconnected contributes nothing and leaves the others untouched; it never empties the whole inbox. Its own repos keep showing their last known state until it recovers. ## What each forge can do Every forge supports the core loop: list and open pull requests, read diffs, comment inline and at top level, review, merge and close. Beyond that they genuinely differ, and Control Center hides what a forge cannot do rather than showing a control that silently fails. | Capability | GitHub | GitLab | Bitbucket | | --------------------------------- | :----: | :----: | :-------: | | Batched (pending) reviews | ✓ | ✓ | — | | Request changes | ✓ | ✓ | ✓ | | Per-file "viewed" state synced | ✓ | — | — | | Emoji reactions | ✓ | ✓ | — | | Team / group reviewers | ✓ | ✓ | — | | Suggested reviewers | ✓ | — | ✓ | | CI results on a PR | ✓ | ✓ | ✓ | | Drill into a CI job's steps/logs | ✓ | ✓ | — | | Stacked pull requests | ✓ | — | — | | PR description templates | ✓ | ✓ | — | | Notification polling | ✓ | — | — | | Rich user profiles | ✓ | ✓ | — | | Server-side PR head ref | ✓ | ✓ | — | Where a capability is missing the feature degrades honestly: - **Viewed state** still works, but only on the device you set it on. - **Batched reviews** on Bitbucket post comment by comment, so reviewers see them arrive individually rather than all at once. - **No PR head ref** on Bitbucket means checking out a pull request fetches its source branch by name, which fails once that branch is deleted. ## Vocabulary GitLab calls a pull request a **merge request**. Control Center says "pull request" throughout its own model, and uses each forge's own noun where it is talking about that forge. ## Troubleshooting ### The PR list is empty and says a forge is not connected You have no credential for that forge. Sign in or paste a token in **Settings → You → Profile & identity → Code hosting**. Other forges' repos are unaffected. ### I connected a forge but its repos still show nothing Check the row's subtitle. If it reads "via this server's app" or "from the environment", a different credential is answering than the one you expected — your own credential wins for anything you ask for, so if it says otherwise yours was not saved. ### A repo will not register Registration needs all three: the path is inside a git work tree, it has an `origin` remote, and that remote points at github.com, gitlab.com or bitbucket.org. See [Add repos to a workspace](/manual/guides/add-repos/). ### Everything stops working for a while after heavy use That is the rate-limit circuit breaker. A forge signalling its primary limit makes the client fail further requests locally, without a network call, until the window elapses — so a retry storm cannot deepen the limit. Transient failures are retried with exponential backoff. ## Related guides - [Add repos to a workspace](/manual/guides/add-repos/) - [Set up GitHub integration](/manual/guides/github-integration/) - [Review and merge a PR](/manual/guides/review-merge-pr/) - [Triage the inbox](/manual/guides/triage-inbox/) ### Connect to a remote server > Point the desktop app or the web client at a cc_server running somewhere else and switch between servers without restarting. Source: https://usectrl.dev/manual/guides/connect-remote-server/ Every client is a renderer over a `cc_server` that owns the data. The desktop can run one inside itself; the web app at [app.usectrl.dev](https://app.usectrl.dev) cannot, because a browser tab cannot host a server. Either way, this guide points a client at a server running elsewhere — a small box, a cloud VM, a build server. ## Prerequisites - A running `cc_server`. If you don't have one, see [Run a headless server](/manual/guides/run-headless-server/). - Either the three pairing values the server minted — the **server URL** (`ws://…/rpc` or `wss://…/rpc`), a **device id** and a **pairing key** — **or** a one-time **invite code** from a workspace admin. Redeeming an invite provisions your user, records your membership and mints this client's credential for you, so you never handle a device id or key. ### Make sure the server is reachable A local server needs nothing: `ws://localhost:9030/rpc` works from both the desktop and the browser, because browsers treat loopback as a trustworthy origin. A server on another machine is different. `app.usectrl.dev` is served over HTTPS, so the browser will only open a `wss://` socket with a certificate it already trusts — and `cc_server` refuses to bind a non-loopback address without TLS in the first place. Give it one of: - a certificate directly, with `--tls-cert` and `--tls-key` - a TLS-terminating reverse proxy in front, with the server started `--insecure` (plaintext behind the proxy, on a trusted network only) - a tunnel or VPN that supplies the certificate — Tailscale, cloudflared, or similar. **Settings → Server → Sharing & remote access** has the built-in tunnel opt-in. Two settings on the server side are easy to miss. Behind a proxy, NAT, or tunnel, set `--public-url` to the address clients should actually dial — it defaults to the local bind, so without it clients try the wrong host. And if you host your own copy of the web build, add its origin to `--allowed-origins`, which defaults to `https://app.usectrl.dev` alone; loopback and native clients are always allowed. ## Connect the desktop app On a fresh install: 1. Launch Control Center. The first-run screen asks **How should Control Center run?** 2. Instead of **Run in this app**, fill in the connect fields: the server URL, plus either an invite code or a device id and pairing key. 3. Click **Connect**. Control Center dials the server and lands on the workspace inbox. On a desktop that already runs its own server: 1. Open **Settings → Server → Connection & status**. 2. Click **Add server**. Enter the server URL (`wss://host:9030/rpc`) and either an invite code or a device id and pairing key. The URL field's suffix button discovers `cc_server` instances on your LAN or tailnet. 3. Click **Connect**, then **Switch** on the new server's row. Switching is live: the app rebuilds around the new session in place, with no restart. If the connect fails you stay on the current server and see the error. The row for the server you are on has no **Switch** action; the local server is the first row, labelled **Run in this app**. Once switched, the desktop owns no database. Every screen reads and writes through the remote server over a WebSocket. ## Connect the web client The web build is always remote. 1. Open [app.usectrl.dev](https://app.usectrl.dev), or your own copy of the web build. 2. Enter the server URL plus either an invite code or a device id and pairing key. 3. The client dials the server and renders the same interface the desktop does. If a pairing deep link is in the URL, the fields are pre-filled. The pairing key rides in the URL **fragment**, which browsers never send to the server hosting the page, so the static host never sees it. :::note[Media and security] Media (avatars, feed images, PR screenshots) is proxied through the connected server, so the browser never fetches an upstream host directly. A per-request Content-Security Policy, stamped from a non-sensitive cookie, allows only the connected server's origin. ::: ### What the browser does not do The web client reads and writes the same server as the desktop, but a few surfaces need a native window or the machine's own devices: - no OS notification toasts, notification sounds, or ambient banner rail (the in-app notification centre still works) - no LAN or tailnet server discovery in the **Add server** dialog - no in-app article reader — newsfeed articles open in a new tab - no re-running a meeting summary and no cancelling one that is processing Two behave differently rather than being missing. Meeting **recording** works in the browser, capturing your microphone plus a screenshare that includes an audio track. **Open in IDE** works too, but asks the server to launch the editor on the _server's_ display, so the button hides itself against a headless host with no editors installed. ## Troubleshooting - **Connection times out**: the server must be reachable from the client. Check the bind (`--bind any`), that the port is open and that `--public-url` names the address the client can actually dial. Over the open internet, TLS is mandatory — a plaintext non-loopback bind is refused at startup unless the server was started `--insecure` behind a proxy. - **The browser refuses to connect but a native client works**: the browser's origin is not on the server's `--allowed-origins` list. The default allows only `https://app.usectrl.dev` (plus loopback). - **Pairing key rejected**: re-run `cc_server pair` on the server to rotate that device's key, then enter the new one. A revoked device finds its credential gone and fails closed; revocation drops a live session within seconds. - **Wrong workspace**: the workspace is part of the URL (`/workspaces/:workspaceId/…`) and travels with every call, so switching workspaces in one client changes only your own view. You can only reach workspaces you are a member of — anything else is refused with "Not a member of this workspace". See [Workspaces and isolation](/manual/concepts/workspaces/). ## Related guides - [Run a headless server](/manual/guides/run-headless-server/) - [Pair a device](/manual/guides/pair-a-device/): the phone relay path ## Related concepts - [Deployment and clients](/manual/concepts/deployment/) - [Remote control and mobile](/manual/concepts/remote-control/) ### Create and configure an agent > Add an agent to a workspace, give it an adapter, model, skills and permissions and confirm it runs. Source: https://usectrl.dev/manual/guides/create-agent/ This guide shows you how to add an agent to a workspace and configure it until it can actually run. Every workspace is seeded with five agents — a CEO plus `qa`, `architect`, `engineer` and `librarian` — so you usually have agents before you have configured any. The steps below apply equally to a seeded agent and a new one. ## Before you start - You are inside a workspace. The whole surface lives at **Settings → Workspace → Agents** (`/settings/workspace/agents`). - The agent will need a backend. Either connect a provider for the built-in harness under **Settings → Server → Model providers**, or install an agent CLI on the machine hosting `cc_server`. - If you want the agent to carry skills, install them first under **Settings → Workspace → Skills**. The agent form only lets you toggle skills that already exist in the workspace. ## Add the agent 1. Open **Settings → Workspace → Agents**. 2. Click **Add agent**. There is no dialog and no role picker. An agent named "Unnamed agent" is created immediately and selected in the detail pane on the right. You should now see "Unnamed agent" in the roster on the left, with the **Settings** tab of its detail pane open and a **Name** field containing "Unnamed agent". ## Name it - **Name** — the short identifier used in @-mentions, for example `alice`. Mention parsing matches `@` followed by word characters, so a name containing a hyphen, a dot or a space can never be mentioned. - **Title** — the human-readable label, for example "Senior coder". - **System prompt** — optional, replaces the default instructions for this agent. Click **Save changes** at the bottom of the form. Every field on this page is saved by that one button. ## Give it an adapter and a model The **Adapter** dropdown lists all eight built-in adapters, whether or not their CLI was found on the server host: | Adapter | Backend | | ------------------------- | --------------------------------------------- | | Control Center (built-in) | The in-process agent loop — no external CLI | | Pi | `pi` CLI | | Claude Code | `claude -p --output-format stream-json` | | Codex | `codex` over the Agent Client Protocol | | Gemini CLI | `gemini` over the Agent Client Protocol | | Goose | `goose` over the Agent Client Protocol | | OpenCode | `opencode` over the Agent Client Protocol | | Cursor | `cursor-agent` over the Agent Client Protocol | Only the built-in adapter enforces modes, steering, pause/resume, completion contracts and subagents. Every external CLI gives you none of those. :::note[An empty adapter means the built-in loop] An agent whose adapter is unset is dispatched to **Control Center (built-in)**, the loop inside the server, so nothing has to be installed on the host. With no model set either it asks Anthropic for its default, so an entirely unset agent runs only if you have connected an Anthropic account. The seeded specialists ship with neither; set both on any agent you intend to use. ::: The **Model** dropdown is driven by the adapter you picked. For the built-in adapter the list comes live from the providers you have connected under **Settings → Server → Model providers**, so a provider with no stored credential contributes zero models. ## Add skills **Skills** is a chip picker over the skill bundles installed in this workspace. Tap a chip to toggle it; the check glyph marks the selected ones. If the workspace has no skills the field reads "No skills available. Create skills in Settings → Skills first." You do not pick a repo's own skills here. A repo that ships them (in `.agents/skills/`, `.claude/skills/` or `.opencode/skills/`) contributes them automatically to whichever agent is working in that repo, and they swap out when it moves to another — see [Repo-scoped skills](/manual/concepts/agent-model/#repo-scoped-skills). ## Set the reporting line and persona - **Reports to** — pick another agent by name. This builds the reporting tree shown by the **Org chart** button in the page header and it is the roster a team leader sees when it delegates. - **Persona** — free text that shapes tone and behaviour, for example: "Prefers simple, readable solutions. Writes tests for all new code." ## Tune the run - **Reasoning effort** — the options come from the selected model's own published levels: Low / Medium / High, plus Extra High on OpenAI-style models and Extra High + Max on Claude-style ones. The field is hidden entirely when the selected model declares no thinking levels and it defaults to the model's own default. - **Context window size** — pre-filled from the model's context window whenever you change model, with one-tap chips for the model default, 200k, 500k and 1M. A value you type after picking the model wins until the next model change. - **Silence timeout (minutes)** — terminate a run after this long with no output. Leave it empty to use the per-mode default. ## Grant permissions **Sandbox permissions** starts on "Use workspace default". Turn the switch on to set them for this agent, then toggle: - **Allow git push** — injects the GitHub token the agent needs to push - **Allow GitHub API calls** — lets the agent read PRs, issues and repo metadata - **Allow ticketing API calls** — injects the ticketing provider key - **Allow general network access** — also enables the built-in harness's `web_fetch` and `web_search` tools Each toggle is checked by the credential broker when the run launches: a capability that is off simply means the matching token is never minted into the run's environment. :::caution[The default is conservative and it is not the setting you see] An agent left on "Use workspace default" runs with git push, GitHub API and ticketing all **off** and network **on** — the server's own conservative default. The capability defaults shown under Settings → Server → Diagnostics & privacy are a client-side preference that the server does not read at dispatch time. If you want an agent to push or open PRs, turn the switch on and grant the capabilities explicitly. Note also that these capabilities gate _credentials and harness tools_, which is a separate layer from the OS sandbox. `cc_server` does wrap agent runs in the host's sandbox where one exists — but Windows has no backend and Linux and WSL2 need `bwrap` and `socat`, so on those hosts the capability flags and the guardrails are what you have. See [Sandbox and security](/manual/concepts/sandbox-security/) for the full set of boundaries. ::: **Strict identity check** is persisted but nothing reads it today; leaving it off changes nothing. ## Verify it runs 1. Open **Settings → Server → Model providers** and confirm the adapter you chose reports an installed version rather than "Not found". Click **Refresh** to re-probe — the probe runs on the machine hosting `cc_server`, not on the machine you are looking at. 2. Open a space with the agent and send it a message, or @-mention it in an existing space. 3. Come back to **Settings → Workspace → Agents**, select the agent and open its **Logs** tab. A run should appear with a status badge, a duration, a started time and the adapter it used. **View** opens that run's event stream. If no run appears at all, work through [Run agent diagnostics](/manual/guides/agent-diagnostics/). ## Delete an agent Select it in the roster and click **Delete** in the detail pane header. ## Related guides - [Run agents in parallel](/manual/guides/parallel-agents/) - [Build an agent team](/manual/guides/build-team/) - [Manage adapters and models](/manual/guides/adapters/) - [Run agent diagnostics](/manual/guides/agent-diagnostics/) - [Agent configuration reference](/manual/reference/agent-configuration/) ## Related concepts - [The agent model](/manual/concepts/agent-model/) - [Modes](/manual/concepts/modes/) - [Sandbox and security](/manual/concepts/sandbox-security/) ### Create a pipeline template > Author a DAG workflow template in the pipeline editor — add nodes, wire them, declare inputs and make it runnable. Source: https://usectrl.dev/manual/guides/create-pipeline/ Pipeline templates are authored in workspace settings. The **Pipelines** entry in the workspace sidebar is the _run queue_, not the template list — it has no "create template" action. For what each step kind, body key and config field means, see the [pipeline steps reference](/manual/reference/pipeline-steps/). ## Create a template 1. Go to **Settings → Workspace → Pipeline templates** (`/workspaces//settings/pipelines`). 2. Press **New template**. 3. Enter a **template ID** — a slug such as `my_pipeline`. It is the only field in the dialog; the template's name starts out the same as its ID. 4. Press **Create**. The editor opens on a seeded skeleton, not a blank canvas: a **Trigger** entry node, one prompt-agent node labelled **Step** and a terminal sentinel that ends the run. The sentinel is not drawn. ## Add nodes The **Node library** sidebar sits permanently on the left of the editor. Filter it with the **Search nodes** box and drag an entry onto the canvas — the node lands where you drop it, with no incoming connection. | Palette node | Step kind | What it does | | ------------------------------- | --------- | ------------------------------------------------------------------------------------ | | **Bash script** | listen | Runs a shell script with `{{state}}` substitution; no agent dispatched | | **Clone PR branch** | listen | Discovers the PR head branch via `gh pr view` and clones it into the run directory | | **Post PR comment** | listen | Posts consolidated findings to the PR as a GitHub review comment | | **Reviewer (prompt)** | listen | Dispatches a specialist reviewer agent; suspends until the agent submits its result | | **Consolidate (join)** | join | Waits for several upstream branches, then asks an agent to consolidate their outputs | | **Custom prompt** | listen | Generic prompt-driven node routed through the `promptAgent` body | | **Hello: greet** | listen | Demo body that writes a greeting into state | | **Hello: world** | listen | Demo body that appends to the greeting | | **Post to space** | listen | Sends a message to a messaging space | | **Condition / switch (router)** | router | Branches the flow on a state value | | **If file exists (router)** | router | Routes `"true"` when a path exists under the clone, else `"false"` | | **Any file exists (OR router)** | router | Routes `"true"` when any listed path exists | | **All conditions (AND router)** | router | Routes `"true"` only when every listed path exists | | **Team dispatch** | listen | Dispatches a whole team and suspends until it finishes | | **Approval gate** | listen | Pauses for an approval decision | | **For each (map)** | forEach | Runs an agent task once per item in a state collection, in parallel | | **Call sub-pipeline** | listen | Runs another template as a nested step | | **Clean up worktrees** | listen | Removes stale isolated worktrees; deterministic, no agent dispatched | The library groups these under **Flow & logic**, **PR review**, **Agents**, **Messaging**, **Code** and **Demo**. ## Wire nodes together There are no ports on the canvas and nothing to drag between nodes. Connections are edited from the node that _receives_ them: 1. Select the downstream node. Its config panel opens on the right. 2. Scroll to **Triggers from** and tap the chip for each upstream node this one should listen to. Tapping a selected chip removes the connection. 3. If an upstream node is a router, a **Route keys** block appears under the chips with one **Route key from <node>** field per router edge. Enter the key that edge fires on — `true` or `false` for the file-exists routers, or the case name for a switch. The trigger node is the single entry point. A node may listen to several upstreams (fan-in) and feed several downstreams (fan-out) and the graph must stay acyclic — nothing in the editor stops you wiring a cycle, so check your work. :::caution[Build the chain upstream of the seeded step] The terminal sentinel is filtered out of the canvas, so it cannot be selected or repointed — it stays wired to the seeded **Step** node for the life of the template. The engine completes a run as soon as one of the terminal's incoming branches completes, which means anything you attach _downstream_ of **Step** never executes. Add your work _before_ **Step** instead: drop the new nodes, wire them to each other, then select **Step** and repoint its **Triggers from** chips at your last new node. **Step** stays the final piece of work before the run ends. ::: ## Configure a node Select a node to open its config panel. Which fields appear depends on the node's body; the panel is the same form for every node type, so there is no per-type editor beyond what is listed here. Every node has **Label**, **Input keys** (comma-separated state keys read at execution time) and, under **Advanced**, **Reducer**, **Timeout (ms)**, **Retry attempts**, **Continue on fail** and **Output schema**. Listen and join nodes can also switch their **Kind** between the two. ### Prompt agent Adds **Agent** (which agent to dispatch), **Prompt** (use `{{key}}` to reference inputs or upstream outputs) and **Output key**. **Timeout (ms)** is in _milliseconds_, not seconds — `900000` is 15 minutes. Entering `900` gives the step 0.9 seconds. The conversation mode a prompt-agent node runs under has no editor field. It rides `extras['mode']` (`chat`, `plan` or `review`) on the node config, which only a generated or hand-edited template can set. The key is matched exactly: any other spelling is silently ignored and the body's default is used. ### Bash script Adds **Script**. There are no working-directory or environment fields, because neither is configurable: - Every bash step in a run shares the cwd `/pipelines//`. - `GITHUB_TOKEN` is injected from the credentials store, so `gh` and `git clone https://x-access-token:$GITHUB_TOKEN@…` work without setup. - The script is spawned as the literal executable `bash`, so `bash` must be on the server host's `PATH`. That is a Windows consideration. ### Router A router evaluates a condition and returns a route key; the engine fires only the downstream edge whose route key matches and marks the other branches skipped. Selecting a router replaces the **Output key** field with the condition editor. Pick one of its four modes: - **File(s) exist — any** — routes `"true"` when any listed path exists. - **Files exist — all** — routes `"true"` only when every listed path exists. - **Comparison** — compares a state value against a literal. - **Switch** — routes on a state key's value, with a default case. Then set the matching route keys on the outgoing edges (see [Wire nodes together](#wire-nodes-together)). Boolean `and` / `or` / `not` groups are supported by the body but cannot be authored from this form — they need a hand-written predicate tree under `extras.predicate`. The full predicate and operator vocabulary is in the [pipeline steps reference](/manual/reference/pipeline-steps/). ### Join No configuration of its own: it waits for all of its upstream steps. ### Approval gate The gate dispatches the approver agent into a hidden conversation with the gate prompt and suspends. The approver seeks your decision in the conversation and submits it with `submit_output` as `{ "decision": "approved" | "rejected", "reason": "..." }`. The decision lands under the output key (default `approvalDecision`), so a switch router downstream can branch on it. Set **Agent** to the approver and **Prompt** to the gate question. :::caution[The palette description is stale] The node's description in the library names `approve_step` and `reject_step`. Neither tool exists — the gate resolves through `submit_output`, as above. ::: ### Team dispatch Adds **Team ID** and **Dispatch mode**. The picker offers exactly two modes: - `allParallel` — one task per member, suspends until all complete. - `manager` — dispatches the leader, who delegates. Any other stored value behaves as `allParallel`. Set **Reducer** to `append` to collect each member's result into a list under the output key. ### For each and Call sub-pipeline :::caution[These two nodes cannot be fully configured from the app] Only the router has an extras editor. `extras.iterableKey`, `extras.itemKey` and `extras.templateId` have no field anywhere in the editor, so a **For each** node always maps over the default `items` state key and a **Call sub-pipeline** node cannot be pointed at a child template from the app at all. ::: **For each** dispatches its agent once per item, referencing the current item as `{{item}}` and aggregates the per-item outputs into a list under the output key when the reducer is `append`. An empty collection completes immediately with an empty list. **Call sub-pipeline** suspends until the child run reaches a terminal state, then merges the child's final state under the output key (default `subflow_result`). A template cannot call itself; a failed or cancelled child fails the parent step, which then feeds that step's retry and continue-on-fail policy. ## Declare inputs and allow manual runs Both live behind **Manual run** in the editor header. 1. Press **Manual run**. 2. Turn on **Allow manual run**. This switch persists immediately — it creates (or deletes) the `manual` trigger row that makes the template appear in the run launcher. 3. Press **Add input** for each form field a manual run should collect: | Field | Description | | ----------------- | -------------------------------------------------------------- | | **Key** | State key the value is stored under; reference it as `{{key}}` | | **Label** | Display label (defaults to the key) | | **Type** | Text, Multi-line text, Number, Toggle, Select, Repository | | **Options** | Comma-separated choices, for Select only | | **Default value** | Pre-filled value | | **Placeholder** | Hint text inside the empty field | | **Help text** | Helper text under the field | | **Required** | Whether the run form blocks on an empty value | A **Repository** input renders a picker over the workspace's repos and populates `repoId`, `repoLocalPath`, `repoFullName` and — when the repo has a GitHub remote — `repoOwner` and `repoName` into the run payload. 4. Press **Save** in the dialog. Input edits are folded into the draft; they persist when you save the template. ## Save and enable 1. Press **Save** in the editor header. The header shows "Unsaved changes" until you do. 2. Go back to **Settings → Workspace → Pipeline templates** and check the template's row switch is on. The engine refuses to start a disabled template and a disabled template never appears in the run launcher. 3. To start it automatically, add triggers on the **Trigger** node — see [Set up pipeline triggers](/manual/guides/pipeline-triggers/). ## Related guides - [Run a pipeline manually](/manual/guides/run-pipeline/) - [Set up pipeline triggers](/manual/guides/pipeline-triggers/) - [Monitor pipeline runs](/manual/guides/monitor-pipelines/) - [Pipeline steps reference](/manual/reference/pipeline-steps/) ## Related concepts - [Pipelines](/manual/concepts/pipelines/) ### Customize the chat bot > Rename the bot, reword what it says about itself, or change its slash command from Control Center. Source: https://usectrl.dev/manual/guides/customize-chat-bot/ This guide shows you how to reshape the provider-side bot app without leaving Control Center. ## Prerequisites - The `owner` or `admin` role in the workspace - A **connected** provider. The **Customize bot** row only appears once the workspace is connected - An app Control Center can manage. For Slack that means the connection holds an app configuration refresh token (`xoxe-…`); if it does not, the row says so and stays disabled ## What you can change | Field | Meaning | Limit | | --------------------- | ----------------------------------------------- | ---------------------------------------------------------- | | **App name** | How the provider lists the app | 1–35 characters | | **Bot display name** | What members type after `@` | 1–80 characters | | **Description** | One line the provider shows | 140 characters or fewer | | **Agent description** | Shown above a DM with the bot | 300 characters or fewer | | **Slash command** | The command members invoke, without the slash | Lowercased, `a–z 0–9 _ -` only, truncated at 32 characters | | **Direct messages** | Whether the bot exposes the DM/agent experience | On or off | Control Center validates the four text fields before calling the provider, so you get "the app name must be between 1 and 35 characters" instead of an opaque `invalid_manifest` from a server you cannot see. The command is not rejected but normalized: a leading slash is stripped, the rest is lowercased, anything outside `a–z 0–9 _ -` is dropped and an empty result falls back to `cc`. ## Edit the bot 1. Go to **Settings → Workspace → General** and scroll to the **Chat bridges** card 2. On the provider block, click **Customize bot** 3. The dialog opens on the app's _live_ values, read back from the provider — so a change someone made in Slack's own UI is what you start from 4. Edit what you need and save Reading those live values costs a token rotation on Slack's side, which is why the row is a deliberate click rather than something the page does on load. The write overlays your changes onto the app's current definition. Anything Control Center does not model — a second slash command you added in Slack, a workflow step, an unrelated setting — survives untouched. Scopes and events are merged in, never subtracted. After saving, Control Center re-reads the bot's identity so the name in settings matches the name in the chat app. ## Reinstall when permissions change :::caution[A permission change is inert until you reinstall] Slack applies the new app definition immediately but keeps the _installed grant_ exactly as it was, so an edit that changes permissions does nothing at all until the app is reinstalled. The bridge keeps running on the old grant in the meantime — the connection is not broken, it is simply not using what you asked for and nothing else will tell you. ::: When a save changes permissions, the dialog stays open and says "Slack changed the app's permissions. Reinstall the app for them to take effect", with a **Reinstall app** button that opens Slack's install-on-team page for that app. In Slack that is **Install App → Reinstall to Workspace**. ## Rename the slash command Change **Slash command** and save. The bridge then writes instructions using whatever command it was last invoked with, so the help text and link instructions follow the rename instead of naming a command that no longer exists. Renaming does not break existing bridged threads: threads are keyed by conversation, not by command. ## What Control Center will not change - **Structural settings.** The transport (Socket Mode), the required scopes and the event subscriptions are asserted, not offered. An app that can be configured into silence from a settings screen is a support ticket, not a feature - **The bot's icon.** Only the provider's own app settings can change it and the dialog says so - **The provider's own feature migrations.** An app already on Slack's legacy assistant shape keeps it; this dialog edits a description, it does not migrate an app ## Troubleshooting ### There is no Customize bot row at all The workspace is not connected yet. The row only appears once the provider block shows a live connection. See [Set up Slack integration](/manual/guides/slack-integration/). ### The Customize bot row is disabled The connection has no app-management credential — the row's subtitle says "Control Center needs an app configuration token to edit the bot. Reconnect and include one." For Slack, generate an app configuration token pair on [api.slack.com/apps](https://api.slack.com/apps) and reconnect with the refresh token filled in. This is expected for an app built by hand or created through **Create in Slack**, because Slack never reports which app it made. ### "Control Center does not know which app to edit" The stored connection carries no app id — typically an app connected by hand before an id was recorded. Reconnect the workspace, or create the app from Control Center. ### The bot's new name has not appeared in chat Providers cache app metadata briefly. If the name is still stale after a minute, check whether the save asked you to reinstall the app. ## Related guides - [Set up Slack integration](/manual/guides/slack-integration/) - [Link your Slack account](/manual/guides/link-chat-account/) - [Chat bridge](/manual/reference/chat-bridge/) ## Related concepts - [Chat bridges](/manual/concepts/chat-bridges/) ### Debug a failing test with an agent > Have an agent set a breakpoint, launch under a debugger and read a real stack frame instead of adding print statements. Source: https://usectrl.dev/manual/guides/debug-a-failing-test/ This guide shows you how to get an agent to use a debugger on a failure that print statements are not explaining. The `debug` tool drives a real Debug Adapter Protocol session. A stopped frame answers every question about that moment at once — every local, every caller, and an expression evaluated in the frame's own scope — where a print statement answers only the question you thought to ask before running. ## Before you start The tool is offered only when an adapter is genuinely usable, which means **both** of: - a project marker in the checkout, and - the adapter's binary resolving. Ask the agent to check: ``` debug(op: "adapters") ``` It answers with what it found, or explains that an adapter needs both halves. | Language | Adapter | Marker | Install | | --------------- | ---------------------------- | ----------------------------------------------- | ---------------------------------- | | Dart / Flutter | `dart debug_adapter` | `pubspec.yaml` | Ships with the SDK — nothing to do | | Python | `python3 -m debugpy.adapter` | `pyproject.toml`, `setup.py`, `requirements.txt` | `pip install debugpy` | | Rust / C / C++ | `lldb-dap` | `Cargo.toml`, `CMakeLists.txt` | Ships with LLVM | | Go | `dlv dap` | `go.mod` | `go install github.com/go-delve/delve/cmd/dlv@latest` | :::note[Python picks the project's interpreter] `debugpy` is looked up in `.venv/bin` before `$PATH`. A `$PATH` python is a different environment, and attaching from the wrong one fails in a way that reads as broken code rather than as the wrong interpreter. ::: ## Set the breakpoint first, then launch The order matters and the tool enforces it: breakpoints may be set **before** a session exists, because that is the sequence the protocol requires. Ask the agent something like: > The `compute` function in `bin/main.dart` returns the wrong value. Set a breakpoint on the line that assigns `product`, launch it under the debugger and tell me what `a` and `b` actually are. It will run roughly: ``` debug(op: "breakpoints", file: "bin/main.dart", lines: [7]) debug(op: "launch", program: "bin/main.dart") ``` The launch reports where it stopped: ``` Launched bin/main.dart on dart (session dart-1). Stopped: breakpoint. Use `stack` to see where. ``` :::caution[If it reports the program exiting instead] The breakpoint did not bind. `breakpoints` reports how many of the ones you set were actually placed — a line that is blank, a comment, or not compiled into the build cannot hold one, and the tool says so rather than letting the program run past it silently. ::: ## Read the frame ``` debug(op: "stack") → #1 compute (bin/main.dart:7:3) #2 main (bin/main.dart:2:18) ``` Then walk into it: ``` debug(op: "scopes", frame_id: 1) → Locals (reference 12) debug(op: "variables", reference: 12) → a (int) = 6 b (int) = 7 product (int) = 42 ``` An object with children reports `[expand: reference N]` — but only when there is genuinely something behind it, so an expansion the agent cannot use is never offered. For anything not visible as a local: ``` debug(op: "evaluate", expression: "config.timeout.inSeconds", frame_id: 1) ``` ## Step through | What you want | Op | | ---------------------- | ----------- | | Run to the next stop | `continue` | | Next line, same frame | `step_over` | | Into the call | `step_in` | | Out to the caller | `step_out` | | Stop a running program | `pause` | ## Attach to something already running For a wedged process rather than one you are starting: ``` debug(op: "attach", port: 5858) ``` ## Finish ``` debug(op: "terminate") ``` Always end the session. A debug adapter owns a **stopped process** holding whatever that process holds — a port, a lock, a database connection. :::note[It cannot run away from you] Every session has a hard TTL it cannot extend, there is exactly one per conversation, and everything is torn down when the server stops. A second `launch` is refused rather than replacing the first: an agent that starts one without ending the other has almost certainly lost track of the first. ::: ## Bounded output A `variables` expansion on a deep object graph is unbounded by nature — one Flutter widget reaches the whole element tree. Children per scope are capped at 50, values at 400 characters and stack frames at 30. A tool result that fills the context window is a tool that ends the run it was helping. ## Related - [Code intelligence](/manual/concepts/code-intelligence/) — why a debugger, a compiler and a matcher answer different questions - [Built-in agent tools](/manual/reference/agent-tools/) — every `debug` op and argument - [Diagnose an agent](/manual/guides/agent-diagnostics/) — when the problem is the agent, not the code ### Delegate work to agents > Hand a ticket to an agent in a conversation and let agents delegate sub-tasks to each other under the depth and cycle guards. Source: https://usectrl.dev/manual/guides/delegate-tickets/ This guide covers getting work off your plate and onto an agent's and what happens when agents pass work between themselves. :::caution[Assigning a ticket does not dispatch anything] There is no ticket dispatcher. Assignment records ownership and nothing more — no space is created, no status changes, no agent starts. Agent work lives in conversations, so that is where you start it. ::: ## Hand a ticket to an agent 1. [Create the ticket](/manual/guides/manage-tickets/) with a title and a description precise enough to work from. 2. Set its **Assignee** to the agent, so ownership is on the record. 3. Open (or create) a space and @-mention that agent. 4. Reference the ticket with a `#` mention in your message — the composer autocompletes workspace tickets — and say what you want done. The agent picks the work up from the conversation. Its progress streams into that space. ## What the agent receives - Your message, including the ticket reference you tagged. - The space to work and report in. - An isolated worktree from the workspace's linked repos. - The space's **mode**, which is what governs the run's tool surface. Mode is a property of the space, not of the ticket — a ticket has no mode. One agent can therefore be in `chat` in one space and `plan` in another. Pick it from the composer's mode dropdown (**Agent**, **Plan** or **Orchestrate**); `review` is set by PR-review spaces and is not selectable. See [Modes](/manual/concepts/modes/). ## Require a structured result An output contract is declared on the dispatch, not on the ticket: `expected_output_schema` plus `output_contract_mode` (strict by default). The agent satisfies it by calling `submit_output`, which validates the payload against the schema and rejects a non-conforming one. Both the accepted output and any rejections land on the **agent run log**. The ticket's own status is untouched by this — a failed contract does not fail the ticket. ## Monitor progress - **The space** — live agent activity, the primary view. - **The ticket** — its status, as whoever moves it sets it. - **Observability → Live** (`/workspaces//observability`) — the live agent roster. - **Observability → Insights** — the run log, with cost and duration. There is no dashboard screen; a bare workspace URL redirects to the inbox. ## Let agents delegate to each other An agent that hits a sub-problem delegates with the `delegate_task` MCP tool. It creates a child ticket assigned to another agent and — when you pass `parent_ticket_id` — records the chain so the guards can see it. Two guards are enforced server-side at the ticketing chokepoint, never by prompt instruction: - **Depth cap** — a chain deeper than 3 hops is refused. - **Cycle detection** — a hop back to an agent already in the chain is refused. A refusal is loud: the tool returns the guard's reason verbatim, so the delegating agent sees exactly why. The `delegatedByAgentId` field records who delegated, forming a delegation tree. :::caution[`delegate_ticket` skips both guards] The older `delegate_ticket` tool is still registered and calls the _unguarded_ path — no depth cap, no cycle check. Prefer `delegate_task`. Two further guards exist in the code but are **not wired in**: the autonomy ceiling (a delegate must not act with more autonomy than its delegator) and the budget envelope (delegation cannot mint budget). Do not rely on either today. ::: This delegation depth cap of 3 is a different limit from the built-in harness's subagent nesting cap of 2 — one bounds tickets between agents, the other bounds subagents inside a single run. ## Put several agents on one ticket Add them as **collaborators** on the ticket (roles: assignee, collaborator, reviewer). That is a roster — a record of who is involved — and grants nothing by itself. To actually put agents in the same conversation, add them as participants from the space header. They can then @-mention each other there. See [Spaces](/manual/guides/spaces/). ## Automated ticket creation No node in the pipeline palette creates tickets, so a pipeline cannot fan work out as assigned tickets. The path that does exist is orchestration: approving a proposal materializes its whole sub-ticket DAG, files every sub-ticket under a generated project, forms a team and starts the pipeline run that drives them. See [Run an orchestration](/manual/guides/run-orchestration/). ## Related guides - [Create and manage tickets](/manual/guides/manage-tickets/) - [Chat with an agent](/manual/guides/chat-with-agent/) - [Run an orchestration](/manual/guides/run-orchestration/) - [Organize work with projects](/manual/guides/projects/) - [Ticket lifecycle reference](/manual/reference/ticket-lifecycle/) ## Related concepts - [Tickets](/manual/concepts/tickets/) - [Dispatch lifecycle](/manual/concepts/dispatch-lifecycle/) - [Modes](/manual/concepts/modes/) ### Direct background workers > Use vibe mode to stop doing the work yourself and start directing several agents that do it in parallel. Source: https://usectrl.dev/manual/guides/direct-background-workers/ This guide shows you how to run a conversation as a **director** rather than as a worker: the session reads and verifies, and background agents do the editing. ## What changes when you type `/vibe` Two things, and only the first is enforced by the machinery: 1. **The session's own tools drop to read-only**, plus five worker verbs. The only way it can affect the repo is through a worker, and the only way it can know what happened is by reading the files a worker touched. 2. **It is told to verify by reading, not by believing.** A worker's report is a claim about what it did, not evidence that it worked. The first is the load-bearing one. A director that can still grep and edit will do the work itself under pressure, which is the failure mode this exists to prevent. ``` /vibe migrate the auth module to the new session API ``` ## Workers start blank A worker has never seen your conversation and never will. The brief is its entire context. That is what keeps the pattern cheap — twenty workers each holding the director's history would cost twenty times the director's context — and it is also the one thing that makes briefs hard to write. "Now do the other one too" reads as a complete instruction to the director and as nothing at all to a fresh session. A good brief names files, commands and the definition of done: > In `lib/features/auth/`, replace every call to `SessionStore.read(key)` with `SessionStore.get(key)`. The new method returns `Future` where the old one returned `String?`, so callers need `await`. Run `fvm flutter analyze lib/features/auth` when you are done and report any errors you could not fix. ## The five verbs | Verb | What it does | | ------------ | ------------------------------------------------------- | | `vibe_spawn` | Starts a worker on a brief. **Returns immediately.** | | `vibe_list` | Tier, status, elapsed time and last result for everyone | | `vibe_send` | A follow-up turn for a worker that has finished | | `vibe_wait` | Blocks until one worker settles | | `vibe_kill` | Stops one | Spawning is asynchronous by design. A director that blocks on every spawn is running workers one at a time, which is a slower way of doing what it was already doing itself. Use `vibe_wait` only when you genuinely cannot proceed without a specific answer. ## Two tiers | Tier | For | | ------ | ---------------------------------------------------------------- | | `fast` | Mechanical work with a clear specification — edits, renames, drafts, running commands | | `good` | Design, judgment, or reviewing what a `fast` worker produced | A common shape is to fan out `fast` workers over independent files, then spawn one `good` worker to review what they collectively did. ## Verifying This is the part that decides whether the pattern is worth anything. ``` vibe_list → w1 [fast] auth-migrate — done (48s): Edited lib/features/auth/session.dart … w2 [fast] tests — running (12s) ``` Then **read the files it named**. The director has `read`, `search`, `lsp` and `ast_grep`; that is the whole point of keeping them. A director that takes the report at face value is a summarizer of other agents' optimism. `lsp(action: "diagnostics", file: "*")` after a round of workers is usually the fastest single check. ## When a worker loses the thread Kill it and re-brief. A fresh, corrected brief is cheaper than an argument, and `vibe_send` only works on a worker that has already finished its turn. ``` vibe_kill(worker_id: "w2") vibe_spawn(brief: "…corrected…", label: "tests", tier: "fast") ``` ## Limits - **Eight concurrent workers.** A ceiling on spend, not a technical limit: each is a full model session, and a director that fans out twenty has stopped tracking what they are all doing. - **No worker outlives the mode.** When the run ends, every one of them is stopped and the transcript says how many. A background agent still editing files after the conversation moved on is the one failure this cannot have. - **Workers are ordinary subagents underneath.** Same depth cap, same guardrail policy, same run-log child. Vibe changes who drives them, not what they are allowed to do — so approval prompts still appear for a worker's write and exec calls. ## When not to use it Vibe mode is for work that decomposes into independent briefs. If the next step depends on the previous step's output, you are describing a [pipeline](/manual/guides/create-pipeline/) or an [orchestration](/manual/guides/run-orchestration/), and both express dependencies properly instead of making the director simulate them with `vibe_wait`. ## Related - [Run agents in parallel](/manual/guides/parallel-agents/) — the other ways to fan out - [Orchestration](/manual/concepts/orchestration/) — when the work has a shape - [Built-in agent tools](/manual/reference/agent-tools/) — the verbs and their arguments ### Dispatch reviewer agents > Send specialist reviewer agents at a PR from the PR view, an MCP tool, or a pipeline step. Source: https://usectrl.dev/manual/guides/dispatch-reviewers/ This guide shows you how to dispatch reviewer agents to review pull requests. ## From the PR view 1. Open the PR and go to its **Overview** tab 2. Open the ⋯ overflow menu and choose **Ask AI** This starts the seeded `pr_review` pipeline, which opens the PR's review space and dispatches the reviewers for the workspace's review level — three by default (the seeded **qa**, **architect** and **engineer** agents) — then has the lead agent consolidate. You do not choose the roles on this path. For control over which roles run and which files each one looks at, use the MCP tool or a pipeline step below. ## Via the `dispatch_reviewers` MCP tool Call it from inside an existing review space: ``` dispatch_reviewers( space_id: "...", workspace_id: "...", reviewers: [ {role: "reviewer"}, {role: "security", scope: "src/auth/**"} ] ) ``` `space_id`, `workspace_id` and `reviewers` are all required. Each reviewer spec takes a `role` plus an optional file-glob `scope` and a `prompt_override` that replaces the default brief. An optional `concurrency` (1–8) caps parallel dispatches; when omitted, the workspace's `review_concurrency` setting applies (default 3, set at Settings → Workspace → General). The tool returns immediately with `dispatched` and `unmatched` lists. Every dispatched reviewer is added to the space as a participant, announced with a system message and dispatched with its brief. Findings land as `review_node` messages in that space. ## From a pipeline step Open **Settings → Workspace → Pipeline templates**, open a template, then: 1. Add a **Reviewer (prompt)** node from the palette 2. Pick the agent to dispatch and write the reviewer prompt, using `{{key}}` placeholders for upstream outputs (for example `{{repoLocalPath}}`, `{{prNumber}}`) 3. Set the output key (default `reviewer_findings`) so downstream steps can read the findings The step dispatches the agent and suspends until that run finishes. The engine then harvests the run's `submit_output` payload into pipeline state under the node's output key. (The palette description still says the step waits for a `complete_task` call — that tool does not exist; ignore the string.) Fan out by wiring several reviewer nodes to the same upstream step, then consolidate their outputs with a **Consolidate (join)** node. :::caution[The terminal sentinel cannot be re-pointed] Every template ends in a hidden terminal sentinel and the editor filters it out of the canvas — so you cannot select it or re-thread it onto a node you add. The engine completes the run the moment the sentinel's incoming branch finishes and returns before scheduling anything else that became ready in the same tick. A node added _downstream_ of the step the sentinel is wired to therefore never executes. Fan-out nodes wired to an _upstream_ step still run. ::: ## Automatic dispatch on a new PR To have a review pipeline fire by itself: 1. Open the template at **Settings → Workspace → Pipeline templates** 2. Select the trigger entry node on the canvas to open the trigger panel 3. Add an event trigger and pick the event 4. Enable both the trigger and the template Two events matter here and they are not interchangeable: | Event | Fires when | Payload keys | | ---------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- | | `PullRequestPublished` | A PR is opened **through Control Center** — the compose surface, or an agent calling `pr_lifecycle.createOnGitHub` | `prId`, `workspaceId`, `repoOwner`, `repoName` | | `ExternalPrDetected` | The PR polling service finds a PR opened directly on GitHub | `repoOwner`, `repoName`, `prNumber`, `prTitle`, `author` | A PR opened on github.com never raises `PullRequestPublished`, so a template wired only to that event will not see it. Note also that `PullRequestPublished` carries no `prNumber` — a step whose script or prompt references `{{prNumber}}` must be fed by `ExternalPrDetected`, or by a manual run that supplies it. Checkpoint: after enabling, open **Pipelines** and confirm a run appears for the next matching PR. A template left disabled makes its trigger fire into nothing — the engine rejects a disabled template and returns without starting a run. ## How a role is matched to an agent `dispatch_reviewers` scores every agent in the workspace against each requested role label, case-insensitively: | Match | Score | | --------------------------------------- | ----- | | A skill containing the role label | +3 | | A title containing the role label | +2 | | An agent name containing the role label | +1 | Scores accumulate and the highest total wins (on a tie, the first candidate found). If no candidate scores above zero, the spec comes back in the tool's `unmatched` list instead of being dispatched — so a role nobody matches is reported, never silently skipped. ## The review space A PR has exactly **one** review space, not one per reviewer. It is created idempotently by `pr.ensureSpace`, linked to the PR through a `ReviewSpaceAssociation` and provisioned against the PR's head ref. Every dispatched reviewer joins that space and posts its `review_node` findings there, which is what makes the findings comparable and lets the lead agent reach consensus across them. ## Monitor the dispatch Each dispatched reviewer gets its own `AgentRunLog`. Follow progress in: - The **review space**, where findings stream in live - The PR's **Review** tab, which shows the review status bar and verdict - **Observability → Live** (`/workspaces/:id/observability`), the agent roster with per-run status, live elapsed time, a transcript drawer and a kill control. There is no keyboard shortcut for it — reach it from the sidebar footer. ## Related guides - [Use AI-powered review](/manual/guides/ai-review/) - [Review and merge a PR](/manual/guides/review-merge-pr/) - [Set up pipeline triggers](/manual/guides/pipeline-triggers/) ## Related concepts - [AI review](/manual/concepts/ai-review/) - [Pipelines](/manual/concepts/pipelines/) ### Explore data in a persistent kernel > Give an agent a Python or JavaScript interpreter that keeps its variables between calls, draws charts into the chat, and can call your own tools from inside a cell. Source: https://usectrl.dev/manual/guides/explore-data-in-a-kernel/ This guide shows you how to have an agent load something expensive once and then ask several questions of it, instead of paying the load on every question. The `eval` tool runs a **persistent interpreter**. Variables, imports and loaded data survive between calls. ## Why not just run a script A one-shot `python -c` pays the setup on every question. Loading a dataframe costs seconds and a hundred megabytes; charting it costs milliseconds. When every question costs the load, an agent asks fewer questions than it should — and each answer comes back as text it then has to re-parse. ## Load once, then ask > Load `data/runs.csv` and tell me the p95 duration by agent. ```python runs = pd.read_csv("data/runs.csv") len(runs) ``` The last expression is echoed the way a notebook does, so `len(runs)` reports the row count without a `print`. Then, in a **separate call**: ```python runs.groupby("agent")["duration_ms"].quantile(0.95).sort_values() ``` `runs` is still loaded. A pandas frame comes back rendered as a table rather than as a repr. ## Charts land in the chat ```python runs.groupby("agent")["duration_ms"].mean().plot.bar() ``` Figures are captured after the cell and returned as images, which the transcript renders. You see the chart; so does the agent. Figures are closed after capture. An uncollected figure would be redrawn and re-sent by the *next* cell too, so a five-cell session would emit the first chart five times. :::note[No Jupyter, no pip, no install step] The runner is plain `python3 -u` speaking a line protocol on stdio. It implements `display()`, MIME-bundle dispatch and figure capture itself, so it works on any Python 3 — including inside a sandbox where installing is exactly what the sandbox is there to prevent. ::: ## The idioms you would type anyway These are rewritten into plain code rather than erroring: | You type | What happens | | ----------- | --------------------------------------------------- | | `%pip install x` | Runs pip, then **clears the module cache** so the next `import` sees it | | `%cd path` | `os.chdir` | | `!command` | A captured subprocess | | `%%bash` | The rest of the cell as a shell script | | `%%timeit` | 1000 timed runs | The `%pip` cache eviction is the one that matters: installing into a live interpreter does nothing for a module that was already imported, so without it the version you just installed is not the one running. ## Calling your own tools from inside a cell ```python paths = tool("search", {"pattern": "TODO", "path": "lib"}) for path in paths.splitlines()[:100]: contents = tool("read", {"path": path}) ... ``` `tool(name, args)` re-enters the agent's own tool registry. That means a cell can fan out over a hundred files, or delegate through `task`, without the model spending a turn per item. :::caution[The bridge does not go around your guardrails] A bridged call resolves against the **same** tool registry a model-issued call does, so `ActionClass` policy, approval prompts and every wrapper apply identically. A bridge that reached past the registry would be a hole shaped exactly like "write a Python one-liner". ::: A bridged call **suspends the cell's timeout** while it is outstanding. The budget measures inactivity, not wall clock — a cell that fans out to subagents and waits four minutes is not hung, and killing it mid-fanout would lose the whole kernel's state. ## Starting over ``` eval(code: "…", reset: true) ``` Discards the interpreter and starts a clean one. Everything loaded is gone, which is the point. ## Where the kernel runs In the conversation's **enclosure** when it has one, on the host otherwise. A persistent interpreter driven by a model reading an untrusted repo is exactly what enclosure-only execution exists for: a one-shot `bash` call at least ends, while a kernel is a shell that remembers. It does **not** boot an enclosure to get there — the same rule that stops a rig tab auto-starting. If you want the kernel enclosed, open the conversation's shell first. See [Give an agent a machine to test on](/manual/guides/use-rigs/). ## Limits - **One cell at a time per kernel.** Two cells racing in one interpreter is two halves of two programs interleaved in one namespace. - **Output is capped.** A cell in a print loop is truncated rather than allowed to fill the context window. - **Four images per cell.** A plotting loop can emit a figure per iteration, and every image is a large fixed cost in the request that carries it. - **The kernel dies with the run.** One still holding a dataframe after the conversation ended is a leak. ## Related - [Sandbox and security](/manual/concepts/sandbox-security/) — what "enclosed" means here - [Give an agent a machine to test on](/manual/guides/use-rigs/) — opening the enclosure - [Built-in agent tools](/manual/reference/agent-tools/) — the arguments ### Use focus mode and soundscapes > Run a timed focus session with a floating countdown pill and suppressed notifications, and shape the adaptive soundscape that plays under it. Source: https://usectrl.dev/manual/guides/focus-mode/ This guide shows you how to run a focus session: a timed block during which Control Center suppresses non-urgent notifications and floats a countdown pill over your other windows. It also covers the soundscape that (by default) plays under it — an endless ambient track your server synthesizes live, shaped by the mood you pick, the weather outside and the hour of the day. A focus session is a shell-level feature. It is not tied to any pillar — it does not change the layout, hide the sidebar, or alter a PR review surface. :::note[Desktop only] The floating pill and the soundscape's pop-out mini player are native sibling windows, mounted only by the desktop bootstrap. The web build at `app.usectrl.dev` has neither, and no OS-notification pipeline, so a focus session has nothing to show or suppress there. The soundscape panel and its playback work wherever you connect to a server. ::: ## Start a focus session 1. Click the focus icon at the right of the title bar (tooltip **Start focus session**) 2. In the dialog, type an optional **Goal** — what you are working on 3. Pick a **Duration**: 25m, 50m, 1h, 1h 30m or 2h. The default is 50m and your last choice is remembered 4. Leave **Block notifications** on (see the caution below for what it actually controls) 5. Press **Start** Checkpoint: the title-bar button is replaced by an accent chip showing the minutes remaining, the floating pill appears over your other windows and — with **Start with focus mode** on — the soundscape starts playing. Two other entry points reach the same session: - **⌘K → Start focus session** opens the same dialog. - **⌘⇧⌥F** starts a session immediately with your remembered duration and no goal — it skips the dialog. Pressing it again ends the session. ## Read the floating pill The pill is a small frameless window you can drag anywhere. It shows: - A live presence dot and the remaining time as `mm:ss` - Your goal, or "Focus session" when you did not set one - A thin progress bar along the bottom edge Hover it to reveal two actions: - **Pause** / **Resume** — stops the countdown. Pausing is local to the pill; the session itself stays active. - **Complete** — ends the session. The pill commits to the dark palette whatever theme the main window is using, because it floats over arbitrary desktop content. If you close the pill from the title bar, the session keeps running. Press the picture-in-picture button next to the focus chip (tooltip **Minimize to bar**) to bring it back. ## Know what gets suppressed While a session is active, Control Center drops every notification except two categories, which are always delivered: - **Meeting starting soon** — a meeting alert you see after the fact is worthless - **Pull request published** Everything else is silently suppressed for the whole session — no sound, no OS toast. Suppressed notifications are not queued for later delivery. :::caution[The "Block notifications" switch does nothing] The switch in the start dialog is persisted with your duration preference, but no code reads it. Suppression is gated on the session being active, so non-urgent notifications are suppressed whether you leave the switch on or turn it off. Treat it as a preference that has not been wired up yet. ::: ## Play a soundscape 1. Click the speaker icon at the right of the title bar (tooltip **Soundscapes**) to open the panel 2. Pick a **Mood** — **Focus**, **Relax** or **Sleep** 3. Press **Play** Checkpoint: the **Now playing** card names the current scene — the weather at your workspace's location, the temperature in °C and the location label. While it reads "Tuning the ambience…" the scene is still resolving. The track is not a loop. Your server synthesizes it continuously from the mood, the live weather at the workspace's location and the time of day. Rain reads bright and busy, a storm loud and gusty, fog muffled; night is darker and more reverberant than midday. The mood sets the vocabulary: | Mood | Sound | | ------ | ---------------------------------------------------------------- | | Focus | Gentle, alert ambience with a low pulse — meant to sit under work | | Relax | Warmer, more consonant pads, no beat | | Sleep | Low drones with almost no events, deep reverb | Switching mood while playing hands playback to the new mood without stopping. The panel is also reachable from ⌘K under the **Focus** category, next to the focus session itself. ## Shape the sound - **Tune** (Focus and Relax only) — a square pad with a draggable puck. Horizontal is **Mellow** to **Energetic** (how much happens), vertical is **Spacy** to **Bright** (how open it sounds). Double-tap the pad to reset to the neutral centre; arrow keys nudge the puck. Your tune is remembered per mood, and adjustments glide into the running mix — the stream never restarts. The Sleep mood is untunable by design. - **Volume** — a slider from 0 to 100%, defaulting to 60%. It is separate from the system volume and is remembered. - **Location** — detected automatically from the workspace. Press **Refresh weather** to refetch the scene. ## Pop out the mini player While audio is playing, the title bar grows a second button (tooltip **Pop out player**). It floats a small always-on-top HUD with play/pause, the weather glyph, the scene name and a **Return to app** button — handy once a focus session has you in another app entirely. Pausing from the mini player does not dismiss it; only its close button (or quitting the app) removes it. ## Run them together The two features are coupled by one switch in the soundscape panel: **Start with focus mode**, on by default. - Starting a focus session starts the soundscape if nothing is already playing. - Ending the session — however it ends, including the timer running out — stops it, even when you started the soundscape by hand. Turn the switch off to keep the two independent. ## End a session Any one of these ends it: - Click the focus chip in the title bar (tooltip **Focus mode active — tap to end**) - Press **Complete** on the pill - Press **⌘⇧⌥F** - Choose **Exit focus mode** in ⌘K - Let the timer run out — the session auto-completes at zero The pill closes, notifications resume immediately and the soundscape stops. ## Understand what does not persist A session lives for exactly one app run. Quit and reopen Control Center and you start inactive with a reset timer, even mid-session — there is no crash recovery and no session history. Only two things carry over: your preferred duration and the block-notifications flag. The goal, the elapsed time and the pill's open state do not. Playback is just as ephemeral — the soundscape is always stopped at launch. Your mood, volume and per-mood tunes persist; the stream itself never resumes on its own. ## Troubleshooting ### The tune pad is missing You are on the Sleep mood, which is deliberately untunable. Switch to Focus or Relax to get the pad back. ### Nothing plays after restarting the app By design — playback is always stopped at app launch. Open the panel and press **Play** again. If it still stays silent, check the panel's volume slider, which is separate from the system volume. ### The weather or temperature looks wrong The scene follows the active workspace's location. Press **Refresh weather** in the panel to refetch it. ## Related guides - [Configure notifications](/manual/guides/notifications/) - [Keyboard shortcuts](/manual/reference/keyboard-shortcuts/) ### Create the GitHub App > Register a GitHub App for your server so members can sign in, and so background work runs as the app rather than as a person. Source: https://usectrl.dev/manual/guides/github-app/ This guide shows you how to give your server its own GitHub identity. It takes about two minutes, only the server owner does it, and it only has to happen once per server. :::note[You may not need it] An official build already ships a GitHub **client id**, so [Sign in with GitHub](/manual/guides/github-integration/) works out of the box with nothing registered — a device flow authenticates with the client id alone, so it is safe to ship. Register your own app when you want the SERVER to act as itself (webhooks, PR polling and ticket sync running on an app installation rather than on a person's credential), or when you are building from source. ::: For connecting your own account afterwards, see [Set up GitHub integration](/manual/guides/github-integration/). ## Why your server needs its own app Two different jobs come out of one registration, and the app is what makes both honest: | Job | What answers it | Why it matters | | ---------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | **A person signs in** | The app's client id, over the device flow | You get a credential that is yours, on the server, on every device — with no token to paste or rotate | | **The server acts** | The app's id + private key → installation token | Webhooks, PR polling and ticket sync stop depending on one human's token still being valid | Note what is **not** in that table: a client secret. GitHub's device flow authenticates with the client id alone — both the device-code request and the token poll — so there is no secret to keep for signing in. Without an app of your own, the first still works through the client id the build ships; the second falls back to the server owner's credential, so background work rides on whoever onboarded first. :::note[There is no "install Control Center's GitHub App" button] A distributed GitHub App would mean one shared client secret and one shared installation across everyone's servers, so every workspace's repository access would flow through credentials nobody in that workspace controls. Each server therefore owns its own app — this guide is about how little that costs. ::: ## Prerequisites - You are the **server owner** — the `providerApps.*` operations are gated to it, and the card is invisible to everyone else - A GitHub account, or an organization you can create apps in - Nothing publicly reachable: the sign-in is a **device flow**, so the server needs no inbound port, tunnel, callback URL or domain ## Register the app 1. Go to [github.com/settings/apps/new](https://github.com/settings/apps/new) (for an org: **Settings → Developer settings → GitHub Apps → New GitHub App**) 2. **GitHub App name** — anything; `Control Center (yourname)` is fine. It shows up on commits and comments the server makes 3. **Homepage URL** — anything, e.g. `https://github.com/your-name` 4. **Callback URL** — leave it empty. The device flow has none 5. Tick **Enable Device Flow**. This is the one setting the sign-in cannot work without 6. Untick **Webhook → Active** unless you are wiring webhooks separately 7. Under **Repository permissions**, grant at least: - **Contents**: Read and write - **Metadata**: Read-only (mandatory) - **Pull requests**: Read and write - **Issues**: Read and write, if you use GitHub Issues for tickets, and for the bot's PR-conversation comment search (see [Talk to Control Center from a GitHub PR](/manual/guides/github-pr-conversations/)) - **Checks** and **Commit statuses**: Read-only, for CI state on the PR page 8. Under **Where can this GitHub App be installed?**, choose whichever fits — "Only on this account" is right for a personal server 9. Click **Create GitHub App** ## Collect the three values On the app's page, immediately after creation: | Value | Where | | --------------- | ------------------------------------------------- | | **App ID** | The **General** tab, near the top | | **Client ID** | The **General** tab, under "Client secrets" | | **Private key** | **Generate a private key** — a `.pem` downloads | The private key file downloads once and GitHub never shows it again. Keep it until you have pasted it; you can always generate another. A **client secret** is optional and buys exactly one thing: if you leave "Expire user authorization tokens" ON, it is what renews someone's token silently instead of asking them to sign in again every eight hours. The sign-in itself never uses it. The simpler setup is to turn that expiry OFF, and then no secret is needed anywhere. If you keep it on, generate a secret and paste it in **Settings → Server → Provider apps** — there is no environment variable for it, and none is baked into a build, so it stays on your server. ## Install it on your account An app that is registered but not installed can authenticate people and read nothing. 1. On the app's page, open **Install App** 2. Install it on your account or org 3. Choose **All repositories**, or select the ones this server should see ## Paste it into Control Center 1. Go to **Settings → Server → Single sign-on** and scroll to the **Provider apps** card 2. On the **GitHub** block, set each field: - **App id** — the numeric App ID - **Private key** — paste the whole `.pem`, `BEGIN` and `END` lines included - **Client id** — from the same page - **Client secret** — only if you generated one (for token refresh) 3. Click **Test** The key is parsed as it is saved, so a truncated or wrong-format paste is rejected there and then rather than failing on a background request hours later. **Test** goes further and asks GitHub which accounts the app is installed on — "saved" and "works" are different claims, and only the second one means anything will run. Checkpoint: the GitHub block reads "The server can act as itself, and people can sign in", and the **GitHub** row under **Settings → You → Profile & identity → Code hosting** now offers **Sign in with GitHub**. ## Configure it with environment variables instead For a headless or scripted install, the server seeds each field from the environment the first time it has no stored value: ```bash GITHUB_APP_ID=123456 GITHUB_APP_PRIVATE_KEY="$(cat control-center.private-key.pem)" GITHUB_CLIENT_ID=Iv1.0123456789abcdef ``` There are no command-line flags for these — a secret on a command line is readable by every process on the host through `ps`. The environment is the only place to configure them, and it beats whatever the build ships. `.env.template` is the complete list. For local development the repo-root `.env` carries the same values and the desktop passes them to the server it spawns. Put the key on **one line** there: a `.env` value is one line, and `\n` inside double quotes decodes to a newline. ``` GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIEow…\n-----END RSA PRIVATE KEY-----" ``` `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY` are the same names the sandbox token broker already reads, so a host that configured fine-grained agent tokens gets the sign-in lane for free. Seeding happens **once per field** — after that the stored value wins, so editing in Settings is not undone by the next restart. ## Do the same for Linear Linear has no device flow, so its sign-in is a browser round-trip and it does need a callback URL — the **Provider apps** card shows you the exact one to register. 1. Create an OAuth application at [linear.app/settings/api/applications/new](https://linear.app/settings/api/applications/new) 2. Set the **Callback URL** to the URL the card shows, verbatim 3. Paste the **Client ID** and **Client secret** into the Linear block 4. Optionally add a workspace **API key** — that is the credential the server uses to sync tickets when no human asked ## Troubleshooting ### The sign-in dialog says the provider returned no device code **Enable Device Flow** is off on the app. Turn it on under **General** and retry. ### Test says the credentials work but the app is not installed anywhere Registration and installation are separate steps. Open **Install App** on the app's page and install it. ### Test says the private key could not be read Paste the whole `.pem` file, including the `-----BEGIN` and `-----END` lines. An `ed25519` or EC key cannot be used — GitHub Apps sign with RSA. ### A member signed in but sees no repositories The app is not installed on the account that owns them, or its installation selects different repositories. Their own sign-in only grants what the app is allowed to see. ## Related guides - [Set up GitHub integration](/manual/guides/github-integration/) - [Talk to Control Center from a GitHub PR](/manual/guides/github-pr-conversations/) - [Connect a code host](/manual/guides/connect-forges/) - [Manage API keys](/manual/guides/api-keys/) - [Set up Slack integration](/manual/guides/slack-integration/) ### Set up GitHub integration > Sign in to GitHub so the PR surface, repo registration and agent runs work. Source: https://usectrl.dev/manual/guides/github-integration/ This guide covers the GitHub-specific parts of the setup. GitHub is one of three supported code hosts — see [Connect a code host](/manual/guides/connect-forges/) for connecting GitLab or Bitbucket, and for how a workspace mixes them. ## Prerequisites - A GitHub account with access to the repos you want to work on - Either a GitHub App registered for your server (see [Create the GitHub App](/manual/guides/github-app/)) so you can **sign in**, **or** a personal access token with `repo` and `read:org` to paste :::note[Credentials live on the server, attached to you] Nothing is stored on the machine you are typing on. Your credential is held by your `cc_server`, keyed by your user id, and every device you sign in from uses it — desktop, web and phone. Another member of the same server cannot read it, and signing them in is something they do themselves. ::: ## Sign in with GitHub A **device flow**: GitHub shows you a short code, you approve it on github.com, and the server receives the credential — no callback URL, no port, nothing to expose. Available whenever the server has a client id, which an official build already ships. 1. Go to **Settings → You → Profile & identity → Code hosting** (or the first step of onboarding) 2. On the **GitHub** row, click **Sign in with GitHub** 3. A dialog shows an eight-character code and opens [github.com/login/device](https://github.com/login/device) in your browser. The code is already on your clipboard 4. Paste it, approve the app, and come back. The row flips to "Signed in as _your-username_" on its own The dialog can be closed while you finish in the browser — the server keeps polling until the code expires (about 15 minutes), so the sign-in still lands. ## Or paste a token The fallback when the server has no app, when the app is not installed on an account you need, or when you would simply rather use a PAT. 1. Go to **Settings → You → Profile & identity → Code hosting** 2. On the **GitHub** row, click **Add token** 3. Paste a token with `repo` and `read:org` and save The token is probed as it is stored, so the row can name the account it belongs to. Click **Disconnect** to remove it. Checkpoint: the **GitHub** row reads "Signed in as _your-username_". A suffix names the source when the credential answering is not yours — "via this server's app" or "from the environment". :::note[Precedence is fixed] A request made on your behalf uses **your** credential, and nothing else — the environment is the server's credential, not yours, so it never stands in for you. Work with no human behind it (webhooks, PR polling, ticket sync) uses the server's app identity, then the server owner's credential, then the environment. ::: ## What the integration gives you | Feature | How | | ------------------- | -------------------------------------------------------------------------------------------------------------- | | PR list and detail | The GitHub REST API, from the server, on the app identity or your own credential | | Check runs | The GitHub Checks API | | Review publishing | A user-gated step that posts your review to the PR | | Repo registration | A checkout is accepted if its `origin` remote points at github.com — or at gitlab.com / bitbucket.org | | Agent GitHub access | The credential broker injects the token as `GH_TOKEN` / `GITHUB_TOKEN`, gated by the agent's capabilities | | Notification pings | A server-side poll of your GitHub notification inbox raises "mentioned in pull request" and "review requested" | | User profiles | Profile hover cards and per-author PR filtering | Some paths use the GraphQL API instead of REST where it is cheaper — branch activity for the compose-PR picker and the live review state that decides whether a review-requested notification is still pending. ## Watch for GitHub outages The title bar shows a status chip when a provider is degraded, covering GitHub, Claude, OpenAI and Kimi. **It is hidden while everything is healthy** — presence reports trouble, not its absence. Click the chip to open a flyout with each provider's state and a refresh button. The statuses refresh on their own every two minutes. ## Know how agents act on GitHub - An agent gets a GitHub token only if it has **Allow GitHub API calls** or **Allow git push**. See [Configure sandbox policies](/manual/guides/sandbox-policies/) - Commits an agent makes are authored **as the agent** — the display name plus ` (agent)`, at a synthetic address keyed by the agent's id — never as a human. The person who asked is credited with a `Co-Authored-By` trailer - With a GitHub App configured, the broker mints fine-grained, repo-scoped, roughly one-hour installation tokens per launch instead of handing over a broad token. The installation is resolved from the run's repo **owner**, so one app installed on several accounts scopes correctly to whichever one the run touches. Without an app, agents receive the environment token - A run requested by a member who has signed in acts under **that member's** GitHub identity — their credential is merged in after the broker's environment, so a commit or comment an agent makes for them is attributable to them ## Troubleshooting ### The PR list is empty and says GitHub is not connected You have no GitHub credential on this server. Sign in or paste a token under **Code hosting**. Repos on other forges are unaffected — only GitHub's contribute nothing. ### The row offers "Add token" but no "Sign in with GitHub" The server has no client id — which is the normal state for a build from source, since credentials are only baked into official builds. See [Create the GitHub App](/manual/guides/github-app/): it takes about two minutes and only the server owner has to do it, once. ### The sign-in dialog says the provider returned no device code The GitHub App exists but its **Device flow** checkbox is off. Turn it on in the app's settings (General → Device flow) and try again. ### The row says "via this server's app" when I expected my own account You have not signed in; what you are seeing is the server's app identity answering. Click **Sign in with GitHub** on that row. ### Adding a repo is rejected Registration requires all three: the path is inside a git work tree, it has an `origin` remote, and that remote points at a supported forge (github.com, gitlab.com or bitbucket.org). See [Add repos to a workspace](/manual/guides/add-repos/). ### Everything stops working for a while after heavy use That is the rate-limit circuit breaker. GitHub signals its primary limit with a `403` carrying `x-ratelimit-remaining: 0` and a reset time; on seeing one, the client fails further requests locally without a network call until the window elapses, so a retry storm cannot deepen the limit. Transient failures are retried up to three times with exponential backoff, honouring `retry-after`. ## Related guides - [Create the GitHub App](/manual/guides/github-app/) - [Connect a code host](/manual/guides/connect-forges/) - [Manage API keys](/manual/guides/api-keys/) - [Add repos to a workspace](/manual/guides/add-repos/) - [Review and merge a PR](/manual/guides/review-merge-pr/) - [Set up Slack integration](/manual/guides/slack-integration/) ### Talk to Control Center from a GitHub PR > Ask the server's GitHub App bot for a review or a question from the PR conversation itself, with no webhook or public URL. Source: https://usectrl.dev/manual/guides/github-pr-conversations/ This guide shows you how to work with Control Center without leaving GitHub: request an AI review from the PR page, ask the bot a question in a comment, and keep the conversation going in its review threads. The bot is your server's own [GitHub App](/manual/guides/github-app/) — the same one that already signs people in and runs background work. Nothing new is registered, and nothing has to be reachable from the internet: the server discovers what was said on GitHub by polling, so a laptop behind NAT behaves exactly like a public deploy. ## Prerequisites - The server has its GitHub App [registered and installed](/manual/guides/github-app/), with **Pull requests: read and write** (for comments, threads and reactions) and **Issues: read** (for the comment search). Both are already in that guide's permission list - The repository is linked to a workspace in Control Center - No webhook URL, no tunnel, no callback — there is nothing to configure on the network side ## Find your bot's name The bot's login is `[bot]`, where `appslug` is the name you gave the app when registering it. 1. Go to **Settings → Server → Single sign-on** and scroll to the **Provider apps** card 2. Open the **GitHub** block and click **Test** 3. The **Bot login** field fills in and is copyable GitHub does not offer app accounts in its `@` autocomplete, so the login has to be typed — that is why the settings card makes it one click to copy. The `[bot]` suffix is optional: `@myapp` and `@myapp[bot]` both address the bot. :::note[Pick a distinctive app name] The short form is matched as text. If a human GitHub user happens to share your app's slug, mentioning that user by the short form also reaches the bot. The full `@myapp[bot]` form is always unambiguous. ::: ## Request a review from GitHub Two ways, both starting the same seeded `pr_review` pipeline the **Ask AI** button starts: - **Add the `ai-review` label** to the PR. This is the closest thing GitHub offers to assigning the bot as a reviewer — app accounts cannot hold the native requested-reviewer slot - **Comment `@myapp review`** (or just `@myapp`) on the PR conversation The bot reacts with 👀 to acknowledge, then replies when the review has started. The reviewers work in the PR's review space; read and publish the findings as in [Use AI-powered review](/manual/guides/ai-review/). Publishing back to GitHub stays a decision you make in Control Center — starting a review from GitHub does not publish anything to GitHub on its own. The label fires once per PR: adding it is the ask, and it is not re-armed when removed and re-added. To run a review again — after a push, say — mention the bot. ## Ask a question Comment on the PR conversation: ```text @myapp why does the flaky test in runner_test.dart fail on CI but not locally? ``` What happens, in order: 1. The bot reacts with 👀 so you know the comment was seen 2. The question is posted into the PR's review space **as you** — same attribution as if you had typed it in Control Center 3. An agent answers in the space, and the answer is posted back on the PR as a comment that @-mentions you A question on a PR with no review yet wakes the workspace's lead agent; once a review has run, the agent that spoke last answers, so follow-ups land with whoever has the context. ## Follow up in a review thread Reply inside any thread the bot is part of — an inline review comment it left, or an answer it posted. No mention is needed; the thread **is** the addressing: ```text > @myapp the retry loop looks O(n²) to me what would the complexity be with the bounded queue instead? ``` Multi-turn works the same way: each reply is another question in the same conversation, with the prior thread as context, and the answer comes back in the thread. ## Who the bot talks to A GitHub comment arrives as little more than a login, so the bot resolves the author before doing anything: - **Your GitHub account must be linked to your identity** in Control Center — [sign in with GitHub](/manual/guides/github-integration/), or paste a token, from **Settings → You → Profile & identity** - **You must be a member with a write role** in the workspace that links the repository Anyone else gets one polite reply explaining what is missing, and their words never enter the workspace. ## What to expect | Thing | Behaviour | | ------------------- | ------------------------------------------------------------------------ | | Acknowledgement | 👀 reaction within about a minute — the sweep interval | | Answers | Posted as the bot (the app), in the lane the question was asked in | | In-space attribution | The question is yours; the agent runs on your behalf, under the space's autonomy settings | | Reviews | Findings land in the review space; publishing to GitHub is manual | The roughly-one-minute latency is the trade for needing no inbound network access at all. Nothing about the sweep is urgent, and nothing is lost while the server is offline — a comment that arrives during downtime is caught on the next sweep. ## Troubleshooting **Nothing happens for a comment that mentions the bot.** - Is the app **installed** on the account that owns the repo, and the repo **linked** to a workspace? An unlinked repo is skipped before anything is recorded, so linking it later still delivers - Is your GitHub account linked, and are you a member with a write role? Check the PR for the bot's refusal reply — it says which - Still nothing? The server log names the sweep and the PR when something fails; `github_pr_conversation:` is the prefix to look for **The label does not start a review.** It fires once per PR. If a review already ran, or the label was added before the server ever saw the PR (the first sweep records the backlog silently rather than storming it), mention the bot instead — `@myapp review` always starts one unless a review is already in flight. **The bot's answer never arrived on GitHub.** The answer itself is never lost — it is in the PR's review space in Control Center. A posting failure (GitHub down, permissions changed) is logged and the reply is not retried; ask again in the thread if it matters. ## Related guides - [Create the GitHub App](/manual/guides/github-app/): the registration the bot is born from - [Use AI-powered review](/manual/guides/ai-review/): reading the findings a mention-started review produces - [Set up GitHub integration](/manual/guides/github-integration/): linking your own GitHub account, which is what the bot resolves you by - [Use spaces](/manual/guides/spaces/): the room all of this lands in ### Set up Linear integration > What exists of the Linear ticket sync today, why there is no in-app way to turn it on and what to do instead. Source: https://usectrl.dev/manual/guides/linear-integration/ :::caution[Linear sync has no in-app configuration path] The multi-vendor sync engine is complete and wired on the server, with a Linear adapter among four. What it needs to do anything is a per-workspace, per-vendor connection row (`ticket_sync_configs`) — and **nothing in the product creates one**. There is no screen, no RPC op and no MCP tool that writes it and the thin client's config repository throws `UnsupportedError` on write. A row has to be inserted out of band. Until that changes, treat this page as an account of what is built rather than a set of steps you can follow. Nothing you can click in the app connects Linear. ::: ## What is actually there | Piece | State | | ------------------------------------------------------------------------ | ------------------------------------------------------- | | `TicketSyncEngine` with Linear, GitHub Issues, Jira and ClickUp adapters | Constructed at server boot | | Outbound push (Control Center → vendor) | Event-driven coordinator, started at boot | | Inbound webhooks (vendor → Control Center) | Handler behind HMAC verification | | `ticket_sync.syncNow` RPC op | Exists, pulls for the bound workspace's enabled configs | | The `ticket_sync_configs` row that enables any of it | **Never written by anything** | | A UI to create that row | Does not exist | Credentials come from the **server's environment**, not from anything you type in the app: | Vendor | Environment | | ------------- | ----------------------------------------------- | | Linear | `LINEAR_API_KEY` | | GitHub Issues | `GITHUB_TOKEN` | | Jira | `JIRA_BASE_URL`, `JIRA_EMAIL`, `JIRA_API_TOKEN` | | ClickUp | `CLICKUP_API_TOKEN` | ## What the in-app Linear settings really do Two controls mention a ticketing provider. Neither connects Linear. **Ticketing provider** (onboarding's first step and the URL-only `/workspaces//api-keys` screen) is a dropdown offering `local` and `linear`, with `jira` and `clickup` shown as "(soon)". Picking `linear` stamps `provider: linear` on tickets you create from then on. It records an intent; it starts no sync. **Ticketing API key** (that same screen and **Settings → You → Profile & identity → Accounts**) stores a key in your device's secure store. It is never sent to the server. The server's own ticketing key for an agent comes from `CC_TICKETING_API_KEY` in its environment and is injected as `TICKETING_API_KEY` only when that agent has the **Allow ticketing API calls** capability. ## Work with tickets locally instead Everything the ticketing pillar does — tickets, projects, links, collaborators, delegation, agent assignment, pipeline coupling — works against the local provider with no external service. See [Create and manage tickets](/manual/guides/manage-tickets/). Control Center's ticket data is primary in the sync design anyway: a vendor connection mirrors it out and pulls changes back, rather than the vendor owning the record. ## Things to know if sync is enabled out of band - **Deleting a ticket is local only.** No adapter deletes on the remote tracker, so a vendor-backed ticket can reappear on the next pull - **`tickets.patch` accepts only four fields** — title, description, priority, labels. Status and workflow changes go through the optimistic-locked `tickets.update` - **The transition graph is enforced for agents and automation only.** Every user-driven status change in the UI bypasses it, so a human can move a ticket anywhere, including reopening a terminal one - **`ticket_sync.syncNow` is irreversible** and never enters the undo stack ## Related guides - [Create and manage tickets](/manual/guides/manage-tickets/) - [Set up GitHub integration](/manual/guides/github-integration/) - [Manage API keys](/manual/guides/api-keys/) ## Related concepts - [Tickets and delegation](/manual/concepts/tickets/) ### Link your Slack account > Prove that a Slack identity is yours so agents act on your messages and your work is attributed to you. Source: https://usectrl.dev/manual/guides/link-chat-account/ This guide shows you how to link your chat identity to your Control Center user and how to unlink one. Every member does this once per chat provider. It is separate from connecting the app: the connection makes the bot reachable for the whole workspace, the link makes _you_ recognizable inside it. ## Why linking is required The bridge refuses to act for a chat account it cannot resolve to a workspace member. That is deliberate — without a link, every message from Slack would have to be attributed to whoever installed the app and the audit trail, per-principal rate limits and ticket reporters would all name the wrong person. An unlinked member who mentions the bot gets a reply in that Slack thread explaining how to link. Nothing is dispatched. ## Link with a code 1. Go to **Settings → You → Profile & identity** and scroll to the **Linked chat accounts** card 2. On the Slack block, click **Link my Slack account** 3. Copy the command it shows, for example: ```text /cc link K7M2QP ``` 4. In Slack, send that command to the bot — a DM is the easiest place 5. The bot confirms with the Control Center user you are now linked to and the dialog you left open updates to say so at the same moment — you do not have to close it and look The code: - Is single-use and expires **15 minutes** after it is minted - Is always minted for _you_, never for a teammate - Replaces any code you already hold, so pressing the button again invalidates the previous one - Lives in memory only, so a server restart invalidates outstanding codes — press the button again A code that is unknown, expired, or minted for a different workspace fails the same indistinguishable way: "that code is not valid (or has expired)". ## Automatic linking by email If your Slack profile carries a verified email that matches a Control Center user who is **already a member of this workspace**, the bridge links you the first time you talk to it. No code needed. Both halves matter: the email match proves identity, but only membership grants access, so a stranger whose email happens to exist on the server links to nothing. The roster shows how each link was made ("matched by email" or "linked with a code"). Automatic linking needs the `users:read.email` scope. Without it, Slack will not disclose the address and the code flow is the only path. ## See who is linked The roster is workspace administration, so it lives on the other half of the surface: **Settings → Workspace → General → Chat bridges → Linked accounts**. It lists every linked member in the workspace, with the Slack account each maps to and how the link was made. Your own state is on your profile page: the **Link my Slack account** row reads "Linked as _your-slack-id_" once you are linked and tells you to link when you are not. The roster is a live subscription, because the change happens on the other side — you type the code in Slack, so no request from the app can carry the answer back. ## Unlink Click **Unlink** on a row in the workspace roster. - Anyone can unlink themselves - Only an admin can unlink another member - Unlinking also revokes any outstanding link code for that member After unlinking, that Slack account can no longer drive agents until it links again. Existing spaces, transcripts and tickets keep their original attribution — unlinking is not a retraction of past work. ## Troubleshooting ### The bot says I am not a member of this workspace Your Slack account is linked to a Control Center user, but that user holds no membership in this workspace. Ask an admin to invite you; see [Multiplayer — identity, membership and presence](/manual/concepts/multiplayer/). ### The bot says my role is read-only `viewer` and `guest` roles cannot start work. An admin has to raise your role to `member` or above. ### `/cc link` does nothing Check that the slash command exists in Slack and that you are sending the command the settings dialog showed you — an admin may have renamed it, in which case the dialog shows the current name. ### Slack tells me to go to "Settings → Accounts → Chat bridges" That path no longer exists. The bot's refusal and instruction copy still names the old location. Go to **Settings → You → Profile & identity** instead and use the **Linked chat accounts** card. ## Related guides - [Set up Slack integration](/manual/guides/slack-integration/) - [Customize the chat bot](/manual/guides/customize-chat-bot/) - [Chat bridge](/manual/reference/chat-bridge/) ## Related concepts - [Chat bridges](/manual/concepts/chat-bridges/) - [Multiplayer — identity, membership and presence](/manual/concepts/multiplayer/) ### Manage costs > See what your agents are spending, cap an autonomous run and cut token use. Source: https://usectrl.dev/manual/guides/manage-costs/ This guide shows you how to see what agent runs cost and what you can actually do to bring the number down. :::caution[There is no per-agent budget you can set] An agent row carries `monthlyBudgetCents` and `budgetPolicyId` and a governance service enforces them — but neither field has a UI control, an MCP tool or a dedicated RPC op and there are no RPC ops for budget-policy rows at all. There is also no surface that lists budget incidents. Nothing on this page asks you to set a budget, because you cannot. What you _can_ cap is an autonomous run; see [Cap an autonomous run](#cap-an-autonomous-run). ::: ## How costs are recorded Every agent run writes a run log carrying its input tokens, output tokens, reasoning tokens, cached read and cached write tokens, an estimated cost in cents and the cost of any subagents it spawned. Pricing comes from the models.dev catalogue, which is used only to enrich the model list with price and context window — never as a source of model ids. A model the catalogue cannot price records **$0** and zero-cost runs are excluded from the usage summary. A local Ollama model or a flat-rate plan such as kimi-code therefore shows no spend at all, though its token counts are still recorded. Under a fallback chain the run is priced against the model that actually served it, not the one requested. ## See what you are spending ### Observability → Insights Reach it from the sidebar footer → **Observability**. The **Insights** tab opens by default and is the main cost surface for the workspace: - A time-range picker: last 24 hours, last 7 days, last 30 days, or all time - Faceted filters over agent, model, status and role, as dismissible chips - A KPI strip: total runs, total cost, error rate, cache rate, tokens per second, average latency and time to first token, each with a change against the previous period (hidden on "All time") - Activity and cost charts, plus a **cost by role** breakdown splitting spend across the main agent, its subagents and its advisor - A per-agent table sorted by cost, top ten with a show-all toggle. Tapping a row filters every other surface to that agent - The granular run log, which docks a transcript drawer for a selected run :::note["All time" is not all time] The run feed subscribes to the 2000 most recent run logs across every workspace, then filters to the active one. On a busy multi-workspace server, older history for a quiet workspace falls out of that window. ::: ### Settings → Server → Model providers The **Usage & cost** card on this page shows the last seven days of observed spend as a single dollar figure, with a per-provider breakdown and, when a provider publishes one, the time until the next quota window resets. ### A single agent's runs **Settings → Workspace → Agents** → select an agent → **Logs** lists its runs with status, duration, start and completion times, PID and adapter. This tab does **not** show cost — use the per-agent table in Observability → Insights for that. ## Cap an autonomous run Two slash commands, typed in a space's composer, are the only runs with a spend cap: ``` /goal ship the auth refactor --budget 20 /loop keep the flaky tests green ``` - Each segment of a `/goal` or `/loop` run stops when its priced spend reaches **500 cents ($5.00)**. At 80% of that the loop first steers the model to wrap up and leave a clean handoff, so it does not die mid-task. - A whole durable goal is capped at **5000 cents ($50.00)** unless you pass `--budget `. The supervisor threads the goal's _remaining_ budget into each segment, so an explicit budget is never overshot by a whole segment. - `--days N` adds a wall-clock deadline and `--max N` a run-count ceiling. Both are opt-in; without them the goal is bounded only by cost. - A goal that hits its wall stops at `budgetExhausted` and can only be resumed by raising the budget. Because the cap is priced, a model the catalogue cannot price never reaches it. Such a run is bounded instead by the doom-loop repetition guard, the supervisor's give-up after three consecutive failures and you. :::caution[A normal turn has no cost cap] Every other run — a message in a space, an @-mention, a pipeline step — is unbounded. There is no turn ceiling in the harness either. A run ends when the model stops, when you stop it, or when the doom-loop guard steers it. ::: ## Cut token use ### Compact a long conversation Type `/compact` in the space's composer. The command is intercepted client-side and never lands in the transcript; the server folds older history into an anchored summary and the conversation continues on the compacted context. Compaction is refused while an agent is mid-turn and when there is nothing old enough to fold. The built-in harness also compacts on its own as history approaches the model's context window. ### Choose the effort level deliberately An agent's **Reasoning effort** (Settings → Workspace → Agents) offers whatever levels the selected model publishes. Higher levels spend more reasoning tokens. Reserve the top levels for work that needs them. ### Choose the model per agent Model is a per-agent field, so a formatting or triage agent can run on a cheap model while an architecture agent runs on an expensive one. Filter the per-agent table in Observability → Insights by model to see which pairing is actually costing you. ### Look for runs with outsized token counts Sort the Insights run log and open the transcript drawer on anything unusual. The common causes are an over-long system prompt or persona, context that should have been compacted and a task that should have been several smaller ones. ## What happens if a budget is set out of band For completeness, if `monthlyBudgetCents` or a budget policy is written directly to an agent row: - Budgets are evaluated **after** a run completes, not before dispatch. A listener on run completion compares the agent's month-to-date spend against its ceiling. - Crossing the soft threshold (80% by default) records a warning incident and fires a `BudgetThresholdCrossed` event you can hang a pipeline trigger on. - Exhausting the budget records a hard incident, fires the same event and flips the agent's lifecycle status to `paused`. - The pre-dispatch block exists but is consulted only by the orphan-run reaper and the stranded-ticket reconciler when scheduling recovery — a normal dispatch never reaches it and nothing on the dispatch path checks the paused status. The only visible signal is the lifecycle badge in the org chart. ## Related guides - [Create and configure an agent](/manual/guides/create-agent/) - [Manage adapters and models](/manual/guides/adapters/) - [Run agent diagnostics](/manual/guides/agent-diagnostics/) - [Agent configuration reference](/manual/reference/agent-configuration/) ## Related concepts - [Agent dispatch lifecycle](/manual/concepts/dispatch-lifecycle/) ### Manage workspace memory > Review, correct and prune the facts and policies agents have written into a workspace. Source: https://usectrl.dev/manual/guides/manage-memory/ This guide shows you how to work with a workspace's knowledge store. The important thing to know before you start: **the memory UI is a review surface, not an authoring one.** Facts and policies are written by agents — through the `propose_fact`, `propose_policy`, `supersede_fact`, `supersede_policy` and `remember` tools, plus automatic harvesting from completed runs. What you do here is read what they wrote, correct it, deactivate it, or delete it. Memory lives at **Settings → Workspace → Memory** (`/settings/memory`). It is not a top-level sidebar destination. ## The three tabs The page has exactly three tabs, each showing a live count: - **Facts** — every fact in the workspace, with a count of the ones that are not superseded - **Policies** — normative rules grouped by domain, with a count of the active ones - **Knowledge graph** — an interactive canvas of domains, topics, facts and policies Domains are not a thing you create. A domain comes into existence when an agent writes the first fact or policy into it; there is no domain-creation control anywhere in the UI. ## Review and correct a fact 1. Open the **Facts** tab. 2. Use the search box to narrow the list. It is a plain substring filter over each fact's domain, topic and content — no keyword index, no ranking. 3. Use the sort toggle to order by recency or by confidence. 4. Click a fact's edit control. The edit dialog lets you change: - **Domain** — moving a fact between domains - **Topic** — its short label - **Content** — the knowledge text - **Confidence** — a slider in 5% steps Save to write it back. There is no **Add fact** button; the dialog only opens on an existing row. ## See superseded facts The Facts tab has a **Show superseded** switch. Superseded facts are hidden by default and revealed by that switch, which is where the audit trail lives — superseding never deletes. ## Delete a fact Use a fact's delete control and confirm. This is a real delete, not a supersede: use it for facts that were simply wrong to record and let agents supersede facts whose _content_ has moved on. ## Manage policies The **Policies** tab groups active policies by domain and demotes inactive ones to a trailing section. Per policy you can: - **Edit** it - **Delete** it - **Deactivate** / **Activate** it — the reversible option and usually the right one. Only active policies reach agent prompts. As with facts, there is no create control. ## Explore the knowledge graph The **Knowledge graph** tab lays domains, topics, facts and policies out as a navigable node canvas — pan, zoom, drag nodes. Tapping a node opens its detail sheet, from which the same edit dialogs are reachable. It is the fastest way to see which domains are actually populated. ## Edit an agent's working memory Working memory is a private scratchpad per agent, injected into its prompt at dispatch time. Agents write it themselves with `record_observation` and `update_my_notes`, but you can read and edit it directly: **Settings → Workspace → Agents** → select the agent → the **Memory** tab. ## Access grants Memory access grants map an agent's role to a permission (`none`, `read`, `write`) per domain. :::caution[Grants have no user interface] There is no screen for access grants — the memory screen has Facts, Policies and Graph tabs and nothing else. The repository is bound on the client, but no widget reads it, so grants are reachable only over the `memory_access_grant.*` RPC ops. You cannot restrict who reads a memory domain from the app today. ::: Two things to understand about how they resolve, whichever way a grant is written: - **Reads fail open.** With no grant row for a (role, domain) pair, the effective permission is `read`. Only writes are hard-enforced and a write without `write` permission throws. Memory is not read-denied by default. - **Grants are keyed on the agent's role.** Agents created through the app have no role — the agent form has no role field — so role-keyed grants do not apply to them. ## Search is not the same everywhere Three different things are called "memory search" and they do not agree: | Where | What it does | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------ | | The Facts tab's search box | Substring filter over the facts already loaded in the client | | The `memory_fact.search` RPC op | FTS5 keyword search only — a thin client cannot ship a query embedding, so hybrid search stays host-internal | | The agent-side `search_memory` tool | `keyword`, `semantic` or `hybrid` (the default), fusing lexical and vector recall | So an agent and the Memory page will not rank the same query the same way. When you are checking what an agent can actually recall, ask the agent. ## Embeddings Facts are embedded **at write time** and only when the on-device embedding model is installed. Check its status under **Settings → Server → Diagnostics & privacy → Semantic search**. :::caution[There is no retroactive backfill] The embedding backfill job is a stub that always reports zero. Facts written before the model was installed stay keyword-only permanently. If semantic recall matters, install the model before agents start writing memory. ::: ## Related guides - [Create and configure an agent](/manual/guides/create-agent/) - [Use the MCP server](/manual/guides/mcp-server/) ## Related concepts - [Memory and knowledge](/manual/concepts/memory-knowledge/) - [Workspaces and isolation](/manual/concepts/workspaces/) ### Install and manage skills > Add GitHub repositories as skill sources, browse their catalogs, and install, update or uninstall skills through the antivirus scan gate. Source: https://usectrl.dev/manual/guides/manage-skills/ This guide shows you how to fill a workspace with skills from GitHub: add a repository as a source, install the skills you want from it, and keep them current over time. The important thing to know before you start: **a source repository is untrusted.** Its name, description and star count are written by whoever owns it. The only safety signal Control Center trusts is the antivirus scan verdict, which every install passes through before anything reaches disk or an agent prompt. If you want the reasoning, read the [agent model's skills section](/manual/concepts/agent-model/); this page stays on the doing. Skills live at **Settings → Workspace → Skills** (`/settings/workspace/skills`). They are workspace-scoped: the skills you install here belong to this workspace only. ## Add a skill repository 1. Open **Settings → Workspace → Skills** and switch to the **Sources** tab. 2. Press **Add repository**. 3. Paste a GitHub repository URL, e.g. `https://github.com/anthropics/skills`, and confirm. The server checks that the repository exists and is visible with its credentials, then adds it to the left rail. There is no curated store in the middle: any GitHub repository works, which also means reputation is yours to judge — a star count is evidence, never a guarantee. A repository's skills are the directories that contain a `SKILL.md` (up to three levels deep, plus the Claude plugin-marketplace layout `plugins//skills/`, with dependency and vendor trees skipped). If the grid later says the repository has no skills, that is what it means: no directory in it matches the skill convention. ## Inspect a skill before installing Select a repository in the rail; its skills appear as a grid on the right, with a filter field above it that narrows the catalog by name, description or slug. Click a card to open the detail view. It shows three things: - **The README**, rendered as markdown — the skill directory's `README.md`, or the `SKILL.md` body when there is no README. - **The scan preview** — the verdict (`pass`, `warn` or `quarantine`), the capabilities the skill's content declares (runs shell, writes files, network access…) and every finding the scanner recorded. - **The file count** — an install writes the skill's whole directory, not just its `SKILL.md`. The preview scans the exact bytes an install would write, so the verdict you see is the verdict the install gets; nothing is re-judged behind your back. ## Install a skill 1. Open the skill's detail view. 2. Read the scan verdict. For a `warn` or `pass` verdict, press **Install**. 3. For a `quarantine` verdict, install stays disabled until you tick **I understand the risk — install anyway**. That override is recorded, not assumed. The install writes the skill's directory into the workspace, records a pin in `skills-lock.json` (source repository, path, the commit it came from and a content hash), and the skill becomes available to agents in the workspace. A card marked **Name in use** means a different skill already owns that name locally — uninstall that one first. ## Update skills over time Cards for installed skills carry badges: **Installed**, or **Update available** when the repository has moved past the commit you pinned. Open the detail view and press **Update** to re-fetch at the new commit. An update passes through the same scan gate as an install — a skill whose new version quarantines is refused and the version you had stays put. The old version's hash is kept as a rollback reference in the lock. ## Uninstall a skill, remove a repository **Uninstall** (on an installed skill's detail view, or delete in the Installed tab) removes the skill's directory and its lock pin together — an uninstalled skill leaves nothing behind. **Removing a repository** from the rail removes only the catalog: skills you installed from it stay installed and still update, because a pin records the repository by name rather than by reference to the source entry. Add the repository back if you want to browse it again. ### Create and manage tickets > Create tickets, move them through their lifecycle, label and link them and record who owns what. Source: https://usectrl.dev/manual/guides/manage-tickets/ This guide shows you how to create and manage tickets. ## Create a ticket ### From the UI 1. Click **Tickets** in the workspace sidebar. 2. Press **New ticket**. 3. Fill in: - **Title** — short summary. It is the only required field. - **Description** — markdown, with a formatting toolbar. - **Status** — **Open** (the default) or **Backlog**. Started and terminal states are reached through the lifecycle, not at creation. - **Priority** — none, urgent, high, medium or low. - **Assignee** — yourself or any agent in the workspace. - **Project** — optional grouping. 4. Press **Create**, or turn on **Create more** first to keep the dialog open for rapid entry. There is no provider picker in the dialog. The ticket is created against the workspace's active ticket provider. ### From an agent Ask an agent in a space: > Create a ticket for fixing the login timeout issue on slow connections. It calls the `create_ticket` MCP tool. Priority on that tool is an **integer**, not a name: ``` create_ticket( workspace_id: "...", title: "Fix login timeout on slow connections", description: "...", priority: 2 ) ``` | Value | Priority | | ----- | -------- | | `0` | None | | `1` | Urgent | | `2` | High | | `3` | Medium | | `4` | Low | Only `workspace_id` and `title` are required; `description`, `priority`, `assignee` (an agent id) and `team_id` are optional. The tool is classed as a vendor sync write and asks for your approval before it runs. ## Update ticket status Tickets follow this lifecycle: ``` backlog → open → inProgress ⇄ blocked ↓ ↑ inReview ↓ done / failed (terminal) ``` `done`, `failed` and `cancelled` are terminal. `cancelled` is reachable from any non-terminal status. `done` and `failed` are reachable from `inProgress` or `inReview`; `open` can also move directly to `blocked`. To change it, use any of: - the **Status** row in the ticket's properties rail, - the **Status** submenu on a ticket's right-click menu in the list, - the **Status** chip on the floating bar after multi-selecting tickets. :::note[The lifecycle graph binds agents, not you] Every status change made from the UI is forced through, bypassing the graph entirely — you can move a ticket to any status, including reopening a `done`, `failed` or `cancelled` one. The diagram above governs agents and automation: an illegal transition attempted by an MCP tool or a reconciler is logged and ignored, not applied. ::: Status changes emit domain events (`TicketStarted`, `TicketCompleted` and others) that can trigger pipelines. See [Set up pipeline triggers](/manual/guides/pipeline-triggers/). ## Assign a ticket 1. Open the ticket. 2. In the properties rail, set **Assignee** — yourself, another workspace member, or an agent. :::caution[Assigning does not start any work] Assignment records ownership and nothing else. It creates no space, changes no status and dispatches no agent — `TicketAssigned` is a pure audit signal with no dispatcher behind it. To get an agent working, message it in a space (see [Chat with an agent](/manual/guides/chat-with-agent/)). The one exception: `TicketAssigned` is an event type you can wire a pipeline trigger to, so a template of your own can react to an assignment. ::: ## Add labels Labels are applied in bulk from the ticket list, not from a single ticket — the properties rail has no label control. 1. Hover a row in the ticket list and tick its checkbox. Tick as many as you need; a floating bar appears once anything is selected. 2. In that bar, open the **Labels** chip. 3. Pick a label. It is added to every selected ticket that lacks it; existing labels are never wiped. **Clear labels** removes all of them. :::caution[You cannot create a label from the app] The picker offers only labels that already exist on some ticket in the workspace and reads "No labels yet" when none do. There is no field anywhere in the UI to type a new label name. Agents can introduce one by setting `labels` through the `tickets.patch` operation. ::: ## Link tickets Relations live on the ticket's **Relations** card in the properties rail, or behind **Relate to** in the list's right-click menu. 1. Open the ticket and find the **Relations** card. 2. Press its add control and choose the relation: | Relation | Meaning | | ----------------- | --------------------------------------- | | **Sub-issue of…** | This ticket's parent is the other one | | **Parent of…** | The other ticket is a child of this one | | **Blocked by…** | This ticket is blocked by the other | | **Blocking…** | This ticket blocks the other | | **Related to…** | Symmetric, no direction implied | | **Duplicate of…** | This ticket duplicates the other | 3. Pick the target ticket. **Duplicated by** appears in the card but not in the picker — it is the inverse of **Duplicate of** and is set from the other ticket. Each listed relation is clickable to open the linked ticket and removable on hover. Parent and sub-issue relations are stored on the ticket itself rather than as link rows, so deleting a parent cascades to its children (see below), while blocking and related links do not. ## Add collaborators Collaborators are a roster on the ticket — who is involved, in what capacity. 1. Open the ticket. 2. Press the person-plus control on the **Add collaborator** card. 3. Pick an agent. The three roles are **assignee**, **collaborator** (the default) and **reviewer**. Collaborators can be human users as well as agents. Adding a collaborator writes a row and publishes an event; it grants no space access. To put an agent into a conversation, add it as a participant from the space header instead — see [Spaces](/manual/guides/spaces/). ## Delete a ticket Use **Delete ticket** at the bottom of the properties rail, the right-click menu, or the floating bar's delete action after multi-selecting. :::caution[Deletion is local only] A ticket that came from an external tracker is not deleted there — no provider supports it — so it can reappear on the next sync. Deleting also cascades: the ticket's collaborators and any child tickets go with it. ::: ## Related guides - [Delegate work to agents](/manual/guides/delegate-tickets/) - [Organize work with projects](/manual/guides/projects/) - [Chat with an agent](/manual/guides/chat-with-agent/) - [Ticket lifecycle reference](/manual/reference/ticket-lifecycle/) ## Related concepts - [Tickets](/manual/concepts/tickets/) - [Domain events](/manual/concepts/domain-events/) ### Use the MCP server > Point an external MCP client at Control Center's tool server, set a bearer token and know what the surface exposes. Source: https://usectrl.dev/manual/guides/mcp-server/ This guide shows you how to connect an external MCP client — Claude Code, an editor, the MCP Inspector — to Control Center's own tool server. Control Center registers **109** tools and serves them over the Model Context Protocol at protocol version `2024-11-05`. Whole tool families you might expect are absent: there are no meeting or calendar tools and no project tools. ## Find the endpoint The MCP surface shares the main `cc_server` listener. There is no separate MCP port. | Endpoint | Method | Purpose | | -------- | -------- | -------------------------------------------------------- | | `/mcp` | `POST` | Streamable HTTP transport — this is what a client speaks | | `/mcp` | `DELETE` | End a session | | `/sse` | `GET` | Server-sent notification stream | So a default local install is at `http://127.0.0.1:9030/mcp`. 1. Go to **Settings → Server → MCP servers** 2. Read the **MCP server** card — it shows running or stopped and the port it is listening on The surface is on by default on a fresh install, so a loopback client works with no settings trip. **Start on app launch** controls whether it comes back after a restart; **Start** / **Stop** are session controls that do not change that preference. The server binds loopback by default, so the endpoint is only reachable from the machine `cc_server` runs on unless you started it with `--bind any`. ## Connect a client Point your client at the Streamable HTTP endpoint. The exact configuration shape is your client's business; the URL is the same either way. ```json { "mcpServers": { "control-center": { "type": "http", "url": "http://127.0.0.1:9030/mcp" } } } ``` ### Claude Code Claude Code will not pick this up from a project `.mcp.json` — it gates project-scoped MCP servers behind an approval prompt that a non-interactive `claude -p` never answers. Pass the config explicitly: ```bash claude --mcp-config /path/to/mcp.json --strict-mcp-config ``` `--strict-mcp-config` makes Claude use only that file, so the same server is not also discovered from the project and registered twice. Checkpoint: ask the client to list its tools. You should see `mcp__*` entries covering tickets, spaces, memory, code graph and review. ## Serve MCP beyond the host 1. On **Settings → Server → MCP servers**, on the **Authentication token** row, press **Set** and paste a token 2. Send it as `Authorization: Bearer ` on every `POST /mcp` request A tokenless surface refuses any non-loopback caller with 403. That is fail-closed by design: the token is what makes off-host service possible at all. Token changes apply to the live surface immediately — no restart. :::note[`GET /sse` takes the token in the query string] The notification stream is token-checked like every other route, but a browser `EventSource` cannot send an `Authorization` header — so `/sse` accepts the token as `?token=` as well. Without one it answers 401. It used to skip the check entirely, which left an unauthenticated, uncapped, long-lived GET open on any non-loopback mount; that hole is closed. ::: ## Scope every call to a workspace Every tool that touches workspace-scoped data requires `workspace_id` and a call without it is refused rather than resolved against some "current" workspace: ```json { "workspace_id": "acme", "title": "Fix login bug", "priority": "high" } ``` The id is the one in your browser or app URL: `/workspaces//…`. The only exemptions are tools that genuinely span workspaces, such as `list_workspaces` and the newsfeed tools — the newsfeed is per-user and an agent has no user of its own, so those tools operate on the server owner's feeds. Repo-scoped tools additionally check that the repo is linked to that workspace. ## Know what the surface will refuse - **Mode gating.** A call is resolved against the mode of the calling agent's space, server-side. An agent in a read-only mode cannot reach a write tool by omitting `space_id` — the mode is looked up from its active run instead. - **Action guardrails.** Mutating tools declare their effect classes and go through the same policy the rest of the product uses. See [Configure guardrails](/manual/guides/configure-guardrails/). - **Confirmation.** Some destructive tools build a confirmation payload that has to be approved before the call proceeds. The full catalogue is advertised in `tools/list` with no discovery gating — an external MCP client refuses to call a tool that is not in its cached list, so hiding tools breaks them rather than tidying them. ## Troubleshooting ### The client sees no tools - Check the **MCP server** card says running - Check the port matches your `cc_server` port - On Claude Code, check you passed `--mcp-config` and `--strict-mcp-config` ### 403 from a machine that is not the server No bearer token is configured. Set one and send it. ### A tool call fails with "Missing or invalid argument: workspace_id" Add `workspace_id`. There is no implicit workspace. ### A tool you read about is not there A tool class no longer sits on disk unregistered — every one that exists is wired, and a ratchet test fails the build otherwise. What you may have read about is a tool that was **deleted**: `create_workspace`, the project tools, `hire_agent`, `fire_agent`, `doctor` and `ask_user_question` are all gone and return "unknown tool". [MCP tools](/manual/reference/mcp-tools/) lists the catalogue and what has no tool behind it. ## Related guides - [MCP tools](/manual/reference/mcp-tools/) - [Configure guardrails](/manual/guides/configure-guardrails/) - [Run a headless server](/manual/guides/run-headless-server/) ## Related concepts - [Architecture](/manual/concepts/architecture/) ### @-mention agents > Mention agents in a space to dispatch them and teammates to notify them. Source: https://usectrl.dev/manual/guides/mention-agents/ This guide shows you how to @-mention agents and teammates in a space. ## Mention an agent Type `@` in the composer and pick an agent from the autocomplete, or type the name yourself: ``` @alice fix the login bug ``` The mentioned agent is added to the space as a participant if it is not already one and dispatched with the space's context and mode. Unmentioned participants see the message but do not dispatch. If you send a message with no agent mention, Control Center picks a responder itself: the agent that last spoke in the conversation, otherwise the first top-level agent among the space's participants. In a one-agent space that is always the sole agent. ## Know how a name is matched A mention token is matched **case-insensitively**, by **exact name or prefix**. So `@arch` reaches an agent named `architect` and `@Alice` reaches `alice`. Two consequences worth planning around: - **A short token is not an error.** If two agents share a prefix, the first one found wins and which one that is depends on ordering — not on any tie-break rule. Use the full name whenever names overlap. - **Only letters, digits and underscores are part of a token.** The parser matches `@(\w+)`, so `@code-reviewer` is read as the token `code` and `@my agent` as `my`. An agent whose name contains a hyphen, a dot or a space cannot be mentioned reliably — including through the autocomplete, which inserts the name as plain text. Name agents in one word, or with underscores. Mentions are stripped from the text before it reaches the agent. A message that is _only_ mentions (`@alice`) therefore dispatches nothing — give it something to do. ## Mention several agents at once ``` @alice refactor the auth module. @bob write tests for it. ``` Both agents dispatch in parallel into the same conversation and both receive the full message. They also **share that space's worktrees**. Repo worktrees are provisioned per space, not per agent; each agent gets its own overlay working directory for its `AGENTS.md`, skills and config, with the shared repos linked in read-write. So two agents mentioned together can see — and collide with — each other's edits. ## Mention a teammate Any `@token` that does not match an agent is tried against the workspace's human members, by handle, with the same case-insensitive exact-or-prefix rule. A resolved human mention **dispatches nothing**. It rides on the message's metadata purely so the notification router can ping that person. Agent names are always tried first and a token claimed by an agent is never offered to the member list — one `@word` can only ever resolve to one principal. ## What the agent receives A dispatched agent gets a "Summons" block in its prompt naming who summoned it and listing the space roster — the other agents (marked top-level or subordinate) and the human members — so it knows who it can hand off to. The block also tells it that mentioning another agent in its reply wakes that agent and that mentioning itself does nothing. ## When an agent mentions an agent An agent can hand off in its own reply. When a turn **finishes cleanly**, the mentions in its text are resolved and those agents are added to the space and dispatched into the same conversation, with the mentioning turn as their prompt. A turn that failed or was interrupted wakes nobody — it stopped mid-thought. The rules are deliberately stricter than the composer's, because nobody is watching to correct a wrong guess: - **Exact names only.** No prefix match. `@arch` reaches `architect` when a human types it; from an agent it reaches nobody. A name matching two agents also reaches nobody. - **Code is not prose.** Mentions inside fenced blocks or `` `backticks` `` are ignored, so quoting a `@Override` annotation or a `pip install foo@1.2` is safe. An unterminated fence blanks everything after it. - **An `@` glued to the previous token is not a mention** — `sam@host.com`, `pkg/@scope` and `node@20` resolve to nobody. - **Hyphens are kept**, so `@code-reviewer` resolves as itself rather than as the token `code`. - **At most three agents per turn.** An agent listing six teammates in a summary is listing them, not summoning them. A wake chain is bounded the same way delegation is: **depth 3**, and an agent already on the chain cannot be woken again by it (no `A → B → A`). An ordered pair is also rate-limited across chains. A refused wake posts a system message into the conversation saying which mention was not delivered and why — an unresolved name does not, since any `@` in a sentence would otherwise narrate itself. For a handoff that must not depend on prose — a request/reply, or reaching an agent outside the space — use the `send_to_agent`, `ask_agent` and `delegate_task` tools instead. ## Related guides - [Chat with an agent](/manual/guides/chat-with-agent/) - [Use spaces](/manual/guides/spaces/) - [Run agents in parallel](/manual/guides/parallel-agents/) ## Related concepts - [Dispatch lifecycle](/manual/concepts/dispatch-lifecycle/) ### Monitor pipeline runs > Work the runs queue, read a run's waterfall and step panel and recover from a failed or stuck step. Source: https://usectrl.dev/manual/guides/monitor-pipelines/ This is the page for everything after a run has started: finding it, reading what each step did and getting a failed run moving again. To start one, see [Run a pipeline manually](/manual/guides/run-pipeline/). ## Find a run 1. Click **Pipelines** in the workspace sidebar. The screen is the workspace's run queue — every run, newest first. 2. Use the left rail to filter. There are exactly three filters, each with a live count: **All**, **Running**, **Failed**. 3. Walk the table with / (or j/k) and press Enter to open the focused run. Clicking a row does the same. Each row shows a glyph for how the run started (**Manual** or **Automatic**), the pipeline name, a status badge, the active duration and how long ago it started. Active duration excludes idle stop-and-restart gaps, so it is not the wall-clock span. There is no filter for completed, suspended or cancelled runs, no per-template filter, no time window and no per-template run history view. Comparing runs of one template means scanning the queue for its name. ## Read the run detail A run's page has three stacked parts. **The meta strip** carries the start time, active duration, step progress ("3 of 7 steps"), a failure summary naming the step that broke and why and the **Retry** and **Delete** actions. **The timing waterfall** gives one bar per step, with its duration and — for agent steps — its cost in cents. A step that was retried shows a `×N` suffix after its duration; that suffix is the only place attempt counts surface. **The graph canvas** draws the template with each node's live status. Selecting a node opens its step panel. ### Run and step statuses | Status | Meaning | | ------------- | ------------------------------------------------- | | **Pending** | Created, not yet started | | **Running** | Executing | | **Suspended** | Waiting on something external — normal, not stuck | | **Completed** | Finished successfully | | **Failed** | Ended with an error | | **Cancelled** | Stopped by a user or by the system | Steps have those six plus **Skipped**, for a branch a router did not take (and its now-unreachable descendants). **Suspended** is where an agent step spends most of its life: a dispatched agent or an approval gate holds the step until its work lands. Two engine backstops fail the whole run rather than resuming: a step suspended longer than 24 hours (approval gates are exempt) and a non-idempotent step that was mid-flight when the server stopped. ## Inspect a step Select a node on the canvas. The step panel shows, in order: - An **Error** callout when the step failed, or a **Skipped** callout with the reason when it was skipped. - **Started**, **Finished** and duration. - **Branch** — the branch index, for parallel branches. - **View conversation** — jumps to the conversation the step ran in. Pipeline conversations are hidden from the sidebar, so this is the only way in. - **Input** and **Output**, as collapsible JSON. - **Agent activity** — one tile per agent run the step dispatched, with its status, summary, duration and cost. For an agent step, **Output** is the structured payload the agent submitted with `submit_output`, not its prose. The reasoning trace lives in **Agent activity** and in the conversation behind **View conversation**. For a bash step, **Output** is the trimmed stdout the script wrote on exit 0. A non-zero exit fails the step with the stderr tail as the error message. ## Recover a failed run 1. Read the failure summary on the meta strip; it names the failing step. The step panel's **Error** callout has the message. 2. Check the step's **Input** — a missing or misspelled state key is the most common cause. A prompt-agent step fails outright and names the unresolved placeholders; a bash step renders them as empty strings and runs anyway, which usually surfaces one step later. 3. For an agent step, open **View conversation** and read what the agent actually did. 4. Press **Retry** on the meta strip. Completed and skipped steps keep their outputs; everything else is deleted and re-executed and the run flips back to running. The duration clock restarts for this attempt. Common causes worth checking first: - **Timeout** — the step exceeded its **Timeout (ms)**. Remember the field is milliseconds. - **Script error** — the bash command exited non-zero, or `bash` is not on the server host's `PATH`. - **Schema validation** — the agent's `submit_output` payload did not satisfy the step's output schema. - **Unresolved placeholder** — a `{{key}}` a prompt-agent step referenced was never written upstream. The error names every unresolved key. - **Unregistered body** — a step whose body key is not registered warns at template load and then simply never executes. :::caution[Retry only works on a failed or cancelled run] The **Retry** button is also shown on completed runs, but the engine returns immediately unless the run's status is `failed` or `cancelled`. Pressing it on a completed run does nothing at all, with no message. ::: ## Stop a step that is stuck Select the running step and press the **Kill running** button in the step panel's header. This kills the step's process, marks the step failed and therefore fails the whole run — every open sibling branch is closed out with it. There is no cancel-run control on this screen. `pipeline.cancel` exists as an RPC operation and the engine implements it, but nothing in the pipelines UI calls it; **Kill running** on a step, or **Delete** on the run, are the only stop gestures available. ## Track cost Cost is reported per step, in the waterfall's right-hand column and on each tile under **Agent activity**. There is no run total on the detail page and no cost column in the runs queue, so comparing the cost of two runs means adding up their steps. ## Related guides - [Run a pipeline manually](/manual/guides/run-pipeline/) - [Create a pipeline template](/manual/guides/create-pipeline/) - [Manage costs](/manual/guides/manage-costs/) - [Pipeline steps reference](/manual/reference/pipeline-steps/) ## Related concepts - [Pipelines](/manual/concepts/pipelines/) ### Curate your newsfeed > Add and manage RSS/Atom feeds, read articles with content blocking and know that your feeds follow you across every workspace. Source: https://usectrl.dev/manual/guides/newsfeed/ This guide shows you how to shape the newsfeed: which sources it pulls, how articles are read and what content blocking does. :::caution[The newsfeed is per-user, not workspace-scoped] Feeds and articles belong to the signed-in user: your feed list, read state and bookmarks follow you into every workspace and to every device you sign in from, and no other user sees them. Feeds live in the server's global database, so the `/newsfeed` URL carries a workspace id only because every in-app route does. ::: ## Read articles 1. Click **Newsfeed** in the sidebar 2. Switch between **All**, **Unread** and **Saved** with the toolbar segments 3. Narrow the list with the search box or the source picker 4. Toggle between the magazine grid and the dense list with the layout buttons Opening an article marks it read. Use **Mark all as read** in the page header — it appears only while something is unread — to clear the backlog in one go and the bookmark control on a card to save one for later. The layout choice persists across launches. ## Add a feed 1. Open **Settings → You → Newsfeed** (the settings icon in the newsfeed header goes straight there) 2. Press **Add feed** 3. Fill in a **name** and the **feed URL** (RSS or Atom) 4. Optionally expand **Advanced** and set a custom **User-Agent** — some publishers serve a different response to a default client 5. Press **Add feed** Checkpoint: the feed appears in the **Feeds** list and its articles land after the first fetch. A URL that is already subscribed is refused with "feed already exists". Thirteen feeds are seeded for every user on first use: The Verge, TechCrunch, Hacker News, ByteByteGo, Product Hunt, TLDR AI, Import AI, The Pragmatic Engineer, Martin Fowler, Will Larson's Irrational Exuberance, The Engineering Manager, Lea Verou and CSS-Tricks. ## Manage feeds Each row in the **Feeds** list gives you three controls: | Control | Effect | | ------------ | ------------------------------------------------------------------- | | Switch | Disable the feed. A disabled row is dimmed and is no longer fetched | | Refresh icon | Fetch this one feed now | | Trash icon | Delete the feed, after a confirmation | **Refresh all** at the top of the list fetches every enabled feed. A feed's subtitle shows its description and how long ago it was fetched; a feed that failed shows the error in its place, in warning colour. There is no edit affordance — to change a feed's name, URL or user agent, delete it and add it again. Fetching runs entirely on the server: once per user at boot (and on first use for accounts created later), then every 30 minutes. ## Block ads and trackers in the reader 1. In Settings → You → Newsfeed, find **Reader preferences** 2. Leave **Open articles in app** on to use the built-in reader, or turn it off to hand articles to your system browser 3. Leave **Block ads, trackers & cookie banners** on Blocking is on by default. The reader is desktop-only: on the web build the browser _is_ the reader and an article opens in a new tab. Control Center does not reformat the article. It renders the publisher's page in an embedded webview and suppresses ads, trackers and cookie banners using cached filter lists. ### Check or update the filter lists The **Filter lists** card shows how many cookie-hiding, ad-hiding and network-block rules are loaded, plus the tracking parameters it strips and when the lists were last updated. Press **Check for updates** to refresh them. Sources are EasyList, i-dont-care-about-cookies, Fanboy Cookiemonster, several uBlock Origin lists and uBO's tracking-parameter list. Until a download succeeds the app runs on the bundled defaults, which the card labels as never updated. ### Allowlist a site Some sites break under blocking. 1. In Settings → You → Newsfeed, go to **Trusted sites** 2. Press **Add trusted site** and enter the domain Blocking is skipped there. ## Troubleshooting ### A feed I added shows up in another workspace Expected. The newsfeed is per-user, not per-workspace — your feeds follow you everywhere you are signed in. See the caution at the top. ### A feed row shows an error instead of a description The last fetch failed and the row shows the reason verbatim. Press the refresh icon to retry and check the URL still serves RSS or Atom. A publisher that rejects unknown clients may need a custom user agent, which means deleting the feed and re-adding it. ### An article page renders badly Turn **Block ads, trackers & cookie banners** off for that domain via **Trusted sites**, or turn **Open articles in app** off and read it in your browser. ## Related guides - [Configure notifications](/manual/guides/notifications/) - [Triage your inbox](/manual/guides/triage-inbox/) ### Configure notifications > Turn categories on or off, set quiet hours and a sound and know which notifications reach you where. Source: https://usectrl.dev/manual/guides/notifications/ This guide shows you how to configure notifications and what each setting really controls. Notifications arrive on two surfaces. The **notification centre** — the bell in the title bar — reads a durable per-workspace feed on the server. **OS toasts** are a separate, desktop-only path fed by live frames pushed from the server. :::note[The web build shows no OS notifications] The toast pipeline is constructed only by the desktop bootstrap. On `app.usectrl.dev` there are no OS toasts, no notification sounds and no ambient banners — the bell and its history still work, because they read the server feed. The phone client has no notification surface at all. ::: ## Turn categories on or off 1. Go to **Settings → You → Notifications** 2. Leave **Enable notifications** on 3. Toggle the categories you want. There are exactly ten: | Category | Fires when | | ------------------------- | ----------------------------------------------------- | | Agent finished | An agent finished a run | | PR published | An agent published a PR | | PR merged | A PR was merged | | New messages | A message arrived in a space you are not looking at | | Mentioned in pull request | GitHub's notification poll saw you mentioned on a PR | | Review requested | Your review was actually requested on a non-draft PR | | Ticket assigned | A ticket was assigned | | Ticket status changed | A ticket changed status | | Meeting starting soon | A calendar meeting is inside your lead window | | Calendar disconnected | A connected calendar's OAuth token expired | Anything outside this list cannot be notified or filtered — there is no "everything else" category. These preferences follow your user across devices, so a phone and a laptop stay in step. Volume is the deliberate exception and stays per device. ## Set quiet hours 1. On the same page, turn **Quiet hours** on 2. Type a start and an end time in `HH:MM` (the default window is 22:00–08:00) During quiet hours, non-urgent notifications are dropped — not queued and replayed later. Two categories are **urgent** and always deliver, bypassing quiet hours _and_ focus mode: **Meeting starting soon** and **PR published**. A meeting alert you only see afterwards is worthless, which is the reason. ## Choose a sound 1. Scroll to **Notification sound** 2. Pick one of the twelve bundled sounds, or **None** 3. Press the test button to hear it and set the volume with the slider The app plays the sound itself; the OS notification is delivered silently so you never hear it twice. ## Set the meeting lead time 1. Scroll to **Alert lead time** 2. Pick **5**, **10**, **15** or **30** minutes That is how far ahead of a calendar event the **Meeting starting soon** notification fires. ## Know when a notification is suppressed A notification is dropped, silently, when any of these is true: - Notifications are globally off, or its category is off - You are already on the route it would take you to — and, for a message, already in that space - Focus mode is active and the category is not urgent - Quiet hours are active and the category is not urgent Two more suppressions live further upstream, in what the server sends at all: - An un-mentioned message from another **human** never becomes a notification - Agent-to-agent spaces are muted by construction. They never bump an unread badge and never fire a toast Within a space there is no quiet corner: every conversation notifies equally, threads included. Read state is space-scoped, so unread aggregates across a space's conversations rather than being tracked per conversation. :::caution[Two settings that look live but are not] **Delivery schedule** (real-time / every 2 hours / daily digest) is read only by the settings row that draws it. Delivery is always real-time whatever you pick, and the value the dropdown shows by default disagrees with the value actually stored. Ignore it. Focus mode's **Block notifications** switch is persisted but never consulted: suppression is gated on the session being active, so non-urgent notifications are suppressed either way. ::: ## Read and clear the notification centre 1. Click the bell in the title bar The popover shows recent activity, deep-linking each entry to what it is about. Opening it marks everything read and read state follows your user across devices. :::note[Clear all is a watermark, not a delete] **Clear all** stamps a per-user watermark that hides everything older; the rows stay. Separately, each workspace retains only its most recent **200** feed rows, pruned as new ones arrive, while the bell subscribes to the newest **50**. History past that is gone regardless of read state. ::: ## Troubleshooting ### No OS notifications on macOS Authorization is requested natively when the app launches. If you declined it, re-enable Control Center under System Settings → Notifications; there is no in-app prompt to re-trigger it. ### Nothing arrives on the web build Expected — the web build has no OS-notification pipeline. Use the bell. ### The bell badge shows zero but there is clearly new activity Until your identity resolves after connecting, entries render as read to avoid a badge that flashes and then empties. It settles once the session identifies you. ### An agent's run finished but nothing pinged me Check that you were not already on the space it happened in — an active route suppresses its own notification. ## Related guides - [Use focus mode and soundscapes](/manual/guides/focus-mode/) - [Triage your inbox](/manual/guides/triage-inbox/) - [Curate your newsfeed](/manual/guides/newsfeed/) ## Related concepts - [Domain events](/manual/concepts/domain-events/) ### Pair a device > Pair your phone (or a second browser or desktop) with your Control Center server so you can follow the fleet remotely. Source: https://usectrl.dev/manual/guides/pair-a-device/ This guide shows you how to pair a phone with Control Center, so you can read messages and tickets and reply from anywhere. The same flow pairs a second browser or desktop app. See [Remote control and mobile](/manual/concepts/remote-control/) for how the link works and its security model. ## Prerequisites - A desktop or web client connected to your server and a workspace set up. Pairing operations require a full client, so a paired phone can never pair — or revoke — another device. - The server reachable from the device you're pairing. On the same network this just works; from further away the brokered relay carries the traffic. If the panel answers **Not reachable** instead of a QR or credentials, publish a path first — the tunnel opt-in at **Settings → Server → Sharing & remote access**, or the server's `--public-url` (see [Run a headless server](/manual/guides/run-headless-server/)). - For a phone: nothing to install. The QR opens the **Remote** web app ([remote.usectrl.dev](https://remote.usectrl.dev/) by default). **Self-hosting.** Remote and the relay broker are open source in this repo: the phone app (`apps/cc_remote`, a Flutter web PWA you can deploy to Cloudflare Pages via its `wrangler.jsonc`, to the `docker/cc_remote` nginx image, or to any static host with HTTPS — service workers and IndexedDB are secure-context-gated) and the relay (`apps/cc_signaling_server`, a single stateless Dart binary that holds no app data and never sees the pairing secret — it forwards only end-to-end-sealed frames). See their READMEs for build and run steps. ## Step 1: Open the pairing panel Open **Settings → You → Your devices**. The **Your devices** section lists the clients paired to your account on this server. (The server owner sees every device on the server; everyone else sees only their own.) ## Step 2: Pair a new client Click **Pair a new client** in the page header, give it a label (e.g. "My phone"), pick the type — **Web browser**, **Desktop app**, or **Phone** — and click **Pair**. - For a **phone**, a QR code appears. Scan it with the phone's camera, or copy the link and open it in any browser. It points the Remote app at your server and carries the credential. - For a **browser** or **desktop**, copy the shown link (or the device id and pairing key) into the new client instead. The one-tap link is only offered when you are pairing from the web client, since a desktop binary has no web origin to build one from. The phone QR and the link `cc_server pair --client-url` prints on the CLI are **different payloads and are not interchangeable**: the CLI link fills in the web client's connect form, while this QR carries the server's full connection descriptor and identity fingerprint for the Remote PWA. On the phone side, opening a pairing link does not pair silently. Remote shows the server's advertised name and asks you to confirm before it stores anything, so a forged link cannot hijack the app onto someone else's server. :::caution[A scanned QR grants access immediately] There is no pending-then-approve step: a minted device is active from the moment it is created and the connect gate checks only that the credential is active and unexpired. Treat the QR and the pairing key like a password and revoke anything you did not mean to hand out. ::: ## Manage paired devices From **Settings → You → Your devices** you can: - **Rename** a device - **Revoke** a device you no longer trust. Revoking destroys its credential and drops its live session within seconds, so it fails closed on reconnect Revoking is the right move for a lost or replaced phone. It is immediate and irreversible from the device's perspective. Credentials minted from this panel are time-boxed: they expire 30 days after they are issued and the connect gate fails them closed after that. A credential minted with `cc_server pair` on the command line carries **no expiry at all** — revoke it by hand when the machine it was for is retired. ## Switch workspaces from the phone A paired phone can switch between workspaces independently of the desktop. The phone sends the workspace with every call and the server checks your membership and role in that workspace on each one — so the phone reaches only workspaces you are a member of and switching there never changes what the desktop shows. A phone session is additionally restricted to a small allow-list of read-heavy tools: it can list and read tickets, agents, spaces, messages and feed articles and it can update or assign a ticket, send a message in a space and mark an article read or saved. Anything outside that set is refused before it reaches the server's dispatcher. ## Related guides - [Run a headless server](/manual/guides/run-headless-server/): make the server reachable in the first place - [Connect to a remote server](/manual/guides/connect-remote-server/): point a full client at it - [Use spaces](/manual/guides/spaces/): what you'll mostly do from the phone - [Create and manage tickets](/manual/guides/manage-tickets/): triage from the road ## Related concepts - [Remote control and mobile](/manual/concepts/remote-control/) - [Deployment and clients](/manual/concepts/deployment/) ### Run agents in parallel > Dispatch several agents at once and keep their work from colliding. Source: https://usectrl.dev/manual/guides/parallel-agents/ This guide shows you how to run several agents at the same time without them overwriting each other. ## The unit of isolation is the space, not the agent When work starts in a space, Control Center provisions one copy-on-write checkout per registered repo under that space's own root, on a branch derived from the workspace's branch template. Each agent in the space then gets its own overlay working directory — but the overlays **share** that space's `repos/` directory. So: - Two agents in **different spaces** work in different checkouts, on different branches. That is real isolation. - Two agents in the **same space** work in the same checkout, on the same branch. Their overlay paths differ, so the dispatch path lock (which serialises runs only when they request the identical working directory) does not serialise them either. They can write the same files at the same time. The rule that follows: **one space per parallel task.** ## Run tasks in parallel — separate spaces 1. Open a space with agent A and send it a task. 2. Open a second space with agent B and send it a different task. 3. Both dispatch immediately, each in its own space checkout on its own branch. This is the recipe to use whenever the agents will be editing files. ## Work together in one space — several mentions In a single space, @-mention more than one agent: ``` @alice refactor the auth module. @bob review what alice lands. ``` Both agents dispatch in parallel and stream into the same space, tagged by sender. Because they share the space's checkout, use this for collaboration — one writing, one reading, or two agents reasoning about the same tree — not for two independent edits. ## Fan out from a pipeline The built-in `pr_review` template is the shipped example of a fan-out: several reviewer nodes run concurrently off one trigger and a **Consolidate (join)** node folds their findings into one report. Run it from the pipelines screen and every reviewer dispatches in parallel. :::caution[Authoring your own fan-out does not work yet] A step you add downstream in the pipeline editor is not executed: the run completes as soon as the seeded first step reaches the terminal sentinel and the terminal node is filtered out of the editor canvas so it cannot be re-threaded. Parallel branches you draw yourself will not run today. Use the built-in templates, or the space recipes above. ::: :::caution[Assigning a ticket dispatches nothing] Assigning a ticket to an agent records ownership. There is no ticket dispatcher — `TicketAssigned` is an audit signal and no work starts from it. The one exception is a ticket assigned to a _team_, which wakes that team's leader. ::: ## Watch them run **Observability → Live** (sidebar footer → Observability, then the Live tab) shows this workspace's agent roster, re-ticking every second: status (running / idle / parked / aborted), what each one is doing, tokens and cost for its latest run and a kill control for a running process. Selecting a row docks a live transcript drawer. **Settings → Workspace → Agents** shows the same roster with a different, attention-first status vocabulary derived from each agent's run history — running, blocked, failed, idle, never run — sorted so anything needing attention floats to the top. Each agent's **Logs** tab holds its own run history. Both surfaces are scoped to the active workspace. ## Handle conflicts Across spaces, each space is on its own branch, so a collision surfaces as an ordinary Git merge conflict when you merge the second pull request. Resolve it the way you would any merge conflict. Inside one space there is no branch boundary and no lock: the agents are writing the same files live and the loser is simply overwritten. If you find yourself wanting two agents to edit in one space, split the work into two spaces instead. To reduce collisions either way: - Give each agent a distinct scope — different files, different modules. - Use the code graph to check what a change touches before dispatching. See [Search code with the code graph](/manual/guides/code-search/). - Review and merge each agent's pull request promptly. ## Resource considerations Each running agent costs: - A space checkout — negligible on disk while copy-on-write is available, a full `git worktree` otherwise - A process on the server host - Model tokens, which accumulate per agent Costs land in **Observability → Insights**. Note that a normal interactive turn has no spend cap; see [Manage costs](/manual/guides/manage-costs/) for the two commands that do have one. ## Related guides - [Create and configure an agent](/manual/guides/create-agent/) - [Build an agent team](/manual/guides/build-team/) - [Manage costs](/manual/guides/manage-costs/) - [Add repos to a workspace](/manual/guides/add-repos/) ## Related concepts - [Agent dispatch lifecycle](/manual/concepts/dispatch-lifecycle/) - [Sandbox and security](/manual/concepts/sandbox-security/) ### Set up pipeline triggers > Wire a template to start on a domain event, a schedule, or an inbound webhook — from the Trigger node in the template editor. Source: https://usectrl.dev/manual/guides/pipeline-triggers/ A trigger is what starts a pipeline. Every trigger a template has — including the one that makes it manually runnable — lives on the **Trigger** entry node in the template editor. There is no triggers tab and no triggers screen. ## Open the trigger panel 1. Go to **Settings → Workspace → Pipeline templates**. 2. Press the pencil on the template's row to open the editor. 3. Click the **Trigger** node on the canvas. The right panel switches from the normal node form to the trigger panel. The panel has two parts: a **Manual run** switch at the top and an **Automatic triggers** list below it. ## Allow manual runs Turn on the **Manual run** switch to make the template appear in the run launcher. It is not a special case — the switch inserts and deletes an ordinary trigger row whose event type is `manual`. It persists immediately; there is nothing to save. ## Add an automatic trigger 1. Press **Add trigger** under **Automatic triggers**. 2. Pick a **Trigger type**: **On an event**, **On a schedule**, or **Via a webhook**. 3. Fill in that type's fields (below). 4. Press **Add**. The trigger is live the moment you press **Add** — there is no separate enable step. Use the switch on its row to turn it off later, or the bin icon to delete it. :::note[One trigger per event type] `pipeline_triggers` is unique on workspace, event type and template. An event type you have already wired disappears from the picker and once you have used them all the dialog says "All available events are already wired." Every webhook trigger shares the synthetic event type `webhook`, so a template can hold at most one webhook. ::: ### On an event Pick the domain event from the **Event** dropdown. These fourteen carry a payload and can fire: | Picker label | Event | | ------------------------- | -------------------------- | | External PR opened | `ExternalPrDetected` | | PR published | `PullRequestPublished` | | PR status changed | `PullRequestStatusChanged` | | PR merged | `PrMerged` | | Message received | `MessageReceived` | | Ticket assigned | `TicketAssigned` | | Ticket completed | `TicketCompleted` | | Ticket failed | `TicketFailed` | | Ticket cancelled | `TicketCancelled` | | Budget threshold crossed | `BudgetThresholdCrossed` | | Repository added | `RepoAdded` | | `MeetingRecordingStopped` | `MeetingRecordingStopped` | | `SkillUpdated` | `SkillUpdated` | | `SpaceDeleted` | `SpaceDeleted` | The last three have no display label yet, so they show their raw type name. `SpaceDeleted` is what the built-in worktree-cleanup pipeline listens on: it carries the deleted space's id, so the run reclaims exactly that space's worktrees instead of sweeping the whole workspace. :::caution[Two offered events can never fire] `TicketCreated` and `TicketStatusChanged` also appear in the picker, but neither has a payload mapping. The dispatcher gives up before it even looks for matching triggers, so a trigger on either is selectable, saveable and permanently inert. ::: For the payload keys each event carries, see the [domain events reference](/manual/reference/domain-events/). #### Filter an event trigger The only filter the dialog can author is the status chip set and it appears only when the event is **PR status changed**. Tap any of `merged`, `closed`, `approved`, `opened`, `reopened`; the trigger then fires only when the event's `status` matches one of them. Leaving every chip unselected means no filter. There is no free-form JSON filter field. A filter key that the event's payload does not carry never matches, so do not hand-author one against a key you have not confirmed in the domain events reference. ### On a schedule | Field | Description | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Schedule (cron or every:seconds)** | A five-field cron expression such as `0 9 * * 1`, or an interval such as `every:86400`. A bare number is coerced to `every:`. | | **Timezone (optional)** | An IANA zone such as `Europe/Paris`. Defaults to UTC. | | **On missed runs** | What to do about fires missed while the server was down: **Run once** collapses every missed slot into one fire on restart (the default), **Skip** resumes at the next future slot. | The trigger row's summary line only spells out the interval for the `every:` form; a cron expression shows as just "Schedule". ### Via a webhook Choosing **Via a webhook** takes no fields — pressing **Add** mints a random token for the trigger. The endpoint is `POST /webhooks/` on the server. The token is also the HMAC secret: the request must carry `X-Hub-Signature-256: sha256=`, where the hex is `HMAC-SHA256(body, token)`. A delivery that fails verification is rejected and logged and is not replayable. Duplicates are ignored by their dedupe key. :::caution[The token and URL are never shown] Nothing in the app displays the generated token or builds the URL for you — there is no copy affordance anywhere in the pipelines UI and the trigger's row just reads "webhook". Until that ships, the value has to be read out of the `pipeline_triggers` table directly. ::: ## How a trigger reaches a run An enabled trigger whose event type and filter both match starts a run with the event's payload as the run's trigger payload — that is what `{{key}}` in a step's prompt or script resolves against. Events that carry a natural key are de-duplicated, so the same event firing twice does not start two runs. A disabled _template_ is refused even when its trigger matches. Eight of the thirteen built-in templates ship disabled, so their seeded triggers fire into nothing until you turn the template on. ## Useful patterns - **Review every new PR** — event **PR published** on the `pr_review` template. - **Clean up after a merge** — event **PR merged**. The built-in `pr_merged_cleanup` template already ships wired this way, plus a daily schedule that is force-enabled on every re-seed because it is garbage collection. - **Nightly sweep** — schedule `0 2 * * *` with a timezone, on a template whose first node is a bash script. - **React to an external system** — a webhook trigger, with the calling system signing its request body as above. ## Related guides - [Create a pipeline template](/manual/guides/create-pipeline/) - [Run a pipeline manually](/manual/guides/run-pipeline/) - [Monitor pipeline runs](/manual/guides/monitor-pipelines/) - [Domain events reference](/manual/reference/domain-events/) ## Related concepts - [Pipelines](/manual/concepts/pipelines/) - [Domain events](/manual/concepts/domain-events/) ### Use plan mode > Switch a space into plan mode, get a typed plan and approve it into running work. Source: https://usectrl.dev/manual/guides/plan-mode/ This guide shows you how to get a structured plan out of an agent before it writes any code. In plan mode the agent researches read-only and its only deliverable is a **typed plan**: a dependency graph of steps you approve, which Control Center then compiles into real work. Use it when you want to see the scope of a change, get the steps broken down, or have risks and dependencies named before anything is touched. ## Switch a space into plan mode Any of these flips the space's mode and the change is persisted: - Pick **Plan** from the mode dropdown on the far left of the composer toolbar - Press **Shift+Tab** - Send `/plan`. A bare `/plan` just arms the mode; `/plan ` arms it and sends the rest as your message `/plan` never travels as text — it sets the stored mode, which is the authority the tool surface, the guard preset and the prompt all read. So the mode holds for this turn and every turn after it, until you change it back. The mode is stored on the space, so the same agent can be in plan mode here and in agent mode elsewhere. ## What the agent produces 1. It researches read-only — memory, the code index, files 2. It calls `submit_plan` **once**, with a `goal` plus `nodes`, each carrying a stable `key`, a `title`, a `description` and the node keys it `dependsOn` (which is what lets independent work run in parallel) 3. It stops The tool validates the graph — duplicate keys, dangling dependencies, cycles — and returns violations for the agent to fix rather than accepting a broken plan. The plan appears **in the conversation** as a compact row showing the goal, its status and the step count. **Plan Studio** — the dependency graph, the per-node inspector and the cost estimate — opens as a tab beside the conversation: press **Open** on the row, or simply wait, because a plan that lands within 30 seconds while you are watching the conversation opens its tab itself. :::note[The read-only guarantee is structural, but only on the built-in harness] Control Center's own harness builds the agent's tool registry from plan mode's surface spec, so there simply is no write, edit or command tool to call — the restriction is not a prompt instruction the model could talk itself out of. External CLI and ACP adapters get no such filtering: the mode reaches them as a request their own runner may or may not honour and Control Center only ever sees their `mcp__*` calls. The enforcement badge beside the mode dropdown appears exactly when that is the case. ::: :::note[The agent can ask you clarifying questions] When the request is ambiguous, a plan agent can pause and ask you up to three blocking questions with the `ask_user` tool. It renders a form in the conversation — options, or free text — and the run blocks until you answer. The tool is read-tier, so it survives plan mode's read-only surface, and it is exempt from the approval wrapper (a prompt in front of a prompt would fail closed with no approver connected, denying the agent the act of asking). It needs a space to render into, so a one-shot dispatch with no conversation does not get it. Front-loading the scope, the target repo and the acceptance criteria in your first message is still faster than a round-trip. ::: Prose is not a deliverable here. A run that ends without calling `submit_plan` posts a visible "Ended without submitting a plan (no `submit_plan` call)." line in the conversation and flags the run as having missed its deliverable. The run's status deliberately stays _completed_ — that keeps pipeline and ticket state machines untouched — so read the line, not the status badge. ## Approve the plan From the plan's row in the conversation, press **Approve and run**. From Plan Studio's approval bar, press **Approve plan** (or **Reject** beside it; approval is disabled while the graph has a validation error). Then: 1. Control Center compiles the plan into an orchestration and materializes it — the tickets and the run. No model is involved in this step; it is deterministic 2. The plan executes as the agent that wrote it. Whether to bring anyone else in is that agent's call at run time — it can `delegate_task` a step to a better-suited teammate, within the workspace's delegation depth cap 3. The plan's goal becomes **the conversation's goal** and the work runs **in that same conversation**: each step posts what the agent was asked and streams its turn back, so you follow it (and can stop a turn) where you already are 4. The goal row in the conversation's **General** side panel (under Todos) carries the run's progress as `settled/total` and its stop button; Plan Studio shows per-node state on the graph and cancels there too ## Keep working without approving If you would rather stay in this conversation than approve the plan, the agent can call `exit_plan_mode`. That is the only sanctioned exit: it opens a durable approval request linked to the conversation and returns `pending` — the space stays in plan mode and the agent must not act. Once the request is approved, the agent's next `exit_plan_mode` call flips the space from plan to agent mode and unlocks its mutating tools. A rejection lets it open a fresh request against a revised plan. :::caution[There is no approvals screen yet] A `plan_exit` approval is a durable governance row and nothing in the desktop or web client reads those rows — no client code calls any `approvals.*` operation. The only way to decide one today is the `decide_approval` MCP tool, which means another agent has to do it. The guardrail approval overlay you see for blocked tool calls is a different, in-memory queue and does not carry plan-exit requests. The reliable path out of plan mode is to switch the mode back yourself (dropdown, Shift+Tab), or to approve the plan. ::: ## Plan mode in a pipeline step A `promptAgent` pipeline step reads its conversation mode from the node config's `mode` **extra** — `extras['mode']: 'plan'`. The key is exact-match: any other spelling, including `conversationMode` or `dispatchMode`, is silently ignored and the step falls back to its default, which is **review** mode, not chat. :::caution[There is no editor field for this yet] The node config editor exposes label, kind, agent, input keys, output key, prompt, script, reducer, timeout, retries, output schema and routing — but no conversation mode. The **Dispatch mode** select you can see there is a different field (`allParallel` / `manager` for team-dispatch nodes) and setting it does nothing to the conversation mode. Today, `extras['mode']` can only be set by a template that ships with it or by writing the node config through the API. ::: ## Related guides - [Chat with an agent](/manual/guides/chat-with-agent/) - [Use spaces](/manual/guides/spaces/) - [Work in Plan Studio](/manual/guides/plan-studio/) - [Run an orchestration](/manual/guides/run-orchestration/) ## Related concepts - [Modes](/manual/concepts/modes/) - [Orchestration](/manual/concepts/orchestration/) ### Work in Plan Studio > Review, edit, estimate, diff and approve a plan in the Plan Studio editor and run parameterized playbooks. Source: https://usectrl.dev/manual/guides/plan-studio/ This guide shows you how to work with plans in Plan Studio. For what orchestrations and plan documents are, see [Orchestration](/manual/concepts/orchestration/) and [Use plan mode](/manual/guides/plan-mode/). ## Open a plan A plan belongs to the conversation that produced it, so that is where you normally reach it: the plan's row in the conversation has an **Open** action and Plan Studio opens as an editor tab beside the conversation. :::note[There is no "Plans" sidebar entry] That is deliberate, not an oversight. The hub at `/workspaces//plans` is a routable deep link only, so bookmark it if you want a standing list. ::: The hub lists three sections: - **Active plans** — orchestrations, proposed first, with their status, step count and revision number. - **Plan documents** — plans a single agent submitted with `submit_plan` in [plan mode](/manual/guides/plan-mode/), while they are proposed or approved. - **Playbooks** — saved, parameterized plan templates. Click a card to open it in Plan Studio. The route is `/workspaces//plans//`, where `kind` is `orchestration` or `document`, so you can deep-link a single plan too. If you opened an **orchestration**, you get the editable canvas, the version timeline and the approval bar. If you opened a **plan document**, the same graph renders read-only — plan documents are agent-authored; you review, estimate and approve or reject them and an approved document shows live per-node run state from the orchestration it compiled into. ## Edit the DAG Edits are possible on an **orchestration** while it is `proposed`. All canvas edits are keyboard-driven: 1. Click the canvas, then move the selection with the **arrow keys**. 2. Press **n** to add a step. It appears selected as "New step"; rename it in the inspector on the right. 3. Press **e** on the step that must run first, then click the dependent step (or select it and press **Enter**) to connect a dependency. Press **Esc** to abandon a pending connection. 4. Press **x** to cut the selected step's newest dependency, or toggle exact dependencies in the inspector's dependency editor. 5. Press **Delete** to remove the selected step; references to it are removed from the remaining steps' dependencies. In the inspector you can also edit the step's description, role and output schema. Edits save automatically as a new revision after a short pause; a validation error (shown in the approval bar) blocks the save until you fix it. Only work steps are editable. Structural nodes — research, discussion and synthesis — are read-only and so is any step whose work has already run (it is marked "already executed", because editing it would fork the plan). Saving is optimistically concurrent on the revision you started from. If the orchestrator replans underneath you, your save is refused with "the plan moved on" rather than merged or silently clobbered; re-read the current revision and redo your edit. Agent-initiated replans are themselves rate-limited to one every two minutes, so this stays rare. :::note The canvas has no drag-to-reposition: the layout is computed from the dependency structure, so "moving" a step means rewiring its dependencies. ::: ## Read cost, time and risk estimates Press **Estimate** in the approval bar. - Each step on the canvas gets a badge with a cost range and the sample size behind it, or "No history yet" when the step's role has never run. Select a step for the full detail in the inspector: cost range with sample size, a duration range and a blast radius (files and symbols) derived from the step's provenance — or "unknown" when the step carries no provenance. - The approval bar shows the plan total: an overall cost range, a duration range for the critical path (parallel branches overlap, so this is not the sum) and the budget ceiling when the plan sets one. - If only some steps have history, the total is marked **partial** and covers only the estimable steps. If the estimate's high end exceeds the budget ceiling, the total turns red with an over-budget warning. Estimates are honest or absent: a step whose role has no completed-run history reports a sample size of zero with no ranges at all and is shown as unknown rather than guessed at. Treat "No history yet" as "nobody knows", not "cheap". The budget ceiling is a display comparison against this estimate. Nothing enforces it once the plan is running — see [Manage costs](/manual/guides/manage-costs/) for controls that actually stop spending. ## Diff a revision against an earlier proposal Every edit and every replan by the orchestrator appends to an append-only revision timeline, shown in the **Versions** panel on the left of an orchestration plan. 1. Click a revision to diff it against the current proposal. With no selection, the panel diffs the previous revision against the current one. 2. Read the diff in the lower half of the panel: steps added, removed, or changed between the two revisions. 3. If the plan is still `proposed`, click the rewind icon on a revision to go back to it. Rewinding saves that proposal as a **new** revision — nothing is lost. If the orchestrator replans while the plan is executing, a banner tells you the live revision is ahead of the one you approved and the panel diffs against your approved revision. ## Approve, reject, or widen a plan From the app a proposed plan is one decision, taken whole: **Approve plan** or **Reject** in the approval bar. **Approve plan** always approves every step — there is no way to select a subset first. Approval is blocked while the proposal has validation errors, which are shown next to the buttons. Approving is operator-only. There is no MCP tool for it, so an agent can propose and revise a plan but never approve one. Once an orchestration is executing, the bar changes: - If a step stopped to ask mid-run, select it and press **Continue node** to resume it. - Press **Cancel run** to stop the whole orchestration. A plan that was approved _partially_ also gets a third control: steps outside the approved set render as deferred and do not run and **Approve selected** widens the set to a step plus everything it transitively depends on (a step can never be approved without its dependencies). :::note[Partial approvals cannot originate in the app] A partial approval only exists when something called `orchestration.approve` over RPC with an explicit `approved_node_keys` list. If you never see deferred steps or **Approve selected**, nothing is wrong — you cannot create that state from the desktop or web client. Plan documents are approved whole in every case, so an executing document offers **Cancel run** only. ::: ## Run a playbook with parameters A playbook is a saved plan template with typed `{{param}}` placeholders in its goal, step titles, descriptions and prompts. Substitution is literal — there is no expression language; if you need logic, use a [pipeline](/manual/guides/create-pipeline/) instead. 1. In the hub's **Playbooks** section, press **Run** on the playbook (or open the ⌘K palette and pick the playbook there). 2. Fill in the parameters. Enumeration parameters render as dropdowns; repo and agent parameters take a repo or agent reference; optional parameters fall back to their default. 3. Pick the **anchor ticket** the plan is proposed against, then confirm. Running a playbook only **proposes** a plan — the instantiated orchestration opens in Plan Studio as `proposed` and nothing executes until you approve it there. To remove a playbook, press the trash icon on its card. Agents create and run playbooks with the `create_playbook` and `run_playbook` MCP tools; see the [MCP tools reference](/manual/reference/mcp-tools/). ## Related guides - [Use plan mode](/manual/guides/plan-mode/): how an agent produces a plan document with `submit_plan` - [Run an orchestration](/manual/guides/run-orchestration/): the approve-once lifecycle from the conversation side - [Create a pipeline template](/manual/guides/create-pipeline/): when you need logic a playbook cannot express - [Monitor pipeline runs](/manual/guides/monitor-pipelines/): the engine view of an approved plan ## Related concepts - [Orchestration](/manual/concepts/orchestration/) ### Organize work with projects > Group tickets into projects from the sidebar, track progress and archive a project when it is done. Source: https://usectrl.dev/manual/guides/projects/ A project is a workspace-scoped grouping of tickets toward a shared goal. Projects are Control Center-only — they never sync to an external provider such as Linear. ## Create a project 1. Expand the **Tickets** group in the workspace sidebar. It lists **All tickets**, one row per non-archived project, then **New project**. 2. Click **New project**. 3. Fill in **Project name**, an optional **Description** and pick a **Color**. 4. Press **Create project**. You land on the new project's overview and the project appears as a sidebar row under **Tickets**. ## Add tickets to a project Project is a single-ticket field, set in either of two places: - Open the ticket and set **Project** in its properties rail. - Right-click the ticket in the list and use the **Project** submenu. The properties-rail picker also offers **New project**, so you can file a ticket into a project you create on the spot. **No project** clears the field. A ticket belongs to at most one project. :::note[Project is not a bulk action] The floating bar that appears when you select several tickets covers status, priority, assignee, labels and delete. It has no project chip, so filing many tickets means doing them one at a time. ::: You can also start from the project: **New ticket** on the project overview creates a ticket and files it there. ## Read project progress Open a project from its sidebar row, or go to `/workspaces//projects/`. The overview shows the project's glyph and name, a status badge, the description and a done-of-total progress bar. Below that, the project's tickets are grouped by status. It does not aggregate anything else — there is no assignee breakdown and no priority distribution. For those, filter the main ticket list instead. ## Change project status Projects have three states: | Status | Meaning | | ------------- | -------------------- | | **Active** | Work is ongoing | | **Completed** | All work is done | | **Archived** | Historical reference | Use the **⋯** menu on the project header. It offers **Edit project**, **Mark completed** / **Mark active**, **Archive** / **Restore** and **Delete project**. Archiving hides the project from the sidebar list and from the ticket pickers. It is reversible — **Restore** puts the project back to active — but the project is only reachable by URL while archived. ## Projects created for you Approving an orchestration creates a project and files every generated sub-ticket under it, alongside anything you filed by hand. That is the only automated path: no pipeline node creates tickets, so a pipeline cannot populate a project. See [Run an orchestration](/manual/guides/run-orchestration/). :::note[Agents cannot manage projects] There are no project MCP tools — `create_project`, `list_projects`, `update_project`, `delete_project` and `set_ticket_project` were deleted rather than wired up, so no agent can create, list, update or delete a project, or set a ticket's project. Projects are an operator surface today. ::: ## Related guides - [Create and manage tickets](/manual/guides/manage-tickets/) - [Delegate work to agents](/manual/guides/delegate-tickets/) - [Run an orchestration](/manual/guides/run-orchestration/) ## Related concepts - [Tickets](/manual/concepts/tickets/) ### Record and summarize a meeting > Record a call, get a transcript and turn it into notes, action items and decisions. Source: https://usectrl.dev/manual/guides/record-meeting/ This guide shows you how to record a meeting and get an AI-generated writeup. See [Meetings and transcription](/manual/concepts/meetings/) for how it works. ## Before you start - **Install a speech model on the server, then restart it.** Go to **Settings → Server → Voice & meeting models** and install one (Parakeet TDT 0.6B v3, the multilingual default, is about 600 MB). The server resolves the model **once at boot**, so a model installed while it is running does nothing until the next start. Without one, recording fails with `Could not start recording on the host: …` — the recording ops are simply absent from the server. - Grant **microphone** permission when your OS asks on the first record. - On **macOS 14.4+**, system audio is captured with Core Audio process taps — no driver to install, but the OS asks a second time for an _audio capture_ permission. That is not Screen Recording and it never lights the screen-recording indicator. Denying it fails the start with `System-audio capture permission denied.` Below macOS 14.4 there is no system-audio capture. - On **Linux**, `parecord` (pulseaudio-utils) or `pw-record` (pipewire) must be on the `PATH` — capture spawns one of them against a PipeWire/PulseAudio monitor source. Without either the start fails with `No system-audio capture tool found`. :::caution[Where your meeting audio goes] Capture, transcription, echo cancellation and speaker diarization all run on-device, with no cloud call — but "device" means the **cc_server host**, which is a different machine when you run the server remotely. And the final writeup is an ordinary agent run: the `meeting_summary` pipeline sends the full transcript and your rough notes to whatever model provider that agent uses. Point that agent at a local provider if the transcript must not leave the machine. The recording screen states this while you record, so the decision is in front of you before there is a transcript to send. ::: ## Start a recording 1. Open **Meetings** in the sidebar. 2. Click **Record meeting**. The live recording HUD opens at `/meetings/record`. Recording starts immediately and captures your microphone plus the system output audio — the other participants. There is no mode prompt and no audio-source picker: the in-person single-mic mode exists in the API but has no UI today. In the browser, the same flow works, with one difference: the "system audio" is a screenshare that must include an audio track. Share a browser tab (or your whole screen) and enable "Share tab audio" / "Share system audio" in the picker. Safari and Firefox and full-screen sharing on macOS, often cannot supply one. The HUD shows a level meter, the elapsed time and a live transcript building up on one side. ## During the meeting - **Take rough notes** in the notes pane. They're saved as you type and handed to the summary agent later, so jot down anything the transcript might miss. - **Pick the note template** in the **Active template** row. It shapes this meeting's summary and is captured with the recording, so a later re-run reproduces the template you chose here. - **Pause and resume** with the pause control. Transcription stops while paused and the timer freezes. - The transcript streams in speaker-tagged: your words as **You**, everyone on the call as **Others** — until diarization splits Others into `Person 1`, `Person 2`, … which you can rename. :::note[A very quiet speaker may not be transcribed] Transcription skips any audio window that never rose above a silence threshold, without decoding it at all. That keeps a quiet channel from burning CPU on hallucinated non-speech tokens, but it also means a faint or distant speaker can produce no transcript. Check the level meter early rather than at the end. ::: ## Stop and summarize 1. Click **Stop & summarize**. 2. Control Center takes you straight to the meeting's detail view, marked **processing**: diarization splits the remote channel into individual speakers and the `meeting_summary` pipeline generates the writeup. 3. The meeting flips to **done** once that pipeline finishes. You don't have to wait on it — the detail view updates itself. ## Review the writeup The meeting detail (`/meetings/:meetingId`) has four tabs: | Tab | What's there | | ---------------- | ------------------------------------------------------------------------------- | | **Notes** | The AI summary and enhanced notes, built from your rough notes + the transcript | | **Transcript** | The full speaker-tagged transcript with timestamps | | **Action items** | Extracted to-dos, each with an optional owner | | **Decisions** | Decisions captured during the call | From here you can: - **Rename speakers**: change `Person 1` to a real name; it applies across the transcript. - **Check off action items** as you complete them. - **Create ticket** from an action item: this files a new, unassigned ticket titled with the action item and links it back to the row. Assign it to an agent from the ticket itself (see [Delegate work to agents](/manual/guides/delegate-tickets/)). - **Re-run summary** for a fresh pass (for example after renaming speakers). It reuses the template captured when the meeting was recorded. Editing notes, the title, action items and decisions works everywhere. **Re-run summary** and cancelling a processing meeting are **desktop-only** — in the browser both buttons do nothing. ## Record straight from a calendar event If you've [connected a calendar](/manual/guides/connect-calendar/), you can skip the manual start. This is **desktop-only**; the web client refuses it. 1. Open the event from **Calendar**. 2. Click **Start recording & link**. The recording is seeded with the event's title and linked back to it, so the notes and the calendar entry stay connected. :::note[Deleting a meeting does not delete its audio] Deleting a meeting removes its rows, but the recorded WAV files under `/meetings//` stay on disk — there is no retention policy, size cap, or cleanup sweep. Remove that directory yourself if the audio matters. ::: ## Related guides - [Connect a Google Calendar](/manual/guides/connect-calendar/) - [Delegate work to agents](/manual/guides/delegate-tickets/) ## Related concepts - [Meetings and transcription](/manual/concepts/meetings/) ### Repo scripts > Per-repository setup and archive scripts that run against a space's worktree. Source: https://usectrl.dev/manual/guides/repo-scripts/ Repo scripts are shell scripts you configure per repository (Settings → Repositories → the terminal icon on a repo's row) that Control Center's server runs against **a space's worktree** of that repo — the isolated checkout under `//spaces//repos//` where agents work. There are two kinds: | Script | When it runs | Use it for | | --- | --- | --- | | **Setup** | Right after a worktree is provisioned for a space | Installing dependencies, generating files, copying `.env` | | **Archive** | Just before a worktree is destroyed or garbage-collected | Cleaning up resources outside the worktree (docker networks, tunnels, external state) | ## Where scripts run Both scripts run via `bash -lc` (a login shell, so Homebrew/nvm-installed tools are on `PATH`) with the **worktree as the working directory**, and these environment variables set: | Variable | Value | | --- | --- | | `CC_WORKSPACE_PATH` | The worktree (the script's cwd) | | `CC_ROOT_PATH` | The registered source repo — your own checkout | | `CC_SPACE_ID` | The space the worktree belongs to | | `CC_SPACE_NAME` | The space's display name | | `CC_REPO_NAME` | The repo's display name | For example, a setup script that installs dependencies and brings in the repo's gitignored `.env`: ```bash pnpm install cp "$CC_ROOT_PATH/.env" .env pnpm run build ``` ## Failure behavior - A **setup** script that exits non-zero (or exceeds its 5-minute timeout) fails the space's provisioning: the space shows the failed state with a retry, and the run's output tail is recorded. A retry provisions from a clean worktree and runs the script again — write setup scripts so a re-run after a partial install works (package managers already behave this way). - An **archive** script is best-effort: a failure (or its 2-minute timeout) is recorded but **never blocks deletion**. Archive runs on GC paths with no one to answer a prompt (space deleted, PR merged, scheduled cleanup). Setup runs only on a **fresh** worktree: re-dispatching agents into a warm space reuses the existing checkout and does not re-pay the install. ## Run history Every execution is recorded with its status, duration, exit code and a bounded output tail, visible in the same settings dialog under "Recent runs" (the `repos.watchScriptRuns` subscription streams it live). ## Permissions Scripts are shell the **server** executes, so writing them is restricted to workspace admins and guarded by the `processSpawn` action class — the same guardrail net that gates every other process-spawning operation. ### Review and merge a PR > Open a PR workbench, read the diff, leave inline comments, submit a review and merge. Source: https://usectrl.dev/manual/guides/review-merge-pr/ This guide shows you how to review and merge a pull request. ## Open the PR workbench 1. Navigate to **Pull requests** in the sidebar 2. Select a PR from the list A PR opens as a tabbed workbench, not a single page. Five tabs open by default, in this order: **Overview**, **Diff**, **Source control**, **Chat** and **Actions** (CI checks grouped by workflow). **Review** — the review pipeline's progress, then the artifact it published with its findings rail — is deliberately not one of them: a PR has no review until someone asks for one, so **Ask AI** opens it. A terminal, a single-file editor, an embedded code-server and an in-app browser open on demand; deployment-preview tabs are injected automatically when a preview URL is detected on the PR. Your tab layout is remembered per PR. The Overview tab carries the PR's metadata (author, branch, labels, assignees, reactions, commits, check status) and the action row: refresh, **Review**, **Merge**, open in IDE and a ⋯ overflow menu. ## Read the diff Open the **Diff** tab to see the whole PR diff at once, with a file tree for jumping between files. - The settings button in the diff toolbar switches **split** (side-by-side) and **unified** view and toggles line wrapping and code-font ligatures. - `@@` hunk headers are replaced by **Show N lines** / **Show end of file** affordances that splice in the surrounding context on click. - Press `j` / `k` to step between files, `c` to collapse or expand the focused file, `t` to return the sidebar to the file tree and `⌘F` to search across the whole diff (`Enter` / `Shift+Enter` walk the matches). - Press `v` to mark the focused file as viewed, and the cursor advances to the next file. On GitHub the viewed state is stored on the forge, so it follows you between clients; GitLab and Bitbucket have no such API, so there it is remembered on this device only. Mouse-drag in the code area selects text down to the exact column and `⌘C` copies the raw source rather than the rendered diff markers. ## Leave inline comments 1. Tap a code row's gutter to open a composer anchored to that line, or use the `+` button on a file header for a file-level comment 2. Write your feedback in Markdown 3. Submit An inline comment is **posted to the forge the moment you submit it** — there is no batching. The thread shows a pending state until the forge confirms it, then flips to synced. If the post fails, the thread is marked with the error and offers a retry. Only the review _body_ is a draft: it is saved server-side against (owner, repo, PR number) a moment after you stop typing, so it survives a reload and follows you between the desktop and web clients. ## Submit a review 1. Press **Review** in the Overview action row (it is hidden on your own PRs and on a closed or merged PR) 2. Write the review body. You can attach an image, insert an emoji or a GIF and toggle between write and preview 3. Press **Approve** (`⌘↵`) or **Request changes** (`⌘⇧↵`) The verdict _is_ the button — there is no separate verdict picker and no comment-only verdict in this overlay. After submitting, the button relabels itself to **Approved** or **Requested changes** and the draft body is cleared. :::caution[Submitting is irreversible] `pr_review.submitReview` publishes to the forge and never enters the undo stack. There is no "unsubmit" from Control Center. ::: ## Merge The **Merge** button appears when you have write access and the PR is open and not a draft. Its colour is a readiness signal derived from required approvals and check state (ready / pending / blocked) — it is advisory only. The button is never disabled by review state; the forge's own branch protection is what enforces the rules. 1. Press **Merge** to open the merge flyout 2. Pick a merge method: **squash**, **merge commit** or **rebase** (squash is the default). Bitbucket has no true rebase merge, so rebase maps to its fast-forward strategy and fails on a diverged branch 3. For squash and merge commit, edit the prefilled commit title and description. Rebase takes neither and hides both fields 4. Read the warnings panel — it names failing checks and pending reviews, but does not block you 5. Press the merge button in the flyout to confirm Merging is irreversible: it pushes commits to the forge, so it gets a confirmation step instead of an undo entry. Afterwards the PR's cache is invalidated, its status flips to **Merged** and a `PullRequestStatusChanged` event fires with status `merged`. That event releases the ephemeral "open in editor" worktree and starts two seeded pipelines: **Stale repository cleanup**, which removes the PR's stale worktrees and conversation folders and **Release notes**, which drafts a changelog entry. :::note[The PR review space is not auto-archived] Auto-archiving the review space's conversations is wired to a separate `PrMerged` domain event that nothing in the product currently emits, so the space stays as you left it after a merge. The daily cleanup sweep still reclaims its worktree; delete the space yourself if you want it out of the sidebar. ::: ## Hand the PR to reviewer agents The ⋯ overflow menu on the Overview tab carries **Ask AI**, which starts the seeded `pr_review` pipeline for this PR. It only appears while the PR is open. See [Use AI-powered review](/manual/guides/ai-review/) for what the pipeline does and how to publish its findings, and [Review Studio](/manual/guides/review-studio/) for the deterministic cohort and axis model — which the server computes and nothing in the app renders. ## Review an external PR Control Center also imports PRs opened by humans outside the app. The review flow above is identical. External PRs are detected by the PR polling service. ## Related guides - [Use AI-powered review](/manual/guides/ai-review/) - [Dispatch reviewer agents](/manual/guides/dispatch-reviewers/) - [Review Studio (cohorts and axes)](/manual/guides/review-studio/) - [Connect a code host](/manual/guides/connect-forges/) ### Review Studio (cohorts, axes and deterministic diffs) > What the review compute pass produces server-side — cohorts, API-contract and visual diffs, per-axis gates — and why none of it has a viewer today. Source: https://usectrl.dev/manual/guides/review-studio/ Review Studio was the semantic multi-modal review surface: a cohort rail, a walkthrough pane, API-contract and visual-diff panes, a blast-radius view and a multi-axis dashboard. **The client no longer has it.** The split Findings / Studio tab pair was folded into a single **review artifact** tab, and everything this page describes now runs server-side with nothing rendering it. :::caution[There is no Review Studio surface in the app] Do not go looking for a **Review studio** chip, a cohort rail, a **Recompute** button, or the Walkthrough / API contract / Visual diff / Blast radius panes — none of them exist. `pr.reviewStudio` survives only as a legacy tab kind so a persisted layout naming it still opens the review artifact tab instead. The compute itself is live: `cc_server` still builds cohorts, runs the deterministic axes and serves them over the `review_studio.*` RPC operations (`cohorts`, `blastRadius`, `cohortImpact`, `ciSignals`, `dependencyDiffs`, `setContractDecision`, `approveVisual`, `compute` and their `watch*` twins). The client binds the repository and then reads none of it — every cohort, axis, contract-diff and visual-diff provider in `review_studio_providers.dart` has zero consumers. Reaching this data today means speaking RPC directly. ::: ## What you actually get in the app The PR's **Review** tab is the review pipeline's live progress while it works, then the artifact it published, with the actions on it. One tab with two states, because they are the same thing at two times. It is not a default tab: a PR has no review until someone asks for one, so **Ask AI** is what opens it. - The consolidating agent publishes an ordinary conversation artifact with `publish_artifact`, so the tab renders it through the same artifact viewer a chat bubble opens — there is no review-shaped copy of the artifact system. - Findings sit in a rail beside it, and the verdict rides a slim bar above the artifact rather than a dashboard header. - A stale banner appears when the PR head moved past the review. See [Use AI-powered review](/manual/guides/ai-review/) for the flow and [Review and merge a PR](/manual/guides/review-merge-pr/) for acting on it. ## Cohorts Cohorts group the PR's changed files into content-derived buckets of files that belong together by meaning, ranked by impact so the riskiest group reads first. They are recomputed on every head-SHA change and replaced wholesale; each cohort has a push-stable key, so a summary and review progress survive a rebase. If the repo is not code-indexed, cohorts fall back to path grouping. To index a repo, use **Settings → Workspace → Repositories** — each repo row carries an **Index code** action that runs the `index_code` pipeline and reports progress in place. The code graph only parses Dart, JavaScript, TypeScript, TSX and PHP. A repo in any other language can never produce semantic cohorts, however often you index it. An agent can record a markdown summary for a cohort with `set_cohort_summary` and attach a structured walkthrough diagram with `add_review_diagram` — a typed sequence, entity-relation or state-machine object, never mermaid text. Every edge is cross-checked against the real code graph, and an edge the graph cannot corroborate is marked unverified rather than drawn as fact. Both tools are registered and callable; what is missing is the surface that would draw the result. ## The API-contract axis The contract axis diffs only **explicit** spec files. It matches the six default names — `openapi.yaml`, `openapi.yml`, `openapi.json`, `swagger.yaml`, `swagger.yml`, `swagger.json` — plus any changed file whose name contains `openapi` or `swagger` and ends in `.yaml`, `.yml` or `.json`. A contract inferred from handler code is out of scope and GraphQL schemas are not diffed. Each change is classified — endpoint, parameter, schema and response additions, removals and modifications — and carries a severity: **breaking** (removed endpoint or param, tightened type, new required param), non-breaking (additive), or informational. A rejected change fails the contract gate, as does a breaking change left undecided; breaking-but-approved warns; no breaking changes passes. A diff marked **derived (advisory)** never gates. Approving or rejecting a change is `review_studio.setContractDecision`. There is no control for it in the app. ## The visual axis If the PR changes UI components in a Flutter repo with a Widgetbook app, the compute pass renders the changed components before and after via a headless `flutter test` golden pass in the PR's base and head worktrees, then diffs the pixels. :::note[This needs a Flutter SDK on the server host] The pure-Dart server cannot render Flutter widgets in-process, so it shells out to `flutter test`. The SDK has to be installed on the machine running `cc_server` — not on your client. Without it the axis reports **unavailable** with the reason "no Flutter SDK on host". It never reports a pass it did not earn. ::: Snapshots marked **changed** or **removed** hold the visual gate until approved; **unchanged**, **added** and **approved** snapshots clear it. Unavailability has one of four reasons: no Flutter SDK on host, no Widgetbook app in repo, no golden-testable use-cases, or a golden harness error. Approving an intended change is `review_studio.approveVisual`. There is no control for it in the app. ## The axes and the verdict | Axis | Driven by | | ------------ | ------------------------------ | | Correctness | Reviewer agents (tokens) | | Security | Reviewer agents (tokens) | | Test gap | Reviewer agents (tokens) | | Visual | Deterministic (golden harness) | | API contract | Deterministic (spec diff) | The three token axes are recorded when a lead agent calls `finalize_review`; the two deterministic axes are recorded by the compute pass. :::note[There is no performance axis today] A `performance` axis exists in the taxonomy and has a display label, but nothing in the product computes a performance result — its only appearance outside the enum is the demo seeder. ::: Each axis reports one of: | State | Meaning | | --------------- | -------------------------------------------------- | | **Pass** | Ran, nothing blocking found | | **Warn** | Ran, non-blocking concerns | | **Fail** | Ran, found a blocking problem | | **Partial** | Ran but could not complete; results are incomplete | | **Unavailable** | Could not run at all | Only **pass** and **warn** clear a gated axis. **Partial** and **unavailable** are deliberately distinct from **fail** and neither clears a gate. The axes aggregate into **Ship**, **Hold** or **Block**, and they can only make the verdict _more_ severe — it is never downgraded. A gated axis that **fails** forces **Block**; one that is **partial** or **unavailable** forces at least **Hold**. So a blocking axis that could not run _holds_ the verdict: absence of evidence never greens a gate. The verdict the app shows you on the review artifact tab is the one `finalize_review` posted. The per-axis breakdown behind it has no viewer. ## Related guides - [Use AI-powered review](/manual/guides/ai-review/) - [Dispatch reviewer agents](/manual/guides/dispatch-reviewers/) - [Review and merge a PR](/manual/guides/review-merge-pr/) - [Search code with the code graph](/manual/guides/code-search/) ### Run a fleet worker > Build, pair and run the cc_worker headless executor so it registers with a cc_server and appears in the fleet panel. Source: https://usectrl.dev/manual/guides/run-fleet-worker/ Run `cc_worker` on a spare machine to add execution capacity to your fleet. It is a pure-Dart binary — no Flutter engine — that pairs with a `cc_server`, declares the host's capabilities, heartbeats, pulls leased jobs, executes them and streams process events back. It holds no durable state: a supervisor restarts it and it re-registers. You need a running `cc_server` first — see [Run a headless server](/manual/guides/run-headless-server/). :::caution[Nothing in the product submits fleet jobs yet] A correctly paired worker will register, heartbeat and then idle. The only way to create a job today is a raw `fleet.submitJob` RPC call — no screen, no MCP tool and no server-side producer reaches it and agent dispatch does not route through the fleet. The implicit "this machine" worker the server registers at boot is worse off still: it has an empty runner map, so a job placed on it fails with `No local runner for job kind …`. Treat the success signal for this guide as the worker appearing in the fleet panel with a fresh heartbeat. ::: ## Prerequisites - The [Dart SDK](https://dart.dev/get-dart). This repo pins its SDK with [fvm](https://fvm.app), so prefix commands with `fvm`. - This repository checked out on the worker machine. - A reachable `cc_server` (loopback for dev, LAN or remote for production). - `git` on the `PATH` — the worker materializes repos and runs `git` probes. ## Step 1: Build the binary ```bash cd apps/cc_worker fvm dart build cli ``` This produces a self-contained bundle at `./build/cli//bundle/bin/cc_worker`. ## Step 2: Mint the worker's credential A production server only accepts a worker that presents a paired-device id and pre-shared key. Mint them on the **server** machine, while no server is holding the data directory: ```bash cc_server pair --data-dir --device my-worker --label "Build box" ``` It prints a `Device id` and a `Pairing key` — those are what `--device-id` and `--psk` take. (Omit the credential only against a loopback dev server.) ## Step 3: Start the worker Against a paired server: ```bash ./build/cli//bundle/bin/cc_worker \ --server wss://host:9030 --device-id my-worker --psk ``` Against a loopback dev server with no auth handshake: ```bash fvm dart run cc_worker --server ws://localhost:9030 ``` | Flag | Description | Default | | ------------------ | ------------------------------------------------------------------------------------------------ | ----------- | | `--server ` | cc_server URL (`ws://`/`wss://`; `http(s)` and a missing `/rpc` path are coerced). **Required.** | — | | `--name ` | Operator-facing worker name. | host name | | `--device-id ` | Stable paired-device id, also used as the worker id. | `cc-worker` | | `--psk ` | Paired-device pre-shared key. Omit only for a loopback dev server. | — | `CC_WORKER_CACHE` overrides the worktree materialization cache directory (default: a `cc_worker_cache` directory under the system temp dir). The worker registers with `fleet.registerWorker`, then heartbeats every 20s and polls for leases every 2s. It runs until `SIGINT`/`SIGTERM`; put it under systemd or launchd so it restarts and re-registers after a crash or reboot. :::caution[There is no protocol compatibility window] A worker whose fleet protocol version differs from the server's is registered as **incompatible** and is never offered a lease and `cc_worker` itself aborts with `Incompatible fleet protocol: worker speaks v… server speaks v… Upgrade cc_worker to match the server release`. Build the worker from the same release as the server. ::: ## Step 4: Verify it in the fleet panel Open **Observability** in the desktop or web app, switch to the **Live** tab and scroll to the bottom. **Workers** lists every registered worker with its platform, cores, capability keys and last heartbeat; **Jobs** lists the jobs distributed across them. Your worker appearing there with a recent heartbeat is the confirmation that pairing and registration worked. From a worker's tile you can **drain** it (finish current jobs, take no new leases), **resume** a draining worker, **revoke** its credential, or **remove** the row. Worker and job rows live in the server's `global.db` — the worker itself writes nothing to disk beyond the materialization cache. ## What the worker advertises On registration the worker auto-detects its capabilities and the scheduler matches jobs against them. You do not configure these by hand: - **OS and arch** (`macos`/`linux`/`windows`, `arm64`/`x64`) — exact probes, with fallbacks to `uname -m` / `PROCESSOR_ARCHITECTURE` - **Cores and RAM** — exact core count; RAM degrades to `0` when unprobeable - **`flutter`** — set when a Flutter SDK is reachable (bare `flutter`, then `fvm flutter`); golden-render jobs require it - **`sandbox`** — `native-macos` on macOS, `native-linux` on Linux. This is a placement hint, not a promise: the worker advertises the backend its platform _has_, but it does not wrap the jobs it runs in one. Unlike `cc_server`, `cc_worker` spawns a leased job directly - **`always-on` and `parallel`** — a dedicated `cc_worker` always advertises both, so it is eligible for eval batches and other throwaway parallel capacity Detection is best-effort: anything it cannot probe degrades to a safe default rather than failing startup. ## What the worker would execute The lease protocol carries six job kinds: | Kind | What it is | | -------------- | ---------------------------------------------- | | `agentRun` | A single agent run (dispatch) | | `pipelineStep` | A pipeline step execution | | `codeIndex` | A repo code-index build | | `goldenRender` | A UI visual golden render (requires `flutter`) | | `benchmark` | A performance benchmark run | | `evalBatch` | An eval batch (prefers `parallel` capacity) | Execution is a real subprocess-streaming implementation, not a full embedded agent runtime. When a lease carries `repoRemote`, the worker does `git clone --depth 1` into a remote-and-SHA-keyed cache directory, then `git fetch` and `git checkout`. For `agentRun` it runs the command in the lease env's `CC_JOB_COMMAND` in the work directory, streaming stdout and stderr back as events; with no `CC_JOB_COMMAND` it echoes the prompt so the transport is still exercised end to end. The other kinds run small real commands (`git rev-parse HEAD`, `git --version`) as honest probes. Job-scoped credentials in the lease env are injected into every subprocess and never written to a log. Events are batched and flushed every 250ms or every 32 events; each job ends with a done event and a completion report. The model is heartbeats plus short-TTL leases: if a worker loses contact its lease expires and the server reaps the job for retry. There is no durable state on the worker and no consensus or work-stealing between workers. ## Related guides - [Run a headless server](/manual/guides/run-headless-server/) - [Connect to a remote server](/manual/guides/connect-remote-server/) - [Pair a device](/manual/guides/pair-a-device/) ## Related concepts - [Deployment and clients](/manual/concepts/deployment/) ### Run a headless server > Run the cc_server headless binary or its Docker image, pair a client to it and configure it with the full CLI flag and environment surface. Source: https://usectrl.dev/manual/guides/run-headless-server/ Run `cc_server` on a machine that stays on, then connect to it from your desktop, a browser, or your phone. The server is a pure-Dart native binary — no Flutter engine — so it runs headless on macOS, Linux, or Windows. It owns the database, runs the agents and makes every external API call; the clients only render. ## Run in Docker (prebuilt images) Every release publishes four images to GHCR — the backend, the two static thin clients and the optional pairing relay — so a self-hosted stack needs no source checkout. Pin a version tag (`ghcr.io/samuelalev/:v0.0.1-rc.1`) or ride `latest`. | Image | What it is | Publishes | | ------------------------------------------------- | -------------------------------------------- | ---------------- | | `ghcr.io/samuelalev/cc-server` | The backend: database, agents, MCP, webhooks | `-p 9030:9030` | | `ghcr.io/samuelalev/cc-webapp` | The web client (static nginx) | `-p 8080:8080` | | `ghcr.io/samuelalev/cc-remote` | The phone companion PWA (static nginx) | `-p 8081:8080` | | `ghcr.io/samuelalev/cc-signaling-server` | The WebRTC pairing relay (optional) | `-p 8788:8788` | The whole stack as a compose file: ```yaml services: server: image: ghcr.io/samuelalev/cc-server:latest ports: ["9030:9030"] volumes: ["cc_data:/data"] restart: unless-stopped web: image: ghcr.io/samuelalev/cc-webapp:latest ports: ["8080:8080"] restart: unless-stopped remote: image: ghcr.io/samuelalev/cc-remote:latest ports: ["8081:8080"] restart: unless-stopped signaling: image: ghcr.io/samuelalev/cc-signaling-server:latest ports: ["8788:8788"] restart: unless-stopped volumes: cc_data: ``` Things worth knowing before you expose any of this beyond loopback: - **The `cc-server` image ships `CC_SERVER_INSECURE=1`** (it must bind `0.0.0.0` to receive published-port traffic), which is only safe behind a TLS-terminating reverse proxy — Caddy, Traefik, nginx or a Cloudflare Tunnel — on a trusted network. To terminate TLS in-process instead, mount a certificate and set `CC_SERVER_TLS_CERT` / `CC_SERVER_TLS_KEY` (then unset `CC_SERVER_INSECURE`). Everything is overridable with `docker run -e …` or the compose `environment:` block. - **`/data` is the only state.** The SQLite databases, paired-device secrets, downloaded models and meeting audio all live in the volume — back it up and the container becomes disposable. - **Pair a client from inside the image** before the first connection, the container equivalent of [step 3](#step-3-pair-a-client-before-first-start): ```bash docker run --rm -it -v cc_data:/data \ --entrypoint /app/bin/cc_server ghcr.io/samuelalev/cc-server:latest pair ``` - **The two client images are static nginx hosts** — nothing to configure at runtime; each client's connect form takes your server URL, device id and pairing key. The signaling relay likewise needs only its port (`SIGNALING_HOST` / `SIGNALING_PORT` overridable); it is stateless, holds no secrets and is a **fallback** — a phone uses loopback, LAN, tailnet or a direct `wss://` path when one is reachable, and only falls back to the relay when none is. Skip it if your clients always reach the server directly. The rest of this guide builds the server from source; the flags and behavior it covers are the same ones the image's environment variables feed. ## Prerequisites - The [Dart SDK](https://dart.dev/get-dart). This repo pins its SDK with [fvm](https://fvm.app), so prefix every command with `fvm`. - This repository checked out. - The native toolchain the staging script needs — Rust/`cargo`, plus `meson`, `ninja`, `pkg-config`, `git` and a C++ compiler. Each build script names what it cannot find and how to install it. - A reachable host: loopback for local use, or a LAN/public address with TLS for remote clients. ## Step 1: Stage the native libraries The natives are **required** — there is no degraded mode. Build them from the repo root before anything else: ```bash scripts/natives/build_natives.sh ``` This builds `rift`, `fff`, tree-sitter plus its grammars, `aec`, `lame`, `ccpty`, `cc_watcher`, `cc_inference` and the SAML native into `build/natives/`. The `cc_server` build hook re-emits whatever is staged there into the bundle, so skipping this step either fails the build outright or — with the escape hatch below — produces a binary whose boot preflight refuses to start and names the missing library. :::note[Compile-only escape hatch] Creating an empty `.cc_natives_allow_missing` file at the repo root downgrades the missing-natives build failure to a warning. It is for syntax-checking a fresh clone; a server built that way cannot boot. It must be a **file** — the build-hook runner does not forward the caller's environment, so an environment variable is ignored. ::: ## Step 2: Build the binary ```bash cd apps/cc_server fvm dart build cli ``` The bundle ships `libsqlite3` and the staged natives alongside the binary — no system SQLite and no Flutter engine needed. The executable lands at `./build/cli//bundle/bin/cc_server`. ## Step 3: Pair a client before first start A fresh data directory has no paired device and no pre-shared key, so a client's pairing-key prompt cannot be satisfied yet. Mint one with the `pair` subcommand. It opens the database directly, so run it **before** starting the server: ```bash ./build/cli//bundle/bin/cc_server pair --data-dir ./data --port 9030 ``` It prints three values to paste into a client's connect form: ``` Connect a thin client (paste into its connect form): Server ws://localhost:9030/rpc Device id web-client Pairing key ``` Useful flags: `--device` (default `web-client`; the id also picks the device tier — `desktop`, `ios`, `android`, anything else is a web client), `--label`, `--host` (the host to print in the URL, since loopback is not reachable from another machine) and `--client-url`. Re-running `pair` rotates that device's key. Pairing never creates a workspace: the connecting client's onboarding names and creates the first one. With `--client-url` it also prints a terminal-scannable QR that opens the web client already filled in: ```bash ./build/cli//bundle/bin/cc_server pair --data-dir ./data --port 9030 \ --bind any --host 192.168.1.42 --client-url https://app.usectrl.dev ``` :::caution[This QR is not the phone QR] `--client-url` builds a link for the **web client** — server URL, device id and key in the URL fragment. The **Remote** phone app expects a different payload (the server's full connection descriptor plus an expiry), minted from **Settings → You → Your devices** in a running client. The two are not interchangeable. See [Pair a device](/manual/guides/pair-a-device/). ::: A credential minted by `cc_server pair` carries **no expiry**; one minted in the app always expires after 30 days. Revoke a CLI-minted device from **Settings → You → Your devices** when you are done with it. ## Step 4: Start the server On loopback — the default and all you need for a client on the same machine: ```bash ./build/cli//bundle/bin/cc_server --data-dir ./data --port 9030 ``` To reach it from another machine, bind every interface. A non-loopback bind without TLS is **refused at startup** (`Refusing to bind non-loopback address … without TLS`), so supply a certificate: ```bash ./build/cli//bundle/bin/cc_server --data-dir ./data --port 9030 \ --bind any --tls-cert /path/fullchain.pem --tls-key /path/privkey.pem \ --public-url wss://server.example:9030/rpc ``` Or terminate TLS in a reverse proxy on a trusted private network and opt into a plaintext bind behind it: ```bash ./build/cli//bundle/bin/cc_server --data-dir ./data --port 9030 \ --bind any --insecure --public-url wss://server.example/rpc ``` `--insecure` is ignored when `--tls-cert` and `--tls-key` are both set — TLS always wins. The server advertises the plaintext state as `insecure: true` in `GET /healthz` and clients badge such a connection as insecure. The server runs until `SIGINT`/`SIGTERM`, then shuts down cleanly. ## Step 5: Connect a client Point a client at the server URL and pairing key from step 3: - **Desktop app**: on the first-run screen (**How should Control Center run?**) fill in the connect fields instead of **Run in this app**, or add the server later at **Settings → Server → Connection & status**. See [Connect to a remote server](/manual/guides/connect-remote-server/). - **Web client**: open [app.usectrl.dev](https://app.usectrl.dev) (or your own copy of the web build) and enter the server URL, device id and pairing key. - **Phone**: pair the **Remote** app from a running client — see [Pair a device](/manual/guides/pair-a-device/). :::note[cc_server serves no web bundle] A standalone `cc_server` never mounts a web root, so `GET /` returns 404. The web client is hosted separately — use `app.usectrl.dev`, or host the web build yourself on any static HTTPS host (the `cc-webapp` image above is exactly that). A browser origin other than loopback must be on `--allowed-origins` or the WebSocket upgrade to `/rpc` is refused; the default list is `https://app.usectrl.dev` and `localhost` / `127.0.0.1` are always allowed. ::: ## Step 6: Connect a Google Calendar (optional) To sync a workspace's calendar with no GUI attached, use the device-code flow: ```bash ./build/cli//bundle/bin/cc_server calendar connect \ --data-dir ./data --workspace \ --google-client-id --google-client-secret ``` It prints a code and a URL to approve on another device, stores the refresh token server-side and exits. A source build ships no Google client, so the id and secret (or their environment variables) are required — see [Connect a Google Calendar](/manual/guides/connect-calendar/). ## Configuration The flags used above are the ones a first setup needs. The complete surface — 23 flag and environment-variable pairs, the four subcommands and the data-directory layout — is in the [`cc_server` CLI reference](/manual/reference/cc-server-cli/). :::caution[Unknown flags are ignored silently] The argument parser is hand-rolled and skips anything it does not recognise, so a typo (`--tls-key-file`, `--bindany`) produces no error — the server just starts with the default. Check the startup log lines when a flag seems to have no effect. ::: The four you are most likely to need: | Flag | Default | Meaning | | -------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------ | | `--data-dir` | the OS per-user application-data dir | Databases, secrets, models and cached media | | `--port` | `9030` | TCP port (`0` = ephemeral) | | `--bind` | `loopback` | `loopback`, or `any` for every interface (needs TLS or `--insecure`) | | `--public-url` | derived from the bind | The RPC URL advertised to paired clients. **Set this explicitly behind a proxy, NAT, or tunnel** | The default data dir is `~/Library/Application Support/control-center` on macOS, `%APPDATA%\control-center` on Windows and `$XDG_DATA_HOME/control-center` (else `~/.local/share/control-center`) elsewhere. A cwd-relative `.cc_server` is only used when no home or app-data directory resolves. ## What the server serves `cc_server` is the whole product surface, not a subset: the repo-RPC data path and subscriptions, the MCP tool endpoint at `POST /mcp` and `GET /sse` on the same port, inbound webhooks, the media and font proxies, single sign-on callbacks, the fleet lease protocol, server-side RSS fetching on a 30-minute schedule and vector search (the `sqlite_vector` extension is registered as a process-global auto-extension, degrading to full-text search only where the extension is unavailable). The dispatcher is **stateless**: there is no per-session workspace binding. Every workspace-scoped call carries its own `workspace_id` and access is decided by the caller's workspace membership and role — not by holding a pairing key. Two things to know before you expose the port beyond loopback: - **MCP fails closed off-host.** A tokenless MCP surface answers loopback only. Binding `any` does not widen it — an off-host request is refused with "MCP requires a bearer token to answer non-loopback clients" until you configure one (`mcp.setToken`, or **Settings → Server → MCP servers**). - **`GET /healthz` is unauthenticated by design**, with wildcard CORS and publishes the server's id, name, identity fingerprint and build version. Clients need the fingerprint to pin the server, so this is deliberate — but it does mean any reachable `cc_server` discloses that much to anyone who asks. ## Related guides - [Connect to a remote server](/manual/guides/connect-remote-server/) - [Pair a device](/manual/guides/pair-a-device/) - [Run a fleet worker](/manual/guides/run-fleet-worker/) - [Connect a Google Calendar](/manual/guides/connect-calendar/) ## Related concepts - [Deployment and clients](/manual/concepts/deployment/) - [Remote control and mobile](/manual/concepts/remote-control/) ### Run an orchestration > Ask an orchestrator for a plan, review and edit it in Plan Studio, approve it once and let a generated pipeline do the work. Source: https://usectrl.dev/manual/guides/run-orchestration/ This guide shows you how to turn one big goal into a whole-team plan that runs itself. See [Orchestration](/manual/concepts/orchestration/) for how it works. ## When to use an orchestration Reach for an orchestration when the ask is bigger than one agent and you don't already know the exact steps. For a single task, message an agent in a [space](/manual/guides/chat-with-agent/); for a process you can spell out by hand, build a [pipeline template](/manual/guides/create-pipeline/). ## Step 1: Ask for the plan 1. Open (or create) a space and, if you want one, open the ticket the work belongs to. Every proposal is anchored on a ticket. 2. Optionally switch the composer's mode dropdown to **Orchestrate**. That mode narrows the agent to reading and proposing — memory, the code graph, repo and ticket reads and `propose_orchestration` — so it cannot start writing code instead of planning. 3. @-mention your orchestrator (usually the CEO agent) and state the goal in plain language: > @ceo Research the competitive landscape for an offline-first meeting recorder > and write a one-page positioning doc. The orchestrator answers with a structured **proposal** card in the space: the roles it needs, the work broken into sub-tickets, an optional research and discussion phase, a synthesis step that produces the deliverable and a budget. Because the proposal is a typed object, the app validates it (well-formed roles, declared output schemas, a DAG with no cycles) before anything runs. A proposal that fails validation cannot be approved. ## Step 2: Review and edit the proposal The in-chat card offers three actions: **Approve plan**, **Reject** and **Open** — which opens the plan in Plan Studio as an editor tab. The card itself cannot be edited. To change anything, press **Open** and work in the right-hand inspector: - **Roles** — each is either an existing agent or a **new hire** the orchestration creates on approval. Swap a role for an agent you trust, or point a hire at an existing agent. - **Title**, **description**, **dependencies** and **output schema** of any sub-ticket. Two things are read-only: structural nodes (research, discussion, synthesis) and any node whose work has already run. Editing is possible only while the orchestration is still `proposed`. Press **Estimate** in the approval bar for cost, duration and blast-radius ranges before you commit. See [Work in Plan Studio](/manual/guides/plan-studio/) for the full editor. :::note[The budget is not a spending limit] A proposal's budget is compared against the _estimate_ in the approval bar: when the estimate's high end exceeds it, the total turns red with an over-budget warning. Nothing checks spend while the plan executes, so it will not stop or fail a run. For an actual spend stop, use per-agent budget policies — see [Manage costs](/manual/guides/manage-costs/). ::: ## Step 3: Approve Approve the plan, from the chat card or from Plan Studio's approval bar. **This is the only decision you make.** Approving is operator-only — there is no MCP tool for it, so an agent can propose and revise but never approve its own plan. From here a deterministic materializer (a pure function, no LLM) turns the proposal into a real [pipeline](/manual/concepts/pipelines/) and creates the scaffolding: - hires any new roles (and records them) - groups the roles into a **team** - files the sub-tickets under a **project** - starts the **pipeline run** that drives it all The status moves to `approved`, then `executing`. ## Step 4: Watch it run The sub-tickets execute as pipeline steps; if you enabled a discussion round, each role posts a position first. Progress surfaces where you already look: - the **parent ticket** the orchestration was anchored on - the shared **space** the team talks in, where the plan's row tracks each node's state - the **pipeline run** detail, with its usual waterfall and per-step cost — see [Monitor pipeline runs](/manual/guides/monitor-pipelines/) - the **project** the sub-tickets were filed under Once the sub-tickets are done, the status moves to `synthesizing` and the synthesis step produces the deliverable onto the parent ticket, then `completed`. ## Stop it early Press **Cancel orchestration** on the in-chat card, or **Cancel run** in Plan Studio's approval bar. Cancellation tears down the in-flight work. An orchestration fails when its generated pipeline run fails — which one failing step is enough to cause, unless that node is set to continue on fail. There is no budget-exceeded failure path and no "every sub-ticket failed" threshold. ## Related guides - [Work in Plan Studio](/manual/guides/plan-studio/): edit, estimate, diff and approve the plan - [Create and manage tickets](/manual/guides/manage-tickets/): the sub-tickets an orchestration creates - [Organize work with projects](/manual/guides/projects/): where those sub-tickets are filed - [Monitor pipeline runs](/manual/guides/monitor-pipelines/): what a materialized orchestration looks like in the engine - [Build an agent team](/manual/guides/build-team/): the roles an orchestration fills or hires ## Related concepts - [Orchestration](/manual/concepts/orchestration/) - [Modes](/manual/concepts/modes/) ### Run a pipeline manually > Start a pipeline run from the launcher, fill in its inputs and land on the run detail. Source: https://usectrl.dev/manual/guides/run-pipeline/ This guide covers starting a run by hand. For watching one, reading step output and recovering from a failure, see [Monitor pipeline runs](/manual/guides/monitor-pipelines/). ## Start a run 1. Click **Pipelines** in the workspace sidebar. 2. Press **Run pipeline** in the header. You land on the launcher (`/workspaces//pipelines/run`). 3. Pick a template from the left rail. Each card shows the template's name, its description and whether it takes inputs. 4. Fill in the input form on the right. Required fields block the button until they have a value; a template with no inputs says so. 5. Press **Run pipeline**. You land on the run's detail page, with the timing waterfall and the graph canvas live. ## If the rail is empty The launcher shows a template only when **both** are true: - The template's switch is on in **Settings → Workspace → Pipeline templates**. - **Allow manual run** is on in the template editor's **Manual run** dialog — that switch is what creates the `manual` trigger the launcher reads. Eight of the thirteen built-in templates ship disabled: `external_pr_welcome`, `cross_review`, `ticket_to_pr`, `pr_triage`, `pre_merge_gate`, `release_notes`, `dep_audit` and `pr_digest`. The engine refuses to start a disabled template, so turn its switch on before looking for it here. There is no per-template **Run** action on the templates list and no run button inside the editor — the launcher is the only manual entry point. ## Fill in a repository input An input of type **Repository** renders a picker over the workspace's registered repos. Choosing one populates `repoId`, `repoLocalPath`, `repoFullName` and — when the repo has a GitHub remote — `repoOwner` and `repoName` into the run payload, so downstream steps can read whichever they need. ## Watch it run The run detail page has three parts: a meta strip (start time, active duration, step progress, a failure banner, **Retry** and **Delete**), a collapsible timing waterfall with per-step duration and cost and the graph canvas. Selecting a node opens that step's detail panel. [Monitor pipeline runs](/manual/guides/monitor-pipelines/) covers all of it, including how to read a suspended step and what to do when one fails. :::caution[There is no dry-run toggle] `pipeline.start` accepts a `dry_run` flag on the wire and the bash body honours it, but no client sets it and there is no control for it in the launcher. Every run you start from the app is a real one. ::: ## Related guides - [Create a pipeline template](/manual/guides/create-pipeline/) - [Set up pipeline triggers](/manual/guides/pipeline-triggers/) - [Monitor pipeline runs](/manual/guides/monitor-pipelines/) ## Related concepts - [Pipelines](/manual/concepts/pipelines/) ### Configure sandbox policies > What constrains an agent run, how to tell whether the OS sandbox is wrapping it and which capability settings the server actually reads. Source: https://usectrl.dev/manual/guides/sandbox-policies/ :::caution[The controls on the sandboxing settings page are still client-only] Whether an agent run is sandboxed is decided entirely on the server, at boot. The master toggle, the backend picker and the **Default capabilities** toggles under **Settings → Server → Diagnostics & privacy** are preferences stored on your device; the server never reads any of them, so changing them does not change how an agent runs. Only the **Backend** card's detection result comes from the server and it reports what the server host _could_ do. Two controls do reach a run and this guide covers both: the per-agent **Sandbox permissions** below and the server's own `--sandbox` flag. ::: ## What constrains an agent run An agent is held by six things. The first is the OS sandbox and it is the one that is conditional — the other five hold on every host. | Boundary | What it does | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The OS sandbox | Seatbelt on macOS, bubblewrap on Linux and WSL2. Wraps the external CLI transports, the ACP transport and the harness `bash` tool. **Absent on Windows and on Linux without `bwrap` and `socat`** | | Its own worktree | Each conversation gets a copy-on-write checkout. The original checkout is folded into the run as a deny-write path and the sandbox is what enforces it | | Brokered credentials | The credential broker mints a per-run environment from the agent's capabilities and revokes it at teardown. No capability, no token in the environment | | Action guardrails | Every mutating tool declares its effect classes and the guard resolves them before the call runs. A `prompt` with no approver connected is denied | | Environment sanitization | The spawned process gets a filtered environment, not yours | | The mode's tool surface | A read-only mode removes the worktree-mutating tools outright and pins the mode's own output verb so it can still deliver | Three things stay weaker than they look: - Only the **built-in harness** enforces the tool gate, completion contracts and structural mode rules. External CLI and ACP adapters do not get them. Claude Code in particular is launched with `--dangerously-skip-permissions`, because a non-interactive `claude -p` would otherwise block forever on its own prompt — so Claude's own read, write, edit and bash calls are neither seen nor gated by Control Center. Where the sandbox is on, it is the boundary around them. - The harness's in-process file tools (read, write, edit, apply patch, search, find) run inside the server process, so no sandbox profile constrains them. Only the `bash` tool goes through the sandboxed command runner. Those tools are bounded by their own path confinement plus the action guardrails. - On a host with no backend the first row does not apply at all. The `bash` tool, the external CLIs and ACP all fall back to environment sanitization plus the command policy and the server says so in its startup log. Check yours below. ## Set an agent's capabilities This is the one capability control the server reads. It is per agent and it decides what the credential broker puts in that agent's environment. 1. Go to **Settings → Workspace → Agents** 2. Select the agent in the list 3. On the **Settings** tab, scroll to **Sandbox permissions** 4. Turn the switch on — the row's caption changes from **Use workspace default** 5. Set the four toggles: | Toggle | What it gates | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Allow git push** | With this off, the run gets `GIT_ASKPASS=/usr/bin/false` and `GIT_TERMINAL_PROMPT=0`, so a push cannot prompt for a credential | | **Allow GitHub API calls** | Together with **Allow git push**, gates whether the broker injects the GitHub token as `GH_TOKEN` / `GITHUB_TOKEN` | | **Allow ticketing API calls** | Gates whether the broker injects `TICKETING_API_KEY` | | **Allow general network access** | Where the sandbox is on, this is a real egress gate: with it off the run's allowlist is empty and every outbound request is refused at the proxy. Where there is no backend, it is recorded on the run but blocks nothing a spawned process does | 6. Save Checkpoint: reopen the agent and the switch is still on with your toggles — a saved capability set round-trips through the server and an agent with none falls back to the conservative default (push off, GitHub API off, ticketing off, network on). :::caution[Two ways this bites] The caption says **Use workspace default**, but there is no workspace-level capability setting the server reads. An agent with the switch off gets the built-in conservative default. Separately, a run requested by a member who has stored their own GitHub token on the server has that token merged in _after_ the broker's environment, so it arrives regardless of the GitHub capability. There is no in-app screen that stores a per-member token today, so on a normal install this does not fire — but do not treat **Allow GitHub API calls** as a hard egress boundary. ::: ## Check whether your agent runs are sandboxed Two things decide it, both on the server: whether the host offers a backend and whether `--sandbox` is on. Check them in that order. 1. Go to **Settings → Server → Diagnostics & privacy** 2. Read the **Backend** card. It reports the machine `cc_server` runs on, not the machine you are looking at: **Native** (Seatbelt on macOS, bubblewrap on Linux and WSL2) or **None** 3. Read the server's startup log. It prints exactly one of three lines: - `agent sandbox ON` plus the backend note — runs are wrapped - `no OS-native agent sandbox on this host` plus an install hint where there is one — runs are not wrapped - `agent sandbox DISABLED by --sandbox off` — a deliberate opt-out `GET /healthz` does not report sandbox state, so the startup log is the check. To turn the sandbox off deliberately — a host where the profile misbehaves is the reason it exists — start the server with `--sandbox off`, or set `CC_SERVER_SANDBOX=off`. The default is `on` and `on` means "use a backend if the host has one", never "require one". Two situations never get a sandbox. On **Windows** there is no backend at all: the probe always reports unavailable there and there is nothing to install. On **Linux and WSL2** both `bwrap` and `socat` must be on the `PATH`; without them the probe reports unavailable and names the missing packages. ## Know what a mode already forbids Mode is stored per space and feeds the guardrail resolver as the **mode preset** layer. Three of the four modes are read-only and a read-only mode refuses ten effect classes outright: | Mode | Worktree | Refused by the preset | | ------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Agent** (`chat`) | Writable | Nothing — the built-in defaults apply | | **Plan** | Read-only | Delete a file, write outside the worktree, commit, push, open a PR, publish or merge, write to an external tracker, install a package, run a process, change workspace structure | | **Review** | Read-only | The same ten. Not user-selectable — the composer offers only Agent, Plan and Orchestrate | | **Orchestrate** | Read-only | The same ten | Network access and secret access are deliberately _not_ refused by a read-only mode: research is the point of one. ## Require approval before an action runs Approval is a guardrail decision, not a sandbox setting. 1. Go to **Settings → Workspace → Agent permissions** 2. Set the action class to **Ask first** at the workspace, agent or space scope An **Ask first** decision interrupts the run and asks you. With no approver connected it fails closed and the action is denied. See [Configure guardrails](/manual/guides/configure-guardrails/) for the full resolution order. ## Troubleshooting ### An agent cannot push and I never denied it Check the agent's **Sandbox permissions**. An agent with no custom capability set gets the conservative default, which has **Allow git push** off — so the broker injects no GitHub token and git has no way to authenticate. ### The backend card says no native sandbox is available Then agent runs on that host are not sandboxed and the remaining boundaries are what you have. On Linux and WSL2, install both missing tools — `sudo apt install bubblewrap socat` — and restart the server; the startup log should then say the sandbox is on. On Windows there is no backend to install, so tighten the guardrails instead: see [Configure guardrails](/manual/guides/configure-guardrails/). ### An agent reached a file outside its worktree Which tool reached it decides whether this is expected. The harness's in-process file tools (read, write, edit, apply patch, search) run in the server process, so no sandbox profile applies to them — their bound is their own path confinement plus the guardrails. A `bash` command, an external CLI or an ACP agent _is_ wrapped where a backend exists and your registered checkouts are deny-write inside that wrap in every mode, including their symlink-resolved spellings (on macOS a path reached through `/tmp` or `/var/folders` resolves under `/private` and both spellings are now denied). On a host with no backend none of that applies. Either way, guardrails can refuse **Write outside the worktree** outright. ## Related guides - [Configure guardrails](/manual/guides/configure-guardrails/) - [Create and configure an agent](/manual/guides/create-agent/) - [Diagnose an agent](/manual/guides/agent-diagnostics/) - [Sandbox backends](/manual/reference/sandbox-backends/) - [cc_server CLI](/manual/reference/cc-server-cli/): the `--sandbox` flag ## Related concepts - [Sandbox and security](/manual/concepts/sandbox-security/) - [Modes](/manual/concepts/modes/) ### Set up Slack integration > Connect a Slack app to a Control Center workspace so members can drive agents from Slack. Source: https://usectrl.dev/manual/guides/slack-integration/ This guide shows you how to connect Slack to a Control Center workspace, either by letting Control Center create the app or by building it yourself. For a guided first run, see [Bridge Slack to your workspace](/manual/tutorials/first-chat-bridge/). :::note[Slack is the only chat provider] The bridge is provider-blind by construction — a descriptor-driven UI over one adapter per provider — but Slack is the only adapter that exists. There is no Discord, Telegram, Teams or Matrix implementation. ::: ## Prerequisites - The `owner` or `admin` role in the Control Center workspace — connecting a chat app is a workspace-settings change - Permission to create and install apps in the Slack workspace - Nothing publicly reachable: the connection is outbound (Socket Mode), so the server needs no inbound port, tunnel, or domain ## Three ways to get the app | Path | You provide | Control Center does | | ------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | **Guided** | An app configuration token (`xoxe-…`) | Creates the app with the right scopes, events, Socket Mode and slash command; hands you the two steps Slack has no API for | | **Create in Slack** | Nothing | Opens Slack with the same configuration pre-filled; you pick a workspace and confirm | | **By hand** | The finished app's tokens | Verifies them and connects | The guided path is the only one that also leaves the app _manageable_ from Control Center, which is what enables [Customize the chat bot](/manual/guides/customize-chat-bot/). A **Create in Slack** app can be promoted to a managed one later by pasting a configuration token in the connect dialog. There is deliberately no "install Control Center's Slack app" button. Installing a distributed Slack app requires a shared client secret (Slack has no PKCE) and Socket Mode distributes an app's events randomly across its connections — so one shared app would deliver your workspace's messages to other people's servers. Every workspace therefore owns its own app and these three paths are about how little work that costs you. ## Option A: let Control Center create the app 1. Generate an app configuration token pair at [api.slack.com/apps](https://api.slack.com/apps) and copy the **refresh** token (`xoxe-…`) 2. In Control Center, go to **Settings → Workspace → General** and scroll to the **Chat bridges** card 3. On the Slack block, click **Create app for me** 4. Paste the token, adjust the app name, bot name, descriptions and slash command, then click **Create app** 5. Follow the two remaining steps the dialog shows, each as a link into Slack: - **Basic Information → App-Level Tokens**: generate a token with the `connections:write` scope (`xapp-…`) - **Install App**: install to the workspace and copy the **Bot User OAuth Token** (`xoxb-…`) 6. Click **Paste the credentials** and continue with [Connect](#connect) The configuration token rotates on every use. Control Center stores the rotated value as it goes, so you never have to paste it again. ## Option B: create it in Slack, pre-filled Same app, no configuration token — Slack hands you its own create screen with the whole manifest already filled in. 1. In Control Center, go to **Settings → Workspace → General** and scroll to the **Chat bridges** card 2. On the Slack block, click **Create app for me** 3. Adjust the app name, bot name, descriptions and slash command — these travel with the link 4. Click **Create in Slack**. Your browser opens Slack's app-creation page with the configuration pre-filled 5. Pick the Slack workspace and confirm. The scopes, events, Socket Mode and slash command are already correct 6. Back in Control Center, follow the two remaining steps the dialog shows: - **Basic Information → App-Level Tokens**: generate a token with the `connections:write` scope (`xapp-…`) - **Install App**: install to the workspace and copy the **Bot User OAuth Token** (`xoxb-…`) 7. Click **Paste the credentials** and continue with [Connect](#connect) Slack does not report which app it created, so Control Center does not learn the app id and the links in step 6 go to your app list rather than straight to that app. For the same reason **Customize bot** stays unavailable until you add a configuration token. ## Option C: build the app yourself Create an app at [api.slack.com/apps](https://api.slack.com/apps), then set it up as follows. **Socket Mode**: enable it and leave every request URL empty. Slack refuses a manifest that asks for both Socket Mode and webhook delivery. **Bot token scopes** (OAuth & Permissions): ```text app_mentions:read assistant:write channels:history chat:write commands groups:history im:history users:read users:read.email ``` **Event subscriptions** (bot events): ```text app_home_opened app_mention message.channels message.groups message.im ``` `message.channels` and `message.groups` are what make a _reply_ inside an already-bridged Slack thread work. Without them, only the first mention ever arrives. **Slash command**: `/cc` (any name works — the bridge uses whatever command it was last invoked with). **App Home**: enable the Messages tab and leave it writable, so members can DM the bot. **Tokens**: generate an app-level token with `connections:write`, then install the app and copy the bot user OAuth token. ## Connect 1. Go to **Settings → Workspace → General** and scroll to the **Chat bridges** card 2. On the Slack block, click **Connect Slack** 3. Fill in the fields: | Field | Value | Where it lives in Slack | | ---------------------------------------------- | -------- | ---------------------------------------------------------- | | **Bot user OAuth token** | `xoxb-…` | OAuth & Permissions → Bot User OAuth Token | | **App-level token** | `xapp-…` | Basic Information → App-Level Tokens (`connections:write`) | | **App configuration refresh token** (optional) | `xoxe-…` | The app configuration token pair on api.slack.com/apps | The prefix of each value is checked on both sides before anything is sent, so a value pasted into the wrong box is refused with the same sentence either way. 4. Click **Connect** Control Center verifies the bot token with Slack's `auth.test` _before_ storing anything, so a typo is reported to you rather than becoming a socket that quietly never works. The app-level token can only be proven by opening the socket, so a bad one shows up a moment later as a connection error on the same row. Without the optional app configuration token, the connection works fine — you just cannot edit the bot from Control Center. ## What connecting does - Stores the credentials in the workspace's own directory, beside its database, owner-readable only. They never travel back to a client - Opens one Socket Mode connection for this workspace and starts receiving mentions, Slack thread replies, DMs and slash commands - Re-arms every Slack thread that was bridged before, so a reconnect continues existing conversations instead of stranding them - Costs nothing for workspaces that have no chat app: no socket, no timers, no traffic :::note[Where the tokens live and why not the keychain] Bot credentials are one plain JSON file per provider at `//chat_credentials/`, written atomically and tightened to owner-only. That is deliberate, not an oversight: deleting a workspace unlinks that directory so its tokens go with it and `workspace.export` and backups copy `workspace.db` — not the directory — so a workspace you hand to someone else never carries live bot tokens inside it. The corollary is that restoring a workspace elsewhere means reconnecting Slack there. Access to the bridge is workspace **membership**, never possession of a token. A Slack account has to be linked to a Control Center user who is a member with a writing role. Connect, disconnect, guided setup, the setup link and bot customization are all admin-gated. ::: ## Verify The Slack row shows a status tag: **Live**, **Connecting…**, **Connection error** (with Slack's own error next to it), or **Not connected**. To check end to end: 1. In Slack, invite the bot to a channel: `/invite @your-bot-name` 2. Mention it with a request 3. A Slack thread reply appears and a matching space appears in Control Center Every member who wants to use the bridge then links their own account — see [Link your Slack account](/manual/guides/link-chat-account/). ## What a reply looks like in Slack Above the streamed words sits one grouped card with a row per step. Setup is one row whose title moves from `Working on it…` to `Cloning …` to `Starting the agent…`. Reasoning adds a `Thinking…` row; when that step finishes, a short thought can appear on it. Each tool is its own row (`Read lib/main.dart`). The answer arrives as ordinary text under the card. The card turns complete when the turn finishes and errored when the run failed, was interrupted, or hit its turn limit. The card starts the moment the bot accepts the mention — `Working on it…` — so the Slack thread is not silent while the workspace is still being built. Your first mention in a Slack channel creates the conversation, which clones the repos the agent works in, so that setup row then reads `Cloning acme/widgets…`, then `Setting up Reviewer…`, then `Starting the agent…`, then a `Thinking…` row once the agent is actually reasoning. On a big repository that first setup takes a while; the card is how you can tell it is working rather than ignoring you. Later mentions in the same Slack thread still open with `Working on it…`, then skip the clone steps because the workspace is already there. Reasoning as a wall of text and tool output, never leave Control Center — the thought on the `Thinking…` row is a short line, not a transcript. **View in Control Center** on the card opens the full conversation in the desktop app — the link goes to your own Control Center server, which hands the browser over to the app. If nothing happens when you click it, the server that sent the message is not reachable from the machine you clicked on (a loopback-only server clicked from a phone, for example), or Control Center is not running there. A server with no reachable address sends a card with no button rather than one that leads nowhere. ## Rotate a token Re-paste the new value in the connect dialog and click **Connect**. Connecting replaces whatever connection was running for this workspace, so the socket is re-dialed with the new credentials. The two required tokens have to be present on every connect. The optional app configuration token keeps its stored value when you leave the box empty, so rotating a bot token never costs you the ability to edit the app. ## Disconnect Click **Disconnect** on the Slack row (admins only). The socket closes and the credentials are forgotten. Space and user links deliberately survive: reconnecting the same app restores every bridged Slack thread. They are workspace-scoped rows, so deleting the workspace removes them anyway. ## Troubleshooting ### The row says "Connection error" Read the error shown next to the tag. A rejected credential (`invalid_auth`, `token_revoked`, `account_inactive`) is terminal — the bridge stops retrying and waits for new tokens, because retrying a revoked token forever only rate-limits you. Re-paste the tokens in the connect dialog. A `missing_scope` error means the app is installed with fewer scopes than the bridge needs. Add the missing scope, reinstall the app in Slack, then reconnect. Anything transient (a dropped socket, a Slack hiccup) reconnects on its own with exponential backoff capped at 30 seconds and Slack's routine socket rotation does not escalate that backoff. ### The bot does not answer a mention - **Is the bot in the channel?** Slack only delivers channel messages to apps that are members. Run `/invite @your-bot-name` - **Are you linked?** An unlinked member gets a private reply explaining how to link, visible only to them. See [Link your Slack account](/manual/guides/link-chat-account/) - **Are you a member of the workspace with a writing role?** A `viewer` or `guest` is told their role is read-only. The bridge never starts work on behalf of someone it cannot attribute - **Does the workspace have any agents?** With no agents, the message is stored but has nobody to answer it ### It answers in a channel but ignores plain messages That is intended. Outside a DM, the bridge only acts on a message that mentions the bot, or a reply inside a Slack thread it already owns. Everything else is other people's conversation. ### Replies arrive as one big message instead of streaming The row says "Live streaming is off for this Slack app — replies arrive as one message." Slack's streaming replies need the Agents & AI Apps feature on a paid plan; when Slack refuses it, the bridge stops asking for the rest of that connection's life and posts whole replies instead. Nothing is lost — the full transcript is always in Control Center. Streaming also needs a Slack thread anchor, so a reply that has no Slack thread to grow in is posted whole. The task card still arrives either way: on a whole reply it rides the same message, already complete. ### The reply has no card above it Slack's task cards are part of the same new Agents & AI Apps surface as streaming and Slack only refuses at call time. When it does, Control Center keeps the reply and drops the card for the rest of that connection's life — an answer without a status line beats a status line without an answer. ### "Control Center needs an app configuration token to edit the bot" The **Customize bot** row is disabled because the connection has no app configuration token. This is expected for an app made by hand or through **Create in Slack** — Slack never told Control Center which app it is. Generate a token pair on api.slack.com/apps and reconnect with the refresh token filled in. ### A long answer is cut off Slack caps a single message at 40,000 characters, so the bridge truncates at 39,000 and appends a pointer to Control Center — a shortened reply beats one Slack rejects outright. The full text is always in the space transcript. ## Related guides - [Link your Slack account](/manual/guides/link-chat-account/) - [Customize the chat bot](/manual/guides/customize-chat-bot/) - [Chat bridge](/manual/reference/chat-bridge/) ## Related concepts - [Chat bridges](/manual/concepts/chat-bridges/) ### Use spaces > Create spaces, set their mode, manage participants and autonomy and open conversations and threads. Source: https://usectrl.dev/manual/guides/spaces/ This guide shows you how to use spaces. A **space** is the messaging surface in Control Center: a workspace-scoped room with one or more participants (you and/or agents), owning a copy-on-write worktree per repo it was created for. One-on-one and many-agent rooms are both just spaces, distinguished only by how many participants they have. The one exception is spaces agents open to talk to each other — see [Agent-to-agent spaces](#agent-to-agent-spaces) below. ## Create a space 1. Navigate to **Spaces** 2. Click the **+** button beside the Spaces heading 3. Give it a **name** — required. An empty name cancels the dialog silently 4. Add one or more agents from the workspace 5. When the workspace has several repos, pick which ones this space provisions worktrees for. All are selected by default 6. Confirm Spaces are workspace-scoped: you only see agents from the current workspace and a space never surfaces data from another workspace. Each repo is checked out at `repos//` inside the space. A repo that ships its own agent skills contributes them to whichever agent is working in it — one repo's set at a time, swapping as the agent moves. From the composer you can reach any of them with `/skill::`. See [Repo-scoped skills](/manual/concepts/agent-model/#repo-scoped-skills). The surface lives at `/workspaces//spaces`, and a single space at `/workspaces//spaces/`. ## Set the space's mode A new space starts in the default mode. Change it from the **mode dropdown** on the far left of the composer toolbar — not from the create dialog (which has no mode field) and not from the space header. | Mode | Effect | | --------------- | ----------------------------------------------------------------------------------------------------- | | **Agent** | Full access. The default; `chat` internally, labelled "Agent" in the dropdown | | **Plan** | Read and research only; the deliverable is a typed plan | | **Orchestrate** | Read and research plus a single `propose_orchestration` verb; the plan runs only after you approve it | Shift+Tab toggles between plan and agent mode without opening the dropdown. A fourth mode, **review**, exists but is not offered in the dropdown — it is applied automatically to PR review spaces. The mode is stored on the space, so the same agent can be in plan mode in one space and agent mode in another. Every conversation inside a space shares that space's mode. :::caution[Watch for the enforcement badge] A badge appears beside the mode dropdown when the space's agents run on an adapter that cannot enforce the mode you picked. Only the built-in Control Center harness filters the tool surface and gates every call; external CLI and ACP adapters receive the mode as a request their runner may or may not honour. No badge means "no claim is being made", not "verified safe". ::: See [Modes](/manual/concepts/modes/) for the full model. ## Manage participants and per-agent autonomy Click the people icon in the space header to open **Manage participants**. For each agent already in the space you can: - Remove it from the space - Set its **Autonomy** for this space: **Default**, **Propose only**, **Act with approval** or **Act freely** The autonomy dial is per (space, agent), so an agent you trust in one room can be held to propose-only in another. ## React to a message Hover a message and click the smile icon to open the reaction palette. It is a fixed set of six: 👍 👎 🎉 ❤️ 👀 ✅. There is no free emoji picker — tapping one toggles it for you and the aggregated chips appear below the message. ## Open a conversation A space hosts several conversations at once and they are flat equals — there is no primary one. The **conversation strip** above the composer lists them and its **New conversation** button opens another, asking for a title (hint: "e.g. quick fix"). Use one to run parallel work with the same agent and worktree, then close it and return. Each conversation keeps its own message history and agent sessions while sharing the space's worktrees and its mode. Every chip on the strip carries a close affordance, which archives only that conversation. A space with no conversations left shows an empty state offering the same **New conversation** action. ## Start a thread Hover any message and choose **Start thread**. That opens a conversation anchored to that message: the anchor seeds the new conversation's agent context with one line and the parent's history is **not** copied. Once a thread has replies, the message it hangs off grows a row underneath it — the faces of who has spoken, the reply count and when the last reply landed. That row is the way back in: clicking it opens (or focuses) the thread's tab. It is the only trace a thread leaves in the stream it branched from, which is why it is there at all. A thread's tab is titled `Thread: ` and carries the branch glyph, so two tabs on the same space are never mistaken for each other. Threads never nest — anchoring to a message that already sits inside a thread is refused. The conversation header links back to the anchor message. Deleting the anchor message deletes the thread, and archiving the parent conversation does not block its existing threads. ## Read state Read marks are scoped to the space, not the conversation: unread aggregates across all of a space's conversations, so there is no per-conversation badge. Unread dots and notifications treat every conversation equally, threads included. ## Agent-to-agent spaces Spaces that agents open to talk to each other are sectioned separately in the sidebar under **Agents**, collapsed by default and rendered muted. They never bump your unread badge and never fire an OS notification — agent chatter is quiet by construction, not by a setting you have to find. A space's kind is one of five values: `topic` (the default for anything you create), `agentPeer`, `system` (pipeline-managed), `pr` (created when a PR surface needs a worktree anchor and hidden from the sidebar until it has messages) and `slack` (created by the chat bridge for a Slack thread or bot DM, and treated as an ordinary human conversation). ## Related guides - [Chat with an agent](/manual/guides/chat-with-agent/) - [@-mention agents](/manual/guides/mention-agents/) - [Use plan mode](/manual/guides/plan-mode/) ## Related concepts - [Modes](/manual/concepts/modes/) - [Chat bridges](/manual/concepts/chat-bridges/): how a Slack thread becomes a space ### Connect an OpenID Connect provider > Wire any OIDC issuer — Pocket ID, Keycloak, Auth0, Zitadel — to Control Center sign-in. Source: https://usectrl.dev/manual/guides/sso-oidc/ Use this guide when your identity provider speaks **OpenID Connect** rather than SAML. Any OIDC-certified issuer works — Pocket ID, Keycloak, Auth0, Zitadel, Google Workspace and friends all follow the same steps; only the spot where your provider shows the values differs. ## Prerequisites - an OIDC provider where you can create a client — note its **client id** and its **issuer URL** (the base the `.well-known/openid-configuration` document lives under), plus its **client secret** when the client is the confidential kind (public PKCE clients have none), - a Control Center server you administer. ## Steps 1. Open **Settings → Server → Single sign-on** and switch to the **OpenID Connect** tab. 2. Fill in the **issuer URL** and the **client id** and the **client secret** when the provider issued one. Control Center discovers the endpoints from the issuer's standard discovery document — no per-provider configuration. 3. At the provider, register the redirect `https:///oidc/callback` on that client. 4. If your groups live under a claim other than `groups`, set the **groups claim** name; map groups to roles if you want (SSO can never grant the owner role). 5. Choose the membership policy (auto-add to every workspace, or require per-workspace invites), flip **Enable this connection** and **Save**. 6. Press **Test connection** — the server fetches the issuer's discovery document and confirms its endpoints, so a green toast means everything short of the browser round-trip works. 7. Take a login: the web connect screen shows **Sign in with OpenID Connect**, or open `https:///oidc/login` directly. Claims (email, name, groups) are read from the issuer's token endpoint over TLS by your server — the browser never supplies them, so no local JWT signature verification is involved. The client should be allowed to request the `openid profile email` scope, which is exactly what Control Center asks for. Because that TLS channel is the only thing authenticating the claims, the issuer URL must be **https** — a plaintext issuer would let any on-path attacker forge identities wholesale. `http` is accepted for loopback issuers (`localhost`, `127.0.0.1`) only, for local development. :::note[Public client or confidential client?] The default — and recommended — shape is a **public client** using authorization code flow with **PKCE** (RFC 7634): the one-time code verifier the server holds replaces the secret and the token exchange is bound to the authorization request that started it. There is no secret to leak and a stolen authorization code is useless without the verifier. Some providers call this "SPA" or "native" client type when you create it. If your provider issued a **confidential** client instead, either mark the client as public at the provider (PKCE stays on either way), or paste the client secret into the connection's **Client secret** field. The secret is stored in the server's 0600 secrets file (never the database), sent to the token endpoint as `client_secret_post` and never returned over RPC; leaving the field blank on save keeps the stored secret. When a login fails at the token exchange, the server log names the provider's own error (`invalid_client` almost always means the secret is missing or wrong). ::: :::note[Reachability] Login itself is browser-mediated: the issuer only redirects the user's browser back to `/oidc/callback` and your server makes an outbound HTTPS call to the token endpoint — no inbound connection from the issuer is needed, so a server behind NAT works as long as users' browsers can reach it (LAN, VPN, Tailscale, or a tunnel). **SCIM provisioning is the exception**: the IdP calls your server directly, which requires a tunnel or a public URL — see [provision users with SCIM](/manual/guides/sso-scim). ::: ### Provision users with SCIM > Let your IdP create, update and deprovision Control Center accounts automatically. Source: https://usectrl.dev/manual/guides/sso-scim/ Use this guide when you want lifecycle control — offboarding must revoke access automatically, not just block future logins. SCIM is the push protocol IdPs (Okta, Entra ID and others) use for exactly that. ## Prerequisites - SSO already working (the [SSO tutorial](/manual/tutorials/sso) or the [OIDC guide](/manual/guides/sso-oidc)), - **a server the IdP can reach over HTTPS** — SCIM calls come from the IdP, not the user's browser. Use the built-in tunnel (Settings → Server → Sharing → cloudflared/ngrok) or a public URL. A server reachable only through the WebSocket relay cannot receive SCIM pushes; JIT provisioning still works there, this guide does not. ## Steps 1. Open **Settings → Server → Single sign-on** → the **User provisioning (SCIM)** card. The card shows the endpoint, e.g. `https:///scim/v2/Users`. 2. Press **Regenerate token** — the bearer token appears once in a dialog and is copied to the clipboard. Store it in your password manager; the server only keeps a constant-time-verifiable copy. 3. In your IdP's provisioning settings, enable SCIM with: - **Base URL**: the endpoint above - **Authentication**: `Authorization: Bearer ` - **User id**: `userName` (email) or `externalId` — both match 4. Test with one user: push them from the IdP, confirm they appear (SCIM create pins the provider's `externalId` onto the account, the same pin a first SSO login sets). ## What deprovisioning does When the IdP sends `active: false` (or `DELETE`): 1. every device credential is revoked first — live sessions drop within seconds, 2. every workspace membership is removed, 3. the account is stamped deactivated (it can never log in, whatever the IdP says later until explicitly reactivated). The user row itself survives — attribution on tickets, messages and commits is permanent. Reactivation (SCIM `active: true`) restores login but **not** memberships: those need a fresh invite or the login-time auto-member policy. ## Troubleshooting - **401 on every call** — the token changed; regenerate and update the IdP. - **The endpoint is not reachable** — the tunnel is down, or the server is relay-only. SCIM needs direct HTTPS; JIT keeps working regardless. - **Group push errors (501)** — intentional: group→role mapping happens at login from the SAML/OIDC group attributes, not from pushed groups. ### Refactor code structurally > Have an agent find and rewrite code by shape rather than by text, and review the change before it lands. Source: https://usectrl.dev/manual/guides/structural-refactor/ This guide shows you how to get an agent to make a wide mechanical change — a codemod, a bulk API migration, a pattern you want gone — using structural search and rewrite, with the change staged for review before anything is written. `ast_grep` matches code by **shape**. `ast_edit` rewrites it. `resolve` commits or discards. ## Before you start Structural tools cover the languages the code graph parses: **Dart, JavaScript, TypeScript, TSX and PHP**. They need the tree-sitter grammars staged, which they always are in a shipped build — a missing grammar stops the server rather than degrading the search. ## Find the shape first Never rewrite something you have not looked at. Ask the agent to search: > Find every place we call `dispose()` on a nullable that we just null-checked. ``` ast_grep(pattern: "if ($X != null) $X.dispose()", language: "dart") ``` The output is `file:line` plus the matched source for each site. ### What the pattern syntax buys you | Token | Matches | | ---------- | ------------------------------------------ | | `$NAME` | Any single node, captured | | `$$$NAME` | Any run of siblings — arguments, statements | | `$_` | Anything, captured under no name | The one that matters: **a repeated `$NAME` must capture the same text**. ``` if ($X != null) $X.dispose() ``` finds a guard that disposes what it tested. It does **not** find `if (a != null) b.dispose()` — which is usually the bug you were looking for. No textual search can express that. :::note[It cannot match inside a string or a comment] The pattern is parsed by the same grammar as the file, so `dispose(x)` written in a doc comment or a string literal is not a call and is not matched. That is the whole reason to reach for this over `grep`. ::: ### When the pattern finds nothing Two things to try, in order: 1. **Make it smaller.** `foo($X)` before `if (a) { foo($X); }`. A pattern that is not valid code on its own often is not the construct you meant. 2. **Name the language.** `language:` is inferred only when `path` names a single file. A pattern no parse context accepts is reported as unparseable — the tool says so rather than reporting "no matches" for something that was never being read the way it looks. ## Rewrite, and look before it lands ``` ast_edit( pattern: "if ($X != null) $X.dispose()", rewrite: "$X?.dispose()", language: "dart", ) ``` The result is a **proposal**: ``` (proposed) ast_edit: if ($X != null) $X.dispose() → $X?.dispose() 12 replacements in 4 files: lib/features/a/x.dart (3) lib/features/b/y.dart (5) … Nothing has been written. Call resolve with edit_id "edit_1" and action "accept" to apply it, or "discard" to drop it. ``` Nothing is on disk yet. Read the list, check a couple of the sites the search reported, then: ``` resolve(edit_id: "edit_1", action: "accept") ``` or ``` resolve(edit_id: "edit_1", action: "discard") ``` :::caution[The commit is all-or-nothing] Every file is compared against the content captured when the change was staged. If any one of them moved — the agent hand-edited it, a formatter ran, a diagnostics pass rewrote it — the **whole** change is refused and dropped. A partly-applied structural rewrite leaves a tree that compiles under neither the old shape nor the new one. Re-run the tool against the current files; do not retry the stale id. ::: ## Check your work with the compiler The point of doing this structurally is that it is wide. Wide changes are exactly where a plausible-looking rewrite breaks a type. Ask for diagnostics after resolving: ``` lsp(action: "diagnostics", file: "*") ``` `file: "*"` runs the project's own checker (`dart analyze`, `tsc --noEmit`, `cargo check`, `go build`) rather than a per-file query — the right shape of question after a change that touched forty files. ## Worked example: migrating a call Say `log(msg, level)` is becoming `log(level: level, message: msg)`. ``` ast_grep(pattern: "log($MSG, $LEVEL)", language: "dart") ``` Look at the hits. If some of them are a *different* two-argument `log`, narrow the pattern by scoping `path` to the directory that owns the one you mean, rather than trying to express the difference in the pattern. ``` ast_edit( pattern: "log($MSG, $LEVEL)", rewrite: "log(level: $LEVEL, message: $MSG)", path: "lib/features/telemetry", language: "dart", ) ``` Then `resolve`, then `lsp(action: "diagnostics", file: "*")`. ## When to use something else | Situation | Better tool | | ----------------------------------------- | ------------------------------------ | | Renaming a symbol | `lsp_rename` — it follows references, not shapes | | Finding a string, a comment or a filename | `search` / `grep` | | "What breaks if I change this?" | The [code graph](/manual/guides/code-search/) | | A language the grammars do not cover | `search` plus `edit` | ## Related - [Code intelligence](/manual/concepts/code-intelligence/) — why structural matching is not a regex with extra steps - [Built-in agent tools](/manual/reference/agent-tools/) — the full argument list - [Search code with the code graph](/manual/guides/code-search/) — the symbol and impact side ### Triage your inbox > Work the unified inbox — clear what blocks you, review what waits on you and track your own PRs. Source: https://usectrl.dev/manual/guides/triage-inbox/ This guide shows you how to work the inbox: the one surface that collects everything that blocks you or explicitly asks for you — pending agent approvals, failed ticket syncs and every pull request that involves you as reviewer or author. ## Open the inbox 1. Click **Inbox** in the sidebar. Its badge counts pending agent approvals, failed syncs and open PRs awaiting your review. 2. Or press ⌘K and run the **Inbox** navigation command. 3. Or deep-link straight to `/workspaces//inbox`. The sidebar entry always shows the live count, so you can see triage work accumulate without opening the page. ## Read severity at a glance Scan top to bottom; the inbox is already ordered by urgency: 1. The pinned **Needs your attention** strip sits above the PR sections. Every card carries a severity chip. Today every card is **Blocked** — both registered sources (an agent stuck on a confirmation prompt, a ticket sync whose vendor mirror is stale) emit that tier. The **Waiting** and **Info** tiers exist for sources not yet wired, so a strip of all-**Blocked** cards is correct, not a bug. 2. Within the strip, the most urgent severity comes first; ties go to the item that has been waiting longest. 3. Below the strip, PRs are grouped into fixed review-lifecycle sections — **Needs your review**, **Returned to you**, **Approved**, **Drafts**, **Waiting for reviewers**, **Merging and recently merged**, **Waiting for author**. The top sections are what needs _you_; the rest are where _your_ PRs stand. **Needs your review** includes PRs that still request a team or group you belong to on forges that have them (the team drops off once any teammate reviews). An item only lands in the inbox when it blocks something or explicitly requests you, so an empty strip plus an empty section list ("You're all caught up") genuinely means nothing is waiting on you. ## One inbox across every forge If the workspace holds repos on more than one code host, the inbox merges them into a single stream — a GitHub pull request and a Bitbucket pull request sit in the same section, ordered by time rather than grouped by vendor. The row's `repo #number` meta is what tells them apart. "Yours" is resolved **per forge**, because you are a different account on each. A pull request only counts as yours, or as requesting your review, when it matches your identity on the forge that repo actually lives on. A forge that is down or not connected contributes nothing and leaves the rest of the inbox intact — one broken connection never empties the whole list. When a forge's identity has not resolved, the empty state says so rather than claiming you are all caught up. ## Clear the attention strip Each strip card has one next action: 1. If a card says an agent is asking to run a command, click **Review** — you land in that agent's space with its pending confirmation. Approve or reject there (see [Chat with an agent](/manual/guides/chat-with-agent/)). 2. If a card says **Sync failed** for a vendor, click **Open** — you land on **Settings → Workspace → General**, whose **Sync health** card shows each connection's last sync, consecutive-failure streak and latest outcome. Only the latest failure per vendor appears in the inbox, so a recovered sync clears the card. 3. Cards disappear on their own once the underlying state resolves (the agent is answered, the sync recovers) — there is nothing to dismiss manually. :::note[These are per-turn confirmations, not governance approvals] An "agent approval" card is a running agent pausing to ask permission for one action, in its own conversation. It is a different thing from the governance `Approval` record agents create with the approval MCP tools — those have no human screen anywhere in the app and never appear here. ::: :::caution[Sync health is read-only] The card reports on ticket-sync connections; apart from a **Sync now** button it cannot create, edit or reconnect one and there is no other screen that can either. With no connection configured it simply reads "No sync connections yet", which is what a fresh workspace shows. ::: ## Filter to what is blocking 1. Press f (with no text field focused) to open the filter menu, or click the filter button in the toolbar. 2. Pick any combination of status, author, reviewers, content, repo owner/name, date windows, or quick-to-review. Facet counts in the menu show how many inbox items each option matches. 3. Active filters appear as chips in the bar above the list; click a chip's ✕ to drop that criterion. 4. Use the left rail to jump between sections — each entry shows its live count and the highlight tracks your scroll position. Click a section header to collapse sections you don't triage (for example **Drafts** while clearing reviews). 5. Click a column header (title, changes, or updated) to sort every section; click the active column again to flip direction. The display-options button controls draft visibility, row properties, ordering and grouping by author. Filters here are independent of the PR queue — narrowing the inbox never changes the queue's filters and vice versa. ## Act on a PR item 1. Click any row to open the PR detail page. 2. If the PR is in **Needs your review**, review it and submit your decision — see [Review and merge a PR](/manual/guides/review-merge-pr/). 3. If the PR is in **Returned to you** (changes requested or CI failed), push fixes from your checkout or hand the follow-up to an agent. 4. If the PR is in **Approved**, merge it from the detail page. 5. If you'd rather delegate a review than do it yourself, see [Dispatch reviewers](/manual/guides/dispatch-reviewers/) and [the dispatch lifecycle](/manual/concepts/dispatch-lifecycle/). Once the underlying state changes (your review lands, the PR merges), the classifier re-files the PR into its new section automatically. ## Undo a mistaken action 1. Press ⌘Z to undo the last action, ⌘⇧Z to redo it. 2. If a text field is focused, ⌘Z edits the field's text instead — click out of the field first to undo an app-level action. ## Look up shortcuts in place Press ? (with no text field focused) to open the keyboard cheat sheet. It lists every binding active on the current route, including the inbox's f filter shortcut. The full map is in the [keyboard shortcuts reference](/manual/reference/keyboard-shortcuts/). ## Drive it from the command palette 1. Press ⌘K anywhere to open the command palette. 2. Type **Inbox** to jump to the page, or type a PR title — the palette lists matching pull requests and opens the one you pick. 3. From anywhere else in the app, use the palette's **Copy link** command to copy the current inbox URL for sharing. ## Refresh on demand The inbox updates live, but if you suspect stale data, click the refresh control in the toolbar — it forces an immediate server-side sweep of every connected forge and refetches the merged/reviewed overlays. The icon spins until every fetch settles. ## Related guides - [Review and merge a PR](/manual/guides/review-merge-pr/) - [Chat with an agent](/manual/guides/chat-with-agent/) - [Dispatch reviewers](/manual/guides/dispatch-reviewers/) ## Related concepts - [Dispatch lifecycle](/manual/concepts/dispatch-lifecycle/) ### Tune an agent's tool context > Inspect what a run actually sends, change which tools load up front, and work out whether a poor tool choice was a retrieval miss or a prompt problem. Source: https://usectrl.dev/manual/guides/tune-tool-context/ This guide covers the practical work around the two-tier tool surface: seeing what a run sends, changing it, and diagnosing an agent that reached for the wrong tool. For why it works this way, read [Tool context and prompt caching](/manual/concepts/tool-context). ## Look at what a run actually sends Open a conversation and use the **context explorer** (the context-usage indicator beside the composer). It rebuilds the exact surface the next dispatch would send, without running anything. Two segments matter here: - **Tool definitions** and **MCP & dynamic tools** — the resident set, with each tool's real token cost. - **Tools loaded on demand** — the deferred index. Each entry is charged only its name, because a name is all the request carries; opening one still shows the full description and schema, so you can see what it *would* cost if the run pulled it in. If the deferred segment is empty, deferral is off for this server. Check `--tool-deferral` on the [`cc_server` CLI](/manual/reference/cc-server-cli). ## Change which tools load up front The resident set is policy, not configuration — it lives in `ModeToolPolicy` (`packages/cc_domain/lib/features/mcp/domain/value_objects/mode_tool_policy.dart`) and applies to every run: - `residentBuiltins` — the file and shell vocabulary. - `residentDiscovery` — `search_tools` and `list_my_tools`. Do not remove these; they are how an agent reaches everything else. - `residentMcpTools` — the handful of Control Center tools worth their schema on every request. - A per-mode block, for the tools a given [mode](/manual/concepts/modes) uses on nearly every turn. Two rules when editing it: **Add a tool only if most runs use it.** Every name costs its schema on every request of every run, forever. A tool that is important when it is used but rare overall belongs in the deferred half — the agent will still find it. **Use the tool's real name.** A resident name that matches no tool is inert: no error, no warning, the tool is simply deferred. `test/tooling/resident_tool_names_test.dart` catches this and also holds each mode under 40 resident tools, which is the point of the exercise — past that band, tool selection measurably degrades. A mode's required and pinned verbs are added automatically, so you never need to list a mode's own output verb. ## Diagnose a bad tool choice When an agent used the wrong tool or claimed it could not do something, work out which of three things happened. ### 1. Did it look? Each run records the deferred tools it loaded and what triggered it — a direct call by name, or a `search_tools` query. Open the run's event stream (**Settings → Workspace → Agents** → the agent → **Logs** → **View**) and look for `[harness] loaded N deferred tool(s)`. - **Nothing logged, and the agent said the capability was missing** — it never looked. That is a prompt problem, not a retrieval one: the deferred index is in its system prompt with an instruction not to declare a capability missing until a search has come back empty. - **A search logged, but nothing loaded** — a genuine retrieval miss. Go to step 2. - **Tools loaded, wrong one used** — the tools it found are too alike. Go to step 3. ### 2. Fix a retrieval miss `search_tools` matches on names, descriptions and argument names. A miss almost always means the description does not contain the words the agent used. Add them. Descriptions are the retrieval index, so **lengthen them rather than trimming** — write the vocabulary an operator would actually type ("assign", "hand off", "give to"), not just the internal noun. Trimming a description to save tokens costs you both the search hit and the selection accuracy, and saves almost nothing next to the schema. If the right tool exists but ranks low, that is what the `limit` argument is for; the tool's own description tells the agent to widen a search that looks close but misses. ### 3. Fix look-alike tools If two tools have descriptions that would read the same to someone who does not already know the difference, the model is guessing. Merge them into one tool with a discriminating argument. That is what `ticket_relation` and `ticket_pr_link` are: each replaced a `link_` / `unlink_` pair that took identical arguments and differed only in direction. A choice made *inside* one call, with the full context of the request, is more reliable than a choice made between two look-alike descriptions. Keep tools separate when they need different approval gating, different auditing or different rendering — those are reasons a merge would cost you something real. ## Check the prompt cache is working Cache hit rate shows up in **Observability** as cache effectiveness. A healthy long-running conversation sits high; if it is persistently low, something is rewriting the prefix every turn. Common causes, in order of likelihood: - **Anything non-deterministic early in the prompt.** A timestamp, a request id, or a set iterated in varying order will do it. - **A model or effort change mid-conversation.** Both change the prefix. Subagents are especially easy to get wrong here: running children at a lower effort to save money can cost more in lost cache sharing than it saves. - **Frequent compaction.** Compaction rewrites history by design, so its cost is real; the fix is to compact rarely and deeply rather than often and shallowly. Turning deferral off with `--tool-deferral=off` is a valid A/B for isolating whether a behaviour change came from the two-tier surface. It reproduces the pre-deferral request exactly, so if the behaviour persists with it off, the surface is not the cause. ### Give an agent a machine to test on > Rigs are disposable VMs an agent drives to test a browser, a desktop or a phone — watched live, and you can take the controls at any time. Source: https://usectrl.dev/manual/guides/use-rigs/ A **rig** is a throwaway machine an agent opens when it needs to actually try something: load a page, click through a desktop app, tap around a phone. You can watch it work in real time, and take the controls whenever you want. The machine is a VM. Nothing an agent does inside a rig touches your computer, the VM's disk is thrown away when it closes, and its network reaches only the hosts you allowed. ## Set it up once Rigs run on the machine hosting `cc_server` — the same machine, not the one you are looking at. There are four surfaces, and only the last two need any setup: | Surface | Runs on | Setup | |---|---|---| | **Terminal (VM)** | A microVM booting a pinned Ubuntu image | None — the image is fetched automatically on first use | | **Browser (VM)** | A microVM booting a pinned headless-Chromium image | None — same | | **Computer (VM)** | QEMU with a Linux desktop image | Install QEMU (`brew install qemu`), then **Import** a desktop image under **Settings → Server → Enclosures** | | **Phone (VM)** | Google's Android emulator | Install the Android SDK (below) | The terminal and browser machines boot from digest-pinned images: the exact bytes are named in advance, fetched once on first boot, and cached — after the first boot of each kind, later machines skip the download entirely and come up in seconds. **Settings → Server → Enclosures** shows what your server can boot and every machine running right now. ### The phone is set up differently There is no image of ours for **Phone (VM)**, and that is not an omission. The other surfaces boot machines we can define; Android runs on Google's emulator, whose system images come from their SDK under their licence. Install Android Studio (or the command-line SDK tools), add a virtual device in its **Device Manager**, and start it — the Enclosures page then reports the mobile surface as available. It also tells you exactly which step you are missing — no SDK, no emulator, no virtual device, or a device that simply is not running — because those have four different fixes and only one of them is a download. If you start `cc_server` from a shell that cannot see the SDK, export it: ```sh export ANDROID_HOME="$HOME/Library/Android/sdk" ``` ## Open a machine Machines live as **tabs**, next to the chat and the diff — in a space or on a PR page, in the same `+` menu as Terminal and Browser: - **Computer (VM)** — a Linux desktop - **Browser (VM)** — a headless Chromium - **Phone (VM)** — an Android device Pick one and press **Start the machine**. The tab says which stage the boot is in ("Starting the microVM", "Waiting for the guest to come up") rather than just spinning. The tab is scoped to that conversation, and so is the agent. Open **Browser (VM)** in a conversation and an agent calling `browser_use` in the same conversation drives *that* machine — you are both looking at one screen rather than at two copies of it. The picture is sized to your panel: drag it wider and the guest changes resolution to match. It is a genuinely different stream from the one the agent sees — the agent gets small, cheap screenshots, you get a full-resolution live view, and neither is compromised for the other. Switch to another tab and the stream stops so a background machine costs you nothing. A rig tab does not start a machine by itself, including when the app restores your tabs at launch — three VMs booting because you opened the app would be an expensive surprise. ## Take the controls Press **Take control** in the tab's header. The agent keeps watching and can still take screenshots, but it cannot type or click until you hand back. The status tag changes to **You have control** and everything you do is recorded against your name. Press **Hand back** when you are done, or the power button to stop the machine. Control is exclusive on purpose: two actors typing into one machine produces input neither of them meant. ## Move things in and out Copy and paste work in both directions across the rig canvas, and so does dragging files. **Copy and paste.** Press the copy chord over the canvas and whatever the machine copied — text, an image, a list of files — lands on your clipboard. Paste sends yours the other way and presses paste inside the machine. On macOS the chord is ⌘C/⌘V; ⌃C still reaches a guest shell as an interrupt. On Windows and Linux use ⌃C/⌃V — the guest receives the same keystroke it would have anyway, so ⌃C in a guest terminal still interrupts and your own clipboard is left alone. Nothing syncs in the background. A password you copy on your own machine does not arrive inside a VM because a timer fired; it crosses when you paste. **Drag files in.** Drop them anywhere on the canvas. - On the **Browser (VM)**, the page receives a *real* drop at the point you let go, so an upload zone behaves exactly as it would with a local file. - On the **Computer (VM)**, the files land in `~/Drops` and their paths go on the machine's clipboard, so ⌃V pastes them into a file manager or an upload field. A host cannot synthesize a drag into an arbitrary Linux app without running a privileged daemon inside the VM, which rigs deliberately do not have — so the toast tells you where the files went rather than pretending a drop happened. - On a **terminal**, the file is copied in and its path is typed at the prompt, ready to pass to a command. Pasting an *image* into a terminal does the same thing: it is saved in the machine and you get the path. **Drag files out** works from the Computer (VM). Start dragging a file inside the machine, carry the pointer out of the canvas, and it becomes a real drag on your desktop — drop it in Finder or Explorer. Copying files in the machine's file manager and pasting on your side works too. The Browser (VM) does not offer this: a headless browser has no drag payload to read, and guessing from the page's text selection would hijack ordinary clicks. :::note The Computer (VM) needs an image built after clipboard support landed. If you imported yours earlier, rebuild it with `scripts/rigs/build_image.sh` — the app says so explicitly rather than reporting a broken machine. ::: ## Terminals inside a rig The terminals in a space or on a PR page can run inside a rig instead of on the server's own machine. When they do, the panel shows an **Enclosed VM** badge. What changes: - Nothing you run reaches the host. A `rm -rf` in there costs you the rig. - The network is deny-by-default. Only hosts on the rig's allowlist are reachable, and everything goes through a gate that enforces it. - `git push` still works. The rig has no stored credential; it asks the server for a short-lived one per operation, and that token is revoked when the rig closes. What to know: - **A restored enclosed terminal waits for you.** Like a rig tab, a `microvm` terminal that comes back with your layout shows an **Open the shell** button rather than booting its VM at launch. A host-shell terminal costs a process; an enclosed one costs a machine. - **Files dropped on the terminal are typed, not opened.** Drop a CSV on an enclosed terminal and it is copied into the machine and its guest path appears at the prompt — the host path would be a command that fails there. - **The copy in the rig is a copy.** Your worktree on the host stays the source of truth. Commits you make inside a rig are carried back into `refs/rigs//*` when you ask — they never move your branch or touch your working tree. - **Host-target work still runs on the host.** A Linux guest cannot build for macOS or run `xcodebuild`. Pick the native backend explicitly for that; the app will ask you to confirm rather than switching quietly. ## Ports Run a dev server in the Terminal (VM) — `pnpm dev` on port 3000, say — and within a few seconds the plug icon in the terminal's corner lights up: the port is forwarded to `localhost:3000` on your machine, reachable at `localhost:3000` inside the Browser (VM), shareable on your network, and nameable as `https://myapp.test`. The whole flow — auto-forwarding, LAN sharing, dev domains with HTTPS — is its own guide: [Forward ports from an enclosed terminal](/manual/guides/vm-ports/). ## Custom images A workspace can point the Terminal (VM) and Browser (VM) at its own images — under **Settings → Server → Enclosures → Custom images**. Typical use: extend the default image with the toolchain your project needs so every terminal starts ready, or reuse an image your team already publishes. The machine still boots inside the same enclosure: same egress gate, same credential broker, same lifecycle. Only workspace admins can change the setting, new machines pick it up (running ones keep theirs), and the reference must be a registry image — local paths are refused. What an image must provide: - **Terminal (VM)** — a Linux image with `bash`. It also needs `git`, `curl` and `socat` — either preinstalled, or installable on first start (any Debian/Ubuntu-based image works: they are installed automatically). Guest port `7911` is reserved for the port forwarder. - **Browser (VM)** — an image with the headless-shell layout: the Chromium binary at `/headless-shell/headless-shell`, plus `bash` and `socat`. The easiest way to get that is to build `FROM chromedp/headless-shell:stable` and add what you need. The server starts the browser itself and expects its DevTools endpoint to be relayed on guest port `9222` — with that layout, it wires this up for you. Guest ports `9222`/`9223` are reserved. Two recommendations: pin the reference by digest (`@sha256:…`) so the bytes cannot change under you, and host it on Docker Hub or GitHub Container Registry — those registries' download hosts are admitted through the egress gate automatically; others may need their content delivery hosts reachable to pull. ## When a rig goes away Rigs are meant to be temporary: - **Idle** — after ~15 minutes with nothing happening it parks (frozen, wakes instantly on the next action). After twice that, it closes. - **Time limit** — every rig has a hard lifetime it cannot extend, whatever it is doing. - **Memory** — if the server needs room for a new rig it closes the least-recently-used one. A rig somebody is driving, watching, or has a terminal open in is never taken. When a rig closes, its disk is discarded. Carry anything you want to keep back first. ## What a rig can reach By default: nothing outbound. You grant hosts explicitly, and the guest's only route to the network is a gate that enforces that list. The credential broker uses the same list — the server will not hand a rig a token for a host it cannot reach. Two honest caveats: - **The mobile surface is less contained.** An Android emulator manages its own networking, so its egress is not fully enforced the way the browser and desktop surfaces are. Full enforcement needs a Linux worker. - **Everything a rig reads is untrusted.** Page text, DOM, accessibility trees and view dumps come back to the agent wrapped in markers that say "this is data, not instructions". A page that tries to give your agent orders reads as a quoted string. You can see everything running, and stop any of it, under **Settings → Server → Enclosures**. ## Related - [Rigs and enclosures](/manual/concepts/rigs/) — the model behind all of this: why VMs, the two display lanes, the credential broker, the lifecycle - [Forward ports from an enclosed terminal](/manual/guides/vm-ports/) — reach a dev server from anywhere it matters - [Rigs reference](/manual/reference/rigs/) — every surface, verb, port and default in one place - [Configure guardrails](/manual/guides/configure-guardrails/) — `Drive an enclosure (rig)` is an action class you can set to allow, ask-first or deny, per workspace, agent or space. Read-only modes deny it outright. ### Forward ports from an enclosed terminal > Reach a dev server running inside a Terminal (VM) — from your machine, from the Browser (VM), on your network, or under a dev domain with HTTPS. Source: https://usectrl.dev/manual/guides/vm-ports/ A server you start inside a [Terminal (VM)](/manual/guides/use-rigs/#terminals-inside-a-rig) listens inside the machine, not on your computer. The ports panel notices it, forwards it, and gives you an address for every place you might want to reach it from. Nothing here opens the machine up: every path is created by the server, on request, and dies with the rig. ## See what is listening 1. Open the conversation's **Terminal (VM)** (in a space or on a PR page). 2. Start your server — `pnpm dev`, `python -m http.server 8000`, anything. 3. Within a few seconds the **plug icon** in the terminal's top-right corner gets a dot. Click it. The panel lists every listening port with the process that owns it, as `3000 → 3000`: the guest port on the left, the port it answers on locally on the right (the same number whenever it was free on your machine). New ports are forwarded automatically. Turn **Auto-forward ports** off in the same panel if you want only the ones you add yourself. ## Reach it from this computer Use **Copy local URL** on the row — `http://localhost:3000`. The forward listens on loopback only, so nothing else on your network can see it. ## Reach it from the Browser (VM) Nothing to configure. The enclosed browser in the **same conversation** reaches every forwarded port at `localhost:` — type `localhost:3000` into it, or have an agent's `browser_use` navigate there to test the page you just started. The two machines are wired together through the server; neither can reach the other any other way. ## Share it on your network **⋯ → Share on local network** publishes the port at `:` — useful for opening the page on your phone, or when the server runs on another machine. - The LAN port is always OS-assigned, never the guest's number, so exposure is a deliberate, visible address rather than a guessable one. - It stays private (loopback-only) until you flip it, and **⋯ → Local only** takes it back off the network. ## Give it a name — with HTTPS **⋯ → Set a browser domain** assigns the port a dev domain such as `myapp.test`. Inside the Browser (VM), `https://myapp.test` then loads your server with a valid padlock — useful for anything that behaves differently on a named origin or a secure context (cookies, service workers, OAuth callbacks). - Domains must end in `.test` or `.localhost` — the two TLDs reserved for development, so a dev name can never shadow a real site. - The certificate comes from a local authority minted on your server the first time it is needed. Its keys live under the server's data directory and never enter any guest; the enclosed browser trusts exactly that one key, so TLS to the real internet still validates normally. - The domain resolves **inside the Browser (VM) only**. Your own browser on the host uses `localhost:` instead. - Plain `http://myapp.test` works too. ## Forward a port by hand **Add port** at the bottom of the panel forwards a guest port before (or whether or not) anything is listening on it. A manual forward keeps its address while your server restarts — the row shows *not listening* until it comes back — where auto-forwards disappear when their port does. ## How it stays contained The hypervisor's own port map is fixed when a machine boots, so all of this rides two narrow channels the server controls: one pre-created inbound lane into the terminal VM that refuses to connect to any port the guest is not actually listening on, and reverse tunnels the server holds open for the Browser (VM). No listener is ever created inside a guest that the server did not put there, and everything is torn down with the rig. The design is explained in [Rigs and enclosures](/manual/concepts/rigs/#ports-without-holes); the exact reserved ports and domain rules are in the [rigs reference](/manual/reference/rigs/#ports-and-dev-domains). ## Related - [Give an agent a machine to test on](/manual/guides/use-rigs/) — opening, watching and taking over rigs - [Rigs reference](/manual/reference/rigs/) — reserved ports, dev TLDs, defaults ### Install > Use Control Center in the browser or install the desktop app, set up an agent backend and choose where its server runs. Source: https://usectrl.dev/manual/install/ Control Center runs two ways: as a **web app** at [app.usectrl.dev](https://app.usectrl.dev), with nothing to download, or as a **desktop app** you install. They are the same application with the same features. The only difference is where the server runs — and the web app cannot run one for you. ## Use it in your browser [app.usectrl.dev](https://app.usectrl.dev) is this application compiled for the browser. Nothing to install, nothing to update. Open it and connect. What a browser tab cannot do is _be_ the server. Every client — desktop, web, phone — is a thin renderer over a headless `cc_server` that owns the database and does the work, so the web app always talks to a server you run. Two ways to give it one: **A server on your own machine.** Start `cc_server` locally and point the web app at `ws://localhost:9030`. Browsers treat `localhost` as trustworthy, so this needs no certificate and no tunnel. It is the quickest way to try the web app and your data never leaves your machine. **A server on another machine.** Because `app.usectrl.dev` is served over HTTPS, a non-local server has to be reachable over `wss://` with a certificate the browser already trusts. `cc_server` refuses to bind a non-loopback address without TLS, so choose one of: - give it a certificate directly, with `--tls-cert` and `--tls-key` - put it behind a TLS-terminating reverse proxy and start it with `--insecure` - expose it through a tunnel or VPN that supplies the certificate — Tailscale, cloudflared, or similar. **Settings → Server → Sharing & remote access** has the built-in tunnel opt-in. Whenever the server sits behind a proxy, NAT, or tunnel, set `--public-url` to the address clients should actually dial. It defaults to the local bind, so without it clients try the wrong host. :::note[Hosting your own copy of the web client?] `cc_server` only accepts browser origins on its `--allowed-origins` list, which defaults to `https://app.usectrl.dev`. Add your own origin there if you self-host the web build. Loopback and native clients are always allowed regardless of that list. ::: A few things stay desktop-only, because they need a native window or the machine's own audio devices: the floating focus-mode pill, OS notification toasts (the in-app notification centre works everywhere), "start recording & link" from a calendar event, and re-running a meeting summary. Everything else — spaces, agents, pull-request review, pipelines, tickets, memory and meeting recording itself — behaves the same. To connect the web app to a server, follow [Connect to a remote server](/manual/guides/connect-remote-server/). To host the server, follow [Run a headless server](/manual/guides/run-headless-server/). ## Desktop system requirements | Requirement | macOS | Windows | Linux | | ------------ | ----------------------------------------------------- | ----------- | -------------------------------------------- | | OS version | macOS 13+ (Ventura) | Windows 10+ | A modern distro with a graphical environment | | Architecture | Apple Silicon (arm64) | x64 | x86_64 | | Git | Required | Required | Required | | Disk space | ~200 MB for the app, plus the server's data directory | ~200 MB | ~200 MB | Intel Macs have no prebuilt download. [Build from source](#build-from-source) instead. The server's data directory outgrows the app. On its first start `cc_server` downloads about 125 MB of on-device models (text embeddings and speaker diarization) and an embedded code-server build and every agent conversation gets a copy-on-write worktree of the repos it works on. A speech model for dictation and meeting transcription is optional and adds 198–626 MB depending on which one you install. The desktop points its server at the OS per-user application-support directory. A `cc_server` started by hand with no `--data-dir` defaults to `/control-center` — `~/Library/Application Support/control-center` on macOS, `%APPDATA%\control-center` on Windows, `$XDG_DATA_HOME/control-center` (or `~/.local/share/control-center`) on Linux. ## Download Every artifact below comes from [GitHub Releases](https://github.com/SamuelAlev/control-center/releases). ### macOS (Apple Silicon) Download `Control-Center--arm64.dmg`, open it, and drag Control Center to Applications. The build is signed with a Developer ID and notarized by Apple, so it opens normally. ### Windows (x64) - `Control-Center--x64-setup.exe`: a per-user installer. - `Control-Center--windows-x64.zip`: a portable andt. :::caution[Windows has not been through a validated release] The Windows build job runs and ships both files, but no Windows artifact has yet been checked by hand before publishing. Treat the first one you download as a validation run. Windows also has no copy-on-write worktrees (plain `git worktree` is the backend there) and no embedded code-server. ::: ### Linux (x86_64) - `Control-Center--x86_64.AppImage` - `Control-Center--linux-x64.tar.gz` ### Standalone server Each release also carries the headless server on its own — `cc_server--macos-arm64.tar.gz`, `cc_server--linux-x64.tar.gz`, `cc_server--windows-x64.zip` — alongside the `cc-server`, `cc-webapp` and `cc-remote` images on GHCR. See [Run a headless server](/manual/guides/run-headless-server/). ## Agent backend Control Center dispatches agents through **adapters**. There are eight of them, the list is fixed, and they are detected automatically — there is no way to register another one. The **Control Center (built-in)** adapter needs no external CLI: the agent loop runs inside the server and talks to model providers over HTTP, so it is always available. Connect a provider under **Settings → Server → Model providers**. Anthropic, OpenAI and Kimi Code offer a browser sign-in; OpenRouter, Groq, Google Gemini, DeepSeek, Mistral, xAI, z.ai and Moonshot take an API key. What you _can_ add on that page is a **custom provider** — any OpenAI- or Anthropic-compatible endpoint (Ollama, LM Studio, vLLM, a private deployment) under **Custom providers → Add provider**. The other seven adapters wrap an external CLI: and - **Claude Code** (`claude`): `npm install -g @anthropic-ai/claude-code` - **Codex** (`codex`): `npm install -g @openai/codex` - **Pi** (`pi`), **OpenCode** (`opencode`), **Gemini CLI** (`gemini`), **Goose** (`goose`) and **Cursor** (`cursor-agent`): install each per its own documentation Detection probes the `PATH` of the machine running `cc_server` — the same machine when the desktop runs its own server, the remote host otherwise. Install external CLIs there, not on the client. The **Refresh** button on that page re-runs the probe. Model lists are served live by each connected provider; [models.dev](https://models.dev) only supplies price and context-window metadata. See [Manage adapters and models](/manual/guides/adapters/). ## GitHub authentication Control Center stores a GitHub personal access token in the OS keychain (macOS Keychain, Windows Credential Store, Linux libsecret). The token is stored as you type it — nothing probes it first, so a typo shows up as a failed request later. Scopes: - `repo`: repository and pull-request access - `read:org`: organization repos, team membership, and reviewer routing Create one at [GitHub → Settings → Developer settings → Personal access tokens](https://github.com/settings/tokens). If your server has a GitHub App registered ([Create the GitHub App](/manual/guides/github-app/)), the row offers **Sign in with GitHub** instead and the token field becomes optional. and Onboarding asks for the token on its first step. After that it lives under **Settings → You → Profile & identity → Code hosting**, where you can replace or clear it. ## Choose where the server runs and Control Center is a thin client over a headless `cc_server` process that owns the database and does all the work; [Deployment and clients](/manual/concepts/deployment/) explains what that split means for the desktop, web and phone. On first launch the desktop asks **"How should Control Center run?"**: - **Run in this app**: the desktop spawns and supervises a `cc_server` on this machine and talks to it over loopback. Nothing else to configure. This is the setup the [quick start](/manual/quick-start/) assumes. - **Connect to a remote instance**: point the desktop at a `cc_server` running elsewhere with its WebSocket URL, a device id, and either a pairing key or a one-time invite code. That server owns the data; this desktop only renders it. Add or switch servers later under **Settings → Server → Connection & status**. Switching reconnects in place — no restart. A browser cannot run a server, so the web client is always remote; see [Connect to a remote server](/manual/guides/connect-remote-server/). To host a server yourself, follow [Run a headless server](/manual/guides/run-headless-server/). It covers building the binary, minting a pairing key before first start, and the `--data-dir` / `--port` / `--bind` flags. and ## Verify your setup 1. **Settings → Server → Model providers** lists all eight adapters. "Control Center (built-in)" reads found with version `built-in`; any external CLI you installed on the server host should read found as well. Press **Refresh** if one is missing. 2. **Settings → Workspace → Repositories** lists this workspace's repos, and **Add repository** browses the server host's folders. and 3. **Settings → Workspace → Agents** lists the five agents your first workspace seeded. Each one needs an **Adapter** and a **Model** set before it can run. 4. **Settings → Server → Diagnostics & privacy** reports the sandbox backends this host offers, the embedding model's state, and logging. ## Build from source and Native libraries are required and there is no degraded mode: a missing one is a hard boot failure, not a slower path. Stage them before anything else. Every Flutter and Dart command goes through `fvm`, because the SDK version is pinned by the repo. and ```bash git clone https://github.com/SamuelAlev/control-center.git cd control-center scripts/natives/build_natives.sh fvm flutter pub get fvm flutter pub run build_runner build --delete-conflicting-outputs fvm flutter gen-l10n fvm flutter run -d macos # or -d windows, -d linux ``` On macOS, secure storage (GitHub, Linear and Google sign-in) needs the app signed by an Apple team — a free Apple ID is enough, and it is a one-time setup described under [local development signing](https://github.com/SamuelAlev/control-center/blob/main/RELEASING.md#local-development-signing-macos). Without it the app still launches; only secure storage is unavailable. Windows and Linux need no signing. To build the headless server binary, stage the natives the same way first, then: ```bash cd apps/cc_server and fvm dart build cli ``` The build hook copies whatever is already in `build/natives/` into the bundle, so building before staging yields a bundle with no natives and a server that refuses to start. For the web client use `scripts/build_web.sh` (`--target remote` for the phone PWA). It is what CI runs: a plain `flutter build web` skips the build-identity stamp, the Web Worker regeneration, the deploy manifest, and the asset-budget check. and ### Quick start > Install Control Center, configure one agent and watch it work on your repo. Source: https://usectrl.dev/manual/quick-start/ This is the shortest complete path from nothing to an agent running against your own code. Follow it in order; every step depends on the one before it. You need Git, a GitHub account and either a model-provider account (Anthropic, OpenAI, Kimi Code, or any provider with an API key) or an agent CLI already installed. Everything else is set up along the way. ## Step 1: Install and launch Install Control Center as described in [Install](/manual/install/), then open it. On first launch the desktop asks **"How should Control Center run?"** Choose **Run in this app**. Control Center spawns a headless `cc_server` on this machine, which owns your data and does all the work. You never see it; the onboarding screen opens instead. :::note[Prefer not to install anything?] The same app runs in the browser at [app.usectrl.dev](https://app.usectrl.dev) with the same features. A browser tab cannot host the server, though, so you supply one — run `cc_server` locally and connect to `ws://localhost:9030`, or point it at a server on another machine. This quick start takes the desktop path because it needs no server setup; see [Install](/manual/install/#use-it-in-your-browser) for the browser route. ::: ## Step 2: Work through onboarding Onboarding has five steps and they run in order — there is no skipping ahead. 1. **Connect**: sign in with GitHub or paste a personal access token with `repo` and `read:org`. The credential is stored on the server, attached to your account. 2. **Workspace**: name your first workspace and optionally pick a logo. Creating it seeds five agents — a CEO plus `qa`, `architect`, `engineer` and `librarian` — and the built-in pipeline templates. 3. **Sandbox**: records a sandboxing preference. Either choice lets you continue. 4. **Adapter**: pick the adapter and model agents should default to. Choose **Control Center (built-in)**, then use the provider picker that appears to sign in to a model provider — the model list stays empty until one is connected. Finishing this step also stamps the adapter and model onto the workspace's CEO agent. 5. **Voice (optional)**: install an on-device speech model for dictation, or skip it. Control Center then opens your workspace inbox. :::note[What bounds an agent run] Agent commands are wrapped in the host's OS-native sandbox when one is available — Seatbelt on macOS, bubblewrap on Linux. Windows has no backend, so runs there are bounded by the other layers only: the conversation's own copy-on-write worktree, credentials brokered per launch and revoked at teardown and the action guardrails. See [Sandbox and security](/manual/concepts/sandbox-security/). ::: ## Step 3: Add a repository Go to **Settings → Workspace → Repositories** and click **Add repository**. The dialog browses the folders on the machine running the server, not your client and it stays inside the roots that server is configured to expose (its user's home directory by default). Only git checkouts get a checkbox. Tick yours and register it. A checkout is only accepted if it is inside a git work tree, has an `origin` remote and that remote points at a supported forge — github.com, gitlab.com or bitbucket.org. Anything else is refused with the reason. Repos are workspace-scoped: registering a checkout here puts it in _this_ workspace. There is no separate linking step. ## Step 4: Give an agent an adapter and a model Go to **Settings → Workspace → Agents**. Select the `engineer` agent in the roster. On the **Settings** tab, set: - **Adapter**: `Control Center (built-in)` - **Model**: pick one from the provider you connected in onboarding Press **Save changes**. This step is not optional in practice. Only the CEO agent inherits the adapter chosen during onboarding; the four specialists are created without one. They fall back to **Control Center (built-in)** with Anthropic's default model, so they run only if you have connected an Anthropic account — and on a model you did not choose if you have. ## Step 5: Send the agent a task 1. In the sidebar, find the **Spaces** section and click the **+** button. 2. Name the space, add the `engineer` agent under **Add agents** and create it. 3. Type a concrete, small task against the repo you registered and press Enter. Control Center provisions a copy-on-write worktree of the workspace's repos for this conversation, assembles a prompt from the agent's role, persona, skills and the space's context and dispatches the run. Your source checkout is never touched. ## Step 6: Watch it work You should see, in order: - The composer's send button turn into a stop button, because an agent is working in this conversation - The agent's reasoning, tool calls and text streaming into the transcript - The send button come back when the run ends Then open **Settings → Workspace → Agents**, select the agent and check the **Logs** tab: one run row, with its status, duration, start and finish times and the adapter it ran on. That is the loop. Everything else in Control Center — pull-request review, pipelines, tickets, meetings, Slack — hangs off it. ## Next steps - [Your first workspace](/manual/tutorials/first-workspace/): the same ground, slower, with the isolation model explained - [Review your first pull request](/manual/tutorials/first-pr/): take an agent's branch through review and merge - [The agent model](/manual/concepts/agent-model/): what an agent is made of - [Create and configure an agent](/manual/guides/create-agent/): the rest of the agent form - [Run an orchestration](/manual/guides/run-orchestration/): one goal, one approval, a generated pipeline - [Pair your phone](/manual/guides/pair-a-device/): follow the fleet from anywhere - [Deployment and clients](/manual/concepts/deployment/): how the desktop, web and phone clients relate to the server ### Reference > Complete, factual catalogs — routes, tools, events, shortcuts, enums and terms. Source: https://usectrl.dev/manual/reference/ Reference pages describe **what is there**, exhaustively and without commentary. Use them to look something up when you already know what you are after: an exact tool name, a step kind, a status transition, a route, a shortcut. They are not written to be read start to finish. For that, use the [concepts](/manual/concepts/); to get something done, use the [how-to guides](/manual/guides/). ## The product surface - [MCP tools](/manual/reference/mcp-tools/) — every tool the MCP server registers, by family - [Route map](/manual/reference/route-map/) — every screen and the URL that reaches it - [Keyboard shortcuts](/manual/reference/keyboard-shortcuts/) — the full keybinding registry - [Domain events](/manual/reference/domain-events/) — every event published on the domain bus - [Rigs](/manual/reference/rigs/) — every rig surface, action verb, lifecycle state, port and default ## Configuration - [Agent configuration](/manual/reference/agent-configuration/) — every field on an agent and what reads it - [Pipeline step kinds](/manual/reference/pipeline-steps/) — step kinds, body keys, configuration and results - [`cc_server` CLI](/manual/reference/cc-server-cli/) — every flag, environment variable, subcommand and data-directory path - [Backup, export and import](/manual/reference/backup/) — the snapshot layout, every backup operation and transfer route - [Sandbox backends](/manual/reference/sandbox-backends/) — the execution backends and their availability - [SSO configuration](/manual/reference/sso/) — endpoints, operations and error codes - [Chat bridge](/manual/reference/chat-bridge/) — providers, commands, credentials and operations ## Models - [Ticket lifecycle](/manual/reference/ticket-lifecycle/) — statuses, transitions and the workflow engine - [Glossary](/manual/reference/glossary/) — the domain vocabulary used throughout the manual ### Agent configuration > Every field on the agent entity, the enums it uses and which persisted fields have no effect. Source: https://usectrl.dev/manual/reference/agent-configuration/ This reference lists every field on the agent entity, plus the enums and flags it uses. For a step-by-step walkthrough of creating or editing an agent in the app, see [Create and configure an agent](/manual/guides/create-agent/). ## Agent entity fields | Field | Type | Required | Description | | ----------------------- | ---------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `String` | Yes | Unique agent identifier | | `name` | `String` | Yes | Short identifier for @-mentions | | `title` | `String` | Yes | Human-readable role title | | `agentMdPath` | `String` | Yes | Absolute path to the agent's `.md` definition file | | `workspaceId` | `String` | Yes | Owning workspace (non-null) | | `reportsTo` | `String?` | No | Parent agent ID | | `skills` | `AgentSkills` | Yes | Set of skill identifiers | | `persona` | `String?` | No | Free-text behavioral instructions | | `systemPrompt` | `String?` | No | Override the default system prompt | | `adapterId` | `String?` | No | Inference adapter id | | `modelId` | `String?` | No | Model id within the adapter | | `strictMode` | `bool` | No | Default `false`. **Inert** — persisted and editable in the agent form, read by no dispatch, prompt, or policy path | | `effort` | `String?` | No | Reasoning level id (e.g. `'low'`, `'xhigh'`), sourced from the selected model's `thinkingLevels` | | `contextSize` | `int?` | No | Max context window size | | `role` | `AgentRole?` | No | Agent role (see below); null for legacy agents | | `capabilities` | `AgentCapabilities?` | No | Per-agent capability default; when null the user-level default applies at dispatch and a conversation may still override | | `monthlyBudgetCents` | `int` | No | Monthly spending cap in cents (default `0`, unlimited) | | `silenceTimeoutMinutes` | `int?` | No | Silence-timeout override in minutes; must be null or in 1–240 (constructor-asserted) | | `maxConcurrentTasks` | `int` | No | Default `1`. The capacity figure the presence model reports (running and queued counts are compared against it); **not** a dispatch concurrency limit — nothing refuses a run for exceeding it | | `visibility` | `AgentVisibility` | No | Default `workspace`. **Inert** — parsed and round-tripped, but no roster, peer-discovery, or UI filter consumes it | | `lifecycleStatus` | `AgentLifecycleStatus` | No | Governance status (default `active`). Written by the budget hard-stop and rendered on the org chart and presence roster. **Not consulted by the dispatch path** — a `paused` agent still dispatches | | `budgetPolicyId` | `String?` | No | Shared budget policy id; when null `monthlyBudgetCents` applies directly | | `runtimeProfileId` | `String?` | No | Custom runtime profile id. **Inert** — no reader in `cc_infra`, `cc_mcp`, or the client | | `createdAt` | `DateTime` | Yes | Creation timestamp | `name` and `title` must both be non-empty (constructor-asserted). `monthlyBudgetCents` and `budgetPolicyId` have no form control, no MCP tool and no dedicated RPC op. The only write path is a raw `agents.upsert`, so in practice every agent keeps the default `0` (unlimited). ## AgentRole enum | Role | Label | Description | | ---------- | -------- | -------------------------------- | | `ceo` | CEO | Coordination, hiring, delegation | | `coder` | Coder | Code implementation | | `reviewer` | Reviewer | PR review and analysis | | `qa` | QA | Testing and quality assurance | | `designer` | Designer | UI/UX implementation | | `security` | Security | Security review and auditing | | `devops` | DevOps | Infrastructure and deployment | | `pm` | PM | Product management | | `general` | General | Flexible, multi-purpose | ## AgentVisibility enum Stored and round-tripped only; no code branches on the value. | Value | Description | | ----------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `workspace` | The default; unknown and null stored values parse to it | | `private` | Intended as "private to the creating user". No visibility filter exists, so a `private` agent is visible exactly like a `workspace` one | ## AgentLifecycleStatus enum | Value | Description | | ---------- | ------------------------------------------------------------------------------------- | | `active` | The default. `isDispatchable` is true only for this value | | `paused` | Set by the budget hard-stop. Shown on the presence roster; does **not** stop dispatch | | `archived` | Retired and hidden from active rosters | `Agent.isDispatchable` is defined but has no caller: no dispatch path reads it. ## AgentCapabilities flags Resolved per dispatch: the agent's default, falling back to the user-level default when null, with a per-conversation override on top. Enforced by absence — the credential broker simply does not mint or inject what a disabled capability covers. | Flag | Type | Default | Description | | ------------------ | ------ | ------- | ---------------------------------------------------------------------------------------- | | `canPushToRepo` | `bool` | `false` | Push commits to the remote; gates GitHub token injection alongside `canCallGitHubApi` | | `canCallGitHubApi` | `bool` | `false` | Call api.github.com; gates `GH_TOKEN` injection and the GitHub egress domains | | `canCallTicketing` | `bool` | `false` | Call the ticketing provider API; gates provider API-key injection and its egress domains | | `canAccessNetwork` | `bool` | `true` | Arbitrary outbound egress. Also gates the harness `web_fetch` and `web_search` tools | `AgentCapabilities.safeDefault` is all four constructor defaults. `legacyDefault` (all four true) is used only by the migration that backfilled conversations created before capabilities existed. A malformed or empty stored blob parses to `safeDefault`; the legacy key `canCallLinear` is read as `canCallTicketing`. ## AgentSkills An immutable, case-insensitive set of skill identifiers. Skills are matched at prompt assembly time to inject relevant context. ``` {'flutter', 'dart', 'rest-api'} ``` ## AgentLiveState (derived) Not stored. Seven values. Declaration order is priority order (`sortPriority`, pinned by a test), which is the order the roster sorts by: | State | `sortPriority` | Condition | | ----------- | -------------- | ------------------------------------------------------------------------------------------------------------------- | | `running` | 0 | Any run log has status `running` | | `blocked` | 1 | The latest run's liveness is `blocked`, `stalled`, or `looping` | | `failed` | 2 | The latest run's status is `error`, or its liveness is `failed` or `dead` | | `queued` | 3 | A run is dispatched but not executing — parked behind another run of the same agent, or written but not yet started | | `succeeded` | 4 | The run finished successfully | | `idle` | 5 | The agent has run before and has nothing in flight | | `neverRun` | 6 | No run logs exist | `queued` and `succeeded` are **per-run only**. `deriveAgentLiveState`, which computes the roster state for an agent, never produces either — it returns `neverRun`, `running`, `failed`, `blocked`, or `idle`. ## DiscoveredAgent An agent definition parsed from an `AGENTS.md` on disk that is not yet registered in the workspace: | Field | Type | Description | | ------------- | -------------- | --------------------------------------- | | `name` | `String` | Unique name from the file's frontmatter | | `title` | `String` | Display title | | `skills` | `List` | Skills declared in the file | | `agentMdPath` | `String` | Absolute path to the `AGENTS.md` | | `reportsTo` | `String?` | Name of the declared manager agent | | `persona` | `String?` | Persona markdown body | :::caution[Not reachable from the app] On-disk agent discovery has no UI and no wire. `AgentDiscoveryService.findImportable` produces these on the server, but no RPC operation returns them and no client code references the type, so there is no way to import a discovered agent from within the app. ::: ## See also - [Create and configure an agent](/manual/guides/create-agent/) - [The agent model](/manual/concepts/agent-model/) - [Agent dispatch lifecycle](/manual/concepts/dispatch-lifecycle/) ### Built-in agent tools > The tool surface the built-in harness gives an agent — language server, structural search, debugger, kernels and worker control — with every operation and its arguments. Source: https://usectrl.dev/manual/reference/agent-tools/ These are the tools the **built-in agent runtime** registers directly, as opposed to the [MCP tools](/manual/reference/mcp-tools/) it bridges in. They are not on the MCP registry: an external MCP client will not see them, because each one is bound to a live server-side resource — a language server process, a debug adapter, an interpreter — that belongs to one run. Every tool here is registered conditionally. A tool whose resource cannot exist on this host is not advertised at all, rather than advertised and failing on call. ## Approval tiers Each tool declares a tier, and the tier decides **which surfaces see it** — not merely whether it prompts. Plan mode and read-only subagents are capped at `read`, so a `write`- or `exec`-tier tool is absent from their schema entirely. | Tier | Meaning | Tools here | | ------- | -------------------------------------- | -------------------------------------------------- | | `read` | No side effects | `lsp`, `ast_grep`, `ask_user`, the five `vibe_*` | | `write` | Changes files | `lsp_rename`, `ast_edit`, `resolve` | | `exec` | Starts or controls a process | `debug`, `eval` | --- ## `lsp` — language-server queries Registered when a language server is detected for the checkout. Detection is an **intersection**: a root marker must exist *and* the server binary must resolve. Either one alone means the tool is not offered. Positions are addressed by `line` plus a `symbol` substring, never by column. A model asked for a column guesses, and a wrong guess resolves to a different symbol on the same line — an answer that looks right and is about something else. Omitting `symbol` on a navigation action is an error, not a first-column guess. | Action | Arguments | Returns | | ----------------- | -------------------------------- | ------------------------------------------ | | `diagnostics` | `file` (or `"*"`) | Errors and warnings; `"*"` runs the project checker instead | | `definition` | `file`, `line`, `symbol` | Where the symbol is declared | | `type_definition` | `file`, `line`, `symbol` | Where its type is declared | | `implementation` | `file`, `line`, `symbol` | Concrete implementations | | `references` | `file`, `line`, `symbol` | Every use, capped at 50 | | `hover` | `file`, `line`, `symbol` | Signature and doc comment | | `symbols` | `query` | Workspace symbol search, capped at 200 | | `code_actions` | `file`, `line` | Quick fixes the server offers | | `status` | — | Which servers are running, and for which roots | | `reload` | — | Restarts the server for this root | ### Diagnostics on write The payoff is not the tool. `write`, `edit` and `apply_patch` are **wrapped**: after the write the file is synced and saved through the language server, and only **newly introduced** diagnostics are folded into the tool result. An agent finds out it broke the build at the moment it broke it, without having to remember to ask. Already-reported diagnostics are suppressed by a per-run ledger keyed on severity, code and message — position deliberately excluded, so a diagnostic that merely moved down four lines is not re-reported as new. ## `lsp_rename` — project-wide rename A separate, `write`-tier tool for exactly one reason: the tier decides visibility. Folding rename into `lsp` would either hand a read-only explorer the ability to rewrite the repo, or put every `hover` behind an approval prompt. | Argument | Meaning | | -------- | ------------------------------------------ | | `file` | Where the symbol is | | `line` | 1-indexed line | | `symbol` | Substring identifying it on that line | | `new_name` | What to call it | --- ## `ast_grep` — structural search Finds code by **shape** rather than by text. `$NAME` matches any node, `$$$NAME` matches a run of siblings, and `$_` is a wildcard that binds nothing. The property that makes it a matcher rather than a regex with extra steps: a metavariable repeated in one pattern must capture the same text. ``` ast_grep(pattern: "if ($X != null) $X.dispose()", language: "dart") ``` matches a guard that disposes what it tested, and does **not** match one that disposes something else. | Argument | Meaning | | ------------- | ----------------------------------------------------------- | | `pattern` | A code fragment with metavariables | | `path` | File or directory to search; defaults to the whole workspace | | `language` | `dart`, `javascript`, `typescript`, `tsx`, `php`. Inferred when `path` names a file | | `max_results` | Default 50 | :::note[The pattern is parsed by the same grammar as the file] So `dispose(x)` matches the call however it is spaced, wrapped or commented — and never matches the same characters inside a string literal or a comment. ::: :::caution[A fragment is parsed in a scaffold, not bare] `dispose(x)` parsed at Dart's top level is a valid *function signature*. The pattern compiler tries a ladder of contexts (statement, expression, class member, declaration) and uses the first that accepts the fragment. A pattern no context accepts is reported as unparseable rather than searched for and silently not found. ::: ## `ast_edit` — structural rewrite, staged Same matching, plus a `rewrite` template with the metavariables substituted back in. **Nothing is written.** The change is staged and the result names an `edit_id`. ``` ast_edit(pattern: "dispose($X)", rewrite: "$X.dispose()", language: "dart") → (proposed) 12 replacements in 4 files … call resolve with edit_id "edit_1" ``` | Argument | Meaning | | ---------- | --------------------------------------------------- | | `pattern` | What to match | | `rewrite` | What to put there, with `$NAME` substituted | | `path` | Scope; defaults to the whole workspace | | `language` | Language id | ## `resolve` — commit or discard a staged change | Argument | Meaning | | --------- | -------------------------------- | | `edit_id` | From the `(proposed)` result | | `action` | `accept` or `discard` | Every file is compared against the content captured at staging time, and **any** mismatch refuses the whole change. A partly-applied structural rewrite leaves a tree that compiles under neither the old shape nor the new one. --- ## `debug` — drive a debugger Registered when a debug adapter is detected for the checkout, by the same intersection rule as the language server. `exec` tier; not parallel-safe (a session is state across calls, so `continue` overtaking `stack` would read a frame that has already moved). One session per conversation, with a hard TTL. A second `launch` is refused rather than silently replacing the first. | Op | Arguments | Notes | | ------------- | ----------------------------- | ---------------------------------------------- | | `adapters` | — | Which adapters are usable here, and why not | | `status` | — | Whether a session is running, and where it stopped | | `breakpoints` | `file`, `lines[]` | Empty `lines` clears the file. May be set **before** launching | | `launch` | `program`, `args[]` | Starts the program stopped at your breakpoints | | `attach` | `port` | Connects to an already-running debuggee | | `continue` | `thread_id?` | Runs until the next stop | | `pause` | `thread_id?` | Stops it where it is | | `step_over` / `step_in` / `step_out` | `thread_id?` | Moves one step | | `threads` | — | Thread ids and names | | `stack` | `thread_id?` | Frames, capped at 30 | | `scopes` | `frame_id` | Scopes on a frame, each with a `reference` | | `variables` | `reference` | Children, capped at 50, values capped at 400 chars | | `evaluate` | `expression`, `frame_id?` | Evaluated in the frame's own scope | | `output` | — | The debuggee's stdout/stderr, bounded | | `terminate` | — | Ends the session and kills the adapter | ### Adapters | Id | Command | Root markers | | --------- | -------------------------- | ----------------------------------------------- | | `dart` | `dart debug_adapter` | `pubspec.yaml` | | `debugpy` | `python3 -m debugpy.adapter` | `pyproject.toml`, `setup.py`, `requirements.txt` | | `lldb` | `lldb-dap` | `Cargo.toml`, `CMakeLists.txt` | | `delve` | `dlv dap` | `go.mod` | `debugpy` prefers a project-local `.venv/bin` over `$PATH`: a `$PATH` python is a different environment, and attaching from the wrong one fails in a way that reads as broken code. --- ## `eval` — persistent interpreters A Python or JavaScript cell whose variables, imports and loaded data survive between calls. `exec` tier; not parallel-safe. | Argument | Meaning | | ---------- | ------------------------------------------------ | | `code` | The cell | | `language` | `python` (default) or `javascript` | | `reset` | Discard the interpreter and start clean first | - The last expression is echoed the way a notebook does. - `display()` works, a pandas frame renders as a table, and a matplotlib figure comes back as an **image the transcript shows**. - The IPython idioms people reflexively type — `%pip`, `%cd`, `!cmd`, `%%bash`, `%%timeit` — are rewritten into plain code. `%pip` also evicts `sys.modules`, so the next `import` sees what was just installed. ### Calling your own tools from inside a cell ```python contents = tool("read", {"path": "lib/auth.dart"}) ``` The bridge **re-enters the harness tool registry**, so guardrails, approval and every wrapper apply exactly as they do to a model-issued call. It rides the kernel's own pipe rather than a loopback socket: no port, no secret, no egress rule. The cell's timeout is an *inactivity* budget and it is **suspended** while a bridged call is outstanding, so a cell that fans out to subagents is not killed mid-fanout. :::note[Where the kernel runs] In the conversation's enclosure when it has one, on the host otherwise. It does not boot an enclosure to get there — the same rule that stops a rig tab auto-starting. ::: --- ## `ask_user` — ask the human a question `read` tier. Renders a structured question in the conversation and blocks the run until someone answers. | Argument | Meaning | | -------------- | ------------------------------------------ | | `question` | What to ask | | `options[]` | Choices, each `{label, description?}` | | `allow_free_text` | Whether a typed answer is accepted | | `multi_select` | Whether several options may be chosen | Registered only when both a question port and a target space exist. Without a human attached the call **fails** rather than blocking forever — the same fail-closed posture a guardrail `prompt` takes with no approver connected. --- ## `vibe_*` — directing background workers Registered only while [vibe mode](/manual/guides/direct-background-workers/) is on. All five are `read` tier: the gate belongs on the worker's tools, where the edit actually happens. | Tool | Arguments | Notes | | ------------ | ---------------------------------------- | ------------------------------------------- | | `vibe_spawn` | `brief`, `label`, `tier?` | Returns immediately; the worker runs on | | `vibe_send` | `worker_id`, `message` | Appends to the brief and re-runs | | `vibe_wait` | `worker_id`, `timeout_seconds?` | Blocks until it settles | | `vibe_kill` | `worker_id` | Stops it | | `vibe_list` | — | Tier, status, elapsed and last result | `tier` is `fast` (mechanical work with a clear specification) or `good` (design, judgment, or reviewing a `fast` worker's output). ### Backup, export and import > The snapshot layout, the manifest, every backup operation and transfer route, and exactly which files a backup does and does not contain. Source: https://usectrl.dev/manual/reference/backup/ Everything Control Center can copy, hand over or adopt, and the exact shape of each artifact. To actually take a backup, see [Back up and restore](/manual/guides/back-up-and-restore/); for why persistence is split this way, see [Workspaces and isolation](/manual/concepts/workspaces/). ## Artifacts There are two, and they are not interchangeable. | Artifact | Written by | Shape | Covers | | ----------------------- | ------------------- | --------------------------------- | --------------------------------- | | **Install snapshot** | `server.backupNow` | A timestamped **directory** | Every database on the install | | **Workspace export** | `workspace.export` | A single `.db` **file** | One workspace's database | Both are written with SQLite `VACUUM INTO`, which is safe against a live WAL database: it takes a read transaction, produces a defragmented and transactionally consistent copy, and never requires stopping the server. ## Snapshot layout ``` /backups/2026-07-28T09-12-33-000Z/ manifest.json global.db /workspace.db one directory per workspace ``` The layout mirrors the live data directory exactly, which is what makes restoring an install a copy back rather than a translation. The directory name is the UTC timestamp with `:` and `.` replaced by `-`, so it is filesystem-safe on Windows and sorts chronologically as a plain string. ### `manifest.json` | Field | Type | Meaning | | -------------------------- | --------------- | ------------------------------------------------------------------- | | `version` | int | Manifest format version. Currently `1` | | `created_at` | ISO-8601 UTC | When the snapshot was taken | | `global.file` | string | Always `global.db` | | `global.schema_version` | int | `GlobalDatabase` schema version at capture time | | `global.bytes` | int | Size of the written `global.db` | | `workspace_schema_version` | int | `WorkspaceDatabase` schema version shared by every workspace file | | `workspaces[]` | array | One entry per captured workspace | | `workspaces[].workspace_id`| string | The workspace this file holds | | `workspaces[].file` | string | Path relative to the snapshot root, `/workspace.db` | | `workspaces[].bytes` | int | Size of that file | | `skipped_workspaces[]` | array of string | Workspaces the backup could **not** capture | A workspace is skipped when it is registered but has never been written to (so no file exists yet), or when its `VACUUM INTO` failed. Either way the snapshot still completes and the manifest names what is missing. ### Completeness `server.listBackups` reports a `complete` flag per snapshot. It is false when the manifest is absent or unparseable, when `global.db` is missing, or when the manifest names a workspace file that is not on disk. An incomplete snapshot is still listed, because a snapshot an operator cannot see is a snapshot they believe they have. When the manifest is unreadable the listing falls back to the layout itself and reports whichever `/workspace.db` files it finds — those remain individually adoptable through `workspace.import` even though the snapshot as a whole cannot restore an install. ## What a backup does not contain Both artifacts are **databases only**. A workspace directory holds more than its database, and none of the rest travels: | Path | In a snapshot? | In a workspace export? | | ----------------------------------- | -------------- | ---------------------- | | `global.db` | Yes | No | | `/workspace.db` | Yes | Yes (it *is* the file) | | `/blobs/` | No | No | | `/skills/` | No | No | | `/agents/` | No | No | | `/chat_credentials/` | No | No | | `/spaces//` | No | No | | `/pr_clones/` | No | No | | `secrets.json` | No | No | | `models/` | No | No | | `meetings//` | No | No | | `rigs/` | No | No | Some of that is disposable — `spaces/` holds copy-on-write worktrees that are re-provisioned from the repos, `pr_clones/` is a cache, `models/` re-downloads, `rigs/` is machine state. Some of it is not: `blobs/` holds pasted images that rows in the database point at, `skills/` and `agents/` hold the files an agent runs with, and `secrets.json` holds every pairing key, provider credential and SSO setting on the install. See [Back up and restore](/manual/guides/back-up-and-restore/#copy-backups-off-the-machine) for covering the rest. :::note[Chat tokens deliberately stay behind] `chat_credentials/` sitting beside the database rather than inside it is why a workspace handed to somebody else carries no live bot tokens. See the [chat bridge reference](/manual/reference/chat-bridge/). ::: ## Operations All four are `fullClient`-only: a paired phone can never trigger them. All four are absent entirely — not refused, structurally missing — on a server with no backup port wired, which is what a demo server is. | Op | Kind | Scope | Minimum role | Arguments | Returns | | -------------------- | ------ | --------- | ------------ | ------------- | ----------------------------- | | `server.backupNow` | mutate | Install | — | none | `{ok, path}` | | `server.listBackups` | read | Install | — | none | `{backups: [...]}` | | `workspace.export` | read | Workspace | `admin` | none | `{ok, path}` | | `workspace.import` | mutate | Workspace | `owner` | `source_path` | `{ok, workspace_id}` | | `workspace.delete` | mutate | Install\* | `owner` | `id` | registry row marked deleted | \* `workspace.delete` is unscoped because the row it marks lives in the global registry, so the dispatcher's role gate never fires for it; the handler checks the caller's membership of the named workspace itself and requires `owner`. `path` in every response is a path on the **server host**. :::note[Taking a snapshot is not owner-gated; downloading one is] `server.backupNow` and `server.listBackups` carry no server-admin gate — any full client may take a snapshot and see which ones exist, and what they get back is a list of paths, not bytes. `GET /backup/snapshot`, which hands over the actual archive of every workspace on the install, requires the install **owner**. ::: ### `server.listBackups` response Newest first, sorted by directory name (which is the timestamp, so the order holds even for a snapshot whose manifest could not be read). ```json { "backups": [ { "path": "/…/backups/2026-07-28T09-12-33-000Z", "name": "2026-07-28T09-12-33-000Z", "created_at": "2026-07-28T09:12:33.000Z", "bytes": 481203712, "complete": true, "workspaces": [ { "workspace_id": "w_9f2c…", "path": "/…/backups/2026-07-28T09-12-33-000Z/w_9f2c…/workspace.db", "bytes": 190840832 } ], "skipped_workspace_ids": [] } ] } ``` `created_at` is omitted when the manifest could not be read. `bytes` at the top level is the real size on disk of the whole directory, not the manifest's own sum — the question an operator pruning snapshots is asking is what the directory costs, not what the backup meant to write. Each `workspaces[].path` is exactly what `workspace.import` takes as `source_path`. That is deliberate: "restore this workspace from this snapshot" *is* the import op, so the two cannot drift into different treatments of the same file. ### `workspace.import` validation Before anything is replaced, the candidate file is opened read-only with a short-lived raw SQLite connection and its `workspace_meta` row is read. | Condition | Result | | ------------------------------------------------ | ---------------------------------------------------------------- | | Not a readable SQLite database, or no `workspace_meta` | Refused. Nothing is touched | | Recorded workspace id ≠ the target | **Allowed**, logged. This is how a workspace is duplicated or restored under a new id | | Recorded install id ≠ this install | **Allowed**, logged. Its paired devices and user ids belong to that install and will not resolve here | | Invalid workspace id, or file does not exist | Refused | On acceptance the target workspace's database is closed and dropped from the manager before the file is copied over it (an open drift connection holds the file and its `-wal`), then reopened so `beforeOpen` reinstalls the FTS and sync triggers, and the FTS indexes are rebuilt — an imported file's content tables are populated but its index may be stale. ## Transfer routes Every op above speaks in server-side paths, which is a complete answer only when the server is your own machine. These three carry the bytes, on the same PSK-signed lane the media proxy and blob store use. The caller signs a target string with its device pre-shared key and passes the signature as `s`. | Route | Method | Query | Signed target | Requires | | ----------------------- | ------ | --------------- | ------------------------------ | ----------------------- | | `/backup/workspace` | GET | `w`, `d`, `s` | `backup-workspace:` | Workspace **admin** | | `/backup/snapshot` | GET | `n`, `d`, `s` | `backup-snapshot:` | Install **owner** | | `/backup/restore` | POST | `w`, `d`, `s` | `backup-restore:` | Workspace **owner** | `w` is a workspace id, `n` a snapshot directory name, `d` the calling device id, `s` the signature. `n` is resolved against the backup listing rather than joined onto the backups root, so the route cannot be aimed at a directory the listing would not have shown. | Status | Cause | | ------ | ----------------------------------------------------------------------------------- | | `400` | A required query parameter is missing, or an upload body is empty | | `403` | Unknown or revoked device, bad signature, or the caller lacks the role | | `404` | No backup port on this host, or the named snapshot/workspace file does not exist | | `405` | `/backup/restore` called with anything but `POST` | | `413` | Upload body exceeds 8 GiB, by declared content-length or by bytes actually read | | `500` | The export or archive failed server-side | Both downloads respond `Cache-Control: no-store` with `Accept-Ranges: none`. Each request mints a **fresh** copy and deletes it once streamed, so a cached or resumed response would splice bytes from two different exports into one file that looks valid and is not. `/backup/snapshot` returns `.zip`, entries relative to the snapshot root so unpacking reproduces `manifest.json` + `global.db` + `/workspace.db`. Entries are written in sorted order, so two archives of the same snapshot are the same archive. `/backup/restore` streams the body straight to a staging file rather than buffering it, bounds it before and during the read, and deletes the staged file on **every** path out including a refusal. :::caution[A relayed connection has no HTTP origin] The brokered relay carries RPC frames, not file transfers. Everything that speaks in server-side paths keeps working over it; the three transfer routes need a direct connection, and the UI disables them with a note rather than failing mid-download. ::: ## Paths and limits | Thing | Value | | ------------------------------ | ------------------------------------------------------------ | | Snapshots | `/backups//` | | Workspace exports | `/backups/exports/-.db` | | Transfer staging | `/backups/transfer/` — a workbench, swept by the route that writes it | | Snapshot archive name | `.zip` | | Upload ceiling | 8 GiB per `POST /backup/restore` | | Retention | None. Nothing rotates, prunes or schedules a snapshot | The `exports/` directory sits under `backups/` and is excluded from the snapshot listing by name — it is a pile of single-workspace files, not a snapshot, and counting it as one would report an install as having backups it does not have. ## See also - [Back up and restore](/manual/guides/back-up-and-restore/) — the recipes these operations belong to - [Workspaces and isolation](/manual/concepts/workspaces/) — why one workspace is one file - [`cc_server` CLI](/manual/reference/cc-server-cli/) — the data directory these paths sit in - [Route map](/manual/reference/route-map/) — where the UI lives - [Architecture](/manual/concepts/architecture/) — the client/server split the paths follow ### cc_server CLI > Every flag, environment variable, subcommand and data-directory path of the headless server. Source: https://usectrl.dev/manual/reference/cc-server-cli/ Every setting takes a CLI flag or an environment variable; the flag wins, then the environment, then the default. :::caution[Unknown flags are ignored silently] The argument parser is hand-rolled and skips anything it does not recognise, so a typo (`--tls-key-file`, `--bindany`) produces no error — the server just starts with the default. Check the startup log lines when a flag seems to have no effect. ::: ## Core | Flag | Env | Default | Meaning | | --------------- | ---------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | | `--data-dir` | `CC_SERVER_DATA_DIR` | the OS per-user application-data dir (see below) | Databases, secrets, models and cached media | | `--port` | `CC_SERVER_PORT` | `9030` | TCP port (`0` = ephemeral) | | `--bind` | `CC_SERVER_BIND` | `loopback` | `loopback`, or `any`/`all`/`0.0.0.0` for every interface (needs TLS or `--insecure`) | | `--repo-roots` | `CC_SERVER_REPO_ROOTS` | the server user's home directory | Comma-separated base directories a client may browse when registering a repo. Browsing above a root is refused | | `--log-level` | `CC_SERVER_LOG_LEVEL` | `warning` | `debug`, `info`, `warning`, or `error`. The booting/ready lines always print | | `--server-name` | `CC_SERVER_NAME` | the machine hostname | Name shown in pickers, discovery and pairing surfaces | ## Reachability and TLS | Flag | Env | Default | Meaning | | ------------------- | --------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `--tls-cert` | `CC_SERVER_TLS_CERT` | unset | PEM certificate chain. Serves `wss://` in-process when set together with the key | | `--tls-key` | `CC_SERVER_TLS_KEY` | unset | PEM private key matching the cert | | `--insecure` | `CC_SERVER_INSECURE` | off | Allow a plaintext non-loopback bind. Only behind a TLS-terminating proxy on a trusted network; ignored when TLS is configured | | `--public-url` | `CC_SERVER_PUBLIC_URL` | derived from the bind | The RPC URL this server advertises to paired clients. **Set this explicitly behind a proxy, NAT, or tunnel** — the default is only a guess at the local bind | | `--allowed-origins` | `CC_SERVER_ALLOWED_ORIGINS` | `https://app.usectrl.dev` | Comma-separated browser origins allowed to dial `/rpc` cross-origin. Loopback and native clients are always allowed | | `--web-client-url` | `CC_SERVER_WEB_CLIENT_URL` | unset | Origin of the hosted web client that single sign-on callbacks bounce back to | | `--signaling-url` | `CC_SERVER_SIGNALING_URL` | `wss://signaling.usectrl.dev` | The relay broker this server joins so a phone can reach it with no direct path | | `--mdns` | `CC_SERVER_MDNS` | `auto` | LAN advertisement: `auto` (only when bound beyond loopback), `on`, `off`. Discovery advertises existence only — joining still needs pairing | | `--tunnel` | `CC_SERVER_TUNNEL` | `off` | Managed tunnel provider: `off`, `cloudflared`, `ngrok`, `tailscale`. Public exposure is opt-in | | `--tunnel-binary` | `CC_SERVER_TUNNEL_BINARY` | resolved from `PATH` | Explicit tunnel binary path | | `--tunnel-sha256` | `CC_SERVER_TUNNEL_SHA256` | unset | Expected SHA-256 of that binary, verified before every spawn | | `--tunnel-args` | `CC_SERVER_TUNNEL_ARGS` | empty | Comma-separated extra arguments for the tunnel invocation | ## Indexing and third-party credentials | Flag | Env | Default | Meaning | | ------------------------ | ------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `--sandbox` | `CC_SERVER_SANDBOX` | `on` | Whether agent runs are wrapped in the host's OS-native sandbox when one is available. An opt-out, not an enable — see below | | `--code-index` | `CC_SERVER_CODE_INDEX` | `on` | Field kill switch for background code-graph indexing. `off` boots clean without a rebuild | | `--code-index-defer` | `CC_SERVER_CODE_INDEX_DEFER` | `15` | Seconds to hold the first index sweep after the ready banner (clamped 0–300) | | `--tool-deferral` | `CC_SERVER_TOOL_DEFERRAL` | `on` | Whether harness runs send a small resident tool set plus a name index, loading the rest on first use. See below | | `--credential-gate` | `CC_SERVER_CREDENTIAL_GATE` | `900` | Seconds a run parks waiting for a human to fix a credential that cannot serve it (clamped 0–3600). See below | | `--google-client-id` | `GOOGLE_OAUTH_CLIENT_ID` | the client baked into a release build | Google device-code client for calendar sync. Empty disables calendar | | `--google-client-secret` | `GOOGLE_OAUTH_CLIENT_SECRET` | as above | The secret for that client | | `--klipy-app-key` | `KLIPY_APP_KEY` | the key baked into a release build | Klipy GIF key. Empty disables the `gif.*` ops | An empty value for a credential reads as unset, so the built-in fallback still applies. A build from source carries no built-in credentials at all. ### `--sandbox` The server probes the host once at boot and wraps agent command execution in the OS-native sandbox when a backend is available: `sandbox-exec` (Seatbelt) on macOS, `bwrap` plus `socat` on Linux and WSL2. Where none is — Windows, or a Linux host missing those tools — runs fall back to environment sanitization, the command policy and the action guardrails and the startup log says which state applies. `--sandbox off` is the field kill switch for the case the probe cannot see: a host where the sandbox profile itself misbehaves. It does not need a rebuild and the server logs a warning for the life of the process so the state is never silent. ### `--tool-deferral` Built-in harness runs send a small resident set of tool definitions plus a name-only index of the rest, which load their schemas the first time they are used. It cuts the tool block by roughly 78% and keeps the always-visible set inside the range where models select tools reliably. Deferred tools stay callable by name throughout, and `search_tools` finds them by intent. See [Tool context and prompt caching](/manual/concepts/tool-context). `--tool-deferral off` makes every admitted tool resident again, reproducing the requests the server made before deferral existed. It is the kill switch for a model that handles the two-tier surface badly — a flag rather than a rebuild. It affects only the built-in harness; the MCP `tools/list` external clients see is always the full catalogue. ### `--credential-gate` A run whose provider credential is missing or spent is **parked** rather than failed: the operator gets a prompt to fix it, and the run resumes if they do. The default deadline is 900 seconds (15 minutes), clamped to 0–3600. The deadline is deliberately much longer than the approval prompt's: an approval is a decision someone makes in seconds, while fixing a credential means opening a terminal, signing in and coming back. The ceiling is what keeps an unattended run — a pipeline step, a cron trigger, a webhook — from waiting on somebody who is asleep. When it expires, the run fails with the message it would have failed with anyway. `--credential-gate=0` is the kill switch: nothing is ever parked and a bad credential ends the turn exactly as it did before the gate existed. ## Subcommands | Command | What it does | | --------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `cc_server` | Runs the server until `SIGINT`/`SIGTERM` | | `cc_server pair` | Provisions a device and prints its id and pairing key. Run it while no server holds the data dir | | `cc_server calendar connect --workspace ` | Connects a Google account to a workspace over the device-code flow, then exits | | `cc_server update` | Checks for, downloads and verifies a newer standalone release | | `cc_server --version` | Prints the build version and git SHA — the same identity `/healthz` and the RPC handshake advertise | ### `pair` | Flag | Default | Meaning | | -------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--device` | `web-client` | The device id. The platform is inferred from it, so `--device desktop` mints a desktop row and `--device ios`/`android` a phone row; anything else is treated as web | | `--label` | derived from the platform | Display name in the devices list | | `--host` | `localhost`, or `` when bound to any interface | Host to embed in the printed URL. Set it to the LAN IP or tunnel host a client will actually dial | | `--client-url` | unset | Origin of the hosted **web** client. Adds a scannable deep link and terminal QR | Re-running `pair` for the same `--device` rotates its key. A credential minted here has **no expiry**, unlike the 30 days an in-app pairing sets. :::caution[The `--client-url` QR is not the phone QR] `pair --client-url` emits a v1 deep link for the web client. The phone pairs with a different v2 payload minted from the in-app pairing panel. The two are not interchangeable — see [Pair a device](/manual/guides/pair-a-device/). ::: ### `update` `--apply` replaces the install; it is refused while a server still answers on the configured port unless you pass `--force`. `--allow-downgrade` permits an older release. The command refuses outright, with an explanation rather than an error, for installs it does not own: | Environment | Detected by | What it says | | ----------------------------- | ------------------------------------------- | ----------------------------------------------------------- | | The desktop's embedded server | `CC_EMBEDDED=1` or `CC_BOOTSTRAP_DEVICE_ID` | Update the app instead — the installer swaps the whole tree | | A `dart run` source checkout | the script path ends in `.dart` | Update it with git | | Docker | container markers | Pull a newer image | ## Process lifecycle `cc_server` runs until `SIGINT` / `SIGTERM`. One extra environment variable governs that, and it is set by the desktop rather than by you: | Variable | Effect | | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `CC_EXIT_WITH_PARENT=1` | The server exits when its parent process does, detected by the parent closing the write end of its stdin pipe. The desktop sets it so a crashed app never leaves an orphaned server holding the data dir | Set it yourself only if you are supervising `cc_server` as a child process and want the same guarantee. ## Data directory layout The default data dir is `~/Library/Application Support/control-center` on macOS, `%APPDATA%\control-center` on Windows and `$XDG_DATA_HOME/control-center` (else `~/.local/share/control-center`) elsewhere. A cwd-relative `.cc_server` is only used when no home or app-data directory resolves. | Path | Contents | | --------------------------------- | ------------------------------------------------------------------------------- | | `global.db` | The workspace registry, users, paired devices, the newsfeed and the fleet queue | | `/workspace.db` | One database per workspace — agents, spaces, tickets, memory, the code graph | | `/chat_credentials/` | Chat bot tokens, as plain JSON, so they go with the workspace | | `secrets.json` | Pairing keys, the provider app identity, per-user and Google tokens, SSO (`0600`) | | `backups//` | One install snapshot — `manifest.json`, `global.db` and a copy of each workspace | | `backups/exports/` | Single-workspace exports, `-.db` | | `backups/transfer/` | Staging for backup downloads and uploads; swept by the route that writes it | | `models/` | On-device embedding, diarization and speech models | | `meetings//` | Retained meeting audio | | `rigs/images/` | Imported rig disk images (the QEMU desktop surface) | | `rigs/smolvm//` | Per-rig microVM runtime state (the broker secret, `0600`) | | `rigs/smolvm-packs/` | Pre-extracted machine packs — the cache that makes repeat rig boots fast | | `rigs/tls/` | The dev-domain certificate authority for rig HTTPS (keys `0600`) | | `rigs/run//` | QEMU overlays, seed images and per-VM keys (`0600`) | ## See also - [Run a headless server](/manual/guides/run-headless-server/) — the setup recipe these flags belong to - [Back up and restore](/manual/guides/back-up-and-restore/) — copying this directory somewhere safe - [Backup, export and import](/manual/reference/backup/) — the snapshot layout and every backup operation - [Connect to a remote server](/manual/guides/connect-remote-server/) — pointing a client at one - [Deployment and clients](/manual/concepts/deployment/) — why the server owns everything ### Chat bridge > Providers, commands, credentials, capabilities, RPC operations and storage for the chat bridge. Source: https://usectrl.dev/manual/reference/chat-bridge/ ## Providers | Provider | Wire value | Transport | Space kind | | -------- | ---------- | -------------------------- | -------------- | | Slack | `slack` | Socket Mode (outbound WSS) | `slack` | **Slack is the only provider.** `ChatProvider` is a single-valued enum and `cc_server` registers one plugin. The schema, every lookup and every unique index are keyed by `provider`, so ids never cross providers and adding one is a server-only change. One provider-side app per **workspace × provider**. ## Chat commands The command name is per app (`cc` by default, so `/cc`). An admin can rename it; the bridge then uses whatever it was last invoked with. | Command | Effect | | ------------------------------------- | ------------------------------------------------------------- | | `/cc help` (or `/cc` with no verb) | Lists what the bot can do | | `/cc link ` | Redeems a one-time code and links the sender's chat identity | | `/cc ticket ` | Files a local ticket reported by the linked member | | `/cc ticket <title> \| <description>` | Same, with everything after the first `\|` as the description | | Unknown verb | Reports the unknown verb plus the help text | Ways to reach an agent: | Gesture | Behavior | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `@bot …` in a channel | Opens a Slack thread and a matching Control Center space; the agent replies in the Slack thread | | Reply in a bridged Slack thread | Continues the same Control Center space — no mention needed | | Plain message in a channel | Ignored (not addressed to the bot and not in a bridged Slack thread) | | DM to the bot | One Control Center space per DM conversation; sets a Slack thread title and an "is thinking…" status while the agent works | ## Credential fields (Slack) Declared by the provider descriptor, which is what generates the connect dialog and validates the submission. | Field id | Label | Required | Prefix | Where it lives | | -------------------- | ------------------------------- | -------- | ------- | --------------------------------------------------------------- | | `botToken` | Bot user OAuth token | Yes | `xoxb-` | OAuth & Permissions | | `appToken` | App-level token | Yes | `xapp-` | Basic Information → App-Level Tokens, scope `connections:write` | | `configRefreshToken` | App configuration refresh token | No | `xoxe` | App configuration tokens on api.slack.com/apps | `configRefreshToken` is the app-management credential: without it the connection works, but the bot cannot be created or edited from Control Center. It rotates on every use and the rotated value is stored immediately. Values are trimmed, undeclared keys are dropped and a prefix mismatch is refused with the same sentence on the client and the server. Required fields must be present on every connect; an optional field left empty keeps whatever was already stored. ## Slack app requirements Bot token scopes: ```text app_mentions:read assistant:write channels:history chat:write commands groups:history im:history users:read users:read.email ``` Bot event subscriptions: ```text app_home_opened app_mention message.channels message.groups message.im ``` Structural settings Control Center asserts when it creates or edits the app: Socket Mode enabled, no request URL, Messages tab enabled and writable, bot `always_online`, the required scopes and events merged into whatever the app already has (never subtracted) and one slash command carrying the description `File a ticket or link your Control Center account` — the marker that identifies Control Center's own command in an exported manifest. ## Connection states | State | Wire | Meaning | | ---------------- | -------------- | --------------------------------------------------------------- | | Not connected | `disconnected` | No credentials stored for this workspace × provider | | Connecting… | `connecting` | Credentials stored, transport dialing or re-dialing | | Live | `connected` | Transport up and receiving events | | Connection error | `error` | Stored but failing; the provider's own error is shown alongside | Reconnection is exponential backoff with jitter, capped at 30 seconds. A provider-initiated socket rotation reconnects promptly without escalating backoff. A rejected credential (`invalid_auth`, `token_revoked`, `account_inactive`, `token_expired`, `invalid_app_id`) or a missing scope is terminal: the bridge stops retrying until new credentials arrive. ## Provider capabilities (Slack) The bridge is written against the richest surface and degrades per capability. | Capability | Slack | Effect when absent | | ------------------------- | ------ | ---------------------------------------------------------------------------------------------- | | `streaming` | yes | The whole reply is posted once, at the end | | `streamingRequiresThread` | yes | A reply target with no thread anchor posts whole replies | | `ephemeralMessages` | yes | A slash-command answer with no usable reply handle is dropped (refusals always post in-thread) | | `threadStatus` | yes | No transient "is thinking…" line | | `threadTitle` | yes | The chat thread keeps its default title | | `slashCommands` | yes | No `/cc` surface | | `taskCards` | yes | Text only — no card above the reply and no "View in Control Center" link | | `maxMessageLength` | 39,000 | Longer replies are truncated with a pointer to Control Center | | `maxStreamChunkLength` | 3,800 | Appends are split at a paragraph, line, or space boundary | Streaming advertised is not streaming promised: Slack's needs the Agents & AI Apps feature on a paid plan. A refusal at call time (`streaming_not_supported`, `feature_not_enabled`, `paid_only`, …) is remembered for the life of the connection and surfaced as `streamingAvailable: false`. Slack's task cards ride the same call, so a Slack that refuses the card's display mode keeps its stream and loses only the card, once, for the life of the connection. A stream also has to name the reader it is for. Slack requires both a recipient user and their team to open one in a **channel** (a DM does not) and refuses with `missing_recipient_user_id` / `missing_recipient_team_id` otherwise — a per-call refusal, so the bridge keeps trying and the turn arrives as one posted reply meanwhile. The recipient is whoever asked, taken from the inbound message (their team, not the app's, so a Slack Connect channel resolves correctly). For a turn nobody started from chat — work kicked off in Control Center on an already-bridged conversation — it is the member whose link created the bridge; if they have since unlinked their chat account, that turn posts whole rather than streaming. ## Task cards One grouped card per agent turn (`task_display_mode: plan`), with a row per step. Each row is a `task_update` of `id` + `title` + `status`, plus a one-shot `details` when the thought is ready. Same-id `details` and `output` concatenate with no separator, so `details` are sent once and `output` is never streamed. The plan's title is the request; the answer is a separate later append carrying a `markdown_text` chunk. | Part | Content | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Plan title | What was asked | | Setup row | `Working on it…`, then `Cloning …`, then `Starting the agent…` — one row, title replaced | | Thinking row | `Thinking…`, added when the agent starts reasoning; the thought is this row's `details`, sent once when reasoning finishes | | Tool rows | One per tool (`Bash which swift`), unique id so they sit side by side | | Status | In progress while that row runs; complete when the next row starts or the turn finishes; error for failed, interrupted and turn-limit | | Call to action | `View in Control Center` as a trailing row when the turn finishes — absent while work is still running and absent when the server has no reachable public URL | What a card never carries: tool output, an argument outside that allow-list, or anything read from the database. Reasoning prose is only the `Thinking…` row's details (capped at 256 characters), not the message body. A card that would say nothing new is not resent. A mention opens the stream immediately so the Slack thread is not silent; a turn that then produced no text at all finishes that same card with a short note rather than posting a second message. A wordless turn on a space that never opened a stream (no cards, or streaming already refused) still posts one message carrying the finished card and the note. Streamed and posted cards are different payloads: | Form | Shape | Prose | | -------- | ---------------------------------------------------- | ------------------------------------------------ | | Streamed | `plan_update` plus `task_update` chunks keyed `id` | Plain string, capped at 256 characters per chunk | | Posted | A `plan` block wrapping `task_card`s keyed `task_id` | Rich text | Failure handling: | Situation | Behavior | | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | Provider refuses the card on an append | The append is retried immediately without it; prose still arrives live and cards are dropped for the life of the connection | | Provider refuses the prose too | The stream is closed and the reply is posted whole | | Stream breaks after part of the reply landed | The stream is closed and nothing is re-posted | See [Chat bridges](/manual/concepts/chat-bridges/) for why the payload is split this way. ### Setup, before there is a turn The card opens when a mention is accepted, before clone and before the first token; the turn that follows adopts the same card and stream. Narration after that comes from `SpaceProvisioningChanged` on the domain event bus — the same progress the desktop's provisioning banner reads off the space row. | Provisioning state | Narration | | --------------------------- | --------------------------------------------------------- | | Mention accepted | `Working on it…` | | Step `repo` | `Cloning <repo>…` | | Step `prCheckout` | `Checking out the pull request in <repo>…` | | Step `agent` | `Setting up <agent>…` | | `provisioning`, no step yet | `Preparing the workspace…` | | `ready` | `Starting the agent…` | | Agent reasoning | `Thinking…` | | `failed` | `Workspace setup failed — details are in Control Center.` | Setup narration requires all four conditions: | Condition | | ----------------------------------------- | | The provider renders cards | | Streaming is available on this connection | | The space has a chat request in flight | | No card is already reporting a turn there | The request stays in flight until a turn starts or setup fails. A `ready` event with no card already open is a warm space and is left quiet and provisioning nobody in chat is waiting for — a conversation opened in the app, a stranded one resumed at boot — produces no card at all. ## The `/open` bounce page Chat products accept `http(s)` in a link and nothing else, so a call to action cannot name the desktop's scheme directly. | Property | Value | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | Route | `GET /open/workspaces/<workspaceId>/(spaces\|tickets)/<id>` | | Origin | Derived from the server's public URL (`ws` → `http`, `wss` → `https`); loopback is kept | | Response | A small no-store HTML page that navigates to `control-center://workspaces/<workspaceId>/(spaces\|tickets)/<id>` and shows a manual button | | Validation | Ids must match `[A-Za-z0-9_.-]{1,128}`; anything else is a 404 | | Access | Public and inert — it reads no database, so it is not an existence oracle; the app it opens does the authorization | ## Bot profile fields | Field | Limit | Notes | | ------------------ | ----------- | ------------------------------------------------------------------------------------------------- | | `appName` | 1–35 chars | Defaults to `Control Center · <workspace name>` | | `botDisplayName` | 1–80 chars | Defaults to `control-center` | | `description` | ≤ 140 chars | | | `agentDescription` | ≤ 300 chars | Shown above a DM with the bot | | `command` | ≤ 32 chars | Normalized: leading slashes stripped, lowercased, `[^a-z0-9_-]` removed, empty falls back to `cc` | | `agentEnabled` | — | Whether the DM/agent experience is exposed | An empty box means "leave it alone", not "clear it". Limits are Slack's, which are the tightest of the products modelled. ## Link codes | Property | Value | | -------- | ---------------------------------------------------------------------------------------- | | Length | 6 characters | | Alphabet | `23456789ABCDEFGHJKLMNPQRSTVWXYZ` (no `0`/`O`, `1`/`I`, `U`) | | Lifetime | 15 minutes | | Uses | One | | Storage | In memory — a server restart invalidates outstanding codes | | Scope | One workspace and one provider; minting a new code invalidates the member's previous one | Redemption is matched case-insensitively. Unknown, expired, foreign-workspace and wrong-provider codes all fail identically on purpose. ## Link methods | Method | Wire | How | | ------ | ------- | ------------------------------------------------------------------------------ | | Code | `code` | The member redeemed `/cc link <code>` | | Email | `email` | The provider's verified email matched a user who is already a workspace member | ## RPC operations All are workspace-scoped. The server is stateless, so each call carries its own `workspace_id` in args; the dispatcher refuses a workspace-scoped op that omits it, before any database opens. Credentials travel _in_ only — status reads never return them. | Operation | Kind | Min role | Action classes | | ----------------------- | ----------- | ---------------------------------------------- | ------------------------------- | | `chat.providers` | read | `guest` | — | | `chat.status` | read | `guest` | — | | `chat.connect` | mutate | `admin` | `secretAccess`, `networkEgress` | | `chat.disconnect` | destructive | `admin` | `secretAccess` | | `chat.listUserLinks` | read | `guest` | — | | `chat.beginUserLink` | mutate | `member` | — | | `chat.unlinkUser` | mutate | `member` for yourself, `admin` for anyone else | — | | `chat.createApp` | mutate | `admin` | `secretAccess`, `networkEgress` | | `chat.setupLink` | read | `admin` | — | | `chat.botProfile` | read | `admin` | `networkEgress` | | `chat.updateBotProfile` | mutate | `admin` | `networkEgress` | Read operations inherit the `guest` floor from their kind; mutations without an explicit floor require `member`. `chat.setupLink` composes a provider app-creation URL from the bot profile. It declares no action class because it reads no secret and dials nothing — the user's own browser makes the only call. One reactive query completes the surface: | Query | Kind | Emits | | --------------------- | ----- | -------------------------------------------------------- | | `chat.watchUserLinks` | watch | The roster, re-pushed whenever a link is made or removed | It exists because the linking happens on the _other_ side: a member types the one-time code into the bot, so no client response can carry the answer back. The settings roster and the open "link my account" dialog both follow it and the dialog switches to a confirmation the moment the member's own link lands. `chat.beginUserLink` always mints a code for the caller's own identity. `chat.updateBotProfile` returns a `remaining_step` only when a step is left (typically a reinstall); its absence means the edit is live. `chat.providers` returns one entry per offered provider — the descriptor plus, when there is one, the connection status. Everything the settings UI renders comes from that payload, which is why adding a provider is a server-only change. ## Storage | What | Where | | -------------------------- | ------------------------------------------------------------------------------------------------------------ | | Connection credentials | `<dataDir>/<workspaceId>/chat_credentials/<provider>.json` | | Half-finished guided setup | `<dataDir>/<workspaceId>/chat_credentials/<provider>_setup.json` | | Legacy Slack files | `<dataDir>/<workspaceId>/slack_credentials.json` and `slack_app_setup.json`, read once and migrated in place | Credentials live beside the workspace's database, not inside it: deleting a workspace takes its chat tokens with it and `workspace.export` or a backup copies `workspace.db` — so a workspace handed to somebody else carries no live bot tokens. Writes are atomic (temp file plus rename) and tightened to owner-only. ## Database tables Both live in the workspace database, which carries a squashed v1 baseline — so a cross-workspace read does not compile. `chat_space_links` — one external conversation ↔ one Control Center space: | Column | Notes | | ------------------------------------------------------ | -------------------------------------------------------------------------- | | `id` | UUID v4 | | `workspace_id` | Workspace scope | | `provider` | `ChatProvider.wire` | | `external_team_id` | Slack team `T…`, empty for providers without the concept | | `external_channel_id` | Slack channel `C…`, group `G…`, DM `D…` | | `external_thread_id` | Slack thread anchor, or null when the whole conversation is the anchor (bot DMs) | | `cc_space_id` | `REFERENCES spaces(id) ON DELETE CASCADE` | | `created_by_user_id`, `created_at`, `last_activity_at` | | Unique on `(workspace_id, provider, external_channel_id, external_thread_id)`, with a partial index enforcing the same for null thread ids (SQLite treats NULLs as distinct). `chat_user_links` — one external member ↔ one Control Center user: | Column | Notes | | -------------------------------------- | ----------------------------------------------- | | `id` | UUID v4 | | `workspace_id` | Workspace scope | | `provider` | `ChatProvider.wire` | | `external_team_id`, `external_user_id` | Slack member `U…` | | `user_id` | Control Center user (global, so no foreign key) | | `method` | `code` or `email` | | `linked_at` | | Unique on `(workspace_id, provider, external_team_id, external_user_id)` and on `(workspace_id, provider, user_id)` — single-valued attribution in both directions, while one person can be linked on several providers. A link is identity resolution, **not** an access grant: membership is re-checked on every inbound event, so a link to a member who has since been removed resolves to a refusal. ## Space kind and message metadata A bridged space is created with the provider's `SpaceKind` (`slack`). It behaves like an ordinary human-facing space — notified and sectioned like `topic` — and `isExternalChat` is the check every behavior (mirroring, badging, retention) goes through. Every message the bridge brings in carries a `metadata['chat']` stamp: ```json { "chat": { "provider": "slack", "teamId": "T024BE7LD", "channelId": "C0G9QF9GW", "threadId": "1700000000.000100", "messageId": "1700000000.000200", "userId": "U024BE7LH", "userLabel": "sam" } } ``` The stamp is also the echo guard: the outbound mirror skips any message that carries it, which is what stops an inbound chat message from being posted straight back. ## Relay behavior | Behavior | Detail | | -------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | What is relayed out | Agent **text** only, plus the task card's status line. Reasoning prose and tool output stay in Control Center | | Streaming cadence | Throttled to one flush per second while a turn runs; the card and the text it explains travel in the same call | | Human messages typed in Control Center | Mirrored to the linked thread, prefixed with the author's display name | | Empty turns | Reported as a short note (run failed, interrupted, or stopped at its turn limit) rather than silence | | Deduplication | Keyed on the provider's own event id; the last 512 are remembered | | Front-door agent | A new bridged conversation wakes the workspace's CEO agent, else its first agent, else nobody | | Space naming | `#channel · <request>` for a Slack channel thread, `Slack DM · <request>` for a DM, titles capped at 48 characters | | Failure isolation | One bad event never takes the bridge down; the connection keeps serving every other conversation | ## See also - [Set up Slack integration](/manual/guides/slack-integration/) - [Link your Slack account](/manual/guides/link-chat-account/) - [Customize the chat bot](/manual/guides/customize-chat-bot/) - [Chat bridges](/manual/concepts/chat-bridges/) - [Guardrails](/manual/concepts/guardrails/) for the action classes the ops declare ### Domain events > Complete catalog of the 53 domain events, their payloads and the server-side subscribers that consume them. Source: https://usectrl.dev/manual/reference/domain-events/ ## Event bus The `DomainEventBus` is an in-process broadcast publish/subscribe bus. Publishers call `publish(event)`; subscribers consume a typed `on<T>()` stream. Every event implements `DomainEvent` and carries `occurredAt`. **The bus runs inside `cc_server`.** Every publisher and every live subscriber lives there. No client sees this bus — the server forwards a curated subset as `notifications/*` JSON-RPC frames, filtered per subscriber by workspace membership. | Measure | Count | | ----------------------------------------------------------- | --------------------------------------------- | | Concrete event classes | 53 | | Source files (`packages/cc_domain/lib/core/domain/events/`) | 14 (plus `domain_event_bus.dart`) | | Abstract bases that are never published directly | 1 (`TaskLifecycleEvent`, a sealed hierarchy) | Every event in the catalog below lives in that one directory; there are no domain events declared elsewhere. ## Workspace, agent and repo events | Event | Payload | Fired when | | ------------------- | ------------------------------------------------------ | ------------------------------------------------------- | | `WorkspaceCreated` | `workspaceId` | Workspace is created | | `AgentRunCompleted` | `agentId`, `runId?`, `workspaceId?`, `conversationId?` | Agent finishes a run (success or failure) | | `RepoAdded` | `repoId`, `path`, `workspaceId` | A repo is registered; triggers background code indexing | | `SkillUpdated` | `workspaceId`, `slug`, `origin`, `computedHash`, `scanVerdict?` | A skill is installed or updated, carrying its supply-chain scan verdict | ## PR and review events The first block is the PR's own lifecycle. The second is the **authored-PR watch**: signals about a PR *you* opened, each carrying `forUserId` so the poll can route them to the person they concern rather than broadcasting. | Event | Payload | Fired when | | -------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | `PullRequestPublished` | `prId`, `workspaceId`, `repoOwner`, `repoName` | Agent opens a PR | | `PullRequestStatusChanged` | `status`, `prId?`, `workspaceId?`, `repoFullName?`, `prNumber?` | PR merged/closed/opened/reopened/approved | | `PrMerged` | `prId`, `workspaceId`, `agentId` | PR is merged — a narrow merge-only signal kept for analytics/notifications | | `PrReviewRequested` | `workspaceId`, `repoOwner`, `repoName`, `prNumber`, `prTitle` | GitHub asks for the server user's review (notifications poll, `review_requested`) | | `PrMentioned` | `workspaceId`, `repoOwner`, `repoName`, `prNumber`, `prTitle` | Server user is mentioned on a PR (notifications poll, `mention`) | | `ExternalPrMerged` | `workspaceId`, `repoOwner`, `repoName`, `prNumber`, `prTitle` | An externally tracked (non-agent) PR merged, detected by the GitHub notification poll; maps to the `prMerged` notification category | | `ExternalPrDetected` | `repoOwner`, `repoName`, `prNumber`, `prTitle`, `author`, `workspaceId?` | Non-agent PR found via polling — pipeline-only (drives `external_pr_welcome`), raises no notification | | `PrHeadChanged` | `repoOwner`, `repoName`, `prNumber`, `prTitle`, `previousHeadSha`, `headSha`, `workspaceId` | The PR's head SHA moved — the author pushed | | `ReviewBecameStale` | `workspaceId`, `spaceId`, `repoOwner`, `repoName`, `prNumber`, `prTitle`, `reviewedHeadSha`, `headSha` | A finished AI review no longer describes the PR it reviewed. Only fires when a review exists for the replaced commit, so it stays a signal rather than a per-push ping | ### The authored-PR watch All five carry `workspaceId`, `repoOwner`, `repoName`, `prNumber`, `prTitle` and an optional `forUserId`. | Event | Additional payload | Fired when | | ------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | | `PrMergeReadinessChanged` | `ready`, `reason` | A PR you authored became mergeable, or stopped being | | `PrReviewDecisionChanged` | `decision`, `reviewersRemaining`, `approverLogin?` | A reviewer approved, requested changes, or had an approval dismissed | | `PrChecksStatusChanged` | `failing`, `failingCheckName?`, `failingCheckUrl?` | CI went red, or recovered | | `PrCommentMentioned` | `commentId`, `authorLogin`, `bodyPreview`, `isReviewComment`, `threadId?`, `path?`, `line?` | Someone mentioned you in a PR comment or review comment | | `PrThreadReplied` | `commentId`, `authorLogin`, `bodyPreview`, `threadId?`, `path?`, `line?` | Someone replied in a review thread you are in | | `PrThreadResolved` | `threadId`, `commentId?`, `path?`, `line?` | Someone resolved a review thread you are in | ## Messaging events | Event | Payload | Fired when | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MessageReceived` | `spaceId`, `messageId`, `senderName`, `contentPreview`, `isAgentMessage`, `workspaceId?`, `mentions`, `requestedByUserId?` | New message arrives | | `SpaceCreated` | `spaceId`, `workspaceId?` | Space is created | | `SpaceDeleted` | `spaceId`, `workspaceId?` | Space is deleted | | `SpaceProvisioningChanged` | `workspaceId`, `spaceId`, `status`, `step?` | The space's workspace enters provisioning, reaches a step (cloning a repo, checking out a PR, setting up an agent), or flips to ready/failed — the chat bridge narrates it on its task card | ## Ticketing events | Event | Payload | Fired when | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | | `TicketCreated` | `ticketId`, `workspaceId` | Ticket is created | | `TicketAssigned` | `ticketId`, `ticketTitle`, `ticketBody?`, `ticketUrl?`, `assignedAgentId?`, `assigneeType`, `assignedTeamId?`, `workspaceId` | Ticket assigned to a principal or team | | `TicketCompleted` | `ticketId` | Work finishes (terminal) | | `TicketFailed` | `ticketId`, `errorMessage` | Agent fails (terminal) | | `TicketCancelled` | `ticketId` | Ticket cancelled (terminal) | | `TicketStatusChanged` | `ticketId`, `workspaceId`, `from`, `to` | Any status change | | `TicketReassigned` | `ticketId`, `workspaceId`, `fromAgentId?`, `toAgentId?` | Ticket reassigned | | `TicketDetailsUpdated` | `ticketId`, `workspaceId` | Title, description, or priority changes | ## Task lifecycle events The unified task-lifecycle stream — one dispatched agent run, keyed by its run-log id. Every event carries `taskId`, a monotonic per-task `seq`, `workspaceId?`, `agentId?` and a `phase`; the rows below list only the additional fields. | Event | Payload | Fired when | | --------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------ | | `TaskDispatched` | — | Task handed to a backend for execution | | `TaskRunning` | — | Task started producing output | | `TaskWaitingLocalDirectory` | `lockedPath`, `holderTaskId?` | Task parked — another task owns the on-disk path lock it needs | | `TaskProgress` | `note?` | Mid-run progress checkpoint | | `TaskMessage` | `messageType`, `content` | Streamed message (text/thinking/tool_use/tool_result/error); reported under the `progress` phase | | `TaskCompleted` | `summary?` | Task finished successfully | | `TaskFailed` | `errorMessage` | Task finished with an error | | `TaskCancelled` | — | Task cancelled before completion | The `TaskPhase` enum also has a `queued` member, but nothing publishes a queued event — there is no `TaskQueued` class. A task's first observable frame is `TaskDispatched`. ## Pipeline events | Event | Payload | Fired when | | ---------------------- | --------------------------------------------- | --------------------------------------------------------------------------- | | `PipelineRunCompleted` | `workspaceId`, `pipelineRunId`, `templateId` | Run finishes | | `PipelineRunCancelled` | `workspaceId`, `pipelineRunId`, `templateId` | Run cancelled (by the user or the system) — a deliberate stop, not an error | | `PipelineRunFailed` | `workspaceId`, `pipelineRunId`, `templateId`, `errorMessage` | Run fails | Only terminal transitions are published. There is no `PipelineRunStarted` and no per-step event: a step's progress reaches clients through the run's own rows and the task-lifecycle stream of whatever agent it dispatched, not through the bus. ## Observability events | Event | Payload | Fired when | | ------------------------ | -------------------------------------------------------------------------------------------------------- | ------------------------- | | `ActivityLogged` | `id`, `actorType`, `actorId?`, `action`, `entityType`, `entityId?`, `details?`, `workspaceId?`, `runId?` | Audit trail entry created | | `BudgetThresholdCrossed` | `scopeType`, `scopeId`, `spentCents`, `budgetCents`, `isHardStop` | Spend exceeds threshold | ## Identity and membership events | Event | Payload | Fired when | | ---------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `WorkspaceMemberAdded` | `workspaceId`, `userId`, `role` | A user joins a workspace (invite redemption or admin add) | | `WorkspaceMemberRemoved` | `workspaceId`, `userId` | A member is removed — live sessions of that user scoped to the workspace must re-check access immediately | | `WorkspaceMemberRoleChanged` | `workspaceId`, `userId`, `role` | A member's role changes | Membership is the whole identity surface on the bus. User creation and device revocation happen, but they are not published as domain events — revocation is enforced directly against live sessions rather than routed through the bus. ## Calendar and meeting events | Event | Payload | Fired when | | ------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `CalendarAuthExpired` | `workspaceId`, `accountEmail` | A connected account's OAuth refresh token is permanently invalid (published once per disconnection episode) | | `MeetingStartingSoon` | `workspaceId`, `eventId`, `title`, `startTime`, `meetingUrl` | A calendar event is starting within the configured lead window | | `MeetingRecordingStopped` | `workspaceId`, `meetingId`, `title`, `userNotes`, `transcript`, `summaryInstructions?` | A meeting recording finishes and triggers the built-in `meeting_summary` pipeline | ## Rig events | Event | Payload | Fired when | | ------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `RigClosedEvent` | `workspaceId`, `rigId`, `reason` | A rig went away, with its close reason; consumers act only on `backendFailure` — every other reason is requested or accompanied by a `RigReaped` | | `RigControlChanged` | `workspaceId`, `rigId`, `controller?` | A human took exclusive control of a rig or handed it back (`controller` null on release — the event does not record who let go) | | `RigReaped` | `workspaceId`, `rigId`, `reason`, `agentId?` | The system reclaimed a machine: idle, TTL, or memory-pressure eviction (reported as `idleTimeout`), with the driving agent | A rig **opening** is not an event. The boot completes and the rig's row flips to ready; nothing is published, so there is no `RigOpened` to subscribe to. ## Subdomains with no events at all Several subsystems that look event-driven from the outside are not. They are listed here because their absence is a design decision worth knowing before you go looking for a hook: | Subdomain | How state moves instead | | ------------------- | --------------------------------------------------------------------------------------------------------- | | Memory | `record_memory_fact` and friends write and return; recall reads. Nothing publishes on a fact or conflict | | Orchestration | The proposal, its approval and the generated pipeline run are rows and RPC calls | | Plan documents | `submit_plan` writes a revision; approval materializes an orchestration. Neither is announced | | Artifacts | `publish_artifact` / `revise_artifact` write revisions directly | | Approvals | Escalation is handled inside the approval workflow service | | Ticket sync | The vendor coordinator subscribes to the ticket events above; inbound webhooks are applied directly | | Calendar refresh | A sync upserts events and the client re-reads; there is no refresh broadcast | | Worktree merges | Recorded in `worktree_merge_log` and read from there | ## Subscribers Every subscriber below is constructed inside `runCcServer` (or a service it builds). There is no client-side subscriber. | Subscriber | Events it consumes | | ---------------------------------- | ---------------------------------------------------------------------------------------------------- | | `WorkspaceSeeder` | `WorkspaceCreated` | | Starter eval-suite seeding (inline) | `WorkspaceCreated` | | Chat-space provisioning (inline) | `SpaceCreated` | | `PipelineTriggerDispatcher` | Every `DomainEvent`; each enabled trigger filters for its own type | | `SubPipelineResumeListener` | Every `DomainEvent` | | `PipelineCostRollupListener` | Every `DomainEvent` | | `BudgetEvaluationListener` | Every `DomainEvent` | | `MeetingSummaryReconciler` | Every `DomainEvent` | | `PipelineStepResumeListener` | `AgentRunCompleted` | | `AgentRunTaskCompleter` | `AgentRunCompleted` | | `GoalSupervisor` | `AgentRunCompleted` | | `CheckerDispatchListener` | `AgentRunCompleted` | | `TeamRoutingService` | `AgentRunCompleted`, `TicketAssigned` | | `OrchestrationRunListener` | `PipelineRunCancelled`, `PipelineRunFailed` | | `MultiVendorTicketSyncCoordinator` | `TicketCreated`, `TicketAssigned`, `TicketReassigned`, `TicketStatusChanged`, `TicketDetailsUpdated` | | `WorktreeGcListener` | `TicketCompleted`, `TicketCancelled`, `SpaceDeleted`, `PrMerged`, `PullRequestStatusChanged` | | `ChatBridgeService` | `MessageReceived`, `SpaceProvisioningChanged` | | `ActivityLogPersister` | `ActivityLogged` | | `RigEventListener` | `RigControlChanged`, `RigReaped`, `RigClosedEvent` — injects a plain-language notice onto the driving agent's steering lane | | `NotificationFeedRecorder` | The twenty-one notification-class events below | | `RemoteEventForwarder` | Those twenty-one plus `TicketReassigned`, the three membership events and every `TaskLifecycleEvent` | `TicketAssigned` is an audit and notification signal and a pipeline trigger. It starts no agent run: there is no ticket dispatcher and assignment records ownership only. `TicketRemoteSyncHandler` is declared but never constructed in production code, so it subscribes to nothing today. The client-side `NotificationEventMapper` is constructed by the desktop bootstrap and maps eleven events onto local toasts, but it reads a client-side bus that no server event reaches — the wire frames below are what actually drive client notifications. ## Notification mapping On the server, `NotificationFeedRecorder` and `RemoteEventForwarder` turn these events into `notifications/*` wire frames (`notification_wire.dart`). On the client, `RpcNotificationMapper` → `mapNotificationFrame` turns a frame into an `AppNotification`. There are exactly **sixteen** notification categories. Several events map to more than one wire method, because the frame carries the edge (failed versus recovered) while the category carries the question ("what is CI doing?"). | Category | Source event | Wire method | | ---------------------- | ------------------------- | ------------------------------------------------------------------------ | | `newMessage` | `MessageReceived` | `notifications/message_received` | | `agentRunCompleted` | `AgentRunCompleted` | `notifications/agent_run_completed` | | `ticketAssigned` | `TicketAssigned` | `notifications/ticket_assigned` | | `ticketStatusChanged` | `TicketStatusChanged` | `notifications/ticket_status_changed` | | `pullRequestPublished` | `PullRequestPublished` | `notifications/pr_published` | | `prMerged` | `PrMerged` | `notifications/pr_merged` | | `prMerged` | `ExternalPrMerged` | `notifications/external_pr_merged` | | `prMentioned` | `PrMentioned` | `notifications/pr_mentioned` | | `prMentioned` | `PrCommentMentioned` | `notifications/pr_comment_mentioned` | | `reviewRequested` | `PrReviewRequested` | `notifications/pr_review_requested` | | `reviewStale` | `ReviewBecameStale` | `notifications/review_stale` | | `prMergeReadiness` | `PrMergeReadinessChanged` | `notifications/pr_ready_to_merge` / `pr_merge_blocked` | | `prReviewDecision` | `PrReviewDecisionChanged` | `notifications/pr_approved` / `pr_changes_requested` / `pr_review_dismissed` | | `prChecksStatus` | `PrChecksStatusChanged` | `notifications/pr_checks_failed` / `pr_checks_recovered` | | `prThreadActivity` | `PrThreadReplied` | `notifications/pr_thread_replied` | | `prThreadActivity` | `PrThreadResolved` | `notifications/pr_thread_resolved` | | `meetingStartsSoon` | `MeetingStartingSoon` | `notifications/meeting_starting_soon` | | `calendarAuthExpired` | `CalendarAuthExpired` | `notifications/calendar_auth_expired` | | `rigStatusChanged` | `RigControlChanged` | `notifications/rig_control_changed` | | `rigStatusChanged` | `RigReaped` | `notifications/rig_reaped` | | `rigStatusChanged` | `RigClosedEvent` | `notifications/rig_closed` | The rig take-over frame is not delivered back to whoever took the wheel, a release is unfiltered (the event does not record who let go), and only a `backendFailure` close produces a `rig_closed` frame — the other reasons are either requested or already covered by the reap frame. The five authored-PR categories carry `forUserId`, so they reach the author rather than everyone in the workspace. `RemoteEventForwarder` additionally forwards `TicketReassigned` as `notifications/ticket_reassigned`, the three membership events, and every `TaskLifecycleEvent` as `notifications/task_<phase>`. None maps to a notification category — they drive live UI and access re-checks, not the notification centre. ## See also - [Domain events](/manual/concepts/domain-events/) - [Pipeline triggers](/manual/guides/pipeline-triggers/) - [Notifications](/manual/guides/notifications/) ### Glossary > Definitions of key terms used throughout Control Center. Source: https://usectrl.dev/manual/reference/glossary/ export const glossaryUrl = "https://usectrl.dev/manual/reference/glossary/"; {/* DefinedTermSet JSON-LD for AI answer engines. Keep these [term, definition] pairs in sync with the prose definitions below (single page, rarely changes). */} export const glossaryTerms = [ ['Agent', 'An AI worker with an identity, role, skills and capabilities. Belongs to exactly one workspace. The primary actor in the system.'], ['Workspace', 'The top-level isolation tenant. Groups agents, repos, spaces, tickets, memory and pipelines. Isolation is structural, not conventional: each workspace has its own SQLite file, so a cross-workspace read does not compile. Every in-app route is prefixed /workspaces/:workspaceId.'], ['Repo', 'A Git repository registered inside one workspace. Workspace-scoped: the same checkout registered in two workspaces is two rows with two ids and repo identity across workspaces is by filesystem path, never by id. Registration requires a git work tree with a github.com origin remote.'], ['Isolated repo', 'A workspace-scoped copy-on-write worktree of a registered repo. Provisioned per conversation so agents never mutate the source checkout.'], ['Space', 'A workspace-scoped collaboration container with human and agent participants, a persisted Mode, provisioning status and a SpaceKind (topic, agentPeer, system, pr or slack) that decides how it is sectioned, muted, badged and garbage-collected. It owns the copy-on-write worktrees its conversations share.'], ['Conversation', 'One message stream inside a Space, with its own id, history and agent-run context. Conversations in a space are flat equals — there is no primary or main stream. Each opens as an editor tab; a space unread state aggregates across all of them.'], ['Thread', 'A Conversation whose anchorMessageId is set: anchored to one message of a sibling conversation in the same Space, with a fresh agent context seeded by that anchor message. One level deep — threads never anchor to threads.'], ['Ticket', 'The unit-of-work aggregate. Vendor-neutral: local by default, with sync adapters for Linear, GitHub Issues, Jira Cloud and ClickUp, though no in-app surface creates a sync connection. Supports hierarchy, links and delegation. Assignment records ownership and dispatches nothing.'], ['Pipeline', 'A DAG-based workflow that chains steps (agent dispatch, scripts, conditionals) together. Defined by templates; executed as runs.'], ['Meeting', 'A locally recorded, transcribed meeting session. Captures mic and system audio, transcribes on-device with a selectable ASR model (Parakeet TDT v3 by default, Whisper variants available), diarizes speakers and summarizes into notes, action items and decisions. The model is opt-in and must be installed before recording works. Workspace-scoped.'], ['CalendarEvent', 'A synced Google Calendar entry scoped to a workspace and connected account. Distinct from a Meeting: an event is a scheduled commitment, a Meeting is a recorded session.'], ['CalendarAccount', 'A connected Google account, per workspace and email. OAuth refresh tokens are stored server-side as google_* keys in the secrets.json file under the data directory and never reach a client; an authExpiredAt flag drives the reconnect banner.'], ['AgentRole', 'Enumerated role (CEO, coder, reviewer, QA, designer, security, devops, PM, general). Determines persona, memory access and hierarchy.'], ['AgentSkills', 'Case-insensitive set of skill identifiers that inject relevant context into prompts at dispatch time.'], ['Skill source', 'A GitHub repository registered per workspace as a skill catalog. Untrusted by construction: the repository\'s metadata is display-only and every install passes the fail-closed scan gate before any content reaches disk or a prompt.'], ['skills-lock.json', 'The per-workspace manifest of installed skills: for each one its source repository, repo path, the commit it is pinned to and a rolled-up content hash. Installs, updates and uninstalls keep it current; verify_skills checks it against disk.'], ['AgentCapabilities', 'Four flags (push to repo, GitHub API, ticketing, network egress) resolved per dispatch: agent default, then user default, with a per-conversation override. Enforced by absence, since a disabled capability means the token or egress is simply never injected.'], ['Mode', 'chat, plan, review, or orchestrate. Stored per space, so one agent can be in different modes in different spaces; every conversation in a space shares it. Gates the system prompt, sandbox write rules and the MCP tool allowlist.'], ['SandboxBackend', 'microvm (an enclosed VM — the one backend with a kernel boundary, applied to interactive terminals), native (OS-native Seatbelt on macOS, bubblewrap on Linux and WSL2) or none. Probed once at boot; native wraps agent dispatch when available. Windows has no native backend and Linux needs bwrap plus socat, so runs there are unwrapped; --sandbox off opts out deliberately.'], ['Rig', 'A disposable VM an agent drives in real time — a headless browser, a Linux desktop, an Android device, or the machine behind an enclosed terminal. Watched live over its own full-resolution lane, take-over enforced server-side, egress deny-by-default, destroyed with its disk when it closes. Scoped to a conversation so the human tab and the agent tools address one machine.'], ['Enclosure', 'The isolation property a rig provides: a kernel boundary, a throwaway overlay disk, a deny-by-default network gate and a credential broker that keeps durable secrets out of the guest. Driving one is its own guardrail action class (enclosureControl).'], ['RunCost', 'Token/cost tally (input tokens, output tokens, estimated cost in cents) for a single agent run.'], ['WakeReason', 'Why an agent was dispatched. Six values: user message, ticket assignment, recovery re-dispatch after a failure, child ticket completed, scheduled follow-up heartbeat and pipeline step.'], ['SandboxPort', 'Manages sandbox lifecycle (probe, launch, events, exec, destroy).'], ['CredentialBrokerPort', 'Mints scoped, capability-gated credentials for sandbox launch and revokes them on teardown.'], ['GitRepoInspectorPort', 'Extracts metadata (owner, repo, branch) from a local Git repo path.'], ['EmbeddingPort', 'Produces unit-norm text embedding vectors for semantic search.'], ['NotificationPort', 'Shows native desktop notifications respecting category/route gating.'], ['TicketProviderPort', 'Vendor-agnostic boundary to a ticketing backend (create, get, list, update, transition, assign).'], ['PipelineEnginePort', 'Starts a pipeline run from a template, decoupling callers from the concrete engine.'], ['AgentDispatchService', 'Launches agent runs: provisions isolated repos, builds prompts, creates run logs, returns live event streams.'], ['TicketWorkflowService', 'Pure-domain ticket lifecycle engine with an optimistic-concurrency mutation chokepoint and workspace isolation enforcement. The transition graph it enforces binds agents and automation only; user-driven UI changes pass force true and bypass it.'], ['PipelineEngine', 'Orchestrates run execution: starts runs, schedules steps, persists state, handles routers/joins/continue-on-fail, resumes in-flight runs after restart.'], ['PipelineTriggerDispatcher', 'Subscribes to domain events and auto-starts matching pipeline runs for enabled triggers.'], ['MemoryAccessPolicy', 'Resolves and enforces agent role memory permissions on domains.'], ['CostTracker', 'Computes per-run token cost and persists it onto the run log.'], ['BudgetEnforcementService', 'Evaluates per-scope monthly spend after each run: a soft threshold records a warning incident, exhaustion records a hard incident and flips the agent lifecycle to paused. Its pre-dispatch block is consulted only by the recovery reconcilers, not by ordinary dispatch.'], ['DomainEventBus', 'In-process broadcast publish/subscribe bus for cross-feature communication. Events are typed; subscribers consume on-type streams.'], ['DomainEvent', 'Base interface. Every event has occurredAt. Key categories: workspace/agent, PR/review, messaging, ticketing, pipeline, observability, analytics.'], ['Clean Architecture', 'Dependency rule: Presentation to Application/Providers to Domain, with Infrastructure pointing inward. Domain has zero infrastructure imports.'], ['Ports and adapters', 'Domain defines abstract interfaces (ports); infrastructure provides concrete implementations (adapters). Composition root binds them.'], ['Shared kernel', 'core/domain/ entities, value objects, ports and services shared across 3+ features.'], ['Feature-first', 'Code is organized by business domain (agents, pipelines, messaging) rather than by technical layer.'], ['Thin-client architecture', 'No client opens the database; a headless cc_server process owns the data and serves it over WebSocket RPC. Every client (desktop, web, phone) is a renderer over that one RPC connection.'], ['cc_server', 'The headless server binary (pure-Dart, no Flutter engine). Owns the Drift/SQLite database, serves repo-RPC over WebSocket and runs the background services (pipelines, MCP, reconcilers).'], ['ServerConnectionMode', 'How the desktop reaches its server: local (spawn a cc_server on this machine) or remote (dial one elsewhere). The web client is always remote.'], ['RemoteRpcClient', 'The transport-agnostic JSON-RPC client every thin client uses to talk to its server, over WSS, loopback, or the brokered relay.'], ['Per-call workspace scoping', 'The server is stateless: there is no per-session workspace binding, so multiple clients on one server never share a current workspace. Every workspace-scoped repo/call and sub/subscribe must carry workspace_id in its args; a missing or unregistered id is refused before any database opens. Access is then gated by workspace membership and role, not by the session.'], ['cc_remote', 'The phone thin client — a Flutter web PWA that remote-controls the fleet over the brokered relay with a default-deny tool policy.'], ['cc_signaling_server', 'A stateless WebSocket relay broker hosting invite-gated rooms. A dumb relay: it forwards only end-to-end-sealed frames, holds no app data and never sees the pairing PSK.'], ['Principal', 'A sealed value unifying humans and agents as the single actor abstraction. UserPrincipal(userId) or AgentPrincipal(agentId). Every message, ticket, review, plan and run log attributes through a Principal.'], ['User', 'A human identity, stored in global.db and therefore global across workspaces. The principal that creates a workspace becomes its owner, stamped on Workspace.ownerUserId with an owner-role membership row; others join by invite or SSO.'], ['WorkspaceMember', 'A membership row binding a User to a Workspace at a WorkspaceRole. Membership — not a pairing key — is the access boundary.'], ['WorkspaceRole', 'Graduated membership role: owner, admin, member, viewer, or guest. The repo-op chokepoint derives a minimum-role floor per op kind.'], ['ParticipantPresence', 'Ephemeral awareness for a Principal (status, locus, cursor, typing, agent live status + running cost). Never persisted; server-hubbed; repo-grant filtered before fan-out.'], ['Follow-mode', 'Click an avatar to ride its viewport until you act, then detach. Works for humans and for agents ("watch an agent work").'], ['Autonomy dial', 'A per-space per-agent control — propose-only / act-with-approval / act-freely — implemented as a named profile over the action-guardrail store.'], ['cc_worker', 'A headless pure-Dart fleet executor binary. Pairs with a cc_server, declares its capabilities, pulls leased jobs, executes them and streams events back. Holds no durable state.'], ['JobSpec', 'The typed spec for everything executable (agent run, pipeline step, code-index, golden-render, benchmark, eval batch): required capabilities, priority, budget, workspace.'], ['ActionClass', 'A closed set of ~12 effect classes (fileDelete, gitPush, prCreate, networkEgress, processSpawn, …). Every mutating tool declares its classes; the guardrail engine resolves allow / prompt / deny per scope.'], ['WriteLedger', 'A workspace-scoped idempotency ledger. Every mutation carries a per-logical-action idempotency key; a replay returns the byte-identical original result. Makes reconnect and multi-client safe by construction.'], ['Idempotency key', 'A client-generated UUIDv7 minted per logical action (not per attempt). Dedupes retries across reconnects and clients.'], ['Needs me inbox', 'A cross-pillar queue with a strict rule: an item appears only if it blocks something (an agent, a merge, a sync) or explicitly requests the operator.'], ['Plan Studio', 'The interactive planning surface: an orchestration proposal or single-agent plan rendered as an editable typed DAG, with per-step cost/time/risk estimates, plan-diff versioning and partial approval.'], ['Review Studio', 'The semantic multi-modal PR review MODEL: graph-derived file cohorts, typed walkthrough diagrams, UI visual diffs, swagger-style API contract diffs, blast-radius map and multi-axis gates. Computed server-side and served over review_studio.*; the surface that rendered it was folded into the review artifact tab, so none of it has a viewer today.'], ['Review space', "A PR's single backing space (mode review, kind pr), created idempotently and linked to the PR by a ReviewSpaceAssociation. Every reviewer files findings in it, which is what makes them comparable; its isolated worktree has the PR checked out at its head."], ['Review node', 'The structured unit of an AI review finding: kind (bug, suggestion, recommendation, question, ticket), priority P0-P3, a category/severity/effort triage triple, a file-and-line anchor, optional suggested fix and a confidence score. Filed with add_review_node; the verdict is computed from nodes, not prose.'], ['Consensus-ready', 'The review node status reached only when a peer reviewer confirms a finding — an agent cannot confirm its own. Publishing includes peer-confirmed findings only, by default.'], ['Review level', 'Workspace policy (light, balanced, thorough) dialing which reviewers fan out (engineer only; +qa+architect; +security+perf) and where sub-threshold findings render. Never deletes a finding.'], ['Review axis', 'One of six per-review scoring lanes: correctness, security and testGap drawn from reviewer findings; performance, visual and apiContract deterministic. Gated axes fold into the verdict and can only make it more severe — a partial or unavailable gate holds the verdict rather than passing it.'], ['Review verdict', 'ship, hold or block. Any P0 finding with confidence >= 0.7 blocks; any other P0 or any P1 holds; otherwise ship. A gated failing axis forces block and cannot be out-voted by findings.'], ['SessionRecording', 'An event stream plus HTTP/LLM cassettes and tool I/O captured for one run, paired with an AgentConfigHash. Defined in the domain and the persisted schema only: no production code writes one today, so replay is not reachable in the product.'], ['Golden session', 'A blessed SessionRecording pinned per agent or playbook, intended to block a prompt or model edit that regresses it. Domain and schema only: with no SessionRecording ever written, there is nothing to bless and the canary gate never runs.'], ]; <StructuredData schema={{ "@context": "https://schema.org", "@type": "DefinedTermSet", name: "Control Center glossary", url: glossaryUrl, hasDefinedTerm: glossaryTerms.map(([name, description]) => ({ "@type": "DefinedTerm", name, description, inDefinedTermSet: glossaryUrl, })), }} /> ## Core entities **Agent**: An AI worker with an identity, role, skills and capabilities. Belongs to exactly one workspace. The primary actor in the system. **Workspace**: The top-level isolation tenant. Groups agents, repos, spaces, tickets, memory and pipelines. Isolation is structural, not conventional: each workspace has its own SQLite file (`<dataDir>/<workspaceId>/workspace.db`), so a cross-workspace read does not compile. Every in-app route is prefixed `/workspaces/:workspaceId` — see the [route map](/manual/reference/route-map/). **Forge**: A code host — **GitHub**, **GitLab** or **Bitbucket Cloud**. The forge is a property of each repo, read from its `origin` remote at registration, and it selects that repo's API adapter and credential. One workspace can hold repos on all three; aggregating surfaces (the inbox, the PR queue) fan out across every forge represented and merge the results, isolating failures so an unreachable forge never empties the others. Forges differ in what they support — see the capability table in [Connect a code host](/manual/guides/connect-forges/). **Repo**: A Git repository registered inside one workspace. **Workspace-scoped** — the same checkout registered in two workspaces is two independent rows with two ids; repo identity across workspaces is by filesystem path, never by id. Registration requires a git work tree whose `origin` remote points at a supported forge. Each repo carries that **forge** (`github` / `gitlab` / `bitbucket`), which selects the API adapter and credential used for it. **Isolated repo**: A workspace-scoped copy-on-write worktree of a registered repo. Provisioned per conversation so agents never mutate the source checkout. **Space**: A workspace-scoped collaboration container with human and agent participants, a persisted `Mode`, provisioning status and a `SpaceKind` (`topic` / `agentPeer` / `system` / `pr` / `slack`) that decides how it is sectioned, muted, badged and garbage-collected. It owns the copy-on-write worktrees its conversations share. **Conversation**: One message stream inside a Space, with its own id, history and agent-run context. Conversations in a space are flat equals — there is no primary or "main" stream. Each opens as an editor tab; a space's unread state aggregates across all of them. **Thread**: A Conversation whose `anchorMessageId` is set: anchored to one message of a sibling conversation in the same Space, with a fresh agent context seeded by that anchor message. One level deep — threads never anchor to threads. **Ticket**: The unit-of-work aggregate. Vendor-neutral: `local` by default, with sync adapters for Linear, GitHub Issues, Jira Cloud and ClickUp — though no in-app surface creates a sync connection. Supports hierarchy, links and delegation. Assignment records ownership and dispatches nothing. **Pipeline**: A DAG-based workflow that chains steps (agent dispatch, scripts, conditionals) together. Defined by templates; executed as runs. ## Value objects **AgentRole**: Enumerated role (CEO, coder, reviewer, QA, designer, security, devops, PM, general). Determines persona, memory access and hierarchy. **AgentSkills**: Case-insensitive set of skill identifiers that inject relevant context into prompts at dispatch time. **Skill source**: A GitHub repository registered per workspace as a skill catalog. Untrusted by construction: the repository's metadata is display-only and every install passes the fail-closed scan gate before any content reaches disk or a prompt. See [install and manage skills](/manual/guides/manage-skills/). **skills-lock.json**: The per-workspace manifest of installed skills: for each one its source repository, repo path, the commit it is pinned to and a rolled-up content hash. Installs, updates and uninstalls keep it current; `verify_skills` checks it against disk. **AgentCapabilities**: Four flags (push to repo, GitHub API, ticketing, network egress) resolved per dispatch — agent default → user default, with a per-conversation override. Enforced by absence: a disabled capability means the token or egress is simply never injected. **Mode**: `chat`, `plan`, `review`, or `orchestrate`. Stored **per space**, so one agent can be in different modes in different spaces; every conversation in a space shares it. Gates the system prompt, the sandbox write rules and the MCP tool allowlist. The composer offers only `chat`, `plan` and `orchestrate`; `chat` is labelled "Agent". **SandboxBackend**: `microvm` (an enclosed VM — the one backend with a kernel boundary, applied to interactive terminals), `native` (Seatbelt on macOS, bubblewrap on Linux and WSL2) or `none`. Probed once at boot; `native` is **applied to agent dispatch** when available, wrapping the external CLI and ACP transports and the harness `bash` tool. Windows has no native backend and Linux needs `bwrap` plus `socat`, so runs there are unwrapped; `--sandbox off` opts out deliberately. **Rig**: A disposable VM an agent drives in real time — a headless browser, a Linux desktop, an Android device, or the machine behind an enclosed terminal. Watched live over its own full-resolution lane, take-over enforced server-side, egress deny-by-default and destroyed with its disk when it closes. Scoped to a conversation so the human's tab and the agent's `browser_use`/`computer_use` calls address one machine. See [Rigs and enclosures](/manual/concepts/rigs/). **Enclosure**: The isolation property a rig provides — a kernel boundary, a throwaway overlay disk, a deny-by-default network gate and a credential broker that keeps durable secrets out of the guest. Driving one is its own guardrail action class (`enclosureControl`). **RunCost**: Token/cost tally (input tokens, output tokens, estimated cost in cents) for a single agent run. **WakeReason**: Why an agent was dispatched. Six values: user message, ticket assignment, recovery re-dispatch after a failure, child ticket completed, scheduled follow-up heartbeat and pipeline step. ## Ports **SandboxPort**: Manages sandbox lifecycle (probe, launch, events, exec, destroy). **CredentialBrokerPort**: Mints scoped, capability-gated credentials for sandbox launch and revokes them on teardown. **GitRepoInspectorPort**: Extracts metadata (owner, repo, branch) from a local Git repo path. **EmbeddingPort**: Produces unit-norm text embedding vectors for semantic search. **NotificationPort**: Shows native desktop notifications respecting category/route gating. **TicketProviderPort**: Vendor-agnostic boundary to a ticketing backend (create, get, list, update, transition, assign). **PipelineEnginePort**: Starts a pipeline run from a template, decoupling callers from the concrete engine. ## Domain services **AgentDispatchService**: Launches agent runs: provisions isolated repos, builds prompts, creates run logs, returns live event streams. **TicketWorkflowService**: Pure-domain ticket lifecycle engine with an optimistic-concurrency mutation chokepoint and workspace isolation enforcement. Its transition graph binds agents and automation only; user-driven UI changes pass `force: true` and bypass it. There is no ticket dispatcher — `AgentDispatchService` is the only owner of dispatch and assigning a ticket starts no run. **PipelineEngine**: Orchestrates run execution: starts runs, schedules steps, persists state, handles routers/joins/continue-on-fail, resumes in-flight runs after restart. **PipelineTriggerDispatcher**: Subscribes to domain events and auto-starts matching pipeline runs for enabled triggers. **MemoryAccessPolicy**: Resolves and enforces agent role memory permissions on domains. **CostTracker**: Computes per-run token cost and persists it onto the run log. **BudgetEnforcementService**: Evaluates per-scope monthly spend after each run — a soft threshold records a warning incident; exhaustion records a hard incident and flips the agent's lifecycle to `paused`. Its pre-dispatch block (`checkInvocationBlock`) is consulted only by the recovery reconcilers (`OrphanRunReaper`, `StrandedTicketReconciler`), never by an ordinary user or agent dispatch. ## Events **DomainEventBus**: In-process broadcast publish/subscribe bus for cross-feature communication. Events are typed; subscribers consume `on<T>()` streams. **DomainEvent**: Base interface. Every event has `occurredAt`. Key categories: workspace/agent, PR/review, messaging, ticketing, pipeline, observability, analytics. ## Meetings and calendar **Meeting**: A locally recorded, transcribed meeting session. Captures microphone and system audio, transcribes on-device with a selectable ASR model (Parakeet TDT v3 by default; Whisper large-v3-turbo and base.en are alternatives), diarizes speakers and summarizes into notes, action items and decisions. The model is opt-in and must be downloaded before recording works. Workspace-scoped; stays on the machine. **MeetingSegment**: One transcribed window of audio, speaker-tagged (`me`/`them`) with a diarization label and millisecond offsets. **MeetingActionItem / MeetingDecision**: Structured outputs extracted by the summary pipeline and stored as discrete rows. Action items can link to a ticket. **CalendarEvent**: A synced Google Calendar entry scoped to a workspace and connected account. Distinct from a Meeting: an event is a scheduled commitment; a Meeting is a recorded session. Linked one-to-one via a `MeetingCalendarLink`. **CalendarAccount**: A connected Google account, per workspace and email. OAuth refresh tokens are stored server-side as `google_*` keys in `<dataDir>/secrets.json` and never reach a client; the device-code flow completes on the server. An `authExpiredAt` flag drives the reconnect banner. ## Architecture terms **Clean Architecture**: Dependency rule: Presentation → Application/Providers → Domain ← Infrastructure. Domain has zero infrastructure imports. **Ports and adapters**: Domain defines abstract interfaces (ports); infrastructure provides concrete implementations (adapters). Composition root binds them. **Shared kernel**: `core/domain/` entities, value objects, ports and services shared across 3+ features. **Feature-first**: Code is organized by business domain (agents, pipelines, messaging) rather than by technical layer. ## Multiplayer, identity and velocity **Principal**: A sealed value unifying humans and agents as the single actor abstraction — `UserPrincipal(userId)` or `AgentPrincipal(agentId)`. Every message, ticket, review, plan and run log attributes through a Principal. **User**: A human identity, stored in `global.db` and therefore global across workspaces. The principal that creates a workspace becomes its **owner** — stamped on `Workspace.ownerUserId` with an owner-role membership row in the same operation. Others join by invite or SSO. **WorkspaceMember** / **WorkspaceRole**: A membership row binding a User to a Workspace at a role (`owner` / `admin` / `member` / `viewer` / `guest`). **Membership — not a pairing key — is the access boundary.** Per-repo grants keep membership from out-privileging the forge. **ParticipantPresence**: Ephemeral awareness for a Principal (status, locus, cursor, typing, an agent's live status and running cost). Never persisted; server-hubbed; repo-grant filtered before fan-out. **Follow-mode**: Click an avatar to ride its viewport until you act, then detach. Works for humans and for agents ("watch an agent work"). **Autonomy dial**: A per-space per-agent control — `propose-only` / `act-with-approval` / `act-freely` — implemented as a named profile over the action-guardrail store. **ActionClass**: A closed set of ~12 effect classes (`fileDelete`, `gitPush`, `prCreate`, `networkEgress`, `processSpawn`, …). Every mutating tool declares its classes; the guardrail engine resolves `allow` / `prompt` / `deny` per scope (space > agent > workspace > mode preset > built-in default). **WriteLedger** / **idempotency key**: Every mutation carries a client-generated idempotency key (UUIDv7, per logical action). The workspace-scoped ledger dedupes before the handler runs; a replay returns the byte-identical original result. Makes reconnect and multi-client safe by construction. **Needs me inbox**: A cross-pillar queue with a strict rule — an item appears only if it _blocks_ something (an agent, a merge, a sync) or explicitly requests the operator. **Plan Studio** / **Review Studio**: The interactive planning surface (editable typed-DAG plans with per-step cost/time/risk and partial approval) and the semantic multi-modal PR review model (graph-derived cohorts, typed diagrams, visual diffs, API contract diffs, multi-axis gates). Plan Studio is a live surface; Review Studio is server-side compute with no client viewer since its tab was folded into the review artifact. **Review space**: A PR's single backing space (mode `review`, kind `pr`), created idempotently and linked to the PR by a `ReviewSpaceAssociation`. Every reviewer files findings in it, which is what makes them comparable; its isolated worktree has the PR checked out at its head. **Review node**: The structured unit of an AI review finding: kind (`bug`, `suggestion`, `recommendation`, `question`, `ticket`), priority P0–P3, a category/severity/effort triage triple, a file-and-line anchor, optional suggested fix and a confidence score. Filed with `add_review_node`; the verdict is computed from nodes, not prose. **Consensus-ready**: The review node status reached only when a peer reviewer confirms a finding — an agent cannot confirm its own. Publishing includes peer-confirmed findings only, by default. **Review level**: Workspace policy (`light`, `balanced`, `thorough`) dialing which reviewers fan out (engineer only; +qa+architect; +security+perf) and where sub-threshold findings render. Never deletes a finding. **Review axis**: One of six per-review scoring lanes: `correctness`, `security` and `testGap` drawn from reviewer findings; `performance`, `visual` and `apiContract` deterministic. Gated axes fold into the verdict and can only make it more severe — a `partial` or `unavailable` gate holds the verdict rather than passing it. **Review verdict**: `ship`, `hold` or `block`. Any P0 finding with confidence ≥ 0.7 blocks; any other P0 or any P1 holds; otherwise ship. A gated failing axis forces block and cannot be out-voted by findings. ## Fleet, evals and replay **cc_worker**: A headless pure-Dart fleet executor binary. Pairs with a `cc_server`, declares its host capabilities, pulls leased jobs, executes them and streams events back. Holds **no durable state** — one authoritative server, N dumb limbs. **JobSpec**: The typed spec for everything executable (agent run, pipeline step, code-index, golden-render, benchmark, eval batch). **SessionRecording**: An event stream plus HTTP/LLM cassettes and tool I/O captured for one run, paired with an `AgentConfigHash`. Designed to power deterministic replay (cassettes stubbed, executes nothing) as distinct from live re-execution (real model, sandboxed). **Golden session**: A blessed `SessionRecording` pinned per agent or playbook, intended to block a prompt or model edit that regresses it. :::caution[Domain and schema only] Nothing in the product writes a `SessionRecording`: `EvalsRepository.upsertRecording` has no production caller, so `evals.recordings` is always empty and `evals.blessGolden` has nothing to bless. `CanaryGateService`, `DriftDetector` and `ReplayDriver` are referenced only by their own unit tests. Replay, golden blessing and the canary gate are not reachable in the product today. ::: ## Deployment and clients **Thin-client architecture**: No client opens the database; a headless `cc_server` process owns the data and serves it over WebSocket RPC. Every client (desktop, web, phone) is a renderer over that one RPC connection. **cc_server**: The headless server binary (pure-Dart, no Flutter engine). Owns the Drift/SQLite database, serves repo-RPC over WebSocket and runs the background services (pipelines, MCP, reconcilers). **ServerConnectionMode**: How the desktop reaches its server — `local` (spawn a `cc_server` on this machine, the default) or `remote` (dial one elsewhere). The web client is always `remote`. **RemoteRpcClient**: The transport-agnostic JSON-RPC client every thin client uses to talk to its server — over WSS, loopback, or the brokered relay. **Per-call workspace scoping**: The server is stateless — there is **no** per-session workspace binding, so multiple clients on one server never share a "current workspace". Every workspace-scoped `repo/call` and `sub/subscribe` must carry `workspace_id` in its args; a missing or unregistered id is refused before any database opens. Access is then gated by workspace membership and role, not by the session. **cc_remote**: The phone thin client — a Flutter web PWA that remote-controls the fleet over the brokered relay with a default-deny tool policy. **cc_signaling_server**: A stateless WebSocket relay broker hosting invite-gated rooms — a dumb relay that forwards only end-to-end-sealed frames, holds no app data and never sees the pairing PSK. ## See also - [Route map](/manual/reference/route-map/) - [Architecture](/manual/concepts/architecture/) - [Workspaces](/manual/concepts/workspaces/) - [Domain events](/manual/reference/domain-events/) ### Keyboard shortcuts > The complete keybinding registry, by scope, with the conditions each shortcut fires under. Source: https://usectrl.dev/manual/reference/keyboard-shortcuts/ Shortcuts come from one `const` registry, `KeybindingRegistry` in `lib/core/constants/keybindings.dart`. It holds **79 bindings**. Settings → You → Keybindings renders that registry as a **read-only reference**: there is no rebinding UI and no user keybindings file. `Cmd` below is the primary command modifier — ⌘ on macOS, Ctrl on Windows and Linux. A binding only fires when its command has a registered handler on the current screen and its `when` clause holds; `!textInputFocus` means the key is passed through to a focused text field instead. `Cmd/Ctrl + 6` is deliberately unassigned. There is no shortcut for Spaces and none for the Observability hub; both are reached from the sidebar. ## Global (27) Scope `global`, so they are live anywhere in the shell. ### Navigation | Shortcut | Action | Destination | | -------------- | ------------------- | ---------------------------- | | `Cmd/Ctrl + 1` | Go to inbox | `/inbox` | | `Cmd/Ctrl + 2` | Go to tickets | `/tickets` | | `Cmd/Ctrl + 3` | Go to pull requests | `/pull-requests` | | `Cmd/Ctrl + 4` | Go to pipelines | `/pipelines` | | `Cmd/Ctrl + 5` | Go to agents | `/settings/workspace/agents` | | `Cmd/Ctrl + 7` | Go to memory | `/settings/memory` | | `Cmd/Ctrl + 8` | Go to newsfeed | `/newsfeed` | | `Cmd/Ctrl + 9` | Go to meetings | `/meetings` | ### System | Shortcut | Action | Condition | | ------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------- | | `Cmd/Ctrl + K` | Command palette | — | | `Shift + /` (`?`) | Keyboard shortcuts cheat sheet | Not typing in a field | | `Cmd/Ctrl + Z` | Undo | Not typing in a field (a focused field keeps its own undo) | | `Cmd/Ctrl + Shift + Z` | Redo | Not typing in a field | | `Cmd/Ctrl + Shift + T` | Toggle theme | — | | `Cmd/Ctrl + ,` | Open settings (lands on `/settings/you/profile`) | — | | `Cmd/Ctrl + Shift + Alt + F` | Toggle focus mode | — | | `Cmd/Ctrl + Shift + W` | Toggle workspace switcher | — | | `Cmd/Ctrl + ]` | Next workspace | — | | `Cmd/Ctrl + [` | Previous workspace | — | | `Cmd/Ctrl + Alt + 1` … `Cmd/Ctrl + Alt + 9` | Switch to workspace 1–9 | — | ## Pull request list (10) Scope `/pull-requests`. All require `route == '/pull-requests'` and no focused text field, so they do not fire while the PR detail route is open over the list. | Shortcut | Action | | -------------- | ------------------------- | | `J` | Next PR | | `K` | Previous PR | | `Enter` | Open PR | | `X` | Select PR (toggle) | | `E` | Merge PR | | `Space` | Peek PR (expand/collapse) | | `R` | Refresh | | `/` | Focus search | | `Cmd/Ctrl + F` | Focus search | | `F` | Open filter menu | ## Pull request detail (9) Scope `/pull-requests/`. All require no focused text field. | Shortcut | Action | | -------------- | ----------------------------------- | | `1` | Overview tab | | `2` | Diff tab | | `3` | Review tab | | `V` | Toggle viewed on the focused file | | `C` | Toggle collapse on the focused file | | `T` | Show file list | | `R` | Refresh | | `Cmd/Ctrl + F` | Search in diff | | `Cmd/Ctrl + W` | Close tab | ## Inbox (1) | Shortcut | Action | | -------- | ---------------- | | `F` | Open filter menu | ## GitHub user profile (7) Scope `/users/`, matched by regex against any `/users/<login>` location. | Shortcut | Action | | -------------- | ------------ | | `J` | Next PR | | `K` | Previous PR | | `Enter` | Open PR | | `Space` | Peek PR | | `R` | Refresh | | `/` | Focus search | | `Cmd/Ctrl + F` | Focus search | ## Spaces (9) Scope `/spaces`. | Shortcut | Action | Condition | | ---------------------- | ---------------------- | --------------------- | | `Enter` | Send message | — | | `Cmd/Ctrl + N` | New space | On a `/spaces` route | | `J` | Next space | Not typing in a field | | `K` | Previous space | Not typing in a field | | `Cmd/Ctrl + Backspace` | Delete selected space | Not typing in a field | | `Cmd/Ctrl + Shift + D` | Push-to-talk dictation | — | | `Cmd/Ctrl + T` | Open editor tab | On a `/spaces` route | | `Cmd/Ctrl + W` | Close editor tab | On a `/spaces` route | | `Cmd/Ctrl + B` | Toggle editor sidebar | On a `/spaces` route | ## Workspace picker (3) Scope `/workspaces`, live only on the picker itself. | Shortcut | Action | | ---------------------- | ------------------------- | | `Cmd/Ctrl + N` | New workspace | | `Enter` | Open selected workspace | | `Cmd/Ctrl + Backspace` | Delete selected workspace | ## Settings (6) | Shortcut | Action | Screen | | ---------------------- | ---------------------- | ----------------------------------- | | `J` | Next settings page | Any `/settings` route, not typing | | `K` | Previous settings page | Any `/settings` route, not typing | | `Cmd/Ctrl + N` | New agent | Settings → Workspace → Agents | | `Cmd/Ctrl + Backspace` | Delete selected agent | Settings → Workspace → Agents | | `Cmd/Ctrl + N` | Add repository | Settings → Workspace → Repositories | | `R` | Rescan for adapters | Settings → Server → Model providers | The last three bindings still declare the pre-namespacing scopes `/settings/agents`, `/settings/repositories` and `/settings/adapters`. Those paths no longer exist, so the bindings are filed under stale scopes on the cheat sheet; they still fire, because the screen that owns each one registers its handler. ## Newsfeed (5) Scope `/newsfeed`. All require `route == '/newsfeed'` and no focused text field. | Shortcut | Action | | -------- | ---------------- | | `J` | Next article | | `K` | Previous article | | `Enter` | Open article | | `S` | Toggle bookmark | | `R` | Refresh feeds | ## Peer review (2) | Shortcut | Action | | -------------------------- | --------------- | | `Cmd/Ctrl + Enter` | Approve | | `Cmd/Ctrl + Shift + Enter` | Request changes | Both are declared in the registry and appear on the keybindings reference, but **no screen registers a handler for either**, so neither currently fires. ## Diff viewer keys outside the registry The PR diff viewer handles these itself (`PrDiffKeyboardHandler`); they are not registry bindings and do not appear on the keybindings reference. | Shortcut | Action | | --------------- | ---------------------------------------------------- | | `Enter` | Next search match (only while find-in-diff is open) | | `Shift + Enter` | Previous search match | | `Escape` | Close find-in-diff, or clear the diff text selection | | `Cmd/Ctrl + C` | Copy the diff selection | ## Browser-reserved combinations On the web build, the browser consumes some accelerators before the page sees them, so the matching bindings can never fire there. They work normally on desktop. Reserved when combined with the primary command modifier: `T`, `W`, `N` and the digits `1`–`9`. That covers `Cmd/Ctrl + 1`–`9` navigation, `Cmd/Ctrl + Alt + 1`–`9` workspace switching, `Cmd/Ctrl + N` (new space, new workspace, new agent, add repository) and `Cmd/Ctrl + T` / `Cmd/Ctrl + W` in spaces and PR detail. ## See also - [Route map](/manual/reference/route-map/) - [Focus mode](/manual/guides/focus-mode/) ### MCP tools > Complete catalog of the MCP tools cc_server registers, by family. Source: https://usectrl.dev/manual/reference/mcp-tools/ `cc_server` hosts one MCP (JSON-RPC 2.0) tool registry, served on the main server port (default `9030`) at `POST /mcp` and `GET /sse`, protocol version `2024-11-05`. Every client reaches the same registry: the desktop, the web build, external MCP clients and the built-in agent runtime. External MCP servers can be bridged into the same registry as a dynamic layer. ## Registry size | Measure | Count | | ------------------------------------------------------------------------- | ----- | | Tools registered by `cc_server` | 109 | | From `packages/cc_mcp` | 107 | | From `packages/cc_mcp_client` (tool discovery) | 2 | | Registered in the base registry (`server_mcp_registry.dart`) | 63 | | Registered post-boot as their services come up (`cc_server_runtime.dart`) | 46 | | Of the base registry's 63, conditional on a code-graph repository | 5 | The five code-graph tools are registered only when the host passes a code-graph repository to `buildServerMcpRegistry`. `cc_server` always passes one, so all 109 are live in the shipped binary. The full registry is advertised in `tools/list` with no discovery gating. Ticket edits are typed tools; the CLI-args-in-JSON `ticket_cli` surface was removed. :::note[External clients always see the whole catalogue] `tools/list` is never trimmed. MCP clients validate tool names against their own cached copy of that list and refuse anything unlisted without asking the server, so a tool hidden there is unreachable rather than merely quiet. The **built-in harness** is different: it loads only a resident subset of definitions per request and pulls the rest in on first use, which is a harness-side behaviour and does not change this registry. See [Tool context and prompt caching](/manual/concepts/tool-context). ::: :::note[Every tool class that ships is registered] There is no longer a set of tool classes that exist on disk but are constructed in no registry. The twenty-one that used to sit in that state (agent lifecycle, the Projects CRUD set, `create_workspace`, `doctor`, `ask_user_question` and the rest) were deleted outright rather than wired up, so a source file you find under `packages/cc_mcp/lib/src/tools/` is a tool an agent can call. `packages/cc_server_core/test/mcp_tool_registration_ratchet_test.dart` pins that: a new tool class that nothing constructs fails the build. ::: ## Common parameters ### `workspace_id` **Required** on every tool that touches workspace-scoped data. A missing or invalid `workspace_id` returns an explicit error and a value that does not match the target entity's workspace is rejected rather than silently ignored. Omit it only on the genuinely global tools (`list_workspaces`). When an agent is dispatched by the server, the workspace is forced server-side, so the agent cannot reach another workspace by passing a foreign id. ## Tool discovery Both come from `cc_mcp_client`, not `cc_mcp` and both consult the mode guard so they report what is callable in the _current_ conversation mode. | Tool | Description | | ------------------ | --------------------------------------------------------------------------------------------------------------------------- | | `search_tool_bm25` | Search the tool catalogue by keyword; returns matching tools, their argument schemas and whether each is callable right now | | `list_my_tools` | List the tools callable right now in this conversation, filtered by its mode | A third discovery tool, **`search_tools`**, exists only inside the built-in harness and is not part of this MCP registry. It ranks the run's own tool surface and, unlike `search_tool_bm25`, **loads** the schemas of what it finds so the agent can call a match on its next turn. Agents on the harness should prefer it; MCP clients have `search_tool_bm25`. ## Agent tools | Tool | Description | | ------------- | ----------------------------------------- | | `list_agents` | List the registered agents in a workspace | ## Agent peer messaging and delegation Agents talk to each other over spaces (durable, roster-visible). `ask_agent` is request/reply with a mandatory timeout; `delegate_task` is guarded server-side by depth cap, cycle detection, budget envelope and autonomy ceiling. | Tool | Description | | --------------- | ---------------------------------------------------------------------------------------------- | | `send_to_agent` | Send a one-way message to another agent, addressed by `to_agent_id` or exact `to_agent_name` | | `ask_agent` | Ask another agent a question and wait for its reply, or time out | | `delegate_task` | Delegate a task to another agent as a tracked child ticket | | `consult_agent` | Add the best-matching specialist by skills to the space and dispatch it with a focused brief | | `todo_read` | Read this conversation's persisted task checklist | ## Skill tools Every install and update passes through a fail-closed scan gate before any skill content reaches disk or an agent prompt. | Tool | Description | | -------------------- | ------------------------------------------------------------------------- | | `list_skills` | List the skill slugs available in a workspace | | `create_skill` | Create a new skill from markdown content | | `install_skill` | Install a skill's whole directory from a GitHub repository at a pinned ref | | `update_skill` | Update an installed GitHub skill to a new ref, re-scanning and re-pinning | | `list_skill_updates` | List skills with a newer version available | | `verify_skills` | Verify locked skills against their on-disk content hashes | | `pin_skill` | Hash an existing on-disk skill into `skills-lock.json` | ## Plan, playbook and artifact tools | Tool | Description | | ------------------ | --------------------------------------------------------------------------------------------------------------------- | | `submit_plan` | Plan mode's output contract: emit a typed `PlanDocument` that opens in Plan Studio | | `create_playbook` | Save a plan as a named, versioned template with typed `{{param}}` placeholders | | `run_playbook` | Instantiate a playbook with parameter values, producing a plan the operator approves | | `publish_artifact` | Publish an ordered list of typed blocks (markdown, table, chart, mermaid, code, JSON) rendered natively in the client | | `revise_artifact` | Replace a published artifact's content | | `list_artifacts` | List the workspace's artifacts | | `get_artifact` | Read one artifact | | `complete_goal` | Declare the conversation's active durable goal (started with `/goal` or `/loop`) achieved | ## Workspace tools | Tool | Description | | ----------------- | ------------------------------------------------------------------------ | | `list_workspaces` | List all workspaces with their ids and names (global; no `workspace_id`) | ## Repo tools | Tool | Description | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `list_repos` | List the repos registered in a workspace. Agent callers receive their conversation's isolated copy-on-write worktree paths, never the original checkouts | ## Messaging tools | Tool | Description | | ---------------------- | --------------------------------------------------------------------------- | | `list_spaces` | List the messaging spaces in a workspace | | `get_messages` | Fetch messages from a space | | `send_message` | Send a message to a space as a named sender agent | | `get_space_notes` | Read a space's shared handoff document | | `update_space_notes` | Replace a space's shared handoff document | | `todo_write` | Replace this conversation's task checklist in one call (pass the full list) | ## Review tools | Tool | Description | | -------------------------- | ------------------------------------------------------------------------------------------------------ | | `start_ai_review` | Start the AI review for a PR: compute the review areas, fan out specialist reviewers into the PR space, author the walkthrough and finalize | | `add_review_node` | Add a structured review finding (file, line, P0–P3 priority) to a review space | | `confirm_review_node` | Confirm a peer's finding; the author cannot self-confirm | | `resolve_review_node` | Mark a finding fixed after actually making the change; posts a trace into the review space | | `dismiss_review_node` | Dismiss a finding that does not apply; records a suppression fact so reviewers stop re-flagging it | | `request_peer_review` | Ask another reviewer agent for a second look on a finding | | `dispatch_reviewers` | Dispatch matched reviewer agents into a review space, concurrently | | `submit_reviewer_verdict` | Post a per-reviewer ship/hold/block verdict with confidence and an explanation | | `finalize_review` | Compute per-node consensus and the per-PR verdict and close the review | | `set_cohort_summary` | Set a review cohort's plain-language summary | | `add_review_diagram` | Attach a structured, graph-verified diagram (`sequence`, `entityRelation`, `stateMachine`) to a cohort | | `publish_review_to_github` | Publish the finalized review to GitHub as inline comments plus a summary body | ## Memory tools | Tool | Description | | ----------------------- | --------------------------------------------------------------------------------------------------- | | `search_memory` | Search facts and policies (keyword, semantic, or hybrid — hybrid is the default) | | `propose_fact` | Store a short markdown fact in a domain | | `supersede_fact` | Mark a fact superseded by another | | `propose_policy` | Store a normative rule in a domain (requires write permission on it) | | `supersede_policy` | Retire a policy — marks it inactive while keeping it for audit | | `list_policies` | List active policies, optionally filtered by domain | | `list_memory_domains` | List the workspace's memory domains with fact and policy counts | | `record_observation` | Record a raw observation into the agent's working memory | | `get_my_notes` | Read the calling agent's working-memory scratchpad | | `update_my_notes` | Update the calling agent's working-memory scratchpad | | `remember` | Add a note to the agent's hot working memory for this session (TTL- and count-bounded) | | `consolidate_memory` | Run a consolidation ("sleep") pass: evict expired hot items, roll durable ones into long-term facts | | `harmonize_memory` | Run cross-agent belief harmonization (SHMR) and flag contradictions | | `list_memory_conflicts` | List detected contradictions and how each was resolved | ## Governance tools | Tool | Description | | ---------------------------- | ----------------------------------------------------------------------------------------------------- | | `create_goal` | Create an organizational goal (company, team, agent, or task level) | | `list_goals` | List goals with level, status and aggregated progress | | `update_goal_progress` | Set a goal's progress | | `create_approval` | Open a board approval for a governed action (plan exit, merge, release, hire, custom) | | `list_approvals` | List approvals, optionally filtered by status | | `decide_approval` | Record a decision: approve, reject, request revision, or resubmit | | `comment_approval` | Comment on a board approval | | `exit_plan_mode` | Open (and, once approved, consume) a `plan_exit` approval, flipping the conversation out of plan mode | | `agent_heartbeat` | Report liveness (alive, idle, stuck); going quiet flips runtime health to recently-lost, then offline | | `list_runtime_health` | List each agent's runtime health | | `list_agent_presence` | List each agent's presence (health, lifecycle, running and queued counts, capacity) | | `get_org_chart` | Read the reporting tree, CEO at the root, nested by `reports_to` | | `create_work_product` | Create a durable deliverable attached to a ticket | | `save_work_product_revision` | Save a new revision; pass `base_revision_id` to be rejected on a concurrent edit | | `list_work_products` | List work products, optionally filtered to one ticket | | `get_work_product` | Read a work product with its revision history | | `create_runtime_profile` | Define a reusable runtime profile (protocol family, CLI command, fixed launch arguments) | | `list_runtime_profiles` | List the workspace's runtime profiles | ## Code graph tools Registered only when the host supplies a code-graph repository. The index covers Dart, JavaScript, TypeScript, TSX and PHP; symbols in any other language are not present. | Tool | Description | | -------------- | ---------------------------------------------------------------------------------- | | `search_code` | Search indexed symbols by name, signature and doc comment (hybrid BM25 + semantic) | | `code_symbol` | Look up symbols by exact name; returns `file:line` and signature | | `code_callers` | List the symbols that call or depend on a symbol (incoming edges) | | `code_callees` | List the symbols a symbol calls or depends on (outgoing edges) | | `code_impact` | Compute the transitive impact radius of a symbol, to a depth | ## Ticketing tools | Tool | Description | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `create_ticket` | Create a ticket on the configured provider | | `get_ticket` | Fetch one ticket by id | | `list_tickets` | List tickets, optionally filtered by status or assignee | | `update_ticket` | Update status, title, description, priority, or labels (status accepts aliases such as `in_progress` / `completed`) | | `assign_ticket` | Assign a ticket to an agent and/or team | | `reassign_ticket` | Reassign a ticket to a different agent | | `add_ticket_collaborator` | Invite an agent to collaborate on a ticket (added to its space) | | `comment_on_ticket` | Comment on a ticket | | `close_ticket` | Close a ticket (marks it done) with optional output | | `fail_ticket` | Mark a ticket failed with an error message | | `delegate_ticket` | Delegate a ticket to an agent, creating a tracked ticket | | `ticket_pr_link` | Link or unlink a ticket and a pull request by PR node id (`action`: `link` \| `unlink`) | | `ticket_relation` | Add or remove a relation between two tickets — `blocked_by`, `blocking`, `related_to`, `duplicate_of`, `duplicated_by`, `sub_issue_of`, `parent_of` (`action`: `add` \| `remove`) | | `list_ticket_relations` | List a ticket's parent, sub-issues and dependency links | ## Team and pipeline tools | Tool | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `record_team_activity` | Record a team leader's activity entry against a ticket | | `preview_trigger` | Dry-run which pipeline runs a hypothetical event would start, committing nothing | | `submit_output` | Submit the structured output payload for the caller's current pipeline run; validated against the run's declared output schema | ## Orchestration tools | Tool | Description | | ----------------------- | --------------------------------------------------------------------------------------------------------------- | | `propose_orchestration` | Emit a structured multi-agent orchestration plan for one upfront approval; validates and returns any violations | ## Newsfeed tools | Tool | Description | | ------------------- | ------------------------------------------------------------ | | `list_feeds` | List the registered RSS/Atom feeds | | `list_articles` | List articles, optionally filtered by feed, unread, or saved | | `get_article` | Fetch one article | | `set_article_read` | Mark an article read or unread | | `set_article_saved` | Save or unsave an article | | `refresh_feeds` | Re-fetch every enabled feed now | ## Enclosure (rig) tools Drive disposable VMs — a browser, a Linux desktop, an Android device — from inside a run. Registered post-boot, unconditionally: whether a machine can actually boot is a probe question the tools answer per call. Repeated calls in one conversation reuse one machine; screenshots come back as image content with a one-image budget per result; extracted page/UI content is fenced as untrusted data. All five declare the `enclosureControl` [action class](/manual/concepts/guardrails/) (the use tools also `networkEgress` and `processSpawn`), so read-only modes deny them outright. The full verb vocabulary per surface is in the [rigs reference](/manual/reference/rigs/#action-verbs). | Tool | Description | | -------------- | ------------------------------------------------------------------------------------------------ | | `computer_use` | Drive a Linux desktop in an enclosed VM: move, click, drag, scroll, type, resize, clipboard, screenshot | | `browser_use` | Drive a headless Chromium, Firefox or WebKit (`engine`): navigate, click, fill, extract (a11y/DOM/console), clipboard, screenshot | | `mobile_use` | Drive an Android device over adb: tap, swipe, type, dump the view hierarchy, install, launch | | `rig_list` | List a workspace's rigs with surface, status, display size and controller | | `rig_close` | Destroy a rig and discard its disk | ## Not exposed over MCP Calendar and meetings have no MCP tools. Agents cannot read your calendar, meetings, transcripts, action items, or decisions. The summary that produces action items and decisions runs as the built-in [`meeting_summary` pipeline](/manual/concepts/meetings/), not as a tool an agent can call. ## Tool gating by mode Gating is enforced twice from one table (`ModeToolPolicy`): once in the MCP dispatcher for external CLI adapters and once in the built-in harness registry. A blocked call returns the reason. | Mode | Tool surface | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `chat` | Every registered tool; no allow-list | | `plan` | A curated allow-list. No worktree writes and no `create` / `assign` / `delegate` ticket verbs, but memory, artifacts, todos, `submit_plan`, `exit_plan_mode` and the ticket-completion verbs on the dispatched ticket are permitted | | `review` | A curated allow-list: review participation, communication, read-only context fetchers and the full ticket verb set including `create_ticket` and `assign_ticket` | | `orchestrate` | A curated allow-list: research and read tools plus `propose_orchestration`, `create_playbook` and `run_playbook`. Hiring, decomposition and ticket completion happen deterministically after approval, never mid-run | Permitted in every mode regardless of allow-list: | Set | Tools | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Memory and artifacts | `search_memory`, `propose_fact`, `propose_policy`, `supersede_fact`, `supersede_policy`, `record_observation`, `update_my_notes`, `get_my_notes`, `list_memory_domains`, `list_policies`, `publish_artifact`, `revise_artifact`, `list_artifacts`, `get_artifact` | | Code graph | `search_code`, `code_symbol`, `code_callers`, `code_callees`, `code_impact` | | Run mechanics | `search_tool_bm25`, `list_my_tools`, `todo_write`, `todo_read` | Being on an allow-list does not make a tool callable, and the reverse also holds: the operator-question capability is a **harness** tool (`ask_user`), so it never appears in this MCP catalogue at all. ## Approval-gated tools A tool may declare `requiresApproval`. The dispatcher then builds an `ApprovalPayload` and surfaces it through the `ConfirmationPort` before the tool runs; `cc_server` wires that port, so the gate is live. Registered tools that declare it: `create_ticket`, `update_ticket`, `assign_ticket`, `reassign_ticket`, `close_ticket`, `ticket_relation`, `create_goal`, `update_goal_progress`, `create_approval`, `decide_approval`, `comment_approval`, `create_work_product`, `save_work_product_revision`, `create_runtime_profile`, `install_skill`, `update_skill`. Independently, the unified action guardrails gate any tool that declares a non-empty `ActionClass` set. A `prompt` decision with no approver connected is denied. See [Guardrails](/manual/concepts/guardrails/). ## Capabilities with no tool behind them Not every capability in the product is reachable by an agent. These have no tool at all, so a name you may have seen referenced elsewhere returns "unknown tool": | Capability | Where it lives instead | | -------------------------------------- | -------------------------------------------------------------------------------------------- | | Hiring, firing or editing an agent | The agents UI. There is no `hire_agent` / `fire_agent` / `update_agent` tool | | Creating a workspace | `workspace.upsert` over RPC, which seeds and stamps ownership | | Projects (create / list / update / file) | The tickets UI. Projects are an operator surface — see [Projects](/manual/guides/projects/) | | Asking the operator a blocking question | The **harness** `ask_user` tool, not MCP. It renders a form in the conversation and blocks on the answer | | Per-agent diagnostics | Nothing. There is no `doctor` — see [Agent diagnostics](/manual/guides/agent-diagnostics/) | | Calendar and meetings | RPC for human clients only (see above) | ## See also - [Use Control Center as an MCP server](/manual/guides/mcp-server/) - [Modes](/manual/concepts/modes/) - [Guardrails](/manual/concepts/guardrails/) ### Pipeline steps > Reference for pipeline step kinds, step body keys, their configuration and step results. Source: https://usectrl.dev/manual/reference/pipeline-steps/ A pipeline step has two independent axes: - **Kind** (`StepKind`) — how the engine _schedules_ the step relative to its triggers. - **Body key** (`BuiltInBodyKeys`) — which registered body closure _executes_ the step's work. ## Step kinds Defined in `packages/cc_domain/lib/features/pipelines/domain/entities/step_kind.dart`. | Kind | Scheduling semantics | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `trigger` | Mandatory entry point — exactly one per template, always the first node. Does no work itself; what starts the run (manual / event / schedule) is tracked separately as `PipelineTrigger` rows. Its body is the no-op `pipeline.trigger`. | | `listen` | Fires when all source steps (in `StepTrigger.sourceStepIds`) complete. | | `join` | Fires when all steps in `waitForStepIds` reach terminal state. | | `router` | Conditional branching — the body returns `StepResult.route(key)`, which fires only the downstream edge whose `routeKey` matches; unselected branches are marked skipped. | | `forEach` | Map / fan-out: runs its body once per item in a state collection (`extras.iterableKey`), then aggregates the per-item outputs into a list under `outputKey`. | | `terminal` | The pipeline run completes when a terminal step finishes. | ## Step bodies Registered body closures keyed by the constants in `BuiltInBodyKeys` (`packages/cc_domain/lib/features/pipelines/domain/templates/builtin_template_seeds.dart`). There are 24, all listed below. Configuration is read from the step's `PipelineNodeConfig` (see below) unless noted; "state keys" are read from pipeline state / trigger payload at execution time. A step whose `bodyKey` is not registered produces a warning at template load and then never executes — it does not fail the run. `trigger` and `terminal` steps are exempt: they are scheduler sentinels and carry no body. ### `pipeline.trigger` No-op body of the mandatory `trigger` node — completes immediately so the engine fans out to downstream listeners. No configuration. ### `pipeline.bashScript` Agentless shell step (`bash -c`). `{{key}}` placeholders in the script are substituted from state + trigger payload with shell-escaped values. | Config field | Description | | ------------ | ------------------------------------------------ | | `script` | Shell command to execute (required) | | `outputKey` | State key that receives trimmed stdout on exit 0 | Runs with cwd `<cc_root>/pipelines/<pipelineRunId>/` (shared by all bash steps in a run) and `GITHUB_TOKEN` in the environment. Also writes `<stepId>_runDir` to state. Non-zero exit fails the step with the stderr tail. ### `conversation.promptAgent` Generic prompt-and-dispatch node: renders the prompt, dispatches the agent into a conversation and suspends until the run finishes. The agent's `submit_output` payload is harvested into state under `outputKey`. | Config field | Description | | ------------------ | ------------------------------------------------------------ | | `agentId` | Workspace-scoped agent UUID to dispatch (required) | | `prompt` | Instruction text; supports `{{key}}` substitution (required) | | `outputKey` | State key for the harvested output | | `outputSchema` | JSON Schema the output must satisfy | | `label` | Human label shown on the canvas | | `extras.channelId` | Existing conversation to work in — key unchanged (default: a hidden one) | | `extras.mode` | Conversation mode: `chat` / `plan` / `review` | ### `pipeline.condition` Router body — reads `config.extras`, evaluates a condition and returns `StepResult.route(key)`. Conditions are **not** JavaScript expressions; three authoring shapes are supported, in priority order: - **Predicate tree** (`extras.predicate`) — a boolean tree that routes `"true"` / `"false"`. Each node is a map with a `type`: - `{"type": "fileExists", "paths": [...], "baseKey": "repoLocalPath", "negate": false, "recursive": false}` — true when any listed path exists on disk; `negate` flips it; `recursive` also searches sub-directories (skipping `.git`, `node_modules`, `build`, `.dart_tool`). Relative paths resolve against `state[baseKey]` (default `repoLocalPath`), else the per-run workspace directory. - `{"type": "comparison", "left": "{{score}}", "op": "gt", "right": 80}` — operators `equals`, `notEquals`, `contains`, `exists`, `notExists`, `gt`, `lt`; reads pipeline state, not the filesystem. - `{"type": "and" | "or", "of": [<predicate>, ...]}` — boolean groups. - `{"type": "not", "of": <predicate>}` — negation. - **Switch** (`extras.switchKey`) — `{"switchKey": "prClass", "cases": [...], "default": "standard"}`; routes to the first case the state value case-insensitively contains, else `default`. - **Comparison** (legacy top-level `extras.left` / `extras.op` / `extras.right`) — equivalent to a `comparison` predicate, kept for templates authored before the tree. Edges out of the node carry a `routeKey` that must match the returned key. ### `prReview.comment` Posts the consolidated findings as a PR review comment via the GitHub PR client. | State key | Description | | ---------------------- | -------------------------------------------- | | `repoFullName` | `owner/repo` (required) | | `prNumber` | PR number — int or numeric string (required) | | `consolidatedFindings` | Comment body (required) | Returns `StepResult.terminal` with `commentReviewId` and `commentedAt` in state. ### `messaging.postSpace` Posts a message to a messaging space via `MessagingPort`. | State key | Description | | ----------- | ------------------------------------------------- | | `channelId` | Target space — key unchanged (required) | | `content` | Message body, read verbatim from state (required) | Writes `postedChannelId` (key unchanged) and `postedAt` to state. ### `team.dispatch` Dispatches a whole team instead of a single agent, then suspends until the members' runs finish. | Config field | Description | | ------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `teamId` | Workspace-scoped team to dispatch (required; mutually exclusive with `agentId`) | | `prompt` | Goal text; supports `{{key}}` substitution (required) | | `dispatchMode` | `allParallel` (default — one task per member) or `manager` (dispatch only the leader to coordinate) | | `outputKey` | State key for the harvested member outputs | | `outputSchema`, `label`, `extras.channelId` | As for `conversation.promptAgent` | ### `human.gate` Approval gate. Dispatches the approver agent into a conversation (review mode) with the gate prompt and a `{decision, reason}` output contract, then suspends until the approver submits via `submit_output`. | Config field | Description | | --------------------------- | ------------------------------------------------------------------------------ | | `agentId` | The approver agent (required) | | `prompt` | Gate prompt (optional; a default is used when absent) | | `outputKey` | State key for the harvested `{decision, reason}` payload | | `outputSchema` | Defaults to an approval schema (`decision`: `approved` / `rejected`, `reason`) | | `label`, `extras.channelId` | As for `conversation.promptAgent` | The gate resolves through `submit_output` only. `BuiltInBodyKeys.humanGate`, the node-type library and one built-in template's prompt text all name `approve_step` and `reject_step` MCP tools; **neither tool exists** in any package or registry. ### `repos.cleanup` Removes stale isolated worktrees; picks its mode from the trigger payload. Honors the run's `dryRun` flag. | State key | Description | | --------------------------- | ------------------------------------------------------------------------------------- | | `ticketId` | Release that ticket's worktrees | | `repoFullName` + `prNumber` | Release the ephemeral PR-editor worktree | | `channelId` | Tear down that conversation's worktrees and folder — key unchanged | | (none of the above) | Sweep the workspace: vanished directories, dead spaces, orphan conversation folders | `outputKey` (optional) receives a summary string. ### `flow.forEach` Map / fan-out over a state collection: dispatches the agent once per item and suspends until all per-item runs finish; outputs are aggregated into a list under `outputKey`. An empty collection completes immediately with an empty list. | Config field | Description | | -------------------- | --------------------------------------------------------------- | | `agentId` | Agent to dispatch per item (required) | | `prompt` | Per-item prompt; supports `{{key}}` substitution (required) | | `extras.iterableKey` | State key holding the collection (required) | | `extras.itemKey` | Key the current item is bound to in the prompt (default `item`) | | `outputKey` | State key for the aggregated list | ### `flow.callPipeline` Runs another pipeline template as a nested sub-step (the sub-pipeline runs as a child run with `parentPipelineRunId` set). The step suspends until the child reaches a terminal state, then the child's final state is merged under `outputKey`. | Config field | Description | | ------------------- | --------------------------------------------------------------- | | `extras.templateId` | Child template to run (required; a pipeline cannot call itself) | | `inputKeys` | State keys copied into the child's trigger payload | | `outputKey` | State key for the child's merged final state | ### `code.index` Background tree-sitter indexing: walks a repo, extracts symbols/edges in worker isolates and ingests them into the workspace code graph. Streams progress into the step-run row; supports dry-run; completes normally (skipped) when the tree-sitter natives aren't installed. | State key | Description | | --------------- | ------------------------------------- | | `repoId` | Repo to index (required) | | `repoLocalPath` | Local path of the checkout (required) | ### `meeting.diarize` Offline speaker diarization. Reads the meeting's retained audio, clusters it into speakers (`Person 1`, `Person 2`, …), relabels the transcript segments and rewrites the `transcript` state; also emits `diarizationSpans` for the parallel `meeting.updateTranscript` step. Passes the transcript through unchanged when no audio was retained or the models aren't installed. | State key | Description | | ----------- | ----------------------------- | | `meetingId` | Meeting to diarize (required) | ### `meeting.saveNotes`, `meeting.addActionItems`, `meeting.addDecisions` Deterministic meeting-summary persist bodies. Each reads `meetingId` and the agent step's structured `meetingOutcome` payload (`{summary, enhancedNotes, actionItems[], decisions[]}`) from state and writes its part to its own table — notes, action items, or decisions. Previously-saved rows are never wiped on a degraded run. ### Other built-in bodies | Body key | Description | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `meeting.identifySpeakers` | Cross-meeting speaker recognition: matches diarized speakers against saved voice profiles and auto-applies confident matches. No-op without profiles/embeddings. | | `meeting.updateTranscript` | Applies `Person N` labels from `diarizationSpans` and merges per-window fragments into per-speaker turns, then persists the cleaned transcript. No-op when diarization produced nothing. | | `meeting.assemblePlayback` | Assembles the meeting's mixed playback track from retained per-channel WAVs. No-op when no audio was retained. | | `orchestration.markPhase` | At the end of the work DAG, writes failure sentinels for sub-tickets with no output and flips the orchestration to `synthesizing`. | | `orchestration.persistDeliverable` | Writes the synthesis output to the parent ticket, completes it, posts the deliverable and marks the orchestration `completed`. | | `orchestration.awaitApproval` | Partial-approval gate: completes immediately when the step's node key is in the orchestration's approved set, else suspends until a later approval resumes it. | | `messaging.createSpace` | Opens the conversation the rest of the template works in, published under the pipeline's space state key. An agent step JOINS a room and never opens one, so a fan-out without this node fails at dispatch — and wiring it once means the whole fan-out shares one checkout instead of provisioning one apiece. | | `prReview.finalize` | The last step of the PR-review template: sorts the accumulated review nodes into consensus buckets and computes the per-PR verdict. | | `skills.analyze` | Runs the skills supply-chain scan over a workspace's installed skills and records each verdict. | ## Step results A step body returns a `StepResult` (`packages/cc_domain/lib/features/pipelines/domain/entities/step_result.dart`). All factories accept an optional `mutatedState` map merged into pipeline state, except `failed`. | Factory | Signature | Description | | --------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------- | | `ok` | `StepResult.ok({mutatedState})` | Normal completion; downstream listeners are evaluated | | `route` | `StepResult.route(nextRouterKey, {mutatedState})` | Router completion; selects the downstream branch whose `routeKey` matches | | `suspendUntilEvent` | `StepResult.suspendUntilEvent(eventType, {mutatedState})` | Pause until a specific domain event type fires | | `suspendUntilTasksComplete` | `StepResult.suspendUntilTasksComplete(taskIds, {mutatedState})` | Pause until all listed tasks reach terminal state | | `terminal` | `StepResult.terminal({mutatedState})` | The pipeline run is finished — no more steps | | `failed` | `StepResult.failed(errorMessage)` | The step failed with an error message | ## StepRetryPolicy Retry behaviour for a failing node body (`PipelineNodeConfig.retryPolicy`; null disables retries). | Field | Type | Description | | ---------------- | -------- | ------------------------------------------------------------------------------ | | `maxAttempts` | `int` | Total attempts including the first (3 = 1 try + 2 retries); must be ≥ 1 | | `backoff` | `String` | `linear` or `exponential` (default) | | `initialDelayMs` | `int` | Delay before the first retry, in milliseconds; later delays grow per `backoff` | ## PipelineNodeConfig Per-node configuration carried inside the step definition (`packages/cc_domain/lib/features/pipelines/domain/entities/pipeline_node_config.dart`). Both built-in and custom nodes share this shape; each body reads only the fields it cares about. | Field | Type | Description | | ---------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `prompt` | `String?` | Prompt template with `{{key}}` substitution | | `script` | `String?` | Bash script body with `{{key}}` substitution | | `agentId` | `String?` | Workspace-scoped agent UUID to dispatch | | `inputKeys` | `List<String>` | State keys this node consumes as input | | `outputKey` | `String?` | State key this node's output is written under | | `label` | `String?` | Human label shown on the canvas (defaults to the step ID) | | `outputSchema` | `Map<String, dynamic>?` | JSON Schema (subset) the output value must satisfy | | `reducer` | `String?` | Merge strategy when parallel branches write the same `outputKey`: `append`, `mergeLists`, `mergeMaps`, `sum`, or `override` (default) | | `retryPolicy` | `StepRetryPolicy?` | Retry policy on body failure; null = single attempt | | `continueOnFail` | `bool` | When true, a terminal failure does not fail the run; the error is stashed under `state['_stepErrors'][stepId]` | | `timeoutMs` | `int?` | Wall-clock timeout for the node body in milliseconds; null = no timeout | | `teamId` | `String?` | Team to dispatch (`team.dispatch` nodes); mutually exclusive with `agentId` | | `dispatchMode` | `String?` | Team execution mode: `allParallel` or `manager` | | `extras` | `Map<String, dynamic>` | Free-form body-specific config (e.g. `iterableKey`, `templateId`, condition trees) | Derived accessors: `spaceId` (`extras['channelId']`, key unchanged — existing conversation to work in) and `modeName` (`extras['mode']` — `chat` / `review` / `plan`). ## See also - [Pipelines](/manual/concepts/pipelines/) - [Create a pipeline](/manual/guides/create-pipeline/) - [Pipeline triggers](/manual/guides/pipeline-triggers/) - [Run a pipeline](/manual/guides/run-pipeline/) ### Rigs > Every rig surface, backend, action verb, lifecycle state, port, default and contract — the complete factual catalog. Source: https://usectrl.dev/manual/reference/rigs/ Facts only. For the model behind them, read [Rigs and enclosures](/manual/concepts/rigs/); to open a machine, follow [the guide](/manual/guides/use-rigs/). ## Surfaces and backends | Surface | Drives | Backend | Value | Contained egress | | --- | --- | --- | --- | --- | | Terminal (exec) | A shell over the VM's exec channel | microVM | `smolvm` | Yes | | Browser | Headless Chromium (CDP), Firefox (WebDriver BiDi) or WebKit (W3C WebDriver) | microVM | `smolvm` | Yes | | Computer | X11 desktop: input via the hypervisor, capture via the guest agent | QEMU | `qemu-hvf` (macOS), `qemu-kvm` (Linux), `qemu-tcg` (unaccelerated fallback, never chosen automatically) | Yes | | Mobile | Android over adb | Google's emulator | `android-emulator` | **No** — the emulator manages its own networking | The terminal and the `computer_use` surface share the `computer` surface value; the terminal is distinguished by its exec image marker and is never reused as a drivable desktop (or vice versa). One conversation reuses one terminal machine. ## Browser engines | Engine | Value | Protocol | Live view | Stills | | --- | --- | --- | --- | --- | | Chromium | `chromium` (default) | Chrome DevTools Protocol | Screencast — the browser pushes frames as the page repaints | JPEG | | Firefox | `firefox` | WebDriver BiDi | Polled stills (BiDi has no screencast), capped at 6 fps | JPEG | | WebKit | `webkit` | W3C WebDriver (classic) | Polled stills, transcoded by the host's ffmpeg | PNG | A rig runs **one** engine for its whole life. One conversation can hold one rig **per engine**, which is what makes a side-by-side comparison possible: opening "Firefox (VM)" beside "Chromium (VM)" is two machines, and closing one leaves the other running. Pass `engine` to `rig.open` or to the `browser_use` tool. An unknown name is refused rather than defaulted — asking for a browser and silently getting a different one answers a compatibility question with the wrong answer. Engine-specific limits, stated rather than worked around: - **Firefox** has no cache-bypassing reload (it rejects BiDi's `ignoreCache` outright), so a hard reload is a normal reload. - **WebKit** has no cache-bypassing reload either, no console feed (output is captured by hooking `console.*` after each navigation, so anything logged during load is missed — every console extract says so), and needs `ffmpeg` on the host for its live view. - **Neither** exposes a platform accessibility tree over its protocol, so `extract (a11y)` returns a DOM-derived approximation, labelled as one. - **Neither** can take a real file drop; a file input can still be pointed at paths inside the machine. ## Machine images | Surface | Image | Fetched | | --- | --- | --- | | Terminal | `ubuntu:24.04`, digest-pinned | Automatically on first boot, via the machine's own gated network | | Browser (Chromium) | `chromedp/headless-shell:stable`, digest-pinned | Automatically on first boot | | Browser (Firefox, WebKit) | `debian:trixie-slim`, digest-pinned, plus the engine's own packages | Automatically on first boot, then cached as a warmed pack | | Computer | A qcow2 desktop image | **Import** under Settings → Server → Enclosures (checksum-pinned downloads where published) | | Mobile | Google's system images | Through the Android SDK | After the first boot of each microVM kind the server caches a pre-extracted copy under the data directory and boots every later machine from it — including pre-warmed images with the packages already installed: `git`, `curl` and `socat` for the terminal, and the engine itself for Firefox and WebKit. Firefox and WebKit share a base image but never share a pack: the cache is keyed on the image **and** the engine, because a machine warmed for one has none of the other in it. Firefox and WebKit have no baked automation image worth taking — the only maintained Firefox one is 33× the size of the Debian base and still ships no `socat`, which every browser guest needs for the ports feature, and there is no maintained WebKit one at all. So both install their engine on first start and reach the Debian archives (`deb.debian.org`, `security.debian.org`, `cloudfront.debian.net`) while doing it. Chromium reaches no package mirror at all. ### Per-workspace custom images | Setting key | Overrides | | --- | --- | | `rigs.smolvm.execImage` | The Terminal (VM) image | | `rigs.smolvm.browserImage` | The Chromium Browser (VM) image | Written through the admin-gated workspace settings store (Settings → Server → Enclosures → Custom images). The value must be a registry reference — local paths, archives and flag-like strings are refused, both at the field and again at boot. An invalid stored value is ignored loudly and the default boots instead. The image's own registry (plus known blob-CDN companions for Docker Hub, GHCR, Quay, GitLab and GCR) is admitted through the egress gate for the pull. **Terminal image contract** — `bash` present; `git`, `curl`, `socat` preinstalled *or* a Debian/Ubuntu apt base (they are installed on first start). **Browser image contract** — the headless-shell layout: Chromium at `/headless-shell/headless-shell`, plus `bash` and `socat`. The server launches the browser itself with DevTools on guest loopback `9223`, relayed to guest port `9222`. Build `FROM chromedp/headless-shell:stable` to keep the layout. The override applies to **Chromium rigs only** — the workload command it boots is headless-shell's, and handing it to Firefox or WebKit would launch a binary that is not there. A Firefox or WebKit rig ignores it loudly and boots its pinned image. **Automation ports** — every browser machine forwards exactly one host loopback port to guest `9222`. Behind it: Chromium's DevTools and Firefox's remote agent sit on guest loopback `9223` with a `socat` relay in front (both bind loopback unconditionally); WebKit's driver takes a `--host` and binds the guest NIC itself. Firefox additionally validates the `Host` header against its own port, so the client sends the guest-side authority rather than the one it dialled. ## Action verbs Every action is parsed totally: a malformed payload returns a message naming the field, never an exception. Observation verbs (marked ○) stay allowed while a human holds control; mutating verbs are refused. ### `computer_use` `screenshot`○, `cursor_position`○, `mouse_move`, `left_click`, `right_click`, `middle_click`, `double_click`, `triple_click`, `left_mouse_down`, `left_mouse_up`, `left_click_drag`, `scroll`, `key`, `hold_key` (0–60 s), `type`, `wait`○ (0–60 s), `set_display`, `clipboard_read`○, `clipboard_write`. Coordinates are guest pixels in the guest's current mode; every screenshot states the display size. Key combinations use X11 spellings (`ctrl+s`, `alt+Tab`, `Return`). `clipboard_read` takes a `selection`: `clipboard` (what ctrl+C fills, the default), `primary` (X11 select-to-copy) or `xdnd` (a drag in flight). An empty result is a normal answer for all three. `clipboard_write` takes `text` only — images and files travel on the file lane, because an action's arguments are persisted to the audit log and image bytes have no business there. Clipboard content comes back fenced as untrusted data. ### `browser_use` `navigate` (http/https only), `click` (selector or coordinate), `fill`, `key` (DOM key names), `scroll`, `extract`○ (`a11y` | `dom` | `console`), `screenshot`○ (`full_page` optional), `set_viewport` (min 320×240), `history`, `wait_for` (0.1–30 s), `clipboard_read`○, `clipboard_write`. Extracted page content returns fenced as untrusted data. The browser has exactly one clipboard, so `clipboard_read` takes no `selection`. Both clipboard verbs need a **secure context**: a page served over plain `http://` has no clipboard API at all, and the verb says so rather than reporting an empty one — use `type` there, which inserts at the caret without going through a clipboard. ### `mobile_use` `tap`, `swipe` (50–5000 ms), `type`, `key` (aliases `back`, `home`, `recents`, `enter`, `delete`, `tab`, `escape`, `volume_up`, `volume_down`, `power`, or any `KEYCODE_*`), `screenshot`○, `ui_dump`○, `install_apk` (confined to the rig's worktree and the server data directory), `start_app`. No clipboard verbs, and that is a property of Android rather than a gap: since Android 10 only the *focused app* may read the clipboard, and no ADB command, shell service call or permission changes it. Relaying it would need an app installed inside the device, which this product does not ship. ### Management tools | Tool | Description | | --- | --- | | `rig_list` | List a workspace's rigs with surface, status, display and controller | | `rig_close` | Destroy a rig and discard its disk | ## Lifecycle | Phase | Meaning | | --- | --- | | `provisioning` | Booting; the status detail carries the current step verbatim | | `ready` | Reachable and accepting actions | | `parked` | Idle-parked: vCPUs stopped, RAM still resident; the next action wakes it | | `closing` | Shutting down | | `closed` | Gone; the row survives for the audit trail | | `failed` | Died or never came up; distinguishable so the UI can offer retry | | Close reason | Fired when | | --- | --- | | `requested` | A person or agent asked | | `idleTimeout` | Idle past 2× the idle window, or evicted for memory | | `ttlExpired` | The hard lifetime ran out | | `conversationEnded` | The owning conversation ended | | `workspaceGone` | The workspace was deleted or membership was lost | | `serverShutdown` | The server is stopping | | `backendFailure` | The hypervisor died underneath it | ## Defaults and ceilings | Value | Terminal | Browser | Computer | Mobile | | --- | --- | --- | --- | --- | | Memory | 512 MB | 2048 MB | 4096 MB | 4096 MB | | vCPUs | 2 | 2 | 4 | 4 | | Hard TTL | 8 h | 2 h | 2 h | 2 h | | Idle window | 45 min | 15 min | 15 min | 15 min | | Boot display | — | 1280×800 | 1280×800 | 1080×1920 | - Agent-lane stills are capped at **1280×800**; the human lane negotiates up to **2560×1600**. - The host's resident-memory budget for all rigs defaults to **12 GB**, counted in resident megabytes (a parked VM still holds its RAM). Eviction is least-recently-used and never takes a machine someone is driving, watching, or has a terminal open in. - An open terminal or watch lane pins the rig against parking and idle reaping; the hard TTL still applies. ## Ports and dev domains | Port | Where | Reserved for | | --- | --- | --- | | `7911` | Terminal guest | The port multiplexer every forward rides | | `9222`, `9223` | Browser guest | The automation relay and endpoint (WebKit binds `9222` directly and uses no relay) | | `80`, `443` | Browser guest loopback | The dev-domain HTTP/HTTPS lanes | - Discovery polls the terminal guest's listening TCP ports every ~4 s, server-side. - At most **16** ports are auto-forwarded per rig; more are listed in the log, not forwarded. - The host-side forward binds loopback, preferring the guest's own port number; **Share on local network** adds an OS-assigned LAN port. - Dev domains must match `name.test` or `name.localhost` (lower-case, max 63-character label). One domain maps to one port. - Dev-domain HTTPS uses a server-local CA (EC P-256, under `<dataDir>/rigs/tls/`, keys 0600 and never in a guest) with one wildcard leaf for `*.test` / `*.localhost`. No `openssl` on the host means no HTTPS lane; plain HTTP still routes. ## Egress defaults | Surface | Default allowlist | | --- | --- | | Terminal | The sandbox developer baseline (forges, package registries, model providers) + the Ubuntu apt mirrors + the worktree's own forge host | | Browser | The home page's host only — plus the Debian archives on a Firefox or WebKit rig's first boot, while it installs its engine | | Computer | Empty — deny everything — until a caller grants hosts | | Mobile | Not enforced (emulator-managed networking) | Every microVM additionally gets its image's registry hosts, for the pull only. The credential broker's allowed-hosts set is derived from the same allowlist: no token is minted for a host the guest cannot reach. ## Clipboard and file transfer Both directions of copy/paste, and dragging files in and out. Available to a person driving a rig; an agent gets the clipboard through the verbs above and no file transfer at all (it already has a shell inside the enclosure). | Lane | Route | Carries | | --- | --- | --- | | Clipboard | `GET`/`POST /rig/clipboard/<rigId>` | Text, one PNG/JPEG (≤16 MB), and the guest paths of files on the clipboard | | Files | `POST /rig/files/<rigId>` | Up to 64 files, ≤256 MB each and ≤512 MB per drop, with an optional guest-pixel drop point | | Files | `GET /rig/files/<rigId>?p=<base64url path>` | One file's bytes, back out | - **HTTP, not RPC.** The RPC socket closes a connection on an inbound frame over 256 KB — a DoS guard that is right for JSON commands and fatal for a pasted screenshot. Bytes ride the same kind of route rig frames and meeting audio already do. - **Auth** is the signed target `rig-files:<workspaceId>/<rigId>` plus workspace membership — a target of its own, so a URL minted to *watch* a machine cannot also write files into it. - **Take-over applies.** Writing the clipboard and dropping files are mutating and are refused while someone else holds control; reading the clipboard and reading a file out are observation and are not. - **Where drops land:** `~/Drops` (desktop), `~/drops` (terminal — beside the worktree, never inside it, so a drop never appears as an untracked change), `/tmp/cc-drops` (browser). The server picks the directory; a caller cannot name it. Existing files are never overwritten — a colliding name lands as `2-name.ext`. - **What the guest is told:** a browser page gets a real drop event at the requested point (`Input.dispatchDragEvent`); a desktop gets the files plus their URIs on its clipboard, because no host can synthesize an XDND drag into an arbitrary toolkit without a privileged in-guest daemon. The reply states which of the two happened. - **Dragging out** is desktop-only and reads the guest's `XdndSelection` while a drag is live. A payload identical to what the guest was already holding when the press began is treated as a stale selection from a finished drag, not a new one — X has no "the drag is over" step. - **Image requirements:** the desktop image must be protocol 2 or later (built after clipboard support landed). An older image answers 404 and the app says to rebuild it rather than reporting a broken machine. - **The audit log records the shape, never the content**: `clipboard_read`, `clipboard_write`, `drop_files` and `read_file` rows carry counts, sizes and paths. A clipboard is where credentials live for a few seconds at a time, and none of it is written down. ## Guardrails, events, storage - Rig tools declare the **`enclosureControl`** action class (plus `networkEgress` and `processSpawn` on the use tools). Allowed by default; read-only modes deny it wholesale; the per-space autonomy dial can demote it to ask-first. - Domain events: `RigOpened`, `RigClosedEvent` (with its close reason), `RigControlChanged` (take-over / hand-back), `RigReaped` (idle, TTL or memory eviction, with the driving agent). See [domain events](/manual/reference/domain-events/). - Storage: `rig_sessions` and `rig_action_log` in each workspace database. The action log records every input event with its principal and a monotonic per-rig sequence; typed text is redacted to a length and hash before it is written. - Data directory: `rigs/images/` (imported qcow2 images), `rigs/smolvm/` (per-rig runtime state), `rigs/smolvm-packs/` (pre-extracted machine packs), `rigs/tls/` (the dev-domain CA), `rigs/run/` (QEMU overlays and seeds). - Routes: the live view is a tab in a space or PR page; `/settings/server/rigs` is the Enclosures settings screen. ### Route map > Every route Control Center registers, with its screen and query parameters. Source: https://usectrl.dev/manual/reference/route-map/ ## App shell The app shell (`ControlCenterLayout`) wraps every in-workspace route via a `ShellRoute`. **Every in-shell destination is workspace-prefixed — `/workspaces/:workspaceId/…` — and the workspace id in the URL is the single source of truth for the active workspace.** Only the pre-context surfaces have no prefix: `/splash`, `/onboarding`, `/signed-out` and the bare `/workspaces` picker, all full-screen and outside the shell. The gate is complete when at least one forge is connected **for the signed-in user** and at least one workspace exists. A missing credential and a missing setup are different screens, and the discriminator is `users.onboarding_finished_at` read off the caller's own `identity.me` — never "workspaces exist", which is wrong the moment someone is invited into a workspace they never created. With no forge connected, an account that has onboarded goes to `/signed-out` to re-authenticate; one that has not goes to `/onboarding`. While the flag is still unknown the guard holds the splash rather than guessing, because it never redirects back out of onboarding. ## Unprefixed routes | Path | Screen | | -------------------------- | ------------------------------------------------ | | `/splash` | Startup splash that resolves the onboarding gate | | `/onboarding` | API keys plus first-workspace setup | | `/signed-out` | Re-authenticate: the same forge-connections card Settings renders, with no step bar and no continue button — the router leaves on its own once a forge reports in | | `/workspaces` | Workspace list and picker | | `/workspaces/:workspaceId` | Redirects to `/workspaces/:workspaceId/inbox` | ## Workspace routes Paths below omit the `/workspaces/:workspaceId` prefix. | Path | Query params | Screen | | --------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `/inbox` | — | Unified inbox: PRs classified by review lifecycle plus non-PR work blocking the operator. The post-guard landing route; a bare `/workspaces/:workspaceId` redirects here | | `/spaces` | — | Space surface, no space selected | | `/spaces/:spaceId` | `?m=<messageId>`, `?tab=<key>` | One space; `m` deep-links to a message and is consumed once on open | | `/tickets` | — | Ticket board | | `/tickets/:ticketId` | — | Ticket master-detail | | `/projects/:projectId` | — | Project overview | | `/pull-requests` | — | PR list | | `/pull-requests/compose` | `?space=<spaceId>` | Compose a new pull request; `space` names the space whose isolated worktree supplies the branch | | `/pull-requests/:owner/:repo/:prNumber` | `?tab=<key>` | PR detail with diff viewer | | `/pipelines` | — | Pipeline run list | | `/pipelines/run` | `?templateId=<id>` | Manual run launcher | | `/pipelines/:runId` | — | Pipeline run detail (step timeline) | | `/plans` | — | Plan Studio hub: active plans, plan documents, playbooks | | `/plans/:kind/:id` | — | Plan Studio for one plan; `kind` is `orchestration` or `document` | | `/meetings` | — | Meetings list | | `/meetings/record` | — | Live meeting recording screen | | `/meetings/:meetingId` | — | Meeting detail: notes, transcript, action items, decisions | | `/calendar` | — | Calendar: month, week and agenda views | | `/calendar/:eventId` | — | Calendar event detail (sibling route; shares the calendar page key) | | `/newsfeed` | — | Newsfeed list | | `/newsfeed/article/:articleId` | — | Article reader | | `/observability` | — | Observability hub: live Agent Hub plus cost, usage, quota and behaviour analytics. Defaults to the Agent Hub tab | | `/users/:login` | — | GitHub user profile | | `/api-keys` | — | API key management. **No navigation entry point** — reachable only by URL | | `/settings/…` | — | Settings screens (below) | Conversations and threads have **no route of their own** — a thread is a conversation inside `/spaces/:spaceId`, opened as an editor tab rather than addressed by URL. There is **no `/dashboard` route** and no dashboard screen. The analytics surface is `/observability`, reached from the sidebar footer. There is **no "Plans" sidebar item**. Plan Studio opens as an editor tab from the plan's row in the conversation that produced it; `/plans` and `/plans/:kind/:id` are deep links. `/observability` and `/pull-requests/compose` have no entry in the route-title or breadcrumb registries, so both show the bare app name in the window title and no breadcrumb trail. ## Settings routes `/settings` redirects to `/settings/you/profile`. The sidebar's settings button navigates to `/settings/you/appearance` instead, so the two entry points land on different pages. `Cmd/Ctrl + ,` goes to `/settings` and therefore to the profile page. Settings paths are namespaced by **scope** — `you/`, `workspace/`, `server/` — because the segment states who a change affects. Two paths predate the namespacing and are not scoped in the URL: `/settings/memory` and `/settings/pipelines`. ### You | Path | Screen | | ----------------------------- | ------------------------------------------- | | `/settings/you/profile` | Profile and identity (the settings landing) | | `/settings/you/appearance` | Theme, language, typography, editor theme | | `/settings/you/notifications` | Per-event toggles, quiet hours | | `/settings/you/keybindings` | Keyboard shortcut reference (read-only) | | `/settings/you/devices` | Paired phones and remote control | | `/settings/you/audio` | Soundscape and audio output | | `/settings/you/newsfeed` | Feed management: sources, refresh, read state | ### Workspace | Path | Screen | | ---------------------------------- | --------------------------------------------------------------------------------------------------- | | `/settings/workspace/general` | Name, logo, secret globs, review concurrency, branch naming, sync health, chat bridges, danger zone | | `/settings/workspace/members` | Roster, invites, audit trail | | `/settings/workspace/agents` | Registered agent identities | | `/settings/workspace/repositories` | Repositories registered in this workspace | | `/settings/workspace/skills` | Skill configuration | | `/settings/memory` | Workspace memory: facts, policies, knowledge graph | | `/settings/workspace/permissions` | Agent permissions: guardrail matrix and what-if probe | | `/settings/pipelines` | Pipeline templates | | `/settings/pipelines/:templateId` | Pipeline template editor (drag-and-drop canvas) | ### Server | Path | Screen | | ------------------------------ | ------------------------------------------------------------------------------------- | | `/settings/server/connection` | Connection and status | | `/settings/server/sso` | Single sign-on (SAML, OIDC, SCIM provisioning) | | `/settings/server/providers` | Model providers and adapters | | `/settings/server/mcp` | Built-in and external MCP servers | | `/settings/server/rigs` | Enclosures: what this host can boot, base images, custom images, running machines | | `/settings/server/voice` | Voice and meeting models (ASR, diarization) | | `/settings/server/diagnostics` | Diagnostics and privacy: sandboxing, embedding, sync engine, logging, crash reporting | | `/settings/server/sandbox` | Sandbox exec grants: commands an agent asked to run and you allowed | | `/settings/server/backup` | Backup and restore: install snapshots, per-workspace export, import and delete | | `/settings/server/about` | About and updates | `kSettingsNav` in `lib/features/settings/settings_nav.dart` is the single source of truth for this list; a test asserts every registered `/settings/` route appears there exactly once. ## Route configuration | Concern | File | | ------------------- | ------------------------------------------------------- | | Router | `lib/router/app_router.dart` | | Route builders | `lib/router/routes.dart` | | Auth guards | `lib/router/guards.dart` | | Onboarding gate | `lib/features/auth/providers/onboarding_providers.dart` | | Settings navigation | `lib/features/settings/settings_nav.dart` | ## See also - [Workspaces](/manual/concepts/workspaces/) - [Architecture](/manual/concepts/architecture/) - [Keyboard shortcuts](/manual/reference/keyboard-shortcuts/) ### Sandbox backends > The sandbox backends, where they are applied and every value object the sandbox runtime uses. Source: https://usectrl.dev/manual/reference/sandbox-backends/ :::caution[Whether a run is sandboxed depends on the host] `cc_server` probes the host once at boot and wraps agent dispatch in the native backend when the probe reports one available and `--sandbox` is not `off`. Both dispatch seams are wrapped: the external-CLI transports (`pi`, `claude`, `codex`) through the sandbox port and the ACP transport plus the built-in harness `bash` tool through the sandbox manager. Where the probe reports no backend — **Windows always** and Linux or WSL2 without `bwrap` and `socat` — agent runs are **not** sandboxed. Both paths log that fact and spawn with environment sanitization only. The remaining boundaries are the run's own copy-on-write worktree, capability-gated credentials brokered per launch and revoked on teardown, the per-mode command policy, the unified [action guardrails](/manual/concepts/guardrails/) (fail-closed), environment sanitization and the tool surface its mode allows. The harness's in-process file tools (read, write, edit, apply patch, search) are outside the sandbox on every host: they run in the server process, so only `bash` is wrapped. ::: ## Backend overview | Backend | Value | Platform | Availability | | ----------- | --------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | Enclosed VM | `microvm` | macOS, Linux, Windows (microVM hypervisor) | Probe-gated on the microVM runtime; applies to interactive terminals — the shell runs inside a [rig](/manual/concepts/rigs/), not on the host | | Native | `native` | macOS (Seatbelt), Linux and WSL2 (bubblewrap) | Probed at boot; wraps agent dispatch when available | | None | `none` | All | The parse default, the terminal's default and the fallback when no backend is available or `--sandbox off` is set | `SandboxBackend.fromName` returns `none` for a null or unrecognized stored value. A stored `"docker"` maps to `native`, preserving the sandboxed intent of preferences written before the `docker` and `auto` backends were removed. **`microvm` is the one backend with a kernel boundary** — the workload genuinely does not run on the host. It is never a silent fallback in either direction: explicitly requesting it on a host that cannot boot a VM fails loudly ("I asked for a VM and got my laptop" is the one degradation the enclosure cannot afford), and only an *unspecified default* steps down to `native`, with a log line saying so. The session's actual backend is reported back to the client and badged, so a terminal can never claim to be enclosed while running on the host. Lifecycle, images and egress for the VM itself are covered by the [rigs reference](/manual/reference/rigs/). **Windows has no native sandbox.** `NativeSandboxAdapter.probe` returns `available: false` with the note "Native sandbox is not yet supported on this platform" on every host that is not macOS or Linux/WSL2, so a Windows host has only the `none` backend. ## Probe results The probe is what gates enforcement. It runs once, at server boot. | Host | `available` | Note / hint | | -------------------------------------- | ----------- | ------------------------------------------------------------------------- | | macOS | `true` | `sandbox-exec` (Seatbelt) — namespace isolation, no kernel boundary | | Linux or WSL2 with `bwrap` and `socat` | `true` | bubblewrap — namespace isolation, no kernel boundary | | Linux or WSL2 missing either tool | `false` | `requiresInstall: true`, with an install hint naming the missing packages | | Any other platform | `false` | Native sandbox is not yet supported on this platform | The server logs the outcome on one startup line: `agent sandbox ON` plus the note, `no OS-native agent sandbox on this host` plus the hint, or `agent sandbox DISABLED by --sandbox off`. `GET /healthz` carries no sandbox field. `--sandbox` / `CC_SERVER_SANDBOX` accepts `on` (default) or `off`. It is an opt-out, not an enable: `on` means "wrap when the host offers a backend", never "require one". ## Native backend ### macOS: Seatbelt Uses Apple's Seatbelt sandbox mechanism (the same system used by macOS App Sandbox). The generated profile is **permissive by default** (`(allow default)`) and carves out denies, because a fully deny-by-default profile makes an interactive shell unusable — there are too many dyld, XPC and Mach calls to enumerate. The practical consequence is that an agent can still read most of the host; your registered checkouts are protected by explicit write and rename denials, not by invisibility. - **Filesystem reads**: denied for the secret paths (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config/gh`, `~/Library/Keychains` and the rest of the deny-read set) - **Filesystem writes**: reset to deny, then explicit subpath allows, with the mandatory-deny paths (shell rc files, `.git/hooks`, Claude config, protected checkouts) blocked even inside a writable root. `file-write-unlink` is denied on every denied path and its ancestors, so a rename cannot bypass a write-deny - **Process**: `process-exec` denies for the always-dangerous binaries, plus a block on executing anything from a writable directory - **Network**: restricted to the in-process proxy ports plus DNS — loopback is not blanket-allowed and Unix sockets are not allowed. The proxies enforce the per-session domain allowlist (see [Network egress](#network-egress)) - **Configuration**: profiles are generated per launch from the `SandboxSpec` Requirements: - `sandbox-exec` (`/usr/bin/sandbox-exec`), part of every macOS install — no additional tooling required ### Linux: bubblewrap Uses bubblewrap (`bwrap`) to create mount and PID namespaces. - **Filesystem**: creates a mount namespace with only the bound paths visible - **Network**: runs in an unshared network namespace; traffic reaches the host proxies through `socat` Unix-socket bridges - **Process**: PID namespace isolation Requirements: - `bubblewrap` and `socat` installed (`sudo apt-get install bubblewrap socat` on Debian/Ubuntu) - Kernel namespace support (standard on modern Linux) ## None backend No sandboxing. The process runs directly on the host with the host's filesystem, network and process access, subject only to environment sanitization and the command policy. This is what agent dispatch falls back to on a host with no available backend or with `--sandbox off` and it is the terminal's default when a client names none. ## Server-hosted terminal The terminal is sandboxed on its own terms, not by the dispatch probe. `TerminalSessionService`'s `defaultBackend` is `none` and `cc_server` does not override it, so a terminal session runs the host shell unless the client explicitly asks for `native` — the desktop's terminal panel does not. When `native` is requested the shell is wrapped through `SandboxManager` under a fixed terminal profile: `$HOME` and the session's working directory are writable, network is open and `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config/gh` and `~/Library/Keychains` are read-denied. Capability gating does not apply — that is a dispatch concern. If the wrap is unsupported the session falls back to the host shell and emits a notice line. ## Network egress When a sandbox is launched, outbound network access is enforced by in-process proxies, not by kernel firewall rules. On a host with no available backend none of this applies and a spawned process reaches the network exactly as the server user would. - The sandbox runtime runs an HTTP/HTTPS proxy and a SOCKS5 proxy on `127.0.0.1` inside the `cc_server` process, shared by all sandbox sessions (`http_proxy.dart`, `socks_proxy.dart`). - Sandboxed processes receive proxy environment variables (`HTTP_PROXY`/`HTTPS_PROXY` → the HTTP proxy, `ALL_PROXY` → the SOCKS proxy; `GIT_SSH_COMMAND` routes git-over-SSH through the SOCKS proxy). On Linux the network namespace is unshared, so the proxies are the only way out; on macOS the sandbox shares the host loopback and talks to them directly. - Each connection's target host is matched against the session's domain allowlist; anything else is denied. Wildcards are supported: `*.example.com` matches any subdomain but not the apex itself. - The allowlist is resolved per session from the curated baseline (`kBaselineAllowedDomains`: LLM providers, Git hosting, package registries), plus capability-derived domains (GitHub domains when the agent has GitHub capabilities; the ticketing provider's domains when it can call ticketing), plus `SandboxSpec.egressAllowlist`. - A fixed deny list (`kBaselineDeniedDomains`: cloud metadata endpoints, telemetry sinks) takes precedence over all allows. - `networkEnabled: false` (or an agent without the network capability) launches the sandbox with no proxies at all. ## SandboxSpec The specification passed to `SandboxPort.launch`: | Field | Type | Description | | ----------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `sessionId` | `String` | Stable session identifier (typically the space id) | | `workspaceId` | `String` | Owning workspace id | | `agentId` | `String?` | Agent currently bound to the sandbox, if any | | `bindMounts` | `List<SandboxBindMount>` | Host→guest mounts; the sandbox sees only the host paths listed here | | `networkEnabled` | `bool` (default `true`) | When false, the proxies are not exposed and the sandbox has no network access | | `egressAllowlist` | `List<String>` (default `[]`) | Extra allowed egress domains; wildcards supported (`*.example.com`). When empty, the curated baseline plus capability-derived domains apply | | `guestWorkdir` | `String?` | Default working directory inside the sandbox; individual `exec` calls can override it | | `mode` | `Mode` (default `chat`) | Carves the filesystem write rules: `chat` keeps bind mounts writable; `review`, `plan` and `orchestrate` are read-only | | `capabilities` | `AgentCapabilities` (default `safeDefault`) | Derives the egress allowlist (GitHub / ticketing domains) and the network on/off decision | | `protectedPaths` | `List<String>` (default `[]`) | Host paths that must never be writable inside the sandbox, in any mode | `protectedPaths` holds the original registered repo checkouts, resolved per dispatch from the workspace's repos. Agents work exclusively in per-conversation copy-on-write worktrees, so the originals must never be writable from inside the sandbox — on macOS, Seatbelt's blanket `$HOME` write allowance would otherwise leave them writable via `bash`. They join the secrets deny-write set in every mode, `chat` included. Every deny-read and deny-write path is emitted in both its literal spelling and its symlink-resolved one. The kernel matches a deny rule against the resolved path, so a rule written against a symlinked path never fires — and on macOS that is the common case rather than the exotic one, since `/tmp` resolves to `/private/tmp` and `/var/folders/…` to `/private/var/folders/…`. Entries containing `*` or `?` are treated as globs and left alone; a path that does not resolve is kept as written. ## SandboxBindMount | Field | Type | Description | | ----------- | ------------------------ | ------------------------------------------------------------------------------------------------------- | | `hostPath` | `String` | Absolute path on the host | | `guestPath` | `String` | Path inside the sandbox (with the native sandbox this equals `hostPath`; retained for adapter symmetry) | | `readOnly` | `bool` (default `false`) | Mount read-only when true | ## SandboxHandle Returned by `SandboxPort.launch`: | Field | Type | Description | | ----------- | ---------------------- | ------------------------------------------------------------------------ | | `sessionId` | `String` | Session identifier | | `backend` | `SandboxBackend` | Which backend is running | | `state` | `SandboxState` | Lifecycle state | | `error` | `Object?` | Set when `state` is `error` | | `details` | `Map<String, Object?>` | Adapter-private bookkeeping (working directory, PID, profile path, etc.) | ### SandboxState | State | Description | | ----------- | ------------------------------------------- | | `created` | Spec created but the VM has not started yet | | `warm` | Running and ready to exec commands | | `active` | Currently executing a command | | `suspended` | Paused / checkpointed to disk to save RAM | | `destroyed` | Torn down; the handle is no longer valid | | `error` | Launch failed — see `SandboxHandle.error` | ## Sandbox events | Event type | Description | | ----------- | ------------------------------------------------------------ | | `stdout` | Standard output from the sandbox | | `stderr` | Standard error from the sandbox | | `exit` | Sandbox process exited (carries `exitCode`) | | `starting` | Sandbox is starting up | | `ready` | Sandbox is ready to accept exec calls | | `killed` | Process killed by the host (OOM, timeout, manual stop) | | `violation` | A sandbox policy was violated (carries a `SandboxViolation`) | ## SandboxViolation | Field | Type | Description | | --------------------- | --------- | -------------------------------------------------------------------------- | | `action` | `String` | The denied verb (e.g. `file-read*`, `network-outbound`) | | `target` | `String` | Path or host that was the target of the denied operation | | `suggestedCapability` | `String?` | The capability flag that would allow this action (e.g. `canCallGitHubApi`) | | `raw` | `String?` | Raw log line, useful for debugging | ## See also - [Sandbox security](/manual/concepts/sandbox-security/) - [Sandbox policies](/manual/guides/sandbox-policies/) - [Guardrails](/manual/concepts/guardrails/) - [cc_server CLI](/manual/reference/cc-server-cli/): the `--sandbox` flag ### Slash commands > Every command you can type in the composer, what it does and where it runs. Source: https://usectrl.dev/manual/reference/slash-commands/ Commands typed in a space's composer. They fall into three groups by where they take effect, and the difference matters: a **local** command never travels as a message, so it neither appears in the transcript nor reaches an agent. | Where it runs | What that means | | ------------- | ---------------------------------------------------------------------------------- | | **Local** | Handled in the client. Nothing is persisted and no agent sees it. | | **Side** | One server-side model call about the conversation. The conversation is not changed. | | **Dispatch** | Sets a mode or directive for the run the message starts. | --- ## Directing a run ### `/plan` **Dispatch.** Puts the conversation in plan mode: a fully read-only worktree and a tool surface capped at `read`. The agent's deliverable is a plan, not an edit. ### `/goal <objective>` **Dispatch.** Starts an autonomous objective the goal supervisor redispatches across segments and restarts. Typing `/goal` with text first opens the **objective interview** — a short pass that refuses to accept a draft until it has all five of: success criteria an evaluator can settle without judgment, an exact verification command, an attempt cap, scope boundaries, and stop conditions. Every step offers *Skip and run as written*: the interview is a default, not a gate. `/goal pause`, `/goal resume`, `/goal drop` and `/goal budget <N|off>` control a running goal. :::note[Why it interrupts at all] An autonomous goal is the one place a vague brief is genuinely expensive. The run works for hours on an objective whose "done" nobody defined, then reports success on its own terms. Six questions are cheaper than one overnight run that finished whenever it felt finished. ::: ### `/loop [budget] [task]` **Dispatch.** Works iteratively until complete. Accepts a bound: `/loop 10`, `/loop 30m`, `/loop 1h30m`, `/loop 10 minutes fix the tests`. A token that *looks* like a limit (starts with a digit or a sign) and fails to parse is a hard error. Prose is just prose, so `/loop keep going until the tests pass` is an unbounded loop with an inline prompt rather than a misparse. ### `/vibe [task]` **Dispatch.** Turns the session into a **director**: its own tools drop to `read` plus five worker verbs, and background workers do the editing. See [Direct background workers](/manual/guides/direct-background-workers/). Workers never outlive the mode — exiting kills every one of them. --- ## Managing context ### `/compact [focus]` **Dispatch.** Folds older history into an anchored summary and continues on the compacted context. The optional free text steers what the summary keeps. ### `/shake [target]` **Local → server.** Drops heavy content **without** summarizing: no model call, every word kept, only the bulk nobody was going to re-read is blanked. | Target | Drops | | ------------- | --------------------------------- | | `tool_output` | Uneventful older tool results (default) | | `images` | Old screenshots, keeping their text | | `all` | Both | This is the escape hatch for "my context is fat but I do not want a summary". ### `/context [agent]` **Local.** Opens the context explorer for this conversation's agent in a tab of its own: every segment and part of its context window — system prompt, skills, context files, tool schemas, history and images — with what each is costing and the verbatim content behind it. The tab is keyed on the (space, agent) pair, so running it twice refocuses one tab rather than stacking copies of the same breakdown. In a space with **more than one agent**, a bare `/context` opens the window the header's context meter is reading — whichever agent last worked here, which is the one whose message is at the bottom of the trail — and the tab is labelled `Context · <name>` so it says whose it is. Name another to switch: `/context qa` (the `@` is optional). A typed name resolves exactly, never fuzzily: two agents hold two different windows, so a near-miss that opened the other one would be a plausible-looking answer to a question you did not ask. The same explorer is behind the context meter chip in the space header, which follows that same agent — the command and the chip can never disagree about what "the context" means here. --- ## Asking about the conversation These spend one model call and **never change the conversation**. The agent's next real turn sees exactly what it would have seen anyway, which is the entire value of asking. ### `/handoff [focus]` **Side.** Writes a handoff document for whoever picks the work up cold: what the task is, what was learned, what remains. ### `/btw <question>` **Side.** Answers a question about the work so far without adding the question or the answer to the transcript. :::note[Two commands, one implementation] Both are the same shape — take the conversation as it stands, ask one question about it, persist neither side. Implemented once so they cannot drift into two different notions of "the context so far". ::: --- ## Navigating and exporting ### `/tree` **Local.** Opens the branch navigator: where the conversation forked, which path it is currently on, and how to get back to one you left. See [Branch a conversation](/manual/guides/branch-a-conversation/). ### `/export` **Local.** Writes the conversation to a self-contained HTML file — collapsible tool cards, no stylesheet, no script, no image host. Lands in `Documents/ControlCenter/exports/` on desktop and downloads on web. ### `/dump` **Local.** Copies the transcript to the clipboard as markdown, with tool calls in collapsible blocks. Reasoning is deliberately excluded: a transcript pasted into a bug report is read by a person. --- ## Everything else ### `/todo [args]` **Local.** Views and edits the conversation's persisted checklist. ### `/skill:<name> [args]` **Dispatch.** Applies an installed skill. Skills live under their own `skill:` prefix so they share no namespace with the built-ins — without that, a skill called `plan` would be permanently unreachable. ### `/<your-command> [args]` **Dispatch.** Any markdown file under `.agents/commands`, `.claude/commands`, `.codex/commands` or `.opencode/commands` in the repo. A nested `foo/bar.md` is also reachable as `foo:bar`. Templating: `$1`, `$@[start:length]` and `$ARGUMENTS`. Frontmatter may set `description`, `allowed-tools` and `model`. A user-authored command wins over a skill of the same name — it is a shortcut a human invoked deliberately, with arguments. --- ## Magic keywords Not commands. A standalone lowercase word anywhere in a prompt attaches a hidden instruction for that turn. | Word | Effect | | ------------- | ------------------------------------------------------------------ | | `ultrathink` | Maximum reasoning effort for the turn | | `orchestrate` | Delegate independent work in parallel and verify each phase | The matcher is deliberately strict about boundaries. `orchestrate,` matches; `orchestrated`, `orchestrate.ts`, `foo::orchestrate` and `orchestrate()` do not, and fenced code blocks, inline spans and XML sections are masked out first. Without that, pasting a stack trace would change how the agent behaves. --- ## Deliberately not shipped `/omfg` (forging a stream rule from a complaint), `/tan` (spawning a background agent on a tangent) and `/share` (an encrypted public link) were considered and left out. ### SSO configuration reference > Every knob, endpoint, op and error code of single sign-on. Source: https://usectrl.dev/manual/reference/sso/ Everything configurable about single sign-on, in one place. Values live in the `sso_connections` rows of `global.db` (server-wide by design), edited via **Settings → Server → Single sign-on** or the `sso.*` RPC ops; the `CC_SAML_*` / `CC_OIDC_*` environment variables seed a never-configured connection on first boot and are then overrides in spirit only — the saved row is the source of truth. ## Connection fields | Field | Applies to | Default | Meaning | | ---------------------- | ---------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `enabled` | both | off | logins may use this connection | | `idpMetadataXml` | SAML | — | the IdP's `EntityDescriptor` XML (required to enable) | | `spEntityId` | SAML | `<origin>/saml` | our entity ID, pinned when the origin varies | | `emailAttribute` | SAML | `email` | attribute carrying the email; an email-shaped NameID is the fallback | | `displayNameAttribute` | SAML | `displayName` | display-name attribute | | `groupsAttribute` | SAML | `groups` | group-names attribute | | `clockSkewSeconds` | SAML | 90 | lifetime-validation skew allowance | | `allowIdpInitiated` | SAML | off | accept unsolicited Responses (no `InResponseTo`) | | `wantResponseSigned` | SAML | off | require a Response-root signature (assertion signatures are always required) | | `issuer` | OIDC | — | issuer base URL (required to enable) | | `clientId` | OIDC | — | public-client id (required to enable) | | `clientSecret` | OIDC | — | confidential clients only; stored in the 0600 secrets file, never the database — write-only over RPC (`sso.getConfig` reports `clientSecretPresent`) | | `groupsClaim` | OIDC | `groups` | claim carrying group names | | `defaultRole` | both | `member` | role when no group maps — never `owner` | | `groupRoleMap` | both | `{}` | group → role; owner mappings are refused at save | | `autoMember` | both | on | auto-add users to every workspace on first login | | `allowJit` | both | on | provision unknown users at login | ## Environment seeds | Variable | Meaning | | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | | `CC_SAML_IDP_METADATA` / `CC_SAML_IDP_METADATA_FILE` | the metadata XML inline, or a path to it (enables SAML when set) | | `CC_SAML_SP_ENTITY_ID` | pins our entity ID | | `CC_SAML_EMAIL_ATTRIBUTE` / `CC_SAML_DISPLAY_NAME_ATTRIBUTE` / `CC_SAML_GROUPS_ATTRIBUTE` | attribute names | | `CC_SAML_DEFAULT_ROLE` / `CC_SAML_GROUP_ROLE_MAP` / `CC_SAML_AUTO_MEMBER` / `CC_SAML_ALLOW_JIT` | provisioning policy (`owner` is refused as a default role) | | `CC_SAML_ALLOW_IDP_INITIATED` / `CC_SAML_WANT_RESPONSE_SIGNED` / `CC_SAML_CLOCK_SKEW_SECS` | validation knobs | | `CC_OIDC_ISSUER` / `CC_OIDC_CLIENT_ID` | enables OIDC when both set; the issuer must be an `https` URL (`http` is accepted for loopback issuers only) | | `CC_OIDC_CLIENT_SECRET` | confidential-client secret (seeds the secrets file on first boot) | | `CC_OIDC_GROUPS_CLAIM` / `CC_OIDC_DEFAULT_ROLE` / `CC_OIDC_GROUP_ROLE_MAP` / `CC_OIDC_AUTO_MEMBER` / `CC_OIDC_ALLOW_JIT` | provisioning policy (`owner` is refused as a default role) | | `CC_SERVER_WEB_CLIENT_URL` | origin SSO callbacks bounce the browser to (credential rides the URL fragment) | ## HTTP endpoints | Endpoint | Auth | Purpose | | ------------------------------------------------ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /saml/login?relay=…` | none (redirect) | start SP-initiated login; `relay=desktop` hands off to the app, `relay=web-popup` postMessages the waiting connect tab (which declares its `client_origin`, honoured only when the origin allow-list trusts it) | | `POST /saml/acs` | none (IdP POST) | the assertion consumer — verifies, provisions, mints | | `GET /saml/metadata` | none | our SP `EntityDescriptor` | | `GET /oidc/login?relay=…` / `GET /oidc/callback` | none | the OIDC round-trip; `relay=desktop` hands off to the app, `relay=web-popup` to the waiting connect tab (same `client_origin` convention) | | `GET /auth/providers` | none, CORS `*` | the connect-screen probe: offered SSO connections (`id`/`kind`/`label`) + `pairingEnabled` | | `/scim/v2/Users…` | Bearer (SCIM token) | Users create/get/list+filter/PUT/PATCH/DELETE | | `/scim/v2/Groups` | Bearer | read-only; group push answers 501 by design | | `/scim/v2/ServiceProviderConfig` | Bearer | capability document | ## RPC ops All `sso.*` ops are server-scoped and gated to the **server-admin role** (the caller must own at least one workspace); pairing alone never grants them. | Op | Purpose | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sso.getConfig` | the saved connection for a kind | | `sso.saveConfig` | validate, persist and live-apply | | `sso.status` | enabled/configured flags, SCIM token presence, canonical origin | | `sso.testConnection` | test a connection short of the browser round-trip (SAML: metadata parse + AuthnRequest build; OIDC: issuer discovery), with optional unsaved on-screen values | | `sso.spMetadata` | emit the SP `EntityDescriptor` for an origin | | `sso.setPairingEnabled` | allow/forbid manual pairing (invite codes, pairing keys); refusing to disable it while no working SSO connection exists guards against lockout | | `sso.scimRegenerateToken` | generate + return the SCIM bearer token (once) | ## Validation error codes `cc_saml_verify_response` failures carry a stable code: | Code | Meaning | | ---------------------- | -------------------------------------------------------------------------------- | | `signature` | missing/untrusted/tampered signature, or a disallowed algorithm/transform | | `expired` | outside `NotBefore`/`NotOnOrAfter` (beyond skew) | | `audience` | audience restriction does not name us | | `destination` | response destination is not our ACS | | `request_match` | `InResponseTo` does not match a pending request, or unsolicited while disallowed | | `issuer` | issuer is not the configured IdP | | `recipient` | subject-confirmation recipient is not our ACS | | `malformed` | XML/schema-level failure | | `status` | IdP-reported status was not Success | | `invalid` / `internal` | anything else / seam failure | ## Database surfaces - `sso_connections` (global.db) — one row per kind, CROSS-WORKSPACE BY DESIGN. - `users.sso_subject` + `users.sso_issuer` — the provider-subject pin, unique per `(issuer, subject)` via a partial index. - `users.deactivated_at` — the SCIM deactivation stamp. - The SCIM bearer token and the OIDC client secret live in the 0600 secrets file, never the database. ### Ticket lifecycle > Ticket fields, statuses, transitions, relations and the mutation chokepoint. Source: https://usectrl.dev/manual/reference/ticket-lifecycle/ ## Ticket statuses | Status | Storage string | Description | | ------------ | -------------- | ----------------------------------------------- | | `backlog` | `backlog` | Captured, not yet triaged into the active queue | | `open` | `open` | Triaged and ready to be worked, not started | | `inProgress` | `inProgress` | Actively being worked | | `blocked` | `blocked` | Started but blocked on something external | | `inReview` | `inReview` | Work done, awaiting review | | `done` | `done` | Finished successfully (terminal) | | `failed` | `failed` | Finished with an error (terminal) | | `cancelled` | `cancelled` | Abandoned (terminal) | Status values are stored as camelCase (`inProgress`, `inReview`), never `in_progress`. A null stored value parses as `open`; an unknown value throws rather than coercing. `tryParseLoose` accepts agent-supplied aliases, ignoring case and `-` / `_` / space: | Status | Accepted aliases | | ------------ | --------------------------------------------- | | `open` | `todo`, `ready`, `pending`, `new` | | `inProgress` | `doing`, `started`, `active`, `wip` | | `blocked` | `stuck` | | `inReview` | `review`, `reviewing` | | `done` | `closed`, `complete`, `completed`, `finished` | | `failed` | `error`, `errored` | | `cancelled` | `canceled`, `abandoned`, `dropped` | ## Status transitions ``` backlog ──→ open ──→ inProgress ──→ inReview ──→ done │ │ │ │ └──→ failed ┘ └──→ blocked ──→ inProgress Any non-terminal status ──→ cancelled; inReview ──→ failed ``` **This graph binds agents and automation only.** `TicketWorkflowService.transitionStatus` checks it when `force` is false — the MCP tools and the reconcilers. An illegal transition there is logged and ignored. Every user-driven status change in the UI passes `force: true` and bypasses the graph entirely, so a human may move a ticket to any status, including reopening a terminal one. A status may always transition to itself. Terminal states have no outgoing edges. | From | Allowed targets | | ------------ | ---------------------------------------------------- | | `backlog` | `open`, `cancelled` | | `open` | `inProgress`, `blocked`, `cancelled` | | `inProgress` | `blocked`, `inReview`, `done`, `failed`, `cancelled` | | `blocked` | `inProgress`, `cancelled` | | `inReview` | `inProgress`, `done`, `failed`, `cancelled` | | `done` | — (terminal) | | `failed` | — (terminal) | | `cancelled` | — (terminal) | ## Ticket priorities Stored as an integer on Linear's native 0–4 scale. An unknown value parses to `none`. | Priority | Stored as | | -------- | --------- | | `none` | `0` | | `urgent` | `1` | | `high` | `2` | | `medium` | `3` | | `low` | `4` | ## Ticket providers An unknown or null stored value parses to `local`. | Provider | Status | | --------- | ----------------------------------------------------------------- | | `local` | Stored entirely in the workspace database; the default | | `linear` | Remote-owned, mirrored locally | | `jira` | Enum value present; documented in the enum as not yet implemented | | `clickup` | Enum value present; documented in the enum as not yet implemented | Sync adapters ship for Linear, GitHub Issues, Jira Cloud and ClickUp under `packages/cc_infra/lib/src/tickets/sync/`. Nothing in the product creates a `TicketSyncConfig` row, so **no vendor sync is configurable in-app**; a config row must be inserted out of band. ## Ticket origin | Value | Meaning | | ----------------- | -------------------------------- | | `manual` | Created by a human (the default) | | `pipelineStep` | Created by a pipeline step | | `agentDelegation` | Delegated by another agent | | `externalSync` | Synced from a remote provider | | `recovery` | Created by the recovery system | ## Ticket fields The aggregate spans two concerns on one row. The **mirror** (provider, keys, title, description, priority, labels, statuses, timestamps) is a cache for a remote provider and is rewritten wholesale by a refresh. The **overlay** (assignment, delegation, space, parent, project, linked PRs) is Control-Center-only and a refresh never touches it. | Field | Type | Description | | ------------------------ | -------------------------- | ------------------------------------------------------------------------------------------ | | `id` | `String` | UUID v4. For local tickets this is also the `externalKey` | | `workspaceId` | `String` | Owning workspace | | `provider` | `TicketProvider` | Backend owning the canonical data (default `local`) | | `externalKey` | `String?` | Provider-native key (e.g. `LIN-123`) | | `url` | `String?` | Web URL on the remote provider | | `title` | `String` | Short summary; must not be empty | | `description` | `String?` | Longer Markdown body | | `priority` | `TicketPriority` | Default `none` | | `labels` | `List<String>` | Free-form labels | | `status` | `TicketStatus` | Canonical normalized status | | `rawStatus` | `String?` | The remote provider's native state name, kept for lossless display | | `parentTicketId` | `String?` | Parent in the delegation / breakdown tree | | `projectId` | `String?` | Owning project; never pushed to a remote | | `assignedAgentId` | `String?` | Assigned principal's id (metadata only — assigning dispatches nothing) | | `assigneeType` | `PrincipalType` | Which kind of principal `assignedAgentId` names — `user` or `agent` (default `agent`) | | `createdByType` | `PrincipalType?` | Kind of principal that created the ticket (`user` or `agent`); null for legacy/system rows | | `createdById` | `String?` | Creating principal's id | | `assignedTeamId` | `String?` | Assigned team (metadata only) | | `delegatedByAgentId` | `String?` | Agent that delegated this ticket | | `delegationDepth` | `int` | Depth in the delegation chain; root is `0`, checked against the max-depth cap | | `delegationRootTicketId` | `String?` | Root of the delegation chain; null for a root ticket | | `spaceId` | `String?` | Conversation the ticket was spun out of, or the delegating agent's space | | `errorMessage` | `String?` | Set when `status` is `failed` | | `linkedPrIds` | `List<String>` | PR node ids this ticket is linked to | | `metadata` | `Map<String, dynamic>` | Free-form bag | | `createdAt` | `DateTime` | Creation time | | `startedAt` | `DateTime?` | When work started | | `blockedAt` | `DateTime?` | When the ticket was blocked | | `cancelledAt` | `DateTime?` | When the ticket was cancelled | | `completedAt` | `DateTime?` | When the ticket completed successfully | | `finishedAt` | `DateTime?` | When the ticket reached any terminal state | | `updatedAt` | `DateTime` | Last mutation (mirror refresh or overlay change) | | `version` | `int` | Optimistic-concurrency counter, incremented on every mutation | | `originKind` | `TicketOriginKind` | How the ticket was created (default `manual`) | | `collaborators` | `List<TicketCollaborator>` | Hydrated by the repository on demand | Derived: `isTerminal`, `isRemote`, `isAssignedToUser` and `displayKey` (the `externalKey` when synced, else the `id`). ## Ticket relations Stored edges are directional rows in `ticket_links`: | Type | Storage string | Meaning | | ------------- | -------------- | ------------------------------- | | `blocks` | `blocks` | Source blocks target | | `relatesTo` | `relates_to` | Symmetric relation | | `duplicateOf` | `duplicate_of` | Source is a duplicate of target | The vocabulary the UI and `ticket_relation` speak is `TicketRelationKind`, derived from a stored edge plus which endpoint the subject sits on: | Kind | Derived from | | -------------- | ---------------------------------------------------- | | `blockedBy` | A `blocks` row where the subject is the target | | `blocking` | A `blocks` row where the subject is the source | | `relatedTo` | A `relates_to` row (symmetric) | | `duplicateOf` | A `duplicate_of` row where the subject is the source | | `duplicatedBy` | A `duplicate_of` row where the subject is the target | | `subIssueOf` | `tickets.parent_ticket_id` on the subject | | `parentOf` | `tickets.parent_ticket_id` on the other ticket | Parent and sub-issue links are **not** `ticket_links` rows — they live on `tickets.parent_ticket_id`. ## Domain events | Event | Fired when | | ------------------------------- | -------------------------------------------------- | | `TicketCreated` | Ticket is created | | `TicketAssigned` | Ticket is assigned to a principal or team | | `TicketStarted` | Work begins | | `TicketCompleted` | Work finishes successfully | | `TicketFailed` | Work fails | | `TicketCancelled` | Ticket is cancelled | | `TicketStatusChanged` | Any status change | | `TicketReassigned` | Ticket reassigned | | `TicketDelegated` | A child ticket is created under a parent | | `TicketCollaboratorAdded` | Collaborator joins | | `TicketDetailsUpdated` | Title, description, or priority changes | | `ExternalTicketWebhookReceived` | An external vendor webhook is verified and applied | `TicketAssigned` is an audit and notification signal and a pipeline trigger. **It starts no agent run.** There is no ticket dispatcher: assignment records ownership and agent work happens in conversations. ## Mutation chokepoint `TicketWorkflowService` owns every mutation. | Mechanism | Detail | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | Single chokepoint | All mutations route through `_mutate`, which loads the row and calls `_assertWorkspace` before applying | | Workspace isolation | `_assertWorkspace` throws `WorkspaceMismatchException` when the loaded row's `workspaceId` does not match the caller's | | Optimistic concurrency | `_mutate` writes with `expectedVersion` set to the `version` it just read and retries on `ConcurrencyConflictException` by re-reading fresh state | | Exclusive claim | `tryCheckout` / `releaseCheckout`; a second agent claiming a checked-out ticket gets a `CheckoutConflictException` | | Transition guard | `canTransitionTo`, applied only when `force` is false | ## See also - [Tickets](/manual/concepts/tickets/) - [Manage tickets](/manual/guides/manage-tickets/) - [Delegate tickets](/manual/guides/delegate-tickets/) - [Domain events](/manual/reference/domain-events/) ### Tutorials > Guided, start-to-finish lessons that take you through Control Center's main surfaces for the first time. Source: https://usectrl.dev/manual/tutorials/ Tutorials are **lessons**. Each one takes you end to end through something real, in one path, with no decisions to make along the way. Follow the steps exactly and they work. They are for learning your way around. Once you know the shape of the product, the [how-to guides](/manual/guides/) are the faster route to a specific task. Do them in this order — each one assumes the previous. 1. [Your first workspace](/manual/tutorials/first-workspace/) — create a workspace, add a repo and see the isolation model 2. [Dispatch your first agent](/manual/tutorials/first-agent/) — give an agent an adapter and a model, then put it to work 3. [Review your first pull request](/manual/tutorials/first-pr/) — read the diff, comment and merge 4. [Build your first pipeline](/manual/tutorials/first-pipeline/) — chain steps into a DAG and run it Two more stand alone; do either once you have a working workspace. - [Bridge Slack to your workspace](/manual/tutorials/first-chat-bridge/) — drive agents from a Slack thread - [Sign your team in with SSO](/manual/tutorials/sso/) — connect an identity provider and take a login end to end :::note[Before you start] Install Control Center first — see [Install](/manual/install/). If you only want the shortest possible path to a running agent, take the [quick start](/manual/quick-start/) instead and come back here afterwards. ::: ### Dispatch your first agent > Create an agent, give it a space and a task and watch the run stream back. Source: https://usectrl.dev/manual/tutorials/first-agent/ ## What you will build By the end of this tutorial, you will have: - An agent you created and configured yourself - A space where you and that agent talk - One completed run, with committed work in an isolated worktree and a row in the agent's run log Prerequisites: [Your first workspace](/manual/tutorials/first-workspace/) completed, so this workspace has a repo registered and at least one model provider connected. ## Step 1: Create an agent 1. Go to **Settings → Workspace → Agents** 2. Click **Add agent**. An agent called "Unnamed agent" is created immediately and selected in the roster — there is no create dialog and nothing to confirm. You configure it in place. 3. On the **Settings** tab, fill in: - **Name**: something short and memorable, like `alice`. This is what you type after `@`. - **Title**: what it does, e.g. "Backend engineer" - **Adapter**: `Control Center (built-in)`. Pick this one deliberately — only the built-in harness can be steered mid-run and paused at a turn boundary and it needs no CLI installed on the server host. - **Model**: choose from the list the connected provider serves - **Skills**: attach any that fit, e.g. `testing` or `architecture` 4. Press **Save changes** :::note[An agent with no adapter runs on the built-in loop] When the Adapter field is unset, dispatch falls back to **Control Center (built-in)** — the agent loop inside the server, so there is nothing to install on the host. It still needs a model: with none set it asks Anthropic for its default, which works if you have connected an Anthropic account and otherwise fails with `No credential for provider "anthropic"`. Set both explicitly anyway, or you will be running on a model you did not choose. ::: **Checkpoint.** The roster row shows your agent's name and its detail header reads "No runs yet". ## Step 2: Start a space 1. In the sidebar, find the **Spaces** section and click the **+** button 2. Give the space a name 3. Under **Add agents**, select the agent you just created 4. If the workspace has more than one repo, pick which ones this space should work on 5. Click **Create** The space opens. In the background Control Center provisions a copy-on-write worktree of the selected repos for this conversation, on its own branch. Your source checkout is never mutated. ## Step 3: Send a task Type one concrete, small task against the repo you registered. Something with a checkable outcome works best — add a test, fix a lint, rename a symbol. Press Enter. You do not need to `@`-mention anyone: with a single agent in the space, a plain message wakes it. In a space with several agents, `@name` picks one. Control Center assembles the prompt from the agent's instructions, persona, skills and the space's context, then dispatches the run. **Checkpoint.** The composer's send button turns into a stop button — an agent is working in this conversation — and text starts arriving. ## Step 4: Watch it work The conversation renders the run as it happens: - **Reasoning**, shown inline as the agent thinks - **Tool calls**: file reads and writes, searches, shell commands, each with its result - **Text**: summaries, explanations and questions for you If the agent asks something, answer in the conversation and it resumes. For a run's own metrics and its pause, resume and stop controls, find the running agent in the **Agents** section of the space's General panel and choose **Open activity**. In the agent registry, the status badge moves from **No runs yet** to **Running**, then to **Idle** when the run ends. A run that stalls or loops reads **Blocked**; one that errors reads **Failed**. What constrains the run: its own copy-on-write worktree, the credentials brokered for it at launch and revoked at teardown, a sanitized environment, the tool surface its mode allows and the action guardrails. Effects like pushing a branch or opening a pull request are gated separately — see [Guardrails](/manual/concepts/guardrails/). ## Step 5: Check the result 1. Open **Settings → Workspace → Agents** and select your agent 2. Open the **Logs** tab **Checkpoint.** One run row, showing its status, duration, start and finish times and the adapter it ran on. The agent's changes are committed on the conversation's branch, in its worktree. Commits are authored as the agent, with you credited in a `Co-Authored-By:` trailer. ## Getting a pull request Nothing opens a pull request on its own. If you want one, ask for it in the same conversation: > Push this branch and open a pull request against the default branch. Pushing (`gitPush`) and PR creation (`prCreate`) both default to prompting for your approval, so expect a confirmation before each. If no approver is connected when the prompt fires, the action is denied rather than allowed — the guardrails fail closed. The PR is opened under the GitHub identity whose token the run used — yours, if you stored a personal token — not under the agent's name. The agent's authorship shows on the commits. You can also open it yourself: the space's **Source control** panel has a **Create pull request** button that carries the conversation's branch into the compose screen and **Pull requests → New PR** opens the same screen empty. That is where the next tutorial picks up. ## Recap You created an agent, configured its adapter and model, gave it a space and a task and watched a real run finish in an isolated worktree without touching your checkout. **Next:** [Review your first pull request](/manual/tutorials/first-pr/) to learn the review workflow. For more on agents: [The agent model](/manual/concepts/agent-model/). For dispatch details: [Agent dispatch lifecycle](/manual/concepts/dispatch-lifecycle/). ### Bridge Slack to your workspace > Create the Slack app, connect it, link your account and get an agent answering in a Slack thread. Source: https://usectrl.dev/manual/tutorials/first-chat-bridge/ ## What you will build By the end of this tutorial, you will have: - A Slack app created for your workspace, with the right permissions and events already set - A live connection between Slack and Control Center - Your own Slack account linked to your Control Center user - An agent answering you in a Slack thread and a ticket filed from a slash command Prerequisites: - [Your first workspace](/manual/tutorials/first-workspace/) and [Dispatch your first agent](/manual/tutorials/first-agent/) completed — a workspace with no agents can receive a Slack message but has nobody to answer it - The workspace's **CEO agent** has an adapter and a model set at **Settings → Workspace → Agents**. The bridge wakes the CEO, not the agent you configured in the previous tutorial, and an agent with no adapter runs on the built-in loop with Anthropic's default model. - The `owner` or `admin` role in the Control Center workspace (connecting a chat app speaks for the whole workspace) - Permission to create and install apps in your Slack workspace Nothing here needs a public address, a tunnel, or an inbound port. Control Center dials _out_ to Slack over Socket Mode, so this works from a laptop behind NAT. ## Step 1: Get a Slack app configuration token This is the one secret you have to fetch by hand. It lets Control Center create and later edit the app for you. 1. Open [api.slack.com/apps](https://api.slack.com/apps) 2. Scroll to the app configuration tokens section at the bottom of the page 3. Generate a token pair for your Slack workspace 4. Copy the **refresh** token — it starts with `xoxe-` ## Step 2: Let Control Center create the app 1. In Control Center, go to **Settings → Workspace → General** and find the **Chat bridges** card 2. On the **Slack** block, click **Create app for me** 3. Paste the app configuration token 4. Review what the bot will look like: - **App name**: how Slack lists the app, e.g. `Control Center · Acme` - **Bot name**: what people type after `@`, e.g. `control-center` - **Short description** and **What the bot says it can do** - **Slash command**: `cc` by default, so the command is `/cc` - **Direct messages**: leave on so members can DM the bot 5. Click **Create app** Control Center composes the whole app definition — Socket Mode on, the bot scopes and event subscriptions it needs, the slash command, the Messages tab — and creates it in Slack. ## Step 3: Finish the two steps only Slack can do Slack has no API for generating an app-level token or installing an app, so the dialog hands you both as links: 1. **Generate an app-level token** — opens **Basic Information → App-Level Tokens**. Create a token with the `connections:write` scope and copy it. It starts with `xapp-` 2. **Install the app** — opens **Install App**. Install to your workspace, then copy the **Bot User OAuth Token**. It starts with `xoxb-` Keep both values around for the next step. ## Step 4: Connect 1. Back in the dialog, click **Paste the credentials** 2. Enter the **Bot token** (`xoxb-…`) and the **App-level token** (`xapp-…`) 3. Leave **App configuration token** empty — the one from step 1 was already stored (Slack rotated it and Control Center saved the new one) 4. Click **Connect** Control Center verifies the bot token with Slack before it stores anything, then opens the socket. The row now reads the bot's name and your Slack workspace name with a **Live** tag. ## Step 5: Link your own Slack account The connection makes the bot reachable; linking makes _you_ recognizable. Until your Slack identity is linked, the bot will not act on your messages — it never attributes a message to whoever installed the app. Linking is personal, so it lives on a different page from the workspace-wide connection. 1. Go to **Settings → You → Profile & identity** and find the **Linked chat accounts** card 2. Click **Link my Slack account** 3. Copy the command it shows, for example `/cc link K7M2QP` 4. In Slack, send that command to the bot in a DM 5. The bot answers "Linked — you are …" The code is single-use and expires in 15 minutes. If your Slack email matches a Control Center user who is already a member of this workspace, the bridge links you automatically the first time you talk to it and you can skip this step. If you mention the bot before this is done, it refuses and says why. The three refusals are distinct, so read which one you got: - **Unlinked sender**: the bot tells you how to link. Its message still names an older settings path — use the one above. Come back to this step. - **Linked, but not a member of this workspace**: ask an admin to invite you. - **Member with a read-only role** (viewer or guest): the bot will not start work on your behalf. ## Step 6: Put an agent on something 1. In Slack, invite the bot into a channel: `/invite @control-center` 2. Mention it with a real request: > @control-center summarize the open PRs on the api repo The bridge: 1. Creates a Control Center space for that Slack thread, named after the Slack channel and your request 2. Wakes the workspace's front-door agent (the CEO agent when there is one, otherwise the first agent) 3. Streams the agent's answer back into the Slack thread as it is written Reply in the same Slack thread to keep going — no further mentions needed, because the Slack thread is now bridged. Or DM the bot instead of using a channel; each DM conversation drives its own Control Center space, with a title and an "is thinking…" status line while the agent works. Streaming needs Slack's streaming API, which not every plan has. If your app is refused it, the bridge stops trying for the whole connection and posts each reply as a single message instead and the Chat bridges card reads "Live streaming is off for this Slack app". Everything else works the same. ## Step 7: File a ticket without leaving Slack ```text /cc ticket Fix the flaky login test | It fails on CI about once a day. ``` The bot answers with the ticket key it filed. The ticket is an ordinary local Control Center ticket, reported by you, ready to be assigned to an agent. `/cc help` lists everything the command can do. ## Recap You created a Slack app from Control Center, connected it over an outbound socket, linked your Slack identity to your Control Center user and drove an agent from a Slack thread. Everything that happened in Slack also exists in Control Center: the Slack thread is a space, the reply is a transcript, the ticket is a ticket. **Next:** [Set up Slack integration](/manual/guides/slack-integration/) covers the by-hand path, rotating tokens and troubleshooting. For the model underneath, read [Chat bridges](/manual/concepts/chat-bridges/). ### Build your first pipeline > Create a pipeline template, configure its step, allow manual runs and start one. Source: https://usectrl.dev/manual/tutorials/first-pipeline/ ## What you will build By the end of this tutorial, you will have: - A saved pipeline template that dispatches an agent with a prompt - Manual runs enabled on it, so it can be started by hand - One completed run, with the step's input and output visible Prerequisites: [Dispatch your first agent](/manual/tutorials/first-agent/) completed, so you have an agent with an adapter and a model. A pipeline is a DAG of steps. Templates define the graph; runs are executions. For the model behind it, read [Pipelines and automation](/manual/concepts/pipelines/); for the full step vocabulary, [Pipeline step kinds](/manual/reference/pipeline-steps/). ## Step 1: Create a template 1. Go to **Settings → Workspace → Pipeline templates** 2. Click **New template** 3. Enter a **Template ID**: `code_quality_check` 4. Click **Create** The editor opens. Two things differ from what "new template" usually means: - The dialog asked for an id and nothing else. The id is also the template's name until you rename it and there is no description field. - The canvas is not blank. A new template comes pre-wired as **Trigger → Step → terminal**, where "Step" is a prompt-agent node and the terminal is an invisible sentinel that marks the end of the run. You configure the Step that is already there. ## Step 2: Configure the step 1. Click the **Step** node on the canvas 2. In the **Node config** panel on the right, set: - **Agent**: your agent from the previous tutorial - **Prompt template**: "Review the code on the current branch for quality issues. List what you find." 3. Leave **Triggers from** alone — the node is already wired to fire after Trigger ## Step 3: Save Press **Save** in the editor header. **Checkpoint.** The "Unsaved changes" caption clears. ## Step 4: Allow manual runs 1. Click the **Trigger** node on the canvas. Its panel is the **Triggers** panel, not the generic node config. 2. Turn on **Manual run** — "Show on the run page and start by hand." This is not optional and it is easy to miss. A new template is created with **no trigger row at all**, so until you turn this on the run page will not list it and there is nothing to start. The switch writes the trigger immediately; it does not wait for another save. The same switch is behind **Manual run** in the editor header, where it sits alongside the template's declared inputs. ## Step 5: Run it 1. Click **Pipelines** in the sidebar 2. Click **Run pipeline** in the header 3. Pick `code_quality_check` in the left rail 4. Click **Run pipeline** on the form — your template declares no inputs, so there is nothing to fill in **Checkpoint.** A toast confirms the run started and Control Center opens the run detail. The engine creates the run record, completes the trigger, schedules the step, dispatches the agent and completes the run when the terminal's incoming branch finishes. ## Step 6: Inspect the result Click the step in the run to open its detail panel: - **Started** and **Finished** timestamps and **Duration** - **Input**: the state passed into the step - **Output**: what it produced — here, the agent's response - **Agent activity**, with a link through to the conversation the step drove **Checkpoint.** The step reads completed and its output holds the agent's review. ## Adding more steps Nodes are added by **dragging** an entry from the node library on the left onto the canvas — there is no add-step button. Edges are made in the node's config panel, not on the canvas: select the new node and tick its upstream step under **Triggers from**. The canvas has no port dragging. :::caution[Mind the terminal sentinel] The terminal node is not drawn on the canvas and cannot be selected, so its wiring cannot be changed from the editor. It was seeded pointing at the template's first Step and a run finishes as soon as the terminal's incoming branch completes. A node you add _downstream_ of that Step will therefore not run — the run ends first. Until the terminal is repointable from the editor, extend a pipeline by editing the stored template, or start from one of the built-in templates, which are already wired end to end. ::: ## Recap You created a template, configured the prompt-agent node it was seeded with, saved it, made it manually runnable, started a run and read the step's input and output. Pipelines get much larger than this — conditional routers, parallel branches, join nodes, for-each fan-out, human gates — but the shape never changes: steps in a DAG, passing state. **Next:** [Bridge Slack to your workspace](/manual/tutorials/first-chat-bridge/), the last tutorial in this sequence. - [Set up pipeline triggers](/manual/guides/pipeline-triggers/) to start pipelines from events or a schedule - [Pipelines and automation](/manual/concepts/pipelines/) for the model underneath - [Pipeline step kinds](/manual/reference/pipeline-steps/) for every step type ### Review your first pull request > Open a PR in the workbench, read the diff, comment, approve and merge. Source: https://usectrl.dev/manual/tutorials/first-pr/ ## What you will build By the end of this tutorial, you will have: - Opened a pull request in Control Center's PR workbench - Read its diff and left an inline comment - Submitted a review verdict - Merged the PR Prerequisites: [Dispatch your first agent](/manual/tutorials/first-agent/) completed and one open pull request on a repo registered in this workspace. Any open PR works — one you pushed from an agent's branch, or one that was already there. ## Step 1: Open the pull request 1. Click **Pull requests** in the sidebar 2. Find your PR and click it **Checkpoint.** The PR opens as a tabbed workbench — Overview, Diff, Source control, Chat, Actions, Review — focused on **Overview**: the title and actions, the description and activity timeline and a sidebar with status, reviewers, assignees, checks and changed files. Clicking a file there jumps to it in the Diff tab. :::note[Empty queue with a "connect GitHub" prompt] That state means the _server_ holds no GitHub token — your client never holds one. Set one under **Settings → You → Profile & identity → Accounts**, then refresh the list. ::: ## Step 2: Read the diff Open the **Diff** tab. A collapsible file tree sits on the left and the strip above the diff carries a commit-range selector — narrow the diff to one commit or a span of them — plus the file count and the running additions and deletions. The settings button in the diff toolbar controls how it renders: - **Split** or **unified** view - **Wrap** or scroll long lines - Code-font ligatures Mark a file as viewed to track your progress through a large diff. Keyboard navigation is in [Keyboard shortcuts](/manual/reference/keyboard-shortcuts/). ## Step 3: Leave an inline comment 1. Hover a line in the diff — a small `+` pill appears in the gutter 2. Click it to comment on that line, or drag it vertically first to select a range 3. Type your feedback and submit :::caution[Inline comments post to GitHub immediately] An inline comment is not held back until you submit a review. Submitting it posts it to GitHub right away: the thread shows **pending**, then **synced** and offers a retry if the post fails. Only the review _body_ you write in step 4 is a local draft. ::: ## Step 4: Submit a review 1. Click **Review** in the PR header — the same button also sits in the Diff tab's toolbar 2. Write a summary in the composer. It autosaves as a draft, so you can close the overlay and come back. 3. Choose a verdict: - **Approve** (⌘↵) - **Request changes** (⌘⇧↵) There is no "Comment" verdict in the overlay: those two are the only submissions it makes. **Checkpoint.** The header button changes to read "Approved" or "Requested changes". ## Step 5: Merge The **Merge** button is present whenever GitHub reports the PR mergeable — it is not gated on your own approval. Its colour reports readiness rather than permission: primary when required approvals and checks are green, secondary while a check is still running, destructive when a requested reviewer has not approved or a check is failing. 1. Click **Merge** 2. Pick a method: **Squash and merge**, **Create a merge commit**, or **Rebase and merge** 3. For the first two, edit the prefilled commit title and message 4. Confirm Any outstanding problems — "Checks failing", "Some reviews are pending" — are listed in the flyout before you confirm. :::caution[Merging is irreversible] Merging pushes commits to GitHub. It never enters Control Center's undo stack and cannot be undone from here. ::: After merging, a `PrMerged` domain event fires; analytics and notifications consume it. See [Domain events](/manual/reference/domain-events/) for what else listens. ## Reviewing with AI Control Center can put a reviewer pipeline on an open PR, but only when you ask: the `pr_review` template has a manual trigger and nothing else. 1. Open the overflow menu in the PR header and choose **Ask AI**. Control Center starts the run and navigates to the pipeline run page, so you can watch the specialists work. 2. Reviewer agents record their findings as review nodes in the PR's space; a final step consolidates them into one report. The pipeline deliberately does not post to GitHub — it ends after consolidating. Publishing is a separate step you take yourself, with the **Publish to GitHub** button on the PR's Review tab. See [Use AI-powered review](/manual/guides/ai-review/). ## Recap You opened a PR in the workbench, read the diff with syntax highlighting, left an inline comment that went straight to GitHub, submitted a verdict and merged — without leaving the app. **Next:** [Build your first pipeline](/manual/tutorials/first-pipeline/) to automate multi-step workflows. ### Your first workspace > Create a workspace, register a repo into it and see the isolation boundary for yourself. Source: https://usectrl.dev/manual/tutorials/first-workspace/ ## What you will build By the end of this tutorial, you will have: - A workspace named after your project - A Git repository registered into it - Five seeded agents, one of which you have configured well enough to run - A second, empty workspace that proves the isolation boundary Onboarding already created a workspace for you. This tutorial creates a fresh one deliberately, so you see every part of the process. Before you start, pick a Git checkout that satisfies all three of these — the registration step refuses anything else: - it is inside a git work tree - it has an `origin` remote - that remote points at github.com, gitlab.com or bitbucket.org ## Step 1: Create a workspace 1. Click the workspace chip in the title bar 2. Choose **Add workspace…** 3. Enter a name that identifies the project, like "api-service" or "frontend-v2" 4. Optionally pick a logo 5. Click **Add workspace** Control Center switches to the new workspace and lands on its inbox (`/workspaces/<id>/inbox`). Behind that, the server publishes a `WorkspaceCreated` event and seeds the workspace with: - a **CEO agent**, whose persona is coordination and delegation - four specialists — `qa`, `architect`, `engineer` and `librarian` — each with its own instructions and skills - the built-in pipeline templates and their trigger rows Every workspace you create gets all of this, not just the first one. Confirm it: open **Settings → Workspace → Agents**. Five agents are in the roster, each with a hollow status ring; select one and its detail header reads "No runs yet". ## Step 2: Register a repository 1. Go to **Settings → Workspace → Repositories** 2. Click **Add repository** 3. Browse to your checkout. The dialog walks the folders on the machine running `cc_server` — not your client — and stays inside the roots that server exposes (its user's home directory by default). A git checkout gets a distinct folder icon and a checkbox; a plain folder gets neither, so only checkouts can be selected. 4. Tick one or more checkouts and register the batch. Selections survive as you navigate, so you can gather several in one pass and one bad pick never drops the rest. Control Center reads each checkout's `origin` remote, resolves owner and repo and creates the repo row **inside this workspace**. There is no second step to link it: registration is what scopes it. Registering the same checkout in another workspace later creates a second, independent repo row with its own id. Across workspaces a checkout is identified by its filesystem path, never by id. Confirm it: the repo appears in the list with its GitHub `owner/repo` and current branch. ## Step 3: Make one agent runnable The five agents this workspace just seeded have no adapter and no model. Dispatch runs them on **Control Center (built-in)** and, with no model set, asks Anthropic for its default one — so they work only if you have connected an Anthropic account, and on a model you did not choose if you have. First-run onboarding stamps its choice onto CEO agents only and only once, so a workspace you create afterwards starts with all five unset. Set one now. 1. Open **Settings → Workspace → Agents** 2. Select `engineer` in the roster 3. On the **Settings** tab, set **Adapter** to `Control Center (built-in)` and pick a **Model** from a provider you have connected under Settings → Server → Model providers 4. Press **Save changes** Confirm it: reopen the agent and the two fields hold your choices. Each agent carries its own adapter and model, so repeat this for any other agent you intend to use. [Create and configure an agent](/manual/guides/create-agent/) covers the rest of the form. ## Step 4: See the isolation boundary Create a second workspace from the title-bar chip, exactly as in step 1. Then look at it: - **Settings → Workspace → Repositories** is empty. The repo you registered a minute ago is not there. - **Settings → Workspace → Agents** holds five agents, but they are different rows — the adapter you set in step 3 is gone. - The spaces list, tickets and memory are all empty. Nothing leaked, because there is nothing to leak from: each workspace's rows live in that workspace's own database file. A cross-workspace read is not a query that gets filtered, it is a query that has nowhere to run. Deleting a workspace is unlinking a file. Switch back with the title-bar chip, or open **Manage workspaces** to see them side by side. For the full model, read [Workspaces and isolation](/manual/concepts/workspaces/). ## Recap You created a workspace, watched it seed a CEO and four specialists, registered a repo into it, gave one agent an adapter and a model and confirmed that a second workspace shares none of it. **Next:** [Dispatch your first agent](/manual/tutorials/first-agent/) to put the workspace to work. ### Sign your team in with SSO > Connect Okta (SAML) to your Control Center server and take a login end to end. Source: https://usectrl.dev/manual/tutorials/sso/ ## What you will build By the end of this tutorial, you will have: - a SAML connection between your Control Center server and an identity provider (we use Okta as the example), - one user provisioned automatically on their first login and - a workspace membership policy you chose deliberately. You will need a server you administer (the owner of at least one workspace) and an IdP application you can configure. Budget 20 minutes. ## 1. Open the single sign-on settings Sign in to Control Center, then go to **Settings → Server → Single sign-on**. The screen shows one tab per protocol Control Center speaks — **SAML** and **OpenID Connect** — plus a **user provisioning (SCIM)** card. Stay on the SAML tab. Everything on this screen is server-wide: one IdP authenticates every workspace's humans, so only server admins can edit it. ## 2. Paste the IdP metadata In Okta, open your app's **Sign On** tab and copy the metadata XML (or download it and paste the file's contents). Back in Control Center, paste it into **IdP metadata XML** and press **Test connection**. The toast reports the IdP's sign-on endpoint — proof that the metadata parsed, the certificate was read and the server can build a login request against it (the test exercises everything short of the browser round-trip). If it fails, the XML was not a complete `EntityDescriptor` (the SAML tab in Okta calls it "Metadata URL"; open the URL and copy what it serves). The same button on the **OpenID Connect** tab checks your issuer's discovery document instead. ## 3. Register Control Center at the IdP Press **Copy SP metadata** and hand it to whoever configures the Okta side (or read the two values they ask for): - **ACS URL**: `https://<your-server>/saml/acs` - **Entity ID**: `https://<your-server>/saml` The URLs derive from the origin your users' browsers reach. If the server runs behind a tunnel or reverse proxy, make sure the origin is stable (Settings → Server → Sharing shows the public URL) — or pin the entity ID explicitly in the **SP entity ID** field. :::note[Behind NAT?] SAML login does **not** need a public IP or domain: the IdP never connects to your server — the user's browser carries the round-trip, so localhost, LAN, VPN, Tailscale, or a tunnel all work. Two things to keep straight: - the origin the browser uses must **match** the ACS URL / entity ID registered at the IdP (pin the entity ID if users reach the server under more than one origin), - **SCIM provisioning is the exception** — the IdP calls your server directly, so it needs a tunnel or public URL ([provision users with SCIM](/manual/guides/sso-scim)). ::: ## 4. Choose what a login grants Two policies decide what a first login does: - **Add users to every workspace on first login** — leave on for a single-team deployment where SSO equals trust. Turn it off when several teams or customers share the server: each newcomer then needs an explicit workspace invite, which is the safer default. - **Provision unknown users on first login** — on by default; turn it off to reject anyone whose account does not already exist. Optionally map an attribute carrying group names (Okta's is commonly `groups`) and translate groups to roles with the map — for example `{"platform-leads": "admin"}`. SSO can never grant the owner role. ## 5. Enable and take a login Flip **Enable this connection**, press **Save**, then open a private browser window at `https://<your-server>/saml/login`. Sign in at Okta and you land back on the web client — logged in, with your account created and its membership granted per the policy you chose. The same entry point appears on the web connect screen (**Sign in with SSO**) the moment you type the server address — the form adapts to what the server offers. The button opens sign-in in a **new tab**; when the round-trip completes, the waiting tab picks the session up automatically. The desktop server-setup screen does the same: type the server URL, a **Sign in with SSO** button appears that opens your browser and the app logs in when the round-trip completes. ## Where to go next - Give your IdP lifecycle control too: [provision and deprovision users with SCIM](/manual/guides/sso-scim). - Your IdP speaks OpenID Connect instead of SAML? [Connect an OpenID Connect provider](/manual/guides/sso-oidc). - Curious what the browser round-trip actually verifies? [How single sign-on works](/manual/concepts/sso).