Carbon6 — Architecture
Status: draft v0.1 (2026-10-09). Implements requirements.md v0.2.
1. System overview
private network / tailnet
┌──────────── browser ────────────┐
│ Leptos CSR app (wasm) │
│ xterm.js · CodeMirror │
└──────────────┬──────────────────┘
│ HTTP/JSON + WebSocket (UI port, plain HTTP, private bind)
┌──────────────▼──────────────────────────────────────────────────────┐
│ c6h │
│ axum API ─ SessionService ─ EventStore(SQLite) ─ BlobStore │
│ ConfigService ─ CredentialBroker(OAuth refresh) ─ WorkerRegistry │
│ RpcRouter(head→worker req/resp) ─ OTLP exporter │
│ tonic WorkerHub │
└──────────────▲──────────────────────────────────────────────────────┘
│ gRPC / HTTP/2 / mTLS (worker port; worker always dials)
┌────────┴─────────┬─────────────────────┐
┌─────┴──────┐ ┌─────┴──────┐ ┌─────┴──────┐
│ c6w (mac) │ │ c6w (linux)│ ... │ c6w │
│ sessions │ │ │ │ │
│ journal │ │ │ │ │──► LLM providers
│ fs/git/pty │ │ │ │ │ (direct, head creds)
└────────────┘ └────────────┘ └────────────┘
- The head (c6h) is the system of record. It serves two ports: a UI/API port and a worker port. Both bind only to private interfaces.
- Workers (c6w) dial the head and keep one long-lived HTTP/2 connection open. Every head→worker request travels back over a stream the worker opened, because the head never dials a worker.
- Workers call LLM providers directly, using short-lived credentials issued by the head.
2. Cargo workspace
carbon6/
proto/ *.proto (source of truth for the wire protocol)
crates/
c6-proto/ prost/tonic generated code + pbjson serde impls
c6-core/ ids, session event model helpers, config schema, errors
c6-api/ HTTP/WS API types shared by c6h and c6-ui (compiles to wasm)
c6-llm/ provider clients, canonical message model, caching/thinking logic
c6-agent/ agent loop, context assembly, compaction, subagents
c6-tools/ Tool trait and built-in tools
c6-testkit/ scripted OpenAI-compatible mock server for tests
c6-mcp/ MCP client integration (wraps rmcp)
c6-head/ head library: storage, services, API, worker hub
c6-worker/ worker library: connection mgr, journal, session runtime, fs/git/pty
c6-ui/ Leptos CSR app (wasm), embedded into c6h at build time
bins/
c6h/ thin main → c6-head
c6w/ thin main → c6-worker
Key dependencies: tokio, tonic/prost, axum, sqlx (sqlite), reqwest (rustls), serde, schemars, rmcp, ignore/globset/grep-searcher (ripgrep internals), portable-pty, notify, tracing + opentelemetry, rustls + rcgen, uuid (v7), blake3, chacha20poly1305, leptos, tailwind.
Why a CSR SPA rather than Leptos SSR/server functions: requirement R-HEAD-4 calls for an API that automations and scripts can also use. With a SPA, the UI uses the same versioned /api/v1 that those clients will, so there is no second, UI-only API surface. The built assets are embedded in c6h, which keeps it a single binary.
3. Identity, enrollment and transport
Head PKI. On first start, c6h creates a private CA (rcgen) and a server certificate in its data directory.
Enrollment.
- In the UI, the user clicks "Add worker". The head creates a one-time enrollment token that contains the head's address, the CA fingerprint and a secret.
- On the worker, the user runs
c6w enroll <token>. The worker generates a keypair, sends a CSR plus the secret, pins the CA, and receives a client certificate whose subject is itsworker_id. - The worker keeps its identity in
~/.local/state/c6w/identity/. This is the only thing c6w stores long-term apart from caches.
Transport. gRPC over HTTP/2 with mTLS on the worker port. h2 keepalive pings detect dead connections; app-level pings measure latency for the UI.
Versioning. Hello carries protocol_version (an integer) and a list of capabilities. The head supports versions N and N−1, and rejects anything else with a clear error shown in the UI. Protobuf field rules are followed: fields are only added, never renumbered.
4. Wire protocol
service WorkerHub {
// Control plane + durable events. Exactly one per worker connection. (Not named Connect: that collides with tonic's generated client constructor.)
rpc Attach(stream WorkerMsg) returns (stream HeadMsg);
// Data-plane streams, opened by the worker in response to a head request
// ("reverse dial"), so bulk/interactive traffic gets its own h2 stream
// and never blocks control messages.
rpc Pty(stream PtyFrame) returns (stream PtyFrame);
rpc PutBlob(stream BlobChunk) returns (BlobAck);
rpc GetBlob(BlobRef) returns (stream BlobChunk);
rpc Telemetry(stream ExportTraceServiceRequest) returns (TelemetryAck); // OTLP proto types
}
message WorkerMsg { oneof msg {
Hello hello; // first message
SessionEventBatch events; // durable, journaled
EphemeralBatch ephemeral; // streaming deltas, tool output chunks; best effort
RpcResponse rpc_response; // reply to a head RpcRequest (fs, git, ...)
CredentialRequest cred_req;
Ping ping;
}}
message HeadMsg { oneof msg {
Welcome welcome; // ack positions, config snapshot/version, accepted?
EventAck ack; // cumulative per-session ack
SessionCommand command; // create/resume session, user input, interrupt, set model, ...
RpcRequest rpc_request; // fs.list, fs.read, git.diff, pty.open, ...
ConfigUpdate config; // new config version (full or delta)
CredentialGrant cred; // token response or proactive refresh push
Pong pong;
}}
Handshake.
Hello{worker_id, protocol_version, c6w_version, os, arch, hostname, capabilities, config_version, sessions:[{session_id, last_journaled_seq}]}Welcome{head_version, acks:[{session_id, acked_seq}], config: ConfigUpdate?, pending_commands}
The worker then resends every journaled event with seq > acked_seq.
Head→worker requests. Each RpcRequest carries {id, deadline, method, payload}. Small replies come back as RpcResponse on the Attach stream. For streaming methods (pty.open, large fs.read), the worker opens the matching data-plane RPC and tags it with the request id. The head's RpcRouter holds the pending requests and the correlation ids.
Flow control. HTTP/2 window flow control carries the bytes. On top of it, the worker bounds the number of unacknowledged durable events it has in flight per session. Ephemeral messages are dropped under backpressure; durable ones never are.
5. Session model and event log
5.1 Entities (head-owned metadata)
Worker{id, name, hostname, os, arch, last_seen, version, status}Workspace{id, worker_id, name, root_path, default_base_branch, use_worktrees}Session{id (UUIDv7), worker_id, workspace_id?, cwd, worktree?, branch?, parent_session_id?, title, model_ref, status, created_at, archived}Terminal{id, worker_id, cwd, status}. A terminal is not a session and is not persisted beyond metadata.
5.2 Single-writer log
Each session has one append-only log, and only the worker writes to it. The head never appends.
- User input reaches the worker as a
SessionCommand::UserInput{command_id, content}. - When the worker accepts the input, it appends a
UserMessage{command_id, ...}event. - Until that event syncs back, the UI shows the input as queued. Commands live in a head-side queue and are redelivered after a reconnect.
command_idmakes delivery idempotent.
With one writer per log, sync is a cumulative ack per session. No merge or conflict logic is needed.
5.3 Durable events
SessionEvent { session_id, seq: u64, ts, kind }
kind =
SessionStarted { model_ref, system_prompt_hash, tool_profile, cwd, git_head }
UserMessage { command_id, parts[] } // text, image(blob), file refs
TurnStarted { turn_id }
AssistantMessage { turn_id, blocks[], stop_reason, provider_opaque? } // tool calls are ToolUse blocks here
ToolResult { call_id, output_parts[], is_error, truncated, full_output_blob? }
Usage { request_id, model, input, output, cache_read, cache_write, reasoning, cost_micros? }
Compaction { summary, replaces_through_seq }
ModelChanged { model_ref }
SubagentSpawned { child_session_id, call_id }
TurnEnded { turn_id, reason: Completed|Interrupted|Error|WorkerRestarted }
Error { message, retryable }
- Ephemeral events (not journaled) include
AssistantDelta,ThinkingDelta,ToolOutputChunkandStatus. They are forwarded live to UI subscribers when the worker is connected. The durable events carry the final content, so losing ephemeral events loses nothing. - Blobs (images, large tool outputs, attachments) are content-addressed by blake3 hash. They are uploaded via
PutBlobbefore the event that references them, and deduplicated on the head. - Storage encoding. Events are stored on both sides as their protobuf bytes, so nothing is transcoded. The API serves them as JSON via pbjson.
5.4 Worker journal and offline operation
- The journal is a SQLite database in WAL mode at
~/.local/state/c6w/journal.db, with tablesevents(session_id, seq, bytes),outbox_blobs,acksandconfig_cache. - Events are fsynced to the journal before they are sent to the head. Rows are trimmed once acked, except that the full log of each live session stays cached so turns can be rebuilt without asking the head.
- To resume a session that isn't cached (for example after a worker restart), the worker asks the head for the log (
SessionCommand::Resume{events}). - If the worker crashes mid-turn, then on restart it finds a turn with no
TurnEnded, appendsTurnEnded{WorkerRestarted}, and does not continue the turn automatically.
6. Head internals
| Component | Responsibility |
|---|---|
WorkerRegistry | Connected workers, their outbound channels, liveness, capabilities |
SessionService | Session create/resume/archive, the command queue, routing commands to workers |
EventStore | Durable events (sqlx/SQLite), acks, history queries |
BlobStore | data/blobs/ab/cdef…, content-addressed files |
LiveHub | A tokio broadcast channel per session and one for the fleet; fans durable and ephemeral events out to WebSocket subscribers |
RpcRouter | Head→worker request/response correlation, deadlines, streaming hand-off |
ConfigService | Versioned config documents; computes per-worker effective config; pushes ConfigUpdate |
CredentialBroker | Encrypted credential storage, OAuth login flows, proactive refresh, CredentialGrant |
Telemetry | Receives worker OTLP batches, adds head spans, exports to the configured OTLP endpoint |
Api | axum /api/v1 REST + WebSocket, serves the embedded UI |
Storage trait. All persistence goes through trait Store (sessions, events, config, credentials). The implementation is SqliteStore using sqlx. A PostgresStore can be added later without touching the services.
Data dir: c6h.toml (bootstrap: bind addresses, data dir, OTLP endpoint), c6h.db, blobs/, pki/, master.key. Everything else is configured in the UI and stored in the database.
6.1 Config model
Config documents are layered: global → worker → workspace → session overrides. Each document has a monotonic config_version. The head computes each worker's effective config: model refs, connection metadata (no secrets), MCP server definitions, skills (content), global instructions, tool settings and prompt templates.
Workers cache this config in a non-authoritative way so they can run offline. The head's copy always wins on reconnect.
6.2 Credentials
Storage. Secrets are encrypted with ChaCha20-Poly1305 under master.key (a file with 0600 permissions, or the OS keychain later).
CredentialGrant{connection_id, auth: ApiKey|Bearer, base_url, extra_headers, expires_at}.
- API keys are long-lived. Workers keep them in memory only.
- OAuth connections (ChatGPT, Claude subscription): the head alone refreshes them, and does so before access tokens expire. It pushes new grants to every connected worker that uses the connection. Workers never see refresh tokens.
- An offline worker keeps working until its cached access token expires.
ChatGPT sign-in. This is OpenAI's "Sign in with ChatGPT" for open-source apps (developers.openai.com/siwc/token-sharing-open-source/sign-in): authorization code with PKCE, dynamic client registration (dynamic_agent_client with agent_name_hint=carbon6 on first use, then the issued oaiapp_… id), the head's ext_agent_host_id (a urn:uuid: made once and kept in the chatgpt_host_id setting), and the chatgpt.tokens.use.direct scope. The redirect must be the loopback URL http://127.0.0.1:<port>/callback (only the port varies). The head uses its UI port, so a browser on the head's machine completes the flow directly. From any other machine, the user pastes the address the browser ended on into the UI. Any number of accounts and workspaces can be signed in, each its own chatgpt connection with the client id issued to it (client ids are per account; a new account always registers afresh). The head verifies each sign-in's ID token (RS256 against the keys from OpenAI's discovery document, issuer, audience = the client id, expiry, nonce) and keeps its sub, email and workspace (chatgpt_account_id, when present) with the tokens. Adding an account that is already here (same sub and workspace) replaces that connection's sign-in instead of duplicating it. "Sign in again" reuses the connection's client id with id_token_hint and refuses a different account. Refreshes check that a new ID token is still the same account. Claude subscription OAuth is not supported (requirements R-LLM-3).
6.3 HTTP API (sketch)
GET /api/v1/workers POST /api/v1/workers/enrollments
GET /api/v1/workspaces POST/PATCH/DELETE ...
GET /api/v1/sessions?worker=&status= POST /api/v1/sessions
GET /api/v1/sessions/{id} GET /api/v1/sessions/{id}/events?after=SEQ
POST /api/v1/sessions/{id}/messages POST /api/v1/sessions/{id}/interrupt
PATCH /api/v1/sessions/{id} (title, model, archive)
GET /api/v1/workers/{id}/fs/list?path= GET /api/v1/workers/{id}/fs/read?path=&range=
GET /api/v1/workers/{id}/fs/search?q= GET /api/v1/workers/{id}/git/{status|diff|log}?path=
POST /api/v1/workers/{id}/terminals DELETE /api/v1/terminals/{id}
GET/PUT /api/v1/config/... /api/v1/connections, /api/v1/connections/{id}/oauth/*
GET /api/v1/blobs/{hash}
WS /api/v1/sessions/{id}/stream durable + ephemeral events, resumes from ?after=SEQ
WS /api/v1/terminals/{id}/attach binary frames (data, resize)
WS /api/v1/live fleet/session status feed
7. Worker internals
c6w daemon
├─ ConnectionManager dial, mTLS, Hello/Welcome, reconnect (exp backoff + jitter), outbox replay
├─ Journal SQLite WAL; durable events, acks, config cache
├─ SessionManager HashMap<SessionId, SessionHandle>; spawns one SessionActor task per session
│ └─ SessionActor agent loop (c6-agent), tool registry, MCP clients, cancellation tokens
├─ CredentialCache in-memory grants; requests/refresh hooks
├─ ProcessManager background shells, process groups, output ring buffers
├─ FsService list/stat/read/search/watch (ignore, grep-searcher, notify)
├─ GitService git CLI wrapper: status/diff/log/branches/worktrees
├─ PtyService portable-pty; detachable terminals with scrollback ring buffer
└─ TelemetryExporter tracing-opentelemetry → buffered OTLP batches → head
- Process groups. Every spawned process runs in its own process group (
setsid). Interrupting a tool or a session kills the whole tree. - Terminals. Each runs a login shell in the requested cwd. A PTY outlives the browser connection. On reattach, the worker replays its scrollback ring buffer (default 1 MiB), then streams live output. Terminals die if the worker restarts.
- Worktrees. When a session is created with
use_worktrees, the worker runsgit worktree add <state>/worktrees/<repo>/<slug> -b c6/<slug> <base>and reports the path and branch to the head, which records them. Archiving a session can remove the worktree; the branch is kept. - Git auth. Git uses the host's own SSH and
ghconfiguration (D-1).c6w git-credentialexists from M0 as a credential-helper entry point. Initially it does nothing; later it will fetch tokens for head-managed git connections.
8. LLM layer (c6-llm)
8.1 Canonical model
enum Block {
Text(String),
Image(BlobRef),
Thinking { text: String, signature: Option<String> }, // Anthropic
RedactedThinking(Vec<u8>),
Reasoning { encrypted: Option<String>, summary: Vec<String> }, // OpenAI Responses
ToolUse { id, name, input: Value },
ToolResult { id, content: Vec<Block>, is_error: bool },
}
struct Message { role: Role, blocks: Vec<Block>, provider: Option<ProviderTag> }
Opaque reasoning data (thinking signatures, encrypted reasoning) is kept and sent back verbatim to the same provider family. If the session switches to a different family, it is dropped and only the visible text is kept.
8.2 Clients
trait ModelClient {
async fn stream(&self, req: ModelRequest, cancel: CancellationToken)
-> Result<BoxStream<'static, Result<ModelEvent>>>;
}
| Client | Covers |
|---|---|
AnthropicMessages | Claude subscription (OAuth), and Anthropic API keys for free |
OpenAIResponses | OpenAI API keys, ChatGPT subscription OAuth |
OpenAIChat | Any OpenAI-compatible Chat Completions server (local LLMs, gateways) |
- HTTP. reqwest with our own SSE parsing. We use no provider SDK crates, so we keep full control of caching, thinking and beta fields.
- Retries. Exponential backoff that honours
retry-after. Overload and 5xx errors are retried. A context-length error triggers compaction and one retry. - Model registry. For each model: context window, max output, thinking/effort support, caching style, image support, pricing, and tool profile. Built-in defaults ship with c6w, and the head can override them.
8.3 Cache and reasoning optimizations
- Stable prefix discipline.
- The order is tools → system → instructions → history. Tool lists are sorted deterministically.
- Volatile context (date, git status) goes in the first user message, not the system prompt.
- MCP tool schemas are frozen for the life of a session.
- Anthropic.
cache_controlbreakpoints go on the tools and system block, plus a rolling breakpoint on the latest message (and the one before it, staying within the 4-breakpoint limit). Thinking blocks are kept, with their signatures, across tool-use loops. Extended or adaptive thinking and effort are set per model. - OpenAI Responses.
store: falseplusinclude: ["reasoning.encrypted_content"], so reasoning carries over statelessly. That way any worker can rebuild any session.prompt_cache_key = session_id, andreasoning.effortis set per session. - Chat Completions. Prefix stability only. Provider-specific extras, such as reasoning fields on local servers, go through
extra_bodyset in the model registry. - Usage accounting. Cache read and write tokens are recorded in
Usageevents, and the UI shows the cache hit rate per session.
9. Agent loop (c6-agent)
SessionActor loop:
recv command
UserInput → append UserMessage; if idle start turn, else queue for next request boundary
Interrupt → cancel token (model stream + running tools); append TurnEnded{Interrupted}
SetModel → append ModelChanged
turn:
loop {
req = ContextBuilder::build(history, model, tool_profile)
stream model → forward deltas as ephemeral → collect AssistantMessage → append
if no tool calls: break
run tool calls (parallel where allowed) → append ToolCall/ToolResult
drain queued user input into the next request
if tokens > compact_threshold: compact
}
append Usage(s), TurnEnded{Completed}
- ContextBuilder assembles, in order:
- The base prompt template (from the head)
- Environment (OS, shell, cwd, whether it's a git repo)
- The
AGENTS.md/CLAUDE.mdhierarchy from the cwd up to the repo root, plus global instructions from the head - The skills index
- MCP server instructions
- History after the latest
Compaction
- Tool profiles per model family. Models do best with the tools they were trained on. GPT/Codex models get
shell+apply_patch+update_plan(Codex-style). Claude models getBash+Read+Edit+Write+TodoWrite(Claude Code-style). Both profiles share the same implementations underneath. - Compaction.
- Triggered automatically at about 85% of the context window, manually from the UI, or by a context-length error.
- The model is asked to summarize, and a
Compaction{summary, replaces_through_seq}event is appended. Later requests use the summary plus the history after it. - Prefix caching restarts after a compaction. That's expected.
- Large tool output. Output is capped inline (default 30k chars, keeping the head and the tail). The full output goes to a blob and to
<state>/tool-output/<call_id>, and the model is told the path so it can read more. - Subagents. The
Task/spawn_agenttool creates a child session (parent_session_id) on the same worker, with its own log and a restricted tool profile. Its final message becomes the tool result. The UI nests child sessions under their parent.
10. Tools (c6-tools)
trait Tool: Send + Sync {
fn name(&self) -> &str;
fn schema(&self) -> ToolSchema; // schemars-generated JSON Schema
fn concurrency(&self) -> Concurrency; // Shared (read-only) | Exclusive
async fn call(&self, ctx: &ToolCtx, input: Value) -> ToolOutput;
}
// ToolCtx: cwd, session_id, cancel token, ephemeral sink (streaming output), ProcessManager, FsService
| Tool | Notes |
|---|---|
| Read | Line-numbered, offset/limit, images → blobs; PDFs later |
| Write / Edit | Exact string replace, replace_all; tracks files read in the session so it can detect files that changed on disk since |
| apply_patch | Codex patch format |
| Bash / shell | Fresh shell per call; cwd persists per session; timeout; run_in_background → id; companion output/kill tools |
| Grep / Glob | ripgrep crates embedded in-process (no external rg needed), respects .gitignore |
| WebFetch | reqwest + HTML→Markdown; size cap |
| WebSearch | Uses the provider's native server-side search tool where one exists; otherwise a configurable search API |
| TodoWrite / update_plan | State goes into the event log; the UI renders it |
| Task / spawn_agent | Subagents (§9) |
| Skill | Loads a skill body on demand (the index is in the prompt) |
| MCP tools | Exposed as mcp__<server>__<tool> via rmcp (stdio servers spawned on the worker, HTTP servers dialed from the worker) |
Shared tools run concurrently when the model makes parallel calls. Exclusive tools run one at a time, in the order the model issued them.
11. Observability
- Workers. Workers instrument with
tracing, andtracing-opentelemetrymaps the spans onto the OTel GenAI semantic conventions:invoke_agentper turn (gen_ai.agent.name,gen_ai.conversation.id = session_id)chat {model}per model request (gen_ai.operation.name,gen_ai.provider.name,gen_ai.request.model,gen_ai.response.model,gen_ai.usage.input_tokens/output_tokens, cache token attributes,gen_ai.response.finish_reasons)execute_tool {name}per tool call (gen_ai.tool.name,gen_ai.tool.call.id)
- Transport. Batches of OTLP protobuf go to the head over the
TelemetryRPC. Offline workers buffer them in a bounded ring and drop the oldest when it fills. Telemetry is best-effort; token usage is recorded durably in the events. - Head. The head adds its own spans (API, sync, credential refresh) and exports traces, metrics and logs to the configured OTLP endpoint. Capturing prompt and response content in spans is opt-in.
12. Web UI (c6-ui)
- Leptos CSR with Tailwind, embedded into c6h with rust-embed. It talks only to
/api/v1, using shared types fromc6-api. - Views:
- Fleet: workers, status, sessions
- Session: streaming transcript, thinking, collapsible tool calls, todo panel, usage and cache stats, model and effort picker, interrupt
- File browser: tree, viewer, search, git diff
- Terminals: tabs per worker
- Settings: connections and OAuth, models, MCP, skills, instructions, workspaces
- JS interop (bundled, no CDN): xterm.js for terminals, CodeMirror 6 for viewing code (and editing later). Markdown is rendered client-side, sanitized.
13. Security posture (single-user, yolo)
- No login. The protection is the network: both ports bind only to the configured private or tailnet addresses, and c6h refuses to bind
0.0.0.0unless forced with a flag. Browser-borne attacks (cross-site requests, cross-site WebSockets, DNS rebinding) are refused byHost/Originchecks (see §16). - The worker port requires mTLS. Without an enrolled certificate, nobody can impersonate a worker or pull credentials.
- Workers never receive refresh tokens. Revoking a worker in the UI invalidates its certificate (CRL check at handshake) and stops grants to it.
- Tools run as the worker's OS user with no sandbox. Recommended: run c6w under a dedicated user account on shared machines.
14. Testing strategy
- Mock LLM server. Scripted SSE responses for all three wire formats give deterministic agent-loop and tool tests.
- In-process integration tests. Head and worker run in one test process, joined by a real tonic channel:
- Kill the connection mid-turn and check that the turn completes, the journal replays, and the head's log matches the worker's exactly.
- Restart the worker mid-turn and check for
TurnEnded{WorkerRestarted}.
- Provider fixtures. Recorded real responses (redacted) guard the parsers for thinking, reasoning and tool-call streams.
- Golden tests for ContextBuilder output, to catch prompt changes that break cache prefixes.
- Eval harness (M5). A small task suite run against real models to compare tool profiles and prompts.
15. Milestones
| M | Scope | Done when |
|---|---|---|
| M0 | Workspace, protos, PKI and enrollment, Attach/heartbeat, fleet page | A worker enrolls and shows as online in the UI |
| M1 | OpenAIChat client, agent loop, core tools (Read/Write/Edit/Bash/Grep/Glob), journal + sync, session view with streaming | A coding task runs end-to-end on a remote worker and survives a disconnect |
| M2 | Anthropic + Responses clients, OAuth connections, caching/thinking, model registry, tool profiles, compaction | Daily use on Claude and GPT models with high cache hit rates |
| M3 | Remote fs browser, git status/diff, worktrees, terminals | IDE-like browsing and shells from the head |
| M4 | MCP, skills, subagents, web tools, background shells, OTLP export | Feature parity with the requirements |
| M5 | Hardening: chaos tests, evals, performance, UX polish | Comfortable as the only harness |
16. Implementation notes (M1–M5)
Decisions made while building M1 that refine the sections above:
- Command delivery is per connection and ordered. Durable commands (create, input) are rows in the head's
commandstable. The API only inserts a row and wakes the worker's connection task, and that one task sends pending rows inseqorder. A session'sCreatetherefore always arrives before its inputs. Interrupts are not stored; they are sent only if the worker is connected. - Queued input is recorded at the request boundary. The worker appends
UserMessagewhen the input actually goes into a model request, not when it arrives. Recording it on arrival would interleave it between tool calls and their results in the log. Until then the head shows the input as queued, and a worker restart simply gets it redelivered. - A rejected
Createfails the session. The session gets statusfailedwith the worker's reason (for example, a missing working directory), and everything queued behind it is rejected. - No head↔worker read loop ever blocks on control replies. Pongs and acks use
try_send: acks are cumulative, and losing a pong is harmless. This prevents a deadlock when both directions' buffers fill. - Providers (M2). There are three wire clients. Anthropic Messages (
anthropic), OpenAI Responses (openai, andchatgptwith plan tokens), and Chat Completions (openai_compat).- Anthropic: adaptive thinking with
display: "summarized"and effort viaoutput_config.effort. Thinking blocks, including empty ones with only a signature, are stored and sent back unchanged. Caching uses an explicit breakpoint on the system prompt plus top-level automatic caching. Tools useeager_input_streaming, and the agent parses tool input strictly. - Responses:
store: falsewithinclude: ["reasoning.encrypted_content"]. Reasoning items are kept in output order and sent back. - A client skips another provider's reasoning blocks.
- Anthropic: adaptive thinking with
- Stop reasons. If a response hits
max_tokenswith tool calls, none of them run: the model gets an error result for each. Arefusalends the turn with an error. - Tool profiles (M2). The profile is chosen from the model when the session starts and recorded in
SessionStarted.tool_profile, so the tool list never changes during a session. All three present the same implementations:- Generic: read, write, edit, bash, grep, glob, update_plan.
- Claude: Read, Write, Edit, Bash, Grep, Glob, TodoWrite, with Claude Code's parameter names.
- Codex: shell (argv), apply_patch (Codex patch format, whole patch validated before writing), update_plan.
- Compaction (M2) is "simple compaction".
- When the last request used 85% of the context window, or the provider reports a context overflow, the same model summarizes the conversation. It runs with the same system prompt and tools, so the cached prefix is reused.
- A
Compactionevent then replaces the whole history with the summary, and nothing older is sent again. This keeps the history append-only, which providers that check reasoning blocks against the history require.
- Head→worker RPC (M3).
RpcRequest/RpcResponseon the Attach stream, with JSON payloads and an id that matches each response to its request. The methods arefs.list,fs.read,git.status,git.diff,git.logandpty.*. The worker handles each one in its own task, so a slow git call never blocks the read loop. The head's API endpoints pass requests straight through to the worker. - Terminals (M3).
- Each terminal is a
portable-ptylogin shell that lives on the worker, with 1 MiB of scrollback, until its shell exits or it is closed. - To attach, the browser opens a WebSocket to the head. The head asks the worker (RPC
pty.attachwith a one-time token) to open aPtygRPC stream back to it, then joins the two. - The worker sends the scrollback, then live output. It takes the scrollback snapshot and subscribes to live output under one lock, so no output is lost or duplicated between them.
- Each terminal is a
- Worktrees (M3).
CreateSession.worktreerunsgit worktree add -b c6/<id> <state>/worktrees/<repo>/<id> HEADand starts the session at the same relative path inside it.SessionStartedrecords the branch and repo root. Worktrees are kept when a session is archived; removing them is manual for now. - Claude Code backend. Each turn runs
claude -p --output-format stream-jsonin the session's directory and maps its output onto session events (see requirements R-LLM-3). - Tools (M4).
- Every profile gains background shells (
run_in_backgroundplus output and kill tools),web_fetch(HTML converted to Markdown), andTask/tasksubagents (Claude and generic profiles). - Anthropic and OpenAI (api.openai.com) sessions also get the provider's own web search tool. Its calls and results are stored as
Opaqueblocks and replayed verbatim; Anthropic'spause_turnresends the request so the provider can continue. - The tool set is fixed when a session starts: the profile's tools,
Skill/load_skillif there are skills, then MCP tools sorted by name.
- Every profile gains background shells (
- MCP and skills (M4).
- Configured on the head (settings
mcp_servers,skills) and copied into eachCreateSession. - MCP stdio servers run on the worker in the session directory; HTTP servers are reached from the worker. A server that fails to start shows as an error event and does not fail the session.
- Skills come only from the head's settings (enabled ones), and the skills index goes at the end of the system prompt.
- Configured on the head (settings
- Subagents (M4; actors since docs/subagents.md).
Taskruns a child session on the same worker, with the same system prompt and model, the parent's tools minusTask, and a fresh conversation. Its final report is returned as the tool result.- The child is a session with its own actor (
SessionManager::start_subagent). Its journaled spec carriesCreateSession.delegation(parent, call, root, the named agent's tool list, depth), so a reload rebuilds the same tools (subagent::delegated_tools). The task goes to it asActorMsg::Delegate(logged as aUserMessagewithdelegated_byand the subagent note inpromptonly); the runner waits for that turn's outcome, read from the log when the turn closes. Stopping the parent's turn sends the childInterrupt. - It uses its parent's live tool hooks and policies (held calls wait in the parent), publisher, memory, notices and head caller (
SessionManagerkeeps each actor'sSessionToolsand loads the parent if needed). Secrets are fetched on behalf of the root session, whose row always exists on the head. No checkpoints: subagents are not rewound. - The parent logs
SubagentSpawned { call_id, child_session_id, agent, description, background_task_id }before the child starts. - A delegation that did not complete returns a digest from the child's log (
subagent::digest: status, turns, tool calls, elapsed time, last message, files its own successful write/edit/patch calls changed, errors, its subagents) followed by the editablesession.subagent_partialprompt, as an error result.recover()gives a foregroundTaskcall left waiting by a restart that digest (status="lost") before closing the turn; a lost background subagent's notice carries it too. - Migration 0040:
sessions.agent,end_reason(the last turn's, set at ingest) androot_id(the top-level session of side chats and subagents);api::Sessionhasagentandend_reason. - Continuing:
SendMessage/send_message(to: a session id or background task id of a subagent this session started) starts a delegation in an idle subagent, or joins a running one's turn (ActorMsg::Delegatewithout a waiter) and returns at once;BashOutput/bash_outputwith a subagent id returns its digest. Subagents take your messages (UserInput.wake) andPOST /sessions/{id}/resume(thesession.subagent_resumeprompt,command: "resume"). A subagent turn no call waited on ends withActorMsg::ChildReportto the parent: a notice that joins a running turn, starts one withwake(within the hourly limit), or else waits for the parent's next input. Side chats on subagents andRestoreof subagents (spec withdelegation; tools fromSessionStarted.tools) are allowed. - Nesting: subagents get
TaskandSendMessagewhile their depth is belowCreateSession.max_subagent_depth(thesubagent-limitssetting, default 3);SessionManager::workingcounts a tree's running delegations againstmax_running_subagents(default 16). Cost and archiving cover the whole tree;GET /sessions/{id}/children?tree=1lists it. - UI: a
Taskcall's card has the subagent's thread under it (subagent_thread: collapsed, a live peek of its last steps while it runs, or its transcript with Stop, Resume and a reply box with wake); aSendMessagecard links to the thread. The header's ◆ list (SubagentsPill) shows the tree and opens threads throughForceOpen. A subagent's page has its lineage and a reply box.Session.subagents_running(top-level sessions in the live list) androot_idtell transcripts when to refresh their children. Find in session and search follow subagents like side chats. - Claude Code sessions mirror Claude Code's own subagents into read-only child sessions (
sessions.observed, migration 0041), built from the stream'sparent_tool_use_idtraffic. - The child's log syncs like any session's. The head creates its row when the first event arrives (
SessionStarted.parent_session_id). Child sessions are hidden from the main list and take no messages.
- Telemetry (M4) differs from §11.
- Spans are built on the head from session events when a turn ends, rather than emitted by workers. There is one trace per session:
invoke_agentper turn,chat {model}per model response (withgen_ai.usage.*including cache read and write), andexecute_tool {name}per tool result. - Spans are exported over OTLP/HTTP protobuf to
otlp_endpointorOTEL_EXPORTER_OTLP_ENDPOINT. Turns that ran while a worker was offline are exported when they sync.
- Spans are built on the head from session events when a turn ends, rather than emitted by workers. There is one trace per session:
- Browser hardening (M5).
- There is no login, but a web page in the user's browser can still reach the head: cross-site WebSockets need no CORS approval, simple cross-site POSTs need no preflight, and DNS rebinding can make a hostile name resolve to the head.
- So every UI/API request must carry a
Hostthe head owns: listen address, hostname,localhost, advertised name orextra_names. Anything else gets 421. - Any request with a foreign
Origingets 403, which covers terminal WebSockets.
- Restore and journal trimming (M5).
- A worker that has lost a session rejects input as "unknown session" (for example after a reinstall, or because the journal trimmed it). The head then queues
Restore{spec, events}, which carries its authoritative log, followed by a fresh copy of the input. This happens at most once a minute per session. - Because of that, workers trim sessions from their journal once they are fully acknowledged, not mid-turn, and idle for 7 days.
- Messages up to 64 MiB are allowed in both directions.
- A worker that has lost a session rejects input as "unknown session" (for example after a reinstall, or because the journal trimmed it). The head then queues
- Wildcard binds. With
bind = ["0.0.0.0"](or::, which needsallow_public_bind), the head treats every interface address of that family as its own: they go into the server certificate, the advertised worker URL (tailnet first) and the Host allowlist. The dev config binds this way. The head also asks Tailscale (tailscale status --json) for its MagicDNS name at startup and answers to it and its short form, sohttp://<machine>.<tailnet>.ts.net:7700works withoutextra_names. - Live deltas are coalesced (M5). The worker batches text, thinking and tool-output deltas over 25 ms and merges consecutive deltas from the same stream before sending.
- Evals (M5).
c6-evalwith tasks inevals/tasks(seeevals/README.md). - Side chats.
- A side chat is a child session (
parent_id, noparent_call_id) that branches after one event of its parent (fork_seq). It runs on the parent's worker, in the parent's real directory, with its own model. - The head builds its context as a
seedinCreateSession: the ancestors' seed, then the parent's events up to the branch point, plus results of tool calls already made by then. The worker prepends the seed to the history and keeps it in the journaled spec, so reloads andRestorekeep the context. - If the seed came from a different model, signed/encrypted reasoning and provider tool blocks are dropped from it. Claude Code sessions get the seed as a text transcript with the first question.
- The base prompt gains a note that this is a side conversation that should not change files unless asked. Side chats take messages and can have their own side chats; the UI shows them as collapsible threads under the message they branch from.
- A side chat is a child session (
- Model switching.
- A message can switch its session to another connection, model or reasoning effort (
UserInput.model; the UI offers it in the reply box of sessions and side chats). The worker applies it at the start of the next turn, never mid-turn: it recordsModelSwitchedbefore the user message, then runs on the new model. The head updates the session row when it ingests the event. - Signed/encrypted reasoning and provider tool blocks from before the switch are dropped from the history, since only the model that wrote them accepts them. A reloaded session resumes on the last switched-to model.
- Tools and system prompt stay as the session started (a stable prefix). Switching into or out of Claude Code is refused. An effort-only switch keeps the reasoning in the history.
- A message can switch its session to another connection, model or reasoning effort (
- Rewind. Before each turn's first message the worker records
Checkpoint { commit }: a commit of the session directory in a private git repository under<state>/checkpoints/<session>.git(its own index; the work tree's .gitignore files plus a default exclude list apply).Rewind { to_seq, restore_files }(head-validated:to_seqmust be a user message; queued like input, so an edit's new message follows it) interrupts a running turn, optionally restores the files to the last checkpoint beforeto_seq(files created since are deleted), and appendsRewound.effective_eventsdrops a session's own events fromto_seqon wherever history is rebuilt (side-chat seeds included, each session's part starting at itsStarted). The log stays append-only: the UI folds rewound events under the marker. - Attachments.
UserInput.attachments/UserMessage.attachmentscarry{name, mime, data}in the log (images: PNG/JPEG/GIF/WebP; anything else is UTF-8 text,text/plain). The head validates uploads (10 MB each, 20 MB per message) onPOST /sessions/{id}/messagesand on create, and serves them back at/sessions/{id}/attachments/{seq}/{index}; the UI API lists only name, mime and size.@pathtokens are resolved by the worker actor when the input arrives (mentions.rs: files ≤256 KB inline, images ≤5 MB, folder listings ≤300 entries), so replays see exactly what the model saw. In history, images becomeBlock::Image(Anthropicimagebase64 source; Chat Completionsimage_urldata URL parts; Responsesinput_image) and text files<file name="…">blocks after the message text; search indexes text attachments.fs.find(worker RPC,GET /workers/{id}/fs/find) is the composer's fuzzy finder: gitignore-aware walk, 50k entries max. - Slash commands. Custom commands are the head's
commandssetting (c6_api::CommandDef). When a message (or a new session's first prompt) starts with/namenaming an enabled one, the head puts its name and template inUserInput.command/command_template, so commands apply as they are when sent. The worker actor expands the message when it arrives (commands.rs), before resolving @-mentions:UserMessage.commandnames the command andUserMessage.promptholds what the model gets instead of the text (history, Claude Code input and transcripts usepromptwhen set). Built-ins (compact,init,review) live on the worker; a custom command of the same name wins.!`cmd`runssh -cin the cwd (30 s, 64 KB). Unknown commands stay text./compact [focus]is a turn of its own: the inbox never merges it with other input, andcompact_turnasks the model for a summary (with the focus) and recordsCompaction; the/compactmessage itself is left out of history./clear(a new session, same worker, folder and model) and/export(GET /sessions/{id}/export: Markdown of the effective events, tool calls folded) are UI-side.GET /api/v1/commands(built-ins fromc6_api::BUILTIN_COMMANDSplus enabled custom ones) feeds the composer's picker. No command files are read. - Named agents. The head's
agentssetting (c6_api::AgentDef) is copied intoCreateSession.agents_json. When a session has enabled agents, its Task tool lists them in its description and takessubagent_type(an enum includinggeneral-purpose). The runner gives a named agent its instructions as the system prompt (plus the cwd and date), only the tools it allows (matched across profiles:Read=read,Bash=shell,Edit=apply_patch…), and itsmodelwhen the connection is Anthropic. No agent files are read. - Hooks. Stored on the head as the
hookssetting (GET/PUT /api/v1/settings/hooks, a validated list ofc6_api::Hook: name, event, matcher, folder, command, timeout, enabled) and copied intoCreateSession.hooks_jsonlike MCP servers and skills, so a session keeps the hooks it started with. No files are read.hooks.rsbuilds aHookRunnerfrom the enabled hooks whose folder contains the session's cwd.c6_tools::Hooks(onSessionTools) is called by the turn loop:pre_tool_usebefore a call (a block becomes an error result),post_tool_useafter (feedback is appended to the logged result),stopwhen the model would end the turn (feedback is logged as aUserMessagewithcommand: "stop-hook", at most 10 times per turn). Subagents get the parent's tool hooks but not Stop. The actor runsUserPromptSubmiton receipt (after command expansion and mentions): a block records the message withUserMessage.blocked(so the head's command completes; history skips it), context goes intoprompt.SessionStartoutput is appended to the system prompt at creation. Hooks run in parallel per event,sh -cin the cwd, 60 s default timeout. Claude Code sessions instead get them converted to Claude Code settings and passed asclaude --settings. - Labels. A session's own labels are a JSON array on its row (
sessions.labels, migration 0010). The automatic ones are derived fromworker_name(set at creation),repo,folderandhome(set fromSessionStarted.repo/cwd/homeat ingest) and the currentmodel, byc6_api::labels::auto_labels.c6_api::labelsholds the matching shared by head, worker, UI and CLI:applies(every selector label must match;folder:/repo:by path-segment prefix, others exactly) andbest(among same-named items, the most labels wins, the later one on a tie). The head passes its labels (yours plusworker:) inCreateSession.labelsand, with each message,UserInput.labels; the worker addsrepo:(git remote get-url origin, normalized to host/owner/name),folder:andmodel:. The worker filters agents, skills and hooks when a session's tools are built. For commands the head sends every enabled variant of the name (command_candidates_json) and the worker picks withbest, so label changes apply to the next message.GET /commands?session=lists the variants that apply;GET /sessions/{id}/configexplains what applies and why not (Applied.skipped);PUT /sessions/{id}/labelsedits yours.GET /search?labels=keeps sessions whose labels match (checked per candidate session, before the result limit). Side chats copy their parent's labels; subagent sessions copy them at ingest. A hook's olderfolderfield acts as afolder:selector. - Background tasks.
c6_tools::Backgroundholds a session's background shell commands and subagents (Taskrun_in_backgroundspawns the subagent run and returns at once;finish_agentrecords the report). With a sink set (the worker's actor, for non-Claude Code sessions), each start and end becomes aTaskEvent; the actor logs aBackgroundTaskevent (state started, finished, stopped, stopped_by_user or lost) and, for ends the agent should hear about (notifyand not stopped by the agent itself), queues aUserMessagewithcommand: "task-notification"and an XML-ish notice. The inbox delivers it at the next model boundary of a running turn, or starts a turn in an idle session (at mostAUTO_TURNS_PER_HOUR= 20 per hour; extra notices wait for the next user input and anErrorevent says so).StopTask(a direct command likeInterrupt,POST /sessions/{id}/tasks/{task}/stop) kills a task as the user. The worker journal counts open tasks per session;recover()resumes those sessions, which mark unfinished taskslost, stop the old process group if it still runs the same command (pgidis in the started event), and notify. The UI derives the running list from the events. - Secrets.
secretstable (migration 0011): name (an environment variable name), value encrypted undermaster.key, description,applies_to.GET /settings/secretslists them without values;PUT /settings/secrets/{name}sets (value optional when updating). Each new session's spec carries the index (secrets_json, no values); the worker lists the applicable names in the system prompt and, at each turn's start, fetches their values as credentials namedsecret:<NAME>over the existing credential channel (head-decrypted, 5-minute expiry; a secret withworker:selectors is refused to other workers).SessionTools::set_secretsputs them in the environment of bash and background commands;Secrets::redactreplaces values (4+ chars) with[secret:NAME]in their output, streamed and final, and in background-task notices. Subagents share the parent's secrets. - SSH targets.
CreateSession.ssh_target(APIssh;sessions.ssh_target, migration 0012) makes a session work on another machine through its worker.c6_tools::remote::SshHostruns everything through OpenSSH withBatchMode, a sharedControlMastersocket under/tmp/c6-ssh-<uid>, andC6_SSH_BINfor tests. Scripts go over stdin into a self-deleting 0600 temp file run by bash (or sh) with stdin from /dev/null, so secrets never reach a command line; scripts record their process group in a pid file so a timeout, an interrupt or a stopped background task kills it on the target.SessionTools::with_sshroutes bash (cwd tracked with a marked line), background commands, read/write/edit/apply_patch (cat,mkdir -p && cat >,stat) and grep (rg, elsegrep -E) and glob (findlisted remotely, matched locally) to the target;~is the remote home. The worker'screate_remoteprobes the target (home, folder, OS line, git remote forrepo:), writes a remote environment section and the target's AGENTS.md/CLAUDE.md into the system prompt, and recordsSessionStarted.ssh_target/system; hooks and MCP servers run in a localscratch/<session>folder; checkpoints and @-mentions are skipped.pty.opentakessshfor a login shell on the target. Labels gainhost:<host>. - Web search. The head's
web_searchsetting (c6_api::WebSearchSetting: provider brave/tavily/searxng, URL, the secret holding the key,always) is copied intoCreateSession.web_search_json.build_toolsaddsc6_tools::WebSearch(web_search/WebSearch) when a provider is set and the model's provider has no server-side search (Chat Completions), or always if asked. The tool reads its key from the session's secrets (so the secret must apply to the session), calls the provider's JSON API, and returns numbered title/URL/snippet results. - Outputs.
c6_tools::Publish(publish_file/PublishFile, in every tool set) reads the file (locally or on the SSH target, up to 20 MB) and sends it to the session's publisher (subagents use the parent's); the actor logs aSessionEvent::Output { name, title, mime, data }, so each publish is a version with its content as it was. The API lists outputs without content (EventKind::Output { size }) and serves them atGET /sessions/{id}/outputs/{seq}(?download=1), withnosniffand, for anything that could run script (HTML, SVG, unknown types),Content-Security-Policy: sandbox …so a page the agent wrote gets an opaque origin and cannot use your session; the UI previews those in sandboxed iframes. The Outputs tab groups versions by name. - Templates. The head's
templatessetting (c6_api::Template).templates::runresolves the targets (named workers,*for every online worker, or the first online one; times each SSH target), the connection (name or id) and model, fills the prompt ({{input}},{{target}},{{date}},{{time}}in the head's local time), and creates one session per target throughsessions::create, each labelled with the template's labels plustemplate:<name>andrun:<id>(an 8-hex run id).POST /templates/{name}/runreturns the sessions and per-target errors. - Automations.
automationstable (migration 0013): a template plus a trigger,schedule(cron::Schedule: five fields with lists, ranges, steps and names,@daily/@hourly/…, dom/dow OR-ed as in cron, evaluated withchrono::Local) orwebhook(a 24-byte token, stored as SHA-256, shown once;POST /api/v1/webhooks/{id}/{token}is exempt from sign-in, compares in constant time, answers 404 to a wrong or disabled token, takes up to 1 MB, pretty-prints JSON into{{input}}). A head task wakes every 30 s and fires each enabled schedule with a time in (last check, now]; missed times are not replayed.fireruns the template with the labelautomation:<name>and records last run, run id and error./api/v1/automationslists, creates, edits, runs now, rotates a webhook token and deletes. - Connectors.
c6_mcp::ServerConfighasapplies_toand, for HTTP servers,bearer;with_secretsfills${NAME}in command, args, env, URL and bearer from the session's secrets, reporting names it lacks. The worker fetches the applicable secret values before building a session's tools (create and resume) and starts only servers that apply and have their secrets; the rest become setup errors in the session. Commands from the head are applied in order by a task of their own, so the connection's read loop keeps going (a create that waits on a credential would otherwise wait on itself). The UI's MCP editor offers presets (GitHub, Slack, PostgreSQL, Playwright browser, remote HTTP). - Memory.
memoriestable (migration 0014: id, scope label, text, writing session). New sessions get the newest 500 inCreateSession.memories_json; the worker keeps those whose scope matches the session's labels (or is empty) and writes a Memory section into the system prompt (most specific first, newest first, 8 KB), naming the session's labels as scopes.c6_tools::Remember/Forgetsend changes to the actor, which logsSessionEvent::Memory { op, id, scope, text }; the head applies them to the table when it ingests the event./api/v1/memorieslists, creates, edits and deletes. - Policies and approvals. The head's
policiessetting (c6_api::Policy: name,applies_to, tool-name regex, input regex,ask/deny, message; regexes checked on save) goes intoCreateSession.policies_json. The worker'spolicy::Policies(the applicable ones) runs first in aChainwith the settings hooks, asHooks::pre_tool_use, which now gets the call id. A denial is the tool's error result.asklogsSessionEvent::Approval { state: "requested", tool, input (4 KB), policy, message }and waits on a oneshot for up to an hour (the turn loop's interrupt ends the wait; a guard logsexpired).POST /sessions/{id}/approvals/{call_id}sends the directApprovecommand; the actor resolves the wait and logsallowed/denied. The head counts open requests insessions.awaiting(migration 0015; reset atTurnEnded) and exposesSession.awaiting_approval, which the UI uses for the inbox section, the badge, the dock, notifications and whether the transcript card shows its buttons. - Memex (phase 6, step 1).
c6m(cratec6-memex) is a fabric node: it is ac6_worker::Workerwith anExtensionthat adds the capabilitymemexand handlesmemex.*RPCs. The head records a node's role (workers.role, migration 0022) at its first attach, from its Hello, and refuses a later change, a second memex, and sessions on the memex. Nodes call the head withHeadCall/HeadCallReply(hub.proto);fabric::handleallows the memexconfig.getandnotify, and workers the session methods (memex.search/get/related/note/timeline) for one of their own sessions, stamping that session's scope (memex::scope_for: the default kinds plusmemex_scopesrules matching its labels) and author before relaying to the memex withregistry.call. The head feeds the memex throughmemex_outbox(in order, retried, dropped after 5 refusals):memex::on_events(fromingest) sends outputs, web pages fromweb_fetch/browserresults, memory changes, and on each top-level turn end a session digest (per turn: asked, tool calls in one line each, the answer; the latest 16 000 characters);memory::put/deleteand, once, all earlier notes too.sessions::createasks formemex.brief(labels known at creation plus the prompt; 4 s at most) and puts it inCreateSession.memex_brief; the worker uses it instead of the Memory section, and offers the memex tools whenmemex_tools(a memex was connected at creation). The memex's store (memex.db):items(unique by source and external id),parts(about 1200-character passages, FTS5 with porter stemming),vectors(unit f32 per part and embedding space),things(keyed by the label they stand for;run:,wfrun:andmodel:make none),links(about, observed, by source) andevidence. Search merges keyword (bm25, all words else any) and cosine (brute force over the space's vectors) rankings by reciprocal rank (k = 60) per passage, then per item. A job loop refreshes its settings from the head each minute and embeds passages in batches of 32 through the configured connection's/embeddings(credentials as a worker gets them).GET /memex/*serves the UI with no scope. - Memex documents and papers (step 2).
docs::extractreads PDF (pdf-extract, plus the/Titlefrom the document information), .docx (the runs ofword/document.xml), HTML (htmd) and text.docs::analyzetakes a PDF as a paper when it has an abstract and a reference list, or a DOI or arXiv id before its references. It takes the title (metadata, else the first line that reads like one), the authors (name-like parts of the lines before the abstract, without affiliation words), and the references (split on[n]orn.markers, else blank lines; each with its DOI, arXiv id, and a title from quotes or the first sentence after the authors).Memex::add_documentstores the file in content-addressedblobs/(uploads; folder files are read in place), the item (paperordocument;meta.paperholds the identifiers), and links made bydocs:isto the paper's own thing (keyedpaper:doi:…,paper:arxiv:…orpaper:title:…),authored_bytoperson:name:…things, andcitesto the cited papers' things with the reference text as evidence. Thing cards and related things now follow every relation. Watched folders (memex_folderssetting, sent inconfig.get) are scanned every 5 minutes and when they change. Thefilestable (migration 0002) records each file's mtime and size, so only changes are read, and files not seen in a scan are removed. Trails:trailsandtrail_steps,memex.trail*methods,/memex/trailsroutes, and thememex_trailtool (sessions may append and read).GET /memex/items/{id}/contentserves an original like an output (nosniff, sandboxed when it could run script). Upload bodies may be 80 MB. Errors the memex returns become 400s; losing the connection stays a 500. - Memex Google (step 3).
- Head:
google.rsholds your OAuth client (thegoogle_clientsetting, its secret encrypted) andgoogle_accounts(migration 0023; refresh and access tokens encrypted).POST /google/connectreturns the consent URL (offline access, the gmail.modify, gmail.send, calendar and contacts scopes) with a one-use state.GET /auth/google/callbacktrades the code and reads the account's address.sessions::credentialservesgoogle:<account>(refreshed on the head) only to the memex node.config.getlists the accounts, the People API base andcontacts_auto.- Endpoints can be overridden with
C6_GOOGLE_*(tests).
- Memex mail (migration 0003):
sync_stateper account and kind.- Gmail:
history.listfrom the cursor each round (a 404 starts the import again), then one page ofmessages.list(100,includeSpamTrash) while importing. Each message is fetched in raw format, parsed withmail-parser, kept as a blob, and stored as anemailitem (spamwhen labelled SPAM, out of sessions' reach) plus amailrow (the label flags) andmail_labels. - Links
from,toandccgo toperson:<address>things (google::personaddsmember_oftoorg:<domain>except for free-mail domains).thing_keyslets a person have several addresses. - Views group by thread with their conditions in SQL;
needs_replyandwaitingcheck each thread's last message. modifyusesthreads.modify. Drafts and sending build MIME withmail-builder(Markdown as HTML viapulldown-cmark, plus In-Reply-To and References).outgoingholds a message untilsend_at(10 s for undo, or a time you chose) andsend_duesends it withmessages.send.- New INBOX mail in the live phase is reported to the head as a
mail.receivedevent, whichautomations::on_memex_eventmatches againstmemextriggers (EventFilter: type plusfrom:/to:/subject:/account:conditions;@domainmatches an address's domain).
- Memex calendar:
calendarListandevents.list(showDeleted, sync tokens; a 410 starts over) fillcalendarsandevents(masters withrecurrence, and instances withrecurring_event_id/original_start, cancelled ones kept to hide them) andeventitems linked toattendeepeople.calendar::eventsexpands masters withrrulein their time zone and drops instances that have an exception row.- Changes: "this" patches the instance id
<id>_<UTC start>; "following" sets UNTIL on the master and inserts a new series; "all" patches the master. RSVP patches the attendee list.
- Memex contacts:
people.connections.listwith a sync token fillscontacts, each linked to one person thing keyed by all its addresses. Candidates are people in mail you sent to and heard from, andcreateContactfiles them in thecarbon6group (made on first use). - Sessions:
CreateSession.memex_mailandmemex_calendar(scope has email or event) add the mail and calendar tools.mail_threadwraps messages as<email …>and marks them untrusted.fabricallows these methods for sessions, and the memex refuses them outside the session's scope.with_builtin_policiesputs anaskpolicy formail_send,calendar_createandcalendar_rsvpahead of yours unlesssend_needs_approvalis off.
- UI: calls go through
POST /memex/rpc(allowlisted prefixes, for you); attachments download from/mail/attachment(nosniff, sandboxed).
- Head:
- Workflows. The
workflowssetting (c6_api::WorkflowofWorkflowSteps: run/approve/notify, template, input, title, message,when=StepConditionon an earlier step,continue_on_failure; checked on save: templates exist, ids unique, conditions point back, no name shared with a template).workflows::startstores aworkflow_runsrow (migration 0020) with the definition as it was and the steps' state (WorkflowStepRunJSON), and kicksworkflows::Runner, whose dispatcher (one driver per run; unfinished runs are kicked at start) runsdrive: the first pending/running/waiting step;whenis checked when a step starts (changedcomparesRESULT:lines with the step's output in the workflow's previous finished run); a run step callstemplates::run(labelsworkflow:<name>,wfrun:<id>) and registers its sessions inrun_sessionsunderwf:<run>:<step>, then polls (1 s, up to 6 h) untilautomations::session_endedhas set all their outcomes, and takes their last replies as output; approve sets the runwaitingand sends anapprovalnote whose signed link useswf:<run>as the session and the step id as the call (the/approvepage handles both);decideresumes or ends the run (denied).finishnotifies on failure and callsautomations::workflow_finished, so an automation whose template names a workflow records the workflow's outcome. - Browser.
c6_tools::browser:find_chrome(C6_CHROME, the usual macOS apps, then Chrome/Chromium/Brave/Edge onPATH) decides whetherbuild_toolsaddsBrowserTool.SessionTools.browser(aBrowserSlot) launches Chromium on first use (--headless=new --remote-debugging-port=0 --user-data-dir=<state>/browser/<session>,kill_on_drop), readsDevToolsActivePort, and speaks CDP over one WebSocket (tokio-tungstenite; a writer task and a reader task matching replies to ids; 30 s per call) to one page attached withflatten.readruns a script that numbers visible interactive elements withdata-c6-refand returnsinnerText(30 000 characters);clickscrolls the element into view and dispatches mouse events at its center;typefocuses and selects, thenInput.insertText(a secret's value from the session's secrets); navigations wait forreadyStatecomplete. Outputs are redacted of secrets. Screenshots (Page.captureScreenshot) go to the publisher. A watcher drops the browser after 10 idle minutes;SessionManager::newprunes profiles older than 30 days. The tool isExclusive. - Web push.
push.rs: RFC 8291 aes128gcm (one record: ephemeral ECDH P-256 withring::agreement, HKDF-SHA256 for the CEK and nonce, AES-128-GCM;sealchecked against the RFC's Appendix A) and RFC 8292 VAPID (an ES256 JWT for the endpoint's origin, 12 h,subthe https public URL or a placeholder; the P-256 key is made once, kept in thevapid_keysetting as PKCS#8).push_subscriptions(migration 0021) holds each device's endpoint (https, or http on loopback for tests), p256dh and auth;POST /push/subscriptionstakesPushSubscription.toJSON(). Channel kindpushsends{title, body, url, tag}(url: the approval link, else the session) to every device withTTLandUrgency; 404/410 removes the device. The UI shipssw.js(show the notification; on click, focus or open its URL),manifest.webmanifestand icons (Trunkcopy-fileto the root), andjs/push.js(permission,pushManager.subscribewith the head's key). - Notifications. The head's
channelssetting (c6_api::Channel: name, kind ntfy/slack/discord/webhook/email, URL or SMTP server, user, recipient, secret name, events,applies_to, enabled; checked on save) andpublic_url.notify::Notifieris an unbounded queue inHeadState; one task sends notes in order, loading channels each time and keeping those whose events and labels match.sessions::notify_events(iningest) makes notes for approval requests,Noticeevents (SessionEventkind 26, from thenotifytool through aNoticeSinklike memory's), outputs, and the end of top-level turns (completed: the last reply, fromturn_events; error: the lastError);automations::fireadds one when a run fails. ntfy posts JSON to the server root (topic, title, priority 2/3/5, click, view actions); Slack and Discord get text; webhooks get the note as JSON (withsession_url,approve_url, labels); email goes through lettre (465: TLS, else STARTTLS). Approval links are/approve/<b64 session>.<b64 call>.<expiry>.<HMAC-SHA256>with a random key in thelink_keysetting and a one-hour expiry;GET(public: outside/api/) shows the request from itsApprovalevent andPOST allow=1|0callssessions::decideif the call still waits. - Automation runs. Migration 0019:
automation_runs(run id, automation, started/finished, trigger, outcome running/ok/failed/stopped, error) andrun_sessions(session → run, outcome of its first turn), plusautomations.disable_after.automations::fire(…, trigger)records the run (failed at once if nothing started) and its sessions.sessions::ingestcallsautomations::session_endedon eachTurnEnded(completed → ok, error → failed, else stopped; only the first sets it) andon_rejecteddoes for sessions that failed to start; when every session has an outcome the run finishes (failed if any failed or some targets did not start), notifiesautomationon failure, andafter_failureswitches the automation off once its lastdisable_afterfinished runs all failed.GET /automations/{id}/runsadds each run's sessions and cost (usage::cost_of, subagents and side chats included);Automation.recenthas the last ten outcomes. - Hosts. The
hostssetting (c6_api::Host: name, target, worker, labels, description,check_everyminutes, enabled; checked on save, labels as yours).sessions::createresolves an SSH target that is a host's name or target (hosts::lookup) to the target and adds the host's labels.Template.hostsis a selector over each host's labels plushost:<name>(*: all);templates::runmakes one target per enabled match, on the host's worker if online (else the template's first), passing the host name as the SSH target.POST /hosts/{name}/checkand the 30-second scheduler (hosts::spawn_checks, one check per host at a time) call the worker'sssh.checkRPC (SshHost::probe("~")capped at 20 s) and store the result inhost_checks(migration 0018); a change in reachability (or a first failure) sends ahostnote.GET /hostsjoins hosts with their last check. - Usage and budgets. The
usagetable (migration 0017: session, seq, ts, connection, model, input, output, cache read/write) gets a row perUsageevent inStore::ingest's transaction (connection and, if the event has none, model from the session row);usage::spawn_backfillfills it once from older events (keyset pages of 2000, settingusage_backfilled). Token counts are asc6-llmnormalizes them (input excludes cache reads; output includes reasoning).prices(c6_api::Price; exact id, else the longestprefix*; numbers or numeric strings) give the cost;GET /usage?days&labelsaggregates calls since local midnightdays-1days ago, filtered bylabels::applieson the session's labels, by day, model, connection, label (yours except run:, plus host:, worker:, repo:) and root session.budgets(c6_api::Budget: labels, day/week/month in local time, limit, notify/block) are checked after each batch with usage (usage::after_calls): a budget whose period spend reaches its limit is recorded inbudget_state(name → period) so it fires once, sends abudgetnote and, for block, interrupts running sessions with its labels.sessions::create(on the labels known then) andsend_inputrefuse work while a blocking budget that applies is used up. - Model routing. The head's
routessetting (c6_api::Route: name,applies_to, connection name or id, model, effort,subagent_model, enabled; checked on save) is read when a session is created.api::CreateSession.connection_idandmodelmay be empty:sessions::routekeeps a given model; otherwise it takes the first enabled rule whose labels match the session's labels at creation (yours plusworker:,folder:for an absolute folder andhost:;repo:andmodel:are not known yet) and whose connection is the requested one or unset; then the connection'sdefault_model, thenStore::latest_model(the latest top-level session's). Without a connection, the latest session's is used, else the first. The rule's subagent model is stored insessions.subagent_model(migration 0016; side chats inherit it) and sent asCreateSession.subagent_model;subagent::Runnerruns subagents on it (a named agent's model overrides it) and records the model in the child'sSessionStarted, which the head uses for the child's row. A selectorkey:with no value matches any label with that key. Templates send an empty connection and model unless they set them, andc6 runsends what was given. - API tokens and
c6.api_tokens(migration 0009) holds SHA-256 hashes ofc6_…tokens;auth::requireacceptsAuthorization: Bearerfor every/api/path except/api/v1/auth/*(so a token cannot mint login codes, tokens or passkeys). Tokens are created under Settings or offline withc6h api-token.bins/c6is a thin client over the HTTP API:runcreates a session (picking the only online worker and the latest session's connection and model by default), then follows it by polling/events?after=until the session is idle with nothing queued, mapping the lastTurnEndedreason to the exit code; Ctrl-C sends an interrupt. - Workspace UI.
c6-ui/src/workspace.rs: aLayoutof up to four tiles, each a stack of columns (View: sessions, session with an optional search target, search, workers, worker, settings, inbox, mission) with a cursor (the column shown; the one before it shows beside it when the tile is at least 60rem wide, via container queries) and an active column (keyboard shortcuts).Golinks open beside the clicked column, truncating the ones after it (⌘-click: new tile). Views are scoped to their column (ColumnCtx): their own scroll (kept while folded), deep links from props rather than the URL, key handlers only in the active column, find-bar highlights per column root. Saved in localStorage; the URL isreplaceStated to the focused column and parsed on load. Attention: running → idle/failed while not shown flags a session (cleared when shown, dismissed or replied to); pins; both feed the dock, the inbox and mission control (activity.rs). - Search. An FTS5 table (
search, trigram tokens, so terms match as case-insensitive substrings of 3+ characters) besideevents. Each event becomes entries of a kind (user, assistant, thinking, tool_call with its string arguments unescaped, tool_result, error, summary), capped at 32 KB, inserted in the ingest transaction, so the index cannot drift from the log. It is derived data: whensearch.versiondiffers, a background task re-indexes each session in one transaction (delete then insert), so concurrent ingest neither loses nor doubles entries.GET /api/v1/searchranks with BM25, groups by session (best three matches each, snippets fromhighlight()cropped around the first match) and filters by kind and worker. Each hit carries the id of the transcript element showing it ({session}-e{seq}, orcall-{id}for tool output, which shows in its call's card) and, for side chats, the top-level session and the chain of side chats to expand. Links are/sessions/{top}?q=…&open={side chats}#{element}: the page expands those side chats as they load, then opens the element, scrolls to it and highlights the terms with the CSS Custom Highlight API. Find in session (GET /api/v1/sessions/{id}/matches?q=) returns every match in the session and its side chats (a recursive query overparent_id/fork_seq) in transcript order: each side chat sorts right after the event it branches from, by creation time; one entry per element. - Sign-in. The UI and API require a session (an HttpOnly cookie holding a random token; the head stores its SHA-256, 90 days sliding). Sessions come from passkeys or one-time login links.
- Login links:
c6h login-linkwrites a token (15 minutes, single use) into the database and prints URLs; the page redeems it with a POST, so link previews cannot use it up. Links are for the first sign-in and for recovery. - One-time codes: a signed-in browser creates one (
POST /api/v1/auth/codes): 8 Crockford base32 characters (about 10¹² values), shown asXXXX-XXXX, valid for 10 minutes, single use, stored hashed beside login links (kind = 'code'). They are redeemed only at/api/v1/auth/code(never as link tokens), and five wrong codes cancel every outstanding code. - Passkeys: discoverable credentials with user verification, no attestation. Registration needs a session and takes the browser's
getPublicKey()(SPKI), so the head needs no CBOR; sign-in verifies the assertion withring(ES256, EdDSA, RS256). The RP id is the host name, so passkeys work on HTTPS names and onlocalhost, not on IPs. - HTTPS:
ui_tls_head_caorui_tls_cert/ui_tls_key. Publicly trusted certificates (Let's Encrypt,tailscale cert) are not used: Certificate Transparency would publish the head's names. The head CA (pki/ui-ca.*) is separate from the worker CA and carries critical name constraints: the DNS names it was created with and private IP ranges. It issues a 397-day leaf at every start; names added later are left out of the leaf with a warning (deleteui-ca.*and re-trust to extend). Devices fetch it at/ca.pemor/ca.mobileconfig(public, also over plain HTTP). TLS and HTTP share the UI port (first byte 0x16); plain HTTP redirects to the HTTPS name except the CA downloads and the ChatGPT OAuth callback, which must behttp://127.0.0.1. Over HTTP/2 the host comes from:authority. - Public without a session: static assets,
/api/v1/auth/{status,link,logout,passkeys/login/*}and the OAuth callback (protected by itsstate). Workers are unaffected (mTLS).
- Login links:
- Chat Completions quirks. An assistant message with no text and no tool calls is sent with
content: "", because Ollama rejectsnullthere. Reasoning is read from eitherreasoning_contentorreasoning.
17. Open items to settle during implementation
- Exact Codex and Claude Code tool schemas and descriptions, to mirror per tool profile. Get them from public sources and evals.
- How to render a long-running session with many subagents in the UI.
- Whether the full event payload or a compact form is stored for
ToolCall.input_jsonon very large edits (blob-backed if above N KB). - Retention policy for blobs and archived sessions.