Cortex Architecture¶
Cortex is an autonomous research agent system for robotics and AI/ML. It runs as a server-client architecture: the agent-server orchestrates work — conversations and their agent runs, scheduling, chat integrations, the Web UI, and MCP tools — while remote agent clients connect via WebSocket to execute commands on remote machines. For deeper dives into these subsystems, see memory.md, scheduling.md, and mcp.md.
Packages¶
The repository is a pnpm workspace. Two packages are published to npm; the others are built into them or deployed on their own.
| Package | Path | Purpose |
|---|---|---|
@cortex-agent/server |
agent-server/ |
Main server: Slack and Feishu adapters, the Web UI host, the TUI gateway, LLM orchestration, scheduling, MCP tools. Provides four CLI binaries: cortex, cortex-hook, cortex-evidence-export, cortex-signal. The published package also carries the built web app and the client bundle |
@cortex-agent/client |
client/ |
Lightweight remote agent daemon (cortex-client). Connects via WebSocket and executes shell/file commands locally |
@cortex-agent/web |
web/ |
Vite + React app for the browser, desktop and mobile layouts. Built to web/dist and served by the server's Web UI host |
@cortex-agent/desktop |
desktop/ |
Tauri v2 desktop and Android shell. Serves the web app through the cortexui:// scheme, from its bundled copy or a newer bundle downloaded from the server |
@cortex-agent/ui-contract |
packages/ui-contract/ |
Type-only re-export of the server's Web UI router and DTO types, which the web app compiles against |
@cortex-agent/deepseek-relay-worker |
packages/deepseek-relay-worker/ |
Cloudflare Worker that relays to DeepSeek for hosts that cannot reach it directly |
| Plugins | agent-server/defaults/plugins/ |
Built-in plugin bundles (cortex-system, cortex-board, cortex-feishu) containing skills. Not npm packages — read in place as directories at runtime |
Agent-Server Architecture: Six Layers¶
The agent-server code at agent-server/src/ is organized into six layers (L0 through L5), plus two adapter directories with rules of their own. A layer may import only from the layers below it at runtime; type-only imports may cross layers. These rules are enforced at test time by agent-server/.dependency-cruiser.cjs — npm test runs depcruise and fails on any runtime violation (npm run test:quick skips it).
L0 core/ → (no runtime dependencies)
L1 store/ → core
L2 events/ → core
L3 domain/ → core, store, events, platform, agent-adapter
L4 orchestration/ → core, store, events, domain, platform, agent-adapter
L5 entry/ → everything (composition root)
platform/ → core, events
agent-adapter/ → core, store, events
orchestration/ must also be free of import cycles.
agent-adapter/— the two-LLM-backend abstraction (Claude Code and PI).domain/runs/adapters.tsis the one place the adapters are assembled.platform/— the messaging and transport layer: the Slack and Feishu adapters, the TUI gateway, the composite adapter that runs several platforms at once (adapters/), the Web UI HTTP host (ui-http/), and the managed browser (browser/).
A further directory, tui/, is the Ink terminal client that cortex tui runs. It talks to the server only over the TUI gateway's WebSocket, sharing the gateway's protocol types and the i18n strings, and has no layer rule of its own.
Layer 0: core/ — Shared Kernel¶
The shared kernel every other layer builds on: types, path constants, settings, and the in-memory run and session state. It has no runtime imports of other layers; it may import their types, which is how core types such as the hook bus entry or the environment definition refer to shapes owned elsewhere.
| File | Purpose |
|---|---|
paths.ts |
Canonical path constants: INSTALL_ROOT, DEFAULTS_DIR, DATA_DIR (~/.cortex/), CONFIG_DIR, STORE_DIR (data/), CONTEXT_DIR, PROJECTS_DIR, WORKSPACE_DIR (tmp/), PLUGINS_DIR, PROMPTS_DIR, HOOKS_DIR, LOGS_DIR, AGENT_CWD, CLAUDE_CONFIG_HOME, and the daemon PID and restart-trigger files |
settings.ts, settings-spec.ts |
The runtime settings in config/settings.json: the key schema with defaults and legacy variables, validation, the cached snapshot, and hot reload (see configuration.md) |
utils.ts |
Utility functions: chunkText, formatDurationCompact, todayISO, listProjectDirs, readableTimestamp, npm prefix resolution |
atomic-write.ts |
atomicWrite(filePath, data) / atomicWriteSync — write to .tmp.<pid>.<ts>.<rand> then fs.rename; mutateFileAtomically for read-modify-write |
json-repository.ts |
Generic JsonRepository<T> base class: read(), write(), mutate(fn), flush(). Lazy orphan .tmp.* sweep on first I/O. Corrupt-JSON backup mechanism |
async-mutex.ts |
AsyncMutex class — promise-based mutex for serializing async disk writes. Used throughout the store layer to prevent concurrent file corruption |
log.ts |
createLogger(tag) — console + daily-rolling file sink with 14-day retention |
i18n.ts, locales/ |
English and Chinese strings for system-generated messages; CORTEX_LANG or the !lang preference picks the locale |
cli-utils.ts |
formatHelp, formatError, readStdinSync, readStdinBufferSync, cliError — shared CLI formatting |
status-format.ts |
Pure formatting: computeElapsed, formatMetricsSuffix, buildSessionTag, buildUserProcessingMessage, renderTurnStatus |
rebuild-progress.ts |
The hot-rebuild progress record the supervisor publishes to daemon-rebuild.json: pure step transitions (planRebuildProgress, startRebuildStep, finishRebuildStep, settleRebuildProgress) plus a read that never throws |
run-registry.ts |
RunRegistry — the single in-memory index of the executions that are live right now, keyed by executionId and indexed by channel. Publishes the agent.* lifecycle events to the EventBus |
session-holds.ts |
SessionHolds — the other half of "is this session busy": a turn that ended while background work continues, with per-owner handles, supersede and stop |
session-state.ts |
sessionState(sessionId) / channelEngineBusy() — the one join of live executions and holds, and the only place that answers whether a session is busy |
agents/ |
Subagent role loading (roles.ts) and the shared delegation schema, model choice and orchestration (subagent/) used by both backends |
types/agent-types.ts |
Backend, AgentResult, AgentProgress, AskUserQuestionInfo, and the todo, context-usage, attachment and notice shapes |
types/environment-types.ts |
Execution environment types: AgentDefinition (one config/environments/*.json file) and the ResolvedEnvironment a run opens with |
config-generator.ts |
Config file initialization for new installs, and the MCP configs written at every start |
gateway-generator.ts |
API gateway YAML generation |
models-generator.ts |
Default model and favorites JSON generation |
Layer 1: store/ — Persistence¶
All write operations are serialized through AsyncMutex to prevent corruption from concurrent writes. The layer uses two repository patterns:
- Pattern A (JsonRepository delegation): Simple CRUD wrappers around
JsonRepository<T>. Used for schedule-repo, cost-repo, board-repo, waitpoint-repo, provider-state-repo and most other repositories. All writes go throughatomicWrite(). - Pattern B (in-memory Map + fire-and-forget persist): Synchronous in-memory
Mapfor reads, with async persist chain for writes. Used for execution-repo where read latency matters and stale-on-crash is acceptable.
| File | Purpose |
|---|---|
outbound-queue.ts |
WAL-based durable outbound message queue. 30-min TTL, 200-entry compaction, 5-second drain loop. Coalesces consecutive updates to the same message |
conversation-ledger-repo.ts |
Façade over session-registry-repo.ts — same per-channel turn API (initConversation, beginTurn, addResponseTs, completeTurn, rollbackTo) with no file of its own |
session-registry-repo.ts |
JSONL-backed UUID session registry and single owner of session records, channel→session bindings, and per-channel conversation headers and turns. Replays an append-only journal (put/patch/delete-intent/delete-commit/bind/unbind/turn/conversation), admits sessions, and compacts snapshots when the log grows |
conversation-history-repo.ts |
Per-session transcript JSONL under data/conversation-history/, the record the Web UI renders a conversation from |
execution-repo.ts |
Pattern B repository: startLocalExecution, completeExecution, failExecution, cancelExecution. At startup markMissingRunningExecutionsStale marks runs that are not alive any more as stale; terminal records older than seven days are archived to data/archive/ |
project-dir-repo.ts |
projectName → machineName → dirPath with reverse channel lookup |
schedule-repo.ts |
Schedule persistence. ScheduleTask interface; schedules.json is shared with out-of-process writers |
cost-repo.ts |
Costs in JSONL (append-only), budget in JSON. 90-day pruning at startup |
models-repo.ts |
Synchronous cached reader. startModelsWatcher() for hot-reload |
waitpoint-repo.ts, provider-state-repo.ts, board-repo.ts |
Waitpoints, provider throttle state and the resume queue, and the board registry |
team-inbox-repo.ts, team-outbox-repo.ts |
Messages between team leads and their workers, kept across restarts |
hook-registry.ts, hook-writer.ts |
The hook registry: built-in declarations with config/hooks/ laid over them, and the writer behind hook switches |
version-migrations.ts |
One-time upgrade migrations of $CORTEX_HOME, run at startup |
Layer 2: events/ — Event Bus¶
A synchronous, type-safe event bus with JSONL logging.
| File | Purpose |
|---|---|
event-types.ts |
The CortexEvent discriminated union, grouped below, plus two meta events (event-bus.handler-failed, event-logger.dropped) |
event-bus.ts |
EventBus class — subscribe(type, handler) / publish(event). Synchronous fan-out. Async handlers fire-and-forget. Re-entrant guard for event-bus.handler-failed. Close hooks for SIGTERM drain |
event-logger.ts |
Subscribes to '*', ring buffer of 1024 entries, 100ms flush interval, daily rolling JSONL under logs/events/, 14-day retention. Gated by the eventLog setting in config/settings.json (legacy env CORTEX_EVENT_LOG=off is read as a deprecated fallback) |
Event categories:
- Session:
session.message,session.message.delta,session.message.delivered,session.status,session.turn,session.turn.completed,session.todos,session.context-usage,session.context-compacted,session.rewound,session.decision,session.board,session.interaction,session.tool-images,session.debug.updated - Interaction:
plan.submitted,plan.approved,ask-user.requested,ask-user.answered - Agent lifecycle:
agent.started,agent.completed,agent.failed,agent.superseded - Auth:
auth.required,auth.recovered - Board:
board.updated,board.style-updated - System:
system.notice,config.changed,llm.active-count-delta,rate-limit.changed,ui.mutate-invoked, andscheduler.tick(declared for the TUI dashboard; no server component publishes it)
Layer 3: domain/ — Business Logic¶
The thickest layer. Contains one subdirectory per domain concern.
| Subdirectory | Purpose |
|---|---|
agents/ |
Engine selections, favorites, model memory, execution environments (agents/environments/), subagent runners and credentials: what a run may be configured as. Starting one belongs to runs/ |
runs/ |
The run layer: startRun and the AgentRun ownership object, RunRequest selection/spec/prompt resolution and its shared builders (builders.ts), the backend-neutral RunEvent stream, and the SessionEngines pool |
sessions/ |
Session lifecycle and records, the session hook pipeline (onNew, onMessageEnd, pre-close) with optional agent injection, team sessions, and the retention sweep |
executions/ |
Execution registry access over store/execution-repo.ts, plus teardown and stop of live executions |
auth/ |
Claude Code, PI and Codex credentials and login flows, auth status and expiry watching, and gateway sync after a login |
pi-providers/ |
Custom PI providers: the model catalog entries and gateway routes behind cortex auth provider |
costs/ |
Cost tracking, gateway management, usage collection, account readings and banked resets, rate limiting with per-provider awareness |
scheduling/ |
Persisted schedule engine plus settings-backed built-in job controller. Scheduler hot-reloads user schedules and opens a new session for each fire; built-in jobs such as the memory-index rebuild own daemon timers |
waitpoints/ |
Waitpoint service: arming, signal resolution, expiry, and the notices that wake a session (see waitpoints.md) |
boards/ |
Board service, paths and slugs, the file watcher that re-renders open boards, and the projection of decisions onto a board |
plugins/ |
Plugin catalog, built-in and user plugin loading, skill projection, and plugin MCP servers |
projects/ |
Project registry: scaffolding, scanning context/projects/, and project metadata |
memory/ |
Memory/index management. index-regen.ts rebuilds the generated index.md files from YAML frontmatter and the access log (CLI: cortex memory regen). File watcher for context changes. AGENTS.md scanning and injection, and the rules loader |
hooks/ |
Read-only view of the hook registry for the Web UI |
monitor/ |
GPU and disk resource monitoring |
system/ |
Host-level concerns: doctor checks, update state and prompts, system notices and their history, and rebuild-hold.ts — the supervisor-pushed "no new turns" state consulted by admission |
remote/ |
Remote device management via WebSocket. SSH-based client bootstrap; clients self-update from server-pushed bundles |
mcp/ |
The bundled Cortex MCP server and its tool modules (see mcp.md) |
ui-service/ |
The backend of the Web UI: the tRPC AppRouter (app-router.ts), its query and mutation handlers, and the event-bus subscription that streams live events to the UI (subscribe.ts) |
tui-session/ |
Session handshake and switching for the TUI gateway |
benchmark/ |
Production benchmark evidence: attempt identity and journals, and the export behind cortex-evidence-export |
Layer 4: orchestration/ — Message Routing and Execution¶
Connects platform messages to domain logic. The hook-bridge (part of this layer) is detailed in hooks.md.
| File | Purpose |
|---|---|
conduit-queue.ts |
Per-conduit serial Promise queue. Ensures only one turn runs per conversation at a time and lets !cancel discard a pending tail |
orchestrator.ts |
Hands each inbound message to AgentRunner |
agent-runner.ts |
Admission for the default-agent path: routing, mid-turn injection, queueing, and the session/lease/browser resolution a turn needs. The turn body itself belongs to turn/turn.ts |
session-gateway.ts |
deliverToSession() — the one door into a conversation turn for everything that is not an inbound platform message (web sends, scheduled fires, agent results, resumes, waitpoint wakes) |
session-events.ts |
Publishes the session.* events the Web UI and TUI render: messages, deltas, status, turns, todos, context usage |
runtime.ts |
The PlatformAdapter + EventBus orchestration holds, injected once by the composition root |
conversation-request.ts |
prepareConversationRequest() — assembles a plain chat turn's RunRequest: agent spec plus the first-turn ambient blocks |
turn/turn.ts |
Turn / openTurn() — the single owner of one conversation turn: status message, ledger turn tracking, session lease, startRun, observer fan-out, terminal render, the running:true/false bracket |
turn/turn-prep.ts |
What a caller resolves before openTurn: the turn's files, its session record and use lease, the browser endpoint the session opted into |
turn/terminal.ts |
A turn's terminal rendering: status seal, the ledger's turn completion, the error body, error-session persistence |
turn/turn-tracking.ts |
The ledger half of a turn's bookkeeping: begin, snapshot, accept, and the supersede-while-pending window |
turn/active-turns.ts |
Per-channel turn state: the streaming callback a turn registered, the edit-supersede flag |
turn/background-hold.ts |
One hold lifecycle for "the turn is over but the session is not": busy bracket, SessionHolds registration, run subscription, six terminal verdicts |
turn/hold-render-platform.ts |
The Slack/Feishu rendering of a held turn — the status line while the background phase runs, and its seal |
turn/hold-render-web.ts |
The web rendering of a held turn — the continuation's prose, tools and notices on the session event stream |
mid-turn-inject.ts, session-compact.ts, session-rewind.ts |
Steering a message into a running turn, compaction on request, and rewinding a conversation to an earlier turn |
subagent-*.ts, team-*.ts |
Delivery of background subagent results and of team worker reports into their parent or lead session |
waitpoint-*.ts |
Waitpoint signal ingress, the periodic sweep, draining spooled signals from connected devices, and wake notices |
session-retention-controller.ts |
Runs the session-retention sweep at startup, every 6 hours, and on a retention setting change |
resume-dispatcher.ts |
Rate-limit resume queue: continues the sessions a usage limit interrupted once the window resets |
busy-tracker.ts |
Tracks active LLM count, sends IPC busy/idle to parent daemon process |
human-answer-backstop.ts |
Leaf registry of channels waiting on a human's free-text reply, consulted by every entry into a session so the answer is consumed instead of opening a turn |
routing/message-router.ts |
Platform message entry point: registers the inbound message handler on the adapter and delegates to the orchestrator |
routing/commands/ |
!command handlers, one module per family: cancel, channel, compact, cost, device, lang, login (with login-custom), mode, nvtop, orient, restart, schedule, sendfile, session, status, tail, usage (with usage-reset). index.ts maps the command words — !agent, !backend, !model, !thinking, !new, !resume, !usage and the rest — to them |
routing/webhook.ts |
The loopback webhook server (WEBHOOK_PORT, default 3001): hook callbacks, cortex-signal, subagent, team and board callbacks |
routing/hook-bridge.ts |
PreToolUse hook → EventBus bridge. Publishes plan.submitted and ask-user.requested |
routing/hook-bridge-subscribers.ts |
Subscribers that turn a question or a submitted plan into an interaction record, which each surface renders as a card |
interactions/ |
AskUserQuestion flow, plan approval state machine, button/modal action routing |
Layer 5: entry/ — Composition Root¶
| File | Purpose |
|---|---|
app.ts |
Composition root. Loads .env and migrates legacy settings, then wires EventBus → event logger → hook bridge → RunRegistry/plan approvals/busy tracker → platform adapters → outbound queue → auth watch → scheduler → commands → interactions → message handler → Web UI host → platform connect → client manager → webhook → memory watcher |
shutdown.ts |
SIGTERM handling: an ordered list of shutdown steps, each run even when an earlier one fails, then exit |
daemon.ts |
Process supervisor. Forks app.js, watches $CORTEX_REPO/src for auto-rebuild (when CORTEX_REPO is set), watches .restart trigger file, crash recovery with exponential backoff (1s→30s max). Publishes per-step rebuild progress, waits for an idle app before install/restart, and holds new turns across that window (see below) |
daemon-notice.ts |
The app side of the downward IPC: turns rebuild-aborted into a system notice and rebuild-hold into the admission hold, and lifts the hold if the supervisor disconnects |
cortex-cli.ts, cli.ts |
cortex CLI entry point. Dispatches to init, config, auth, doctor, feishu, restart, setup-gateway, ui, client, install, schedule, memory, start, daemon, tui, each implemented in its own *-cli.ts module (see cli-reference.md) |
hook-cli.ts, signal-cli.ts, production-evidence-export-cli.ts |
The cortex-hook, cortex-signal and cortex-evidence-export binaries |
init.ts |
Interactive first-time initialization |
start-ui-http.ts, ui-http-gate.ts, ui-service-wiring.ts |
Start the Web UI host when CORTEX_UI_HTTP is on, and wire the UiService it serves |
startup-helpers.ts |
Cleanup logs, ensure MCP config |
startup-notify.ts |
Send startup DM to admin channel |
platform-connect.ts |
Start the platform adapters. The TUI gateway is bound before boot continues; Slack and Feishu connect in the background, so boot never waits on a network handshake (outbound posts use their HTTP APIs, not the socket). A failed connect is retried after 5 s, 30 s and 2 min, then reported as an error notice; a platform still connecting after 60 s is reported as a warning |
Hot Rebuild: Published Progress and the Turn Hold¶
With CORTEX_REPO set, a source change makes the supervisor rebuild the packages, reinstall itself,
and replace the app process. The build runs as three concurrent lanes. One is an ordered chain —
server, then ui-contract, then web-typecheck — because ui-contract re-exports the server's built
types and web typechecks against ui-contract's built output. The other two, web-bundle and
web-depcruise, read only web sources and start at once beside it. Every lane runs to its end, and
any failed step stops the rebuild before the install, with a notice naming each step that failed.
Two mechanisms make the rebuild visible and safe.
Progress lives in a file, not in memory. Before step one the pipeline writes its whole plan to
$STORE_DIR/daemon-rebuild.json — the steps this checkout will run (the build steps above, then
install and restart), which are in flight, and how each ended (done, failed,
skipped), with the install step recording whether it took the fast staged-rename path or
npm pack + npm install -g. A file is the only option: the pipeline's last act replaces the very
process that would have held that state, so nothing in memory can tell the new app.js what the
rebuild it was born from did. system.daemonStatus reads the record and the daemon page renders it,
polling once per second while a rebuild runs. A running record whose daemonPid is not the live
supervisor is dropped rather than shown forever; a terminal record is kept, so "the last rebuild
failed at web-typecheck" is still readable after the restart. The terminal statuses are succeeded,
aborted, and deferred.
The dangerous half of the pipeline waits for an idle app. The build steps read the repo and
write dist/; the install step overwrites the files the running app.js loaded and restart
kills it. So busy/idle is checked twice: once before the build, and again after it, because a build
takes minutes and a turn can legitimately have started meanwhile. A busy child at the second gate
parks the trigger in pendingRebuild, publishes the record as deferred, and the whole pipeline
runs again on the next idle transition — real work is waited for, not refused, because a refused
turn is not a delayed turn (a waitpoint wake or a background agent's result would be dropped
outright).
No new turns in the seconds the process is being replaced. Entering install or restart — or
queueing a restart — makes the supervisor send rebuild-hold down the fork IPC, and admission
(agent-runner.ts) refuses before tracking, queueing, or mid-turn injection:
a turn started now is orphaned by the SIGTERM a moment later, with its backend CLI left writing into
a pipe no one reads. The scope is deliberately narrow — never the build — so the only messages it
can catch are those landing in the tick-level race between the idle check and the SIGTERM. A person
who typed gets an in-channel reply naming the current phase; a callback or system message nobody is
waiting on becomes a warning-level system notice naming what was dropped. The hold carries a
five-minute lease renewed by each phase message, so a supervisor that dies mid-pipeline cannot mute
the app forever, and an IPC disconnect lifts it immediately.
LLM Backend Adapter¶
The agent-adapter/ directory abstracts two LLM backends behind a unified interface:
| Backend | Adapter | Notes |
|---|---|---|
| Claude Code | claude/adapter.ts |
Stateless engine adapter: opens a Claude process session (print mode), spawn-args builder, event parser and translator |
| PI | pi/adapter.ts |
Stateless engine adapter: opens an in-process PI session, MCP bridge, hook bridge, tool shims |
The session pool lives in domain/runs/engines.ts (SessionEngines), not in either adapter.
Each adapter translates its backend's events into the unified NormalizedEvent shapes (normalize/) and the RunEvent stream (run-events.ts). The capabilities.ts file declares a Capability enum with capability sets for Claude and PI. See backends.md for how runs, sessions and engines fit together.
Platform Adapter¶
platform/adapter.ts defines the PlatformAdapter interface. Slack (adapters/slack.ts), Feishu (adapters/feishu.ts) and the TUI gateway (adapters/tui/) implement it, and adapters/composite-adapter.ts combines whichever are online into one adapter that routes each outbound call to the platform owning the conduit (slack:, feishu:, tui-). The Web UI does not go through this interface; it reads and drives sessions through domain/ui-service/ (see Web UI Protocol).
| Member | Purpose |
|---|---|
name / capabilities |
Identity and what the platform supports |
start / stop |
Lifecycle |
onMessage / onMessageEdit / onAction / onModalSubmit |
Event registration |
postMessage / updateMessage / deleteMessage |
Outbound messaging |
postInteractive |
Interactive messages with buttons |
openModal |
Modals |
markQueued / unmarkQueued |
The queued marker on a message waiting behind a running turn |
uploadFile / downloadFile |
File operations |
openOutputStream |
A stream that renders an agent's output as it arrives |
bindProjectConduit / unbindProjectConduit / getProjectConduits / resolveInboundProject |
Project ↔ channel bindings |
ownsConduit |
Whether a conduit id belongs to this platform |
getPermalink / adminChannelKey / setAdminChannel |
Utility and the admin channel used for system notices |
OutputStream (platform/output-stream.ts, with Slack, Feishu and chunked implementations under adapters/) carries a turn's output to a chat platform: committed text, mutable regions such as the status line that later edits update in place, and interactive messages, split into chunks the platform accepts and queued so updates arrive in order.
WebSocket Protocol (Server ↔ Client)¶
The WebSocket protocol is used for remote device command execution, not for chat or UI traffic. The server runs a WebSocket server (default port 3002, CORTEX_CLIENT_PORT) via startClientManager(). Every connection presents the client token in the x-cortex-token header. For the full cross-machine story — deployment, network topology, and security — see cross-machine.md.
Message Flow¶
- Client connects → sends
{ type: 'hello', device, platform, capabilities, gpuCount, bundleHash, pid? } - Server validates → closes with
4001when the device name is missing,4005whenmachines.jsondoes not list it, and4002when that device is already connected - Heartbeat → client sends
{ type: 'heartbeat', device, timestamp }every 5 seconds. Server closes a device silent for 15 seconds (code4003) - Command dispatch → server sends
{ type: 'command', commandId, action, params }. Client executes and replies with{ type: 'result', commandId, success, data, error } - Update → when the
bundleHashdiffers from the server's client bundle, the server sends{ type: 'update', hash, files }; the client installs it, answersupdate-result, and restarts on the new bundle - Streams →
open-stream(a TCP port forward) andopen-file-stream(a file transfer) ask the client to dial back a separate WebSocket for the bytes; a callback naming an unknown stream is closed with4004
Command Actions¶
The client supports these remote actions: bash (shell execution with timeout/background), read (file read with text/image/PDF support), write (file write with CRLF detection), edit (text replacement with replace_all), glob (file glob with VCS exclusion), grep (ripgrep with pagination), file.stat (pre-transfer metadata probe).
Client Architecture¶
The client (client/src/client.ts) is a lightweight WebSocket daemon that maintains a persistent connection. It supports automatic reconnection with exponential backoff (1s→30s max). It does not launch or supervise long-running jobs: those run independently and report back through a waitpoint.
Web UI Protocol¶
The browser workbench, the desktop app and the mobile app all talk to the server through one HTTP host (platform/ui-http/), started when CORTEX_UI_HTTP is on. It listens on 127.0.0.1:3004 (CORTEX_UI_PORT); remote access goes through a tunnel or a LAN proxy (see browser-access.md and desktop-app.md).
- tRPC under
/trpc/. Queries and mutations are plain HTTP requests against theAppRouterindomain/ui-service/app-router.ts; the web app gets its types frompackages/ui-contract. - Live events use one
subscribesubscription over Server-Sent Events. The client names the event types it wants, optionally scoped to a project or session, and the server forwards matchingCortexEvents from the bus through a bounded queue, sendingui-subscribe.droppedif the client falls behind. A subscriber to streamed text first receives a snapshot of the assistant message in progress. - Authentication, checked in order: the
x-cortex-tokenheader carrying the client token (the desktop and mobile apps), acortex_uisession cookie from token login (/api/ui/login,/api/ui/logout,/api/ui/session), or a Cloudflare Access JWT. Browser origins outside the same origin need an entry in theuiCorsOriginssetting. - Other routes: file download and attachment upload (
/api/files/download,/api/attachments/upload), board assets (/api/boards/asset), the web-bundle update the desktop app downloads (/api/ui-ota/manifest.json,/api/ui-ota/bundle.zip), the native app update manifest (/api/app-update/manifest.json), and port forwarding to remote machines (/api/forward/*, with the/forwardWebSocket). - The same host serves the built web app, so a browser loads the UI and its API from one origin.
The TUI uses a separate WebSocket gateway on port 3003 (CORTEX_TUI_PORT), and hooks and signals reach the server through the loopback webhook on port 3001.
Event Bus Topology¶
The EventBus is created in app.ts. runRegistry, planApprovals and busyTracker are constructed with no dependencies and wired with setBus(bus); every other component receives the bus as an argument.
| Component | Publishes | Subscribes |
|---|---|---|
runRegistry |
agent.started/completed/failed/superseded |
— |
eventLogger |
event-logger.dropped |
'*' (all events → JSONL) |
hookBridge |
plan.submitted, ask-user.requested |
— |
routing/hook-bridge-subscribers.ts |
— | plan.submitted, ask-user.requested |
planApprovals |
plan.approved |
— |
interactionHandlers |
ask-user.answered |
— |
busyTracker |
llm.active-count-delta (pending background tasks) |
llm.active-count-delta, counted to send IPC busy/idle |
orchestration/session-events.ts |
session.* |
— |
domain/auth/ |
auth.required, auth.recovered |
auth.* (auth watch) |
domain/boards/ |
board.updated |
board.updated (board watcher) |
domain/system/system-notice.ts |
system.notice |
— |
app.ts |
config.changed, rate-limit.changed |
session.status (session holds) |
resume-dispatcher.ts |
— | agent.completed/failed/superseded |
team-delivery.ts, subagent-delivery.ts |
— | session.turn.completed, session.status |
domain/ui-service/ |
ui.mutate-invoked |
the event types each UI subscription asks for |
State Storage¶
Cortex stores all state on the filesystem under ~/.cortex/. There is no database — everything is JSON or JSONL on disk, with the session registry and history paths intentionally append-oriented. Paths below are relative to ~/.cortex/; configuration.md lists the whole tree.
| Path | Purpose |
|---|---|
data/agent-state.json |
Channel selections, model memory, new-session choice, and default agent |
config/models.json |
Default engine selection and favorite models |
config/budget.json |
Daily and monthly budget limits |
config/environments/ |
Execution environments — one JSON file per environment |
data/schedules.json |
Persistent schedule list |
data/boards.json |
Board registry: each board's project, slug, title, creation time and last use (created, attached to by a session, or its files changed). The page itself lives in the project context under boards/<slug>/ |
data/session-registry.jsonl |
Append-only JSONL journal and single owner of session records, channel→session bindings, and per-channel conversation headers and turns. Events: put/patch/delete-intent/delete-commit/bind/unbind/turn/conversation; startup replays the stream to rebuild live and pending state. Compaction keeps live records + bindings + live turns; it runs once the journal holds more than max(512, live×4, 2×compacted) events — where compacted is the number of events a compaction would write — or exceeds both 16 MB and twice its size after the last compaction. A one-time upgrade migration imports the retired sessions.json and conversation-ledger.json and renames them to *.pre-2026.9.14.bak |
data/retention-candidates.json |
Two-sweep orphan retention candidates for history and PI cleanup |
data/conversation-history/ |
Per-session transcript/history JSONL keyed by stable Cortex session id |
data/executions.json |
Unified execution registry |
data/archive/ |
Terminal execution records older than a week, moved out of executions.json |
data/provider-state.json |
Provider usage, throttle windows, and the provider-attributed resume queue |
data/waitpoints.json |
Waitpoints and their signals |
data/team-inbox.json, data/team-outbox.json |
Messages between team leads and workers |
data/ui-sessions.json |
Browser login sessions for the Web UI |
data/daemon-rebuild.json |
The supervisor's published hot-rebuild record: plan, per-step status and timings, and the terminal outcome of the last rebuild |
data/costs.jsonl |
Per-call cost records (90-day rolling) |
logs/sessions/ |
Claude capture logs used for retention protection/cleanup |
logs/sessions-pi/ |
PI transcript bundles keyed by backend session id |
logs/ |
Daemon and backend logs, and the daily event log under logs/events/ |
The session registry is an append-only journal rather than a mutable JSON file. session-registry-journal.ts owns an append-only JSONL file whose events carry the full session record for put and delete-intent, plus a final delete-commit. A delete intent also freezes the validated Claude turn-backup cleanup manifest, so a later retry does not depend on a ledger that an earlier partial cleanup already removed. Startup first migrates any legacy session-registry.json file into the journal, writes a backup of that legacy file, truncates any unterminated tail, and then stream-replays the journal line by line into in-memory live and pending maps. When the journal grows too large, compaction rewrites a minimal snapshot consisting of one full-record event per live session plus one delete-intent event per pending deletion.
Session retention is coordinated separately from registry mutation. session-retention-controller.ts starts a session-retention.ts sweep in the background at app startup — boot does not wait for it, and the session registry's own lock serializes it against other session mutations — then runs it every 6 hours, with an immediate debounced rerun when sessionRetentionDays hot-reloads. The startup sweep begins after any ready provider-resume dispatch, and queued direct resumes retain their stable tracking id as a liveness guard. The coordinator retries pending delete commits, writes registry delete intents for all expired idle sessions in one append, then works through them in batches of 100 — one append each releases their channel bindings and their conversations, their files are deleted, and one append commits the sessions whose cleanup succeeded. Stopping the controller aborts the sweep between batches, leaving uncommitted deletions pending for the next sweep. It also prunes orphan conversation-history, prunes orphan PI transcript bundles, prunes inactive Claude capture logs, prunes orphan tmp/tool-images/ session directories, and syncs Claude user cleanupPeriodDays.
Naming Conventions¶
- Execution IDs:
exec_<kind>_<base36-ts>_<4-rand>(e.g.,exec_local_1a2b3c_xyzw) - Schedule IDs: 8 hex characters (from
randomBytes(4)) - Session IDs: UUIDs identify sessions in the registry and UI. Unlabeled sessions display the first 8 characters of the UUID.
/resumeaccepts a full UUID or a unique prefix of at least 6 characters. - Board IDs: 8 hex characters (from
randomBytes(4)). A board's directory is a slug of its title, with a numeric suffix when the slug is taken, orboard-<id>when the title has no slug-safe characters. - Waitpoint IDs:
wp_followed by 12 hex characters (fromrandomBytes(6)); each waitpoint also carries its own secret forcortex-signal. - File format: All TypeScript uses
.tswith ESM import style (import { X } from './foo.js')