Architecture
Claude Remote is a modular monolith (apps/server) plus a separate privileged daemon (apps/runtime),
a static web client (apps/web) and a shared type package (packages/shared). The binding rules are in
CONTRACT.md; the network path is in network-architecture.md.
This document describes what the code does.
Processes
browser ── HTTPS/WSS ──> Caddy (gateway) ── WireGuard ──> apps/server :8620
│ PostgreSQL 17 (loopback)
│
unix socket 0600 + bearer token (HTTP + WS)
▼
apps/runtime
│ execFile / spawn (argv arrays, no shell)
▼
tmux server -L claude-remote (daemonized; outlives the runtime)
cr-<uuid> terminal session: $SHELL -lc 'exec claude <args>' | $SHELL -l
crt-<uuid> task runner: /bin/sh tasks/<id>/run.sh| Process | Listens on | Owns | Never does |
|---|---|---|---|
apps/server (dist/main.js) |
TCP CR_HOST:CR_PORT |
PostgreSQL, authentication, authorization, audit, notifications, cluster adapter, OpenAPI document, static files, WebSocket proxying | spawn processes; read or write project files |
apps/runtime (dist/main.js) |
unix socket CR_RUNTIME_SOCKET only |
tmux, PTY attach clients, scrollback mirror, tasks, fs/git operations, skills and MCP configuration, Claude introspection, host metrics | open a TCP listener; talk to the database |
| tmux server | its own unix socket | the session and task processes | — (it is a stock tmux started by the runtime with a generated config) |
| PostgreSQL 17 | loopback | metadata (see "Storage") | store terminal content or file content |
The web application is not a process: next build --webpack produces a static export
(apps/web/out, trailingSlash: true, no dynamic route segments — pages use query parameters such as
/terminal/?s=<id>), served by apps/server/src/static.ts.
Request flow
- Every HTTP request and WebSocket upgrade under
/api/passes one globalonRequestguard (apps/server/src/security/guard.ts). Routes arefull(session with completed second factor) unless they explicitly declarepublic,preauthorrestricted. See authentication.md. - Bodies, queries and parameters are validated with zod schemas from
@cr/shared(parse()inapps/server/src/http.ts); a failure is a 400invalid_request. - For anything that touches the host, the server calls the runtime through
apps/server/src/runtime-client.ts(HTTP or WebSocket over the unix socket,Authorization: Bearer). The server sends an absoluterootit derived from the project table and a validated relative path; the runtime re-validates both (apps/runtime/src/paths.ts,core-util.ts). - Runtime errors with status 400/403/404/409/413/415/422/429/507 are forwarded with their code; an
unreachable runtime becomes 503
runtime_unavailable, anything else 502runtime_error(apps/server/src/paths.ts,app.ts).
Terminal data path
xterm.js ⇄ WS /api/v1/sessions/:id/stream ⇄ server ws-pipe ⇄ WS /sessions/:id/attach ⇄ node-pty ⇄ tmux attach ⇄ pane- Binary frames are raw PTY bytes in both directions; text frames are JSON control messages
(
TermClientMsg:resize,ping;TermServerMsg:ready,pong,exit,error) defined inpackages/shared/src/wire.ts. - The server pipe (
apps/server/src/ws-pipe.ts) preserves frame types, validates client text frames and drops invalid ones, and applies backpressure in both directions. It never logs or inspects binary frames. - One browser connection is one
tmux attach-sessionclient in anode-pty(apps/runtime/src/core-attach.ts). Closing the WebSocket kills that client only. - The browser scrollback is written by the runtime from the tmux pane history, not by the attach stream
(
core-scrollback.ts). See terminal-runtime.md.
Events
tmux poll (2 s) / hook POST / task watcher ──> runtime EventBus ──> WS /events ──> server
──> SessionService / TaskService (state transitions in Postgres) ──> EventHub ──> WS /api/v1/events ──> browser- Runtime events (
RtEvent):session.exit,session.activity,claude.hook,task.updated. - Browser events (
ServerEvent):hello,session.updated,session.removed,task.updated,notification,auth.expired. - Both fan-outs drop a consumer whose outbound buffer exceeds 1 MiB instead of buffering without bound.
- The server's subscription to the runtime reconnects with a 1 s → 10 s backoff; on every reconnection and
every 15 s a reconciler compares the database with what the runtime reports
(
apps/server/src/app.ts,modules/sessions/service.ts,modules/tasks/service.ts). - The web client refetches the affected lists on every reconnect of its events socket
(
apps/web/src/lib/events.ts).
State machines
Defined once in packages/shared/src/states.ts, enforced server-side in transition() under a row lock;
every session transition writes a terminal_events row.
Terminal sessions:
| From | Allowed next states |
|---|---|
CREATED |
STARTING, FAILED, STOPPED |
STARTING |
RUNNING, DETACHED, FAILED, STOPPED |
RUNNING / DETACHED / WAITING_FOR_INPUT |
each other, STOPPING, STOPPED, FAILED, RECOVERABLE |
STOPPING |
STOPPED, FAILED |
RECOVERABLE |
STARTING (restart), STOPPED |
STOPPED, FAILED |
terminal |
RUNNING/DETACHEDfollow the number of attached clients.WAITING_FOR_INPUTis set only from a Claude Code hook (Notification,Stop);UserPromptSubmitreturns the session toRUNNING. Terminal output is never parsed.RECOVERABLEmeans the runtime is healthy and does not know the session (tmux session gone).
Tasks: QUEUED → RUNNING → SUCCEEDED | FAILED | CANCELLING → CANCELLED | TIMED_OUT | LOST. The server
inserts a QUEUED row and immediately hands it to the runtime; there is no scheduler or queue depth
limit. LOST (runner vanished without an exit file, or unknown to a healthy runtime) is never retried
automatically. Retry is a user action allowed only for tasks created with idempotent: true.
Health vocabulary (ONLINE, OFFLINE, DEGRADED, UNKNOWN): stale data is UNKNOWN, never ONLINE.
Storage
| Store | Location | Content |
|---|---|---|
PostgreSQL claude_remote |
loopback | users, devices (passkeys), auth_sessions, preauth_challenges, login_attempts, projects, terminal_sessions, terminal_events (lifecycle only), agent_tasks, task_artifacts (metadata), cluster_nodes, node_metrics, mcp_servers / installed_skills (listing caches), notifications, audit_events, application_settings, schema_migrations |
Data directory CR_DATA_DIR (0700) |
~/claude-remote-data |
run/runtime.sock, run/hook-token, run/tmux.conf, tasks/<id>/{command,run.sh,log,exit,started,time,meta.json}, setup-token (only while no administrator exists) |
Workspace root(s) CR_WORKSPACE_ROOTS |
~/workspace |
project directories; the only tree the file API can reach |
| Claude Code configuration | ~/.claude, ~/.claude.json |
owned by Claude Code; the runtime reads it and edits it only through claude mcp …, skill file moves and the hook block |
| tmux server memory | — | the live processes and up to 20 000 history lines per pane |
Retention is applied daily by apps/server/src/janitor.ts: terminal_events per the user setting
sessionRetentionDays (default 90), node_metrics 14 days, login_attempts 30 days, audit_events
400 days, notifications 90 days, auth_sessions 7 days after expiry or revocation, expired
preauth_challenges.
Migrations (apps/server/migrations/*.sql) are applied in lexical order, each in its own transaction,
under a session-level advisory lock, by dist/migrate.js and also at every server start.
Module map
| Area | Server (apps/server/src) |
Runtime (apps/runtime/src) |
Web (apps/web/src) |
|---|---|---|---|
| Auth | security/*, modules/auth/* |
auth.ts (IPC tokens) |
app/(auth)/*, components/auth, components/settings/security.tsx |
| Sessions / terminal | modules/sessions/*, modules/terminal-ws.ts, ws-pipe.ts, live.ts |
sessions.ts, tmux.ts, core-attach.ts, core-scrollback.ts, core-backpressure.ts, hooks.ts |
components/terminal/*, app/(app)/terminal, app/(app)/sessions |
| Projects / files | modules/projects, modules/files |
paths.ts, fs.ts, fsx-transfer.ts, fsx-search.ts, git.ts |
app/(app)/projects, app/(app)/files, components/files, components/editor |
| Tasks | modules/tasks/* |
tasks.ts, core-task-manager.ts, core-task-script.ts |
app/(app)/tasks, components/tasks |
| Skills / MCP | modules/skills-mcp/* |
skills.ts, fsx-skills-scan.ts, mcp.ts, fsx-mcp-config.ts, claude.ts |
app/(app)/skills, app/(app)/mcp |
| Cluster | modules/cluster/* |
metrics.ts |
app/(app)/cluster, components/cluster |
| Observability | modules/system, audit.ts, modules/notifications |
metrics.ts, events.ts |
app/(app)/system, components/shell/notifications.tsx |
Design decisions visible in the code
- The runtime is the only spawner. All subprocesses use argv arrays (
execFile/spawn, never a shell). A user string reaches a shell in exactly two places, both by design: the task command and the terminal itself. - Claude arguments are built server-side only (
buildClaudeArgsinmodules/sessions/index.ts): the browser can request "new", "continue" or "resume<id>"; raw CLI arguments are not accepted. The runtime independently refuses permission-bypass flags and option-like--resumevalues. - Never guess. If the runtime is unreachable the reconciler changes no state; metrics that cannot be
measured are
null; stale cluster data isUNKNOWN. - tmux as the persistence layer. Sessions and tasks live in a tmux server that outlives both Node processes; state is rebuilt from tmux and from files on disk after a restart.
Limitations / not verified
- The identity that runs everything is the cluster operator's macOS account. Process separation, the 0600 socket and path confinement are the isolation mechanisms; there is no OS-level user separation between server, runtime and the terminal sessions. See security.md.
- Single host, single PostgreSQL instance, no replication.
CONTRACT.mdlistsartifacts/,uploads/andbackups/underCR_DATA_DIR; the code creates none of them. Artifacts stay in the task's working directory (only metadata is stored), uploads are streamed to their destination through a temporary file in the same directory, and backups go to~/backups/claude-remote.ClusterNode.execAllowedis documented inpackages/shared/src/dto.tsas "on the allowlist AND reachable with the runtime key"; the implementation (modules/cluster/mapping.ts) only checks the allowlist.