Architecture

May 21, 2026 · View on GitHub

Nerve is a local-first web interface for OpenClaw: chat, voice input, TTS, workspace tooling, and agent monitoring in the browser. It usually talks to the OpenClaw gateway over WebSocket, but some UI surfaces also depend on whether the Nerve server can reach the agent workspace on its own filesystem.

System Diagram

┌──────────────────────────────────────────────────────────────────┐
│  Browser (React SPA)                                             │
│                                                                  │
│  ┌──────────┐  ┌──────────┐  ┌───────────┐  ┌────────────────┐  │
│  │ ChatPanel│  │ Sessions │  │ Workspace │  │ Command Palette│  │
│  └────┬─────┘  └────┬─────┘  └─────┬─────┘  └────────────────┘  │
│       │              │              │                             │
│  ┌────┴──────────────┴──────────────┴────────────────────────┐   │
│  │           React Contexts (Gateway, Session, Chat, Settings)│   │
│  └────────────────────────────┬──────────────────────────────┘   │
│                               │ WebSocket (/ws proxy)            │
└───────────────────────────────┼──────────────────────────────────┘

┌───────────────────────────────┼──────────────────────────────────┐
│  Nerve Server (Hono + Node)   │                                  │
│                               │                                  │
│  ┌────────────────────────────┴─────────────┐                    │
│  │         WebSocket Proxy (ws-proxy.ts)     │                   │
│  │  - Intercepts connect.challenge           │                   │
│  │  - Injects device identity (Ed25519)      │                   │
│  └────────────────────────────┬──────────────┘                   │
│                               │                                  │
│  ┌───────────────┐  ┌────────┴─────┐  ┌───────────────────────┐ │
│  │ REST API      │  │ SSE Stream   │  │ Static File Server    │ │
│  │ /api/*        │  │ /api/events  │  │ Vite build → dist/    │ │
│  └───────┬───────┘  └──────────────┘  └───────────────────────┘ │
│          │                                                       │
│  ┌───────┴──────────────────────────────────────────────────┐    │
│  │  Services: TTS (OpenAI, Replicate, Edge), Whisper,       │    │
│  │  Claude Usage, TTS Cache, Usage Tracker                  │    │
│  └──────────────────────────────────────────────────────────┘    │
└──────────────────────────────┬───────────────────────────────────┘
                               │ HTTP / WS
                    ┌──────────┴───────────────┐
                    │  OpenClaw Gateway         │
                    │  (default:                │
                    │   ws://127.0.0.1:18789,   │
                    │   but may be remote)      │
                    └──────────────────────────┘

Locality model

Nerve is easiest to reason about if you separate three boundaries:

  1. Browser ↔ Nerve server
  2. Nerve server ↔ OpenClaw gateway
  3. Nerve server ↔ agent workspace filesystem

The biggest docs trap is assuming only the gateway matters. It does not.

Full deployment-A behavior depends on Nerve having local access to the same workspace filesystem as the gateway-backed agent. That is true in deployment A, and usually also in same-host deployment C. It is not true in deployment B, or in split-host deployment C, unless you deliberately mount or replicate the workspace onto the Nerve host.

What each boundary changes

BoundaryLocal caseRemote case
Browser ↔ NerveLoopback browser access is implicitly trusted for official gateway auto-connect flowsRemote browsers should use NERVE_AUTH=true. Trusted authenticated sessions can still get server-side gateway token injection for the official gateway URL
Nerve ↔ GatewayDefault path. Minimal config, loopback origin, simplest support surfaceRequires WS_ALLOWED_HOSTS, correct gateway controlUi.allowedOrigins, and in some cases NERVE_PUBLIC_ORIGIN so server-side gateway RPC uses the real browser-facing origin
Nerve ↔ workspace filesystemFull file browser, file mutations, raw previews, local watchers, and normal workspace UXFalls back to gateway RPC only where specific routes support it. This is partial, not full parity

Remote workspace fallback boundaries

When the Nerve server cannot access the workspace directory locally, the app enters a partial fallback mode.

SurfaceLocal workspaceRemote workspace
Allowlisted config files (SOUL.md, TOOLS.md, USER.md, AGENTS.md, HEARTBEAT.md, IDENTITY.md)Normal local read/writeGateway fallback read/write for allowlisted top-level files only
File treeFull nested treeTop-level listing only
Text file read/writeAny allowed path in the workspaceTop-level text files only
Rename / move / trash / restoreSupported501 Not supported for remote workspaces
Raw image / binary previewSupportedNot available
Memory dataFull MEMORY.md plus recent daily filesLimited fallback for MEMORY.md; daily files are local-only, and the current UI treats remote workspaces as a constrained path rather than deployment-A parity
Skills tabLocal openclaw skills list scoped to the selected workspaceNo dedicated gateway file fallback. Verify behavior in split-topology installs instead of assuming parity

This is why deployment B and split-host deployment C feel different from deployment A even when chat itself is healthy.

Frontend Structure

Built with React 19, TypeScript, Vite, and Tailwind CSS v4.

Entry Point

FilePurpose
src/main.tsxMounts the React tree: ErrorBoundary → StrictMode → AuthGate. The auth gate checks session status before rendering the app
src/App.tsxRoot layout — wires contexts to lazy-loaded panels, manages keyboard shortcuts and command palette

Context Providers (State Management)

All global state flows through four React contexts, nested in dependency order:

ContextFileResponsibilities
GatewayContextsrc/contexts/GatewayContext.tsxWebSocket connection lifecycle, RPC method calls, event fan-out via pub/sub pattern, model/thinking status polling, activity sparkline
SettingsContextsrc/contexts/SettingsContext.tsxSound, TTS provider/model, wake word, panel ratio, theme, font, font size, telemetry/events visibility. Persists to localStorage
SessionContextsrc/contexts/SessionContext.tsxSession list (via gateway RPC), granular agent status tracking (IDLE/THINKING/STREAMING/DONE/ERROR), busy state derivation, unread session tracking, agent log, event log, session CRUD (delete, spawn, rename, abort)
ChatContextsrc/contexts/ChatContext.tsxThin orchestrator composing 4 hooks: useChatMessages (CRUD, history, scroll), useChatStreaming (deltas, processing stage, activity log), useChatRecovery (reconnect, retry, gap detection), useChatTTS (playback, voice fallback, sound feedback)

Data flow pattern: Contexts subscribe to gateway events via GatewayContext.subscribe(). The SessionContext listens for agent and chat events to update granular status. The ChatContext listens for streaming deltas and lifecycle events to render real-time responses.

Feature Modules

Each feature lives in src/features/<name>/ with its own components, hooks, types, and operations.

features/auth/

Authentication gate and login UI.

FilePurpose
AuthGate.tsxTop-level component — shows loading spinner, login page, or the full app depending on auth state
LoginPage.tsxFull-screen password form matching Nerve's dark theme. Auto-focus, Enter-to-submit, gateway token hint
useAuth.tsAuth state via useSyncExternalStore. Module-level fetch checks /api/auth/status once on load. Exposes login/logout callbacks
index.tsBarrel export

features/chat/

The main chat interface.

FilePurpose
ChatPanel.tsxFull chat view — message list with infinite scroll, input bar, streaming indicator, search
InputBar.tsxText input with voice recording, image attachment, tab completion, input history
MessageBubble.tsxRenders individual messages (user, assistant, tool, system) with markdown
ToolCallBlock.tsxRenders tool call blocks with name, arguments, and results
DiffView.tsxSide-by-side diff rendering for file edits
FileContentView.tsxSyntax-highlighted file content display
ImageLightbox.tsxFull-screen image viewer
SearchBar.tsxIn-chat message search (Cmd+F)
MemoriesSection.tsxInline memory display within chat
edit-blocks.tsParses edit/diff blocks from tool output
extractImages.tsExtracts image content blocks from messages
image-compress.tsClient-side image compression before upload
types.tsChat-specific types (ChatMsg, ImageAttachment)
utils.tsChat utility functions
useMessageSearch.tsHook for message search filtering
operations/Pure business logic (no React): loadHistory.ts, sendMessage.ts, streamEventHandler.ts
components/Sub-components: ActivityLog, ChatHeader, HeartbeatPulse, ProcessingIndicator, ScrollToBottomButton, StreamingMessage, ThinkingDots, ToolGroupBlock, useModelEffort

features/sessions/

Session management sidebar.

FilePurpose
SessionList.tsxHierarchical session tree with parent-child relationships
SessionNode.tsxIndividual session row with status indicator, context menu
SessionInfoPanel.tsxSession detail panel (model, tokens, thinking level)
SpawnAgentDialog.tsxDialog for spawning top-level agents and subagents with task, model, thinking, and subagent After run cleanup config
sessionTree.tsBuilds tree structure from flat session list using parentId
statusUtils.tsMaps agent status to icons and labels

features/file-browser/

Full workspace file browser with tabbed CodeMirror editor.

FilePurpose
FileTreePanel.tsxCollapsible file tree sidebar with directory expand/collapse
FileTreeNode.tsxIndividual file/directory row with icon and indent
EditorTabBar.tsxTab bar for open files with close buttons
EditorTab.tsxSingle editor tab with modified indicator
FileEditor.tsxCodeMirror 6 editor — syntax highlighting, line numbers, search, Cmd+S save
TabbedContentArea.tsxManages chat/editor tab switching (chat never unmounts)
editorTheme.tsOne Dark-inspired CodeMirror theme matching Nerve's dark aesthetic
hooks/useFileTree.tsFile tree data fetching and directory toggle state
hooks/useOpenFiles.tsOpen file tab management, save with mtime conflict detection
utils/fileIcons.tsxFile extension → icon mapping
utils/languageMap.tsFile extension → CodeMirror language extension mapping
types.tsShared types (FileNode, OpenFile, etc.)

features/workspace/

Workspace file editor and management tabs.

The workspace scope is derived from the owning top-level agent. Memory, Config, Skills, file-browser state, and persisted drafts follow that top-level agent. Crons and Kanban stay global.

FilePurpose
WorkspacePanel.tsxContainer for workspace tabs, scoped by the current top-level workspace agent
WorkspaceTabs.tsxTab switcher (Memory, Config, Crons, Skills)
tabs/MemoryTab.tsxView/edit the selected top-level agent's MEMORY.md and daily files
tabs/ConfigTab.tsxEdit scoped workspace files (SOUL.md, TOOLS.md, USER.md, etc.) for the selected top-level agent
tabs/CronsTab.tsxCron job management (list, create, toggle, run). Remains global
tabs/CronDialog.tsxCron creation/edit dialog
tabs/SkillsTab.tsxView installed skills for the selected top-level agent workspace
hooks/useWorkspaceFile.tsFetch/save scoped workspace files via REST API + agentId
hooks/useCrons.tsCron CRUD operations via REST API
hooks/useSkills.tsFetch scoped skills list via agentId
workspaceScope.tsDerives workspace scope and localStorage keys from the owning top-level agent
workspaceSwitchGuard.tsPure guard logic for dirty-file prompts when switching between top-level agents

Workspace scope rules

  • Root sessions use their own top-level agent as workspace scope
  • Subagent and cron-run views inherit the owning top-level agent workspace
  • The child session itself does not create a separate workspace scope
  • Cross-agent dirty file prompts only fire when the owning top-level agent changes
  • Crons and Kanban stay global even while Memory, Config, Skills, and file-browser state switch per agent

features/settings/

Settings drawer with tabbed sections.

FilePurpose
SettingsDrawer.tsxSlide-out drawer container. Includes logout button when auth is enabled
ConnectionSettings.tsxGateway URL/token, reconnect
AudioSettings.tsxTTS provider, model, voice, wake word
AppearanceSettings.tsxTheme, font family, font size selection

features/tts/

Text-to-speech integration.

FilePurpose
useTTS.tsCore TTS hook, speaks text via server /api/tts endpoint. Supports OpenAI, Replicate, Edge (default), and Xiaomi MiMo providers
useTTSConfig.tsServer-side TTS voice configuration management for Qwen, OpenAI, Edge, and Xiaomi MiMo settings

features/voice/

Voice input and audio feedback.

FilePurpose
useVoiceInput.tsWeb Speech API integration for voice-to-text with Whisper fallback
audio-feedback.tsNotification sounds (ping on response complete)

features/markdown/

Markdown rendering pipeline.

FilePurpose
MarkdownRenderer.tsxreact-markdown with remark-gfm, syntax highlighting via highlight.js
CodeBlockActions.tsxCopy/run buttons on code blocks

features/charts/

Inline chart rendering with three renderers: TradingView widgets for live financial data, Lightweight Charts for custom time-series and candlestick data, and Recharts for bar/pie charts.

FilePurpose
InlineChart.tsxChart router — dispatches [chart:{...}] markers to the correct renderer based on type
extractCharts.tsBracket-balanced parser for [chart:{...}] markers. Validates chart data by type (bar, line, pie, area, candle, tv)
LightweightChart.tsxRenders line, area, and candlestick charts using lightweight-charts (TradingView). Dark theme, gradient fills, crosshair, percentage change badges
TradingViewWidget.tsxEmbeds TradingView Advanced Chart widget via official script injection for real financial tickers (e.g. TVC:GOLD, BITSTAMP:BTCUSD)

Chart type routing:

TypeRendererUse case
tvTradingView WidgetLive financial tickers (stocks, crypto, forex, commodities)
line, areaLightweight ChartsCustom time-series data
candleLightweight ChartsCustom OHLC candlestick data
bar, pieRechartsCategory comparisons, proportions

features/command-palette/

Cmd+K command palette.

FilePurpose
CommandPalette.tsxFuzzy-search command list
commands.tsCommand definitions (new session, reset, theme, TTS, etc.)

features/connect/

FilePurpose
ConnectDialog.tsxInitial gateway connection dialog. Uses official gateway URL + serverSideAuth metadata from /api/connect-defaults to decide whether the token field is needed

features/activity/

FilePurpose
AgentLog.tsxScrolling agent activity log (tool calls, lifecycle events)
EventLog.tsxRaw gateway event stream display

features/dashboard/

FilePurpose
TokenUsage.tsxToken usage and cost display
MemoryList.tsxMemory listing component
useLimits.tsClaude Code / Codex rate limit polling

features/memory/

FilePurpose
MemoryEditor.tsxInline memory editing
MemoryItem.tsxIndividual memory display with edit/delete
AddMemoryDialog.tsxDialog for adding new memories
ConfirmDeleteDialog.tsxDelete confirmation
useMemories.tsMemory CRUD operations

Shared Components

PathPurpose
components/TopBar.tsxHeader with agent log, token data, event indicators
components/StatusBar.tsxFooter with connection state, session count, sparkline, context meter
components/ResizablePanels.tsxDraggable split layout (chat left, panels right)
components/ContextMeter.tsxVisual context window usage bar
components/ConfirmDialog.tsxReusable confirmation modal
components/ErrorBoundary.tsxTop-level error boundary
components/PanelErrorBoundary.tsxPer-panel error boundary (isolates failures)
components/NerveLogo.tsxSVG logo component
components/skeletons/Loading skeleton components (Message, Session, Memory)
components/ui/Primitives: button, card, dialog, input, switch, scroll-area, collapsible, AnimatedNumber, InlineSelect

Hooks

HookFilePurpose
useWebSockethooks/useWebSocket.tsCore WebSocket management — connect, RPC, auto-reconnect with exponential backoff
useConnectionManagerhooks/useConnectionManager.tsAuto-connect logic, official gateway resolution, serverSideAuth gating, and credential persistence in localStorage
useDashboardDatahooks/useDashboardData.tsFetches memories and token data via REST + SSE
useServerEventshooks/useServerEvents.tsSSE client for /api/events
useInputHistoryhooks/useInputHistory.tsUp/down arrow input history
useTabCompletionhooks/useTabCompletion.tsTab completion for slash commands
useKeyboardShortcutshooks/useKeyboardShortcuts.tsGlobal keyboard shortcut registration
useGitInfohooks/useGitInfo.tsGit branch/status display

Libraries

FilePurpose
lib/constants.tsApp constants: context window limits (with dynamic getContextLimit() fallback), wake/stop/cancel phrase builders, attachment limits
lib/themes.tsTheme definitions and CSS variable application
lib/fonts.tsFont configuration
lib/formatting.tsMessage formatting utilities
lib/sanitize.tsHTML sanitization via DOMPurify
lib/highlight.tsSyntax highlighting configuration
lib/utils.tscn() classname merge utility (clsx + tailwind-merge)
lib/progress-colors.tsColor scales for progress indicators
lib/text/isStructuredMarkdown.tsDetects structured markdown for rendering decisions

Backend Structure

Built with Hono (lightweight web framework), TypeScript, running on Node.js ≥22.

Entry Point

FilePurpose
server/index.tsStarts HTTP + HTTPS servers, sets up WebSocket proxy, file watchers, graceful shutdown
server/app.tsHono app definition — middleware stack, route mounting, static file serving with SPA fallback

Middleware Stack

Applied in order in app.ts:

MiddlewareFilePurpose
Error handlermiddleware/error-handler.tsCatches unhandled errors, returns consistent JSON. Shows stack in dev
LoggerHono built-inRequest logging
CORSHono built-in + customWhitelist of localhost origins + ALLOWED_ORIGINS env var. Validates via URL constructor. Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Security headersmiddleware/security-headers.tsStandard security headers (CSP, X-Frame-Options, etc.)
Body limitHono built-inConfigurable max body size (from config.limits.maxBodyBytes)
Authmiddleware/auth.tsWhen NERVE_AUTH=true, requires a valid signed session cookie on /api/* routes (except auth endpoints and health). WebSocket upgrades checked separately in ws-proxy.ts
CompressionHono built-ingzip/brotli on all routes except SSE (/api/events)
Cache headersmiddleware/cache-headers.tsHashed assets → immutable, API → no-cache, non-hashed static → must-revalidate
Rate limitingmiddleware/rate-limit.tsPer-IP sliding window. Separate limits for general API vs TTS/transcribe. Client ID from socket or custom header

API Routes

RouteFileMethodsPurpose
/healthroutes/health.tsGETHealth check with gateway connectivity probe
/api/auth/statusroutes/auth.tsGETCheck whether auth is enabled and current session validity
/api/auth/loginroutes/auth.tsPOSTAuthenticate with password, set signed session cookie
/api/auth/logoutroutes/auth.tsPOSTClear session cookie
/api/connect-defaultsroutes/connect-defaults.tsGETReturns the official gateway WS URL plus authEnabled / serverSideAuth metadata. token is always null; trusted flows use server-side token injection
/api/eventsroutes/events.tsGET, POSTSSE stream for real-time push (memory.changed, tokens.updated, status.changed, ping). POST for test events
/api/ttsroutes/tts.tsPOSTText-to-speech with provider auto-selection (OpenAI → Replicate → Edge). LRU cache with TTL
/api/tts/configroutes/tts.tsGET, PUTTTS voice configuration per provider (read / partial update)
/api/transcriberoutes/transcribe.tsPOSTAudio transcription via OpenAI Whisper or local whisper.cpp (STT_PROVIDER). Multipart file upload, MIME validation
/api/transcribe/configroutes/transcribe.tsGET, PUTSTT runtime config (provider/model/language), model readiness/download status, hot-reload updates
/api/languageroutes/transcribe.tsGET, PUTLanguage preference + Edge voice gender management (NERVE_LANGUAGE, EDGE_VOICE_GENDER)
/api/language/supportroutes/transcribe.tsGETFull provider × language compatibility matrix + current local model multilingual state
/api/voice-phrasesroutes/voice-phrases.tsGETMerged recognition phrase set (selected language + English fallback)
/api/voice-phrases/statusroutes/voice-phrases.tsGETPer-language custom phrase configuration status
/api/voice-phrases/:langroutes/voice-phrases.tsGET, PUTRead/save language-specific stop/cancel/wake phrase overrides
/api/agentlogroutes/agent-log.tsGET, POSTAgent activity log persistence. Zod-validated entries. Mutex-protected file I/O
/api/tokensroutes/tokens.tsGETToken usage statistics — scans session transcripts, persists high water mark
/api/memoriesroutes/memories.tsGET, POST, DELETEAgent-scoped memory management. Full local behavior reads MEMORY.md plus daily files. Remote fallback is limited to MEMORY.md because gateway file access does not cover subdirectories
/api/memories/sectionroutes/memories.tsGET, PUTRead/replace a specific memory section by title, scoped via agentId. Remote fallback applies to MEMORY.md, not daily files
/api/gateway/modelsroutes/gateway.tsGETConfig-backed model catalog from the active OpenClaw config. Returns { models, error, source: "config" }
/api/gateway/session-inforoutes/gateway.tsGETCurrent session model/thinking level
/api/gateway/session-patchroutes/gateway.tsPOSTHTTP fallback for model changes. Thinking changes belong on WS sessions.patch
/api/server-inforoutes/server-info.tsGETServer time, gateway uptime, agent name
/api/versionroutes/version.tsGETPackage version from package.json
/api/version/checkroutes/version-check.tsGETCheck whether a newer published version is available
/api/channelsroutes/channels.tsGETList configured messaging channels from OpenClaw config
/api/gateway/restartroutes/gateway.tsPOSTRestart the OpenClaw gateway service and verify readiness
/api/sessions/hiddenroutes/sessions.tsGETList hidden cron-like sessions from stored session metadata
/api/sessions/:id/modelroutes/sessions.tsGETRead the actual model used by a session from its transcript
/api/workspaceroutes/workspace.tsGETList allowlisted workspace files for the selected agent workspace. Falls back to gateway file RPC when the workspace is not local
/api/workspace/:keyroutes/workspace.tsGET, PUTRead/write allowlisted workspace files (soul, tools, identity, user, agents, heartbeat) via agentId. Remote fallback is top-level allowlisted files only
/api/cronsroutes/crons.tsGET, POST, PATCH, DELETECron job CRUD via gateway tool invocation
/api/crons/:id/toggleroutes/crons.tsPOSTToggle cron enabled/disabled
/api/crons/:id/runroutes/crons.tsPOSTRun cron job immediately
/api/crons/:id/runsroutes/crons.tsGETCron run history
/api/skillsroutes/skills.tsGETList skills for the selected agent workspace via a scoped local OpenClaw config. This route does not use the remote file-browser fallback path
/api/keysroutes/api-keys.tsGET, PUTRead API-key presence and persist updated key values to .env
/api/filesroutes/files.tsGETServe local image files (MIME-type restricted, directory traversal blocked)
/api/files/treeroutes/file-browser.tsGETAgent-scoped workspace directory tree. Full nested tree locally, top-level-only fallback remotely
/api/files/readroutes/file-browser.tsGETRead scoped file contents with mtime for conflict detection. Remote fallback is top-level text files only
/api/files/writeroutes/file-browser.tsPUTWrite scoped file contents with optimistic concurrency (409 on conflict). Remote fallback is top-level text files only
/api/files/renameroutes/file-browser.tsPOSTRename a file or directory within the selected workspace. Local workspaces only
/api/files/moveroutes/file-browser.tsPOSTMove a file or directory within the selected workspace. Local workspaces only
/api/files/trashroutes/file-browser.tsPOSTTrash a file or directory, or permanently delete when using FILE_BROWSER_ROOT. Local workspaces only
/api/files/restoreroutes/file-browser.tsPOSTRestore a trashed file or directory. Local workspaces only
/api/files/rawroutes/file-browser.tsGETServe scoped image previews from the selected workspace. No remote fallback
/api/claude-code-limitsroutes/claude-code-limits.tsGETClaude Code rate limits via PTY + CLI parsing
/api/codex-limitsroutes/codex-limits.tsGETCodex rate limits via OpenAI API with local file fallback
/api/kanban/tasksroutes/kanban.tsGET, POSTTask CRUD -- list (with filters/pagination) and create
/api/kanban/tasks/:idroutes/kanban.tsGET, PATCH, DELETEGet, update (CAS-versioned), and delete tasks
/api/kanban/tasks/:id/reorderroutes/kanban.tsPOSTReorder/move tasks across columns
/api/kanban/tasks/:id/executeroutes/kanban.tsPOSTSpawn agent session for task
/api/kanban/tasks/:id/completeroutes/kanban.tsPOSTComplete a running task (auto-called by poller)
/api/kanban/tasks/:id/approveroutes/kanban.tsPOSTApprove task in review -> done
/api/kanban/tasks/:id/rejectroutes/kanban.tsPOSTReject task in review -> todo
/api/kanban/tasks/:id/abortroutes/kanban.tsPOSTAbort running task -> todo
/api/kanban/proposalsroutes/kanban.tsGET, POSTList and create proposals
/api/kanban/proposals/:id/approveroutes/kanban.tsPOSTApprove pending proposal
/api/kanban/proposals/:id/rejectroutes/kanban.tsPOSTReject pending proposal
/api/kanban/configroutes/kanban.tsGET, PUTBoard configuration

Server Libraries

FilePurpose
lib/config.tsCentralized configuration from env vars — ports, keys, paths, limits, auth settings. Validated at startup
lib/session.tsSession token creation/verification (HMAC-SHA256), password hashing (scrypt), cookie parsing for WS upgrade requests
lib/ws-proxy.tsWebSocket proxy — client→gateway with session cookie auth on upgrade and Ed25519 device identity injection
lib/device-identity.tsEd25519 keypair generation/persistence (~/.nerve/device-identity.json). Builds signed connect blocks for gateway auth
lib/gateway-client.tsHTTP client for gateway tool invocation API (/tools/invoke)
lib/file-watcher.tsDiscovers agent workspaces, watches each MEMORY.md and memory/ directory, and optionally watches full workspaces recursively. Broadcasts agent-tagged memory.changed / file.changed SSE events
lib/file-utils.tsFile browser utilities — path validation, directory exclusions, binary file detection
lib/files.tsAsync file helpers (readJSON, writeJSON, readText)
lib/mutex.tsAsync mutex for serializing file read-modify-write. Includes keyed mutex variant
lib/env-file.tsMutex-protected .env key upserts for hot-reload settings writes (writeEnvKey)
lib/language.tsLanguage/provider compatibility helpers (isLanguageSupported, fallback metadata)
lib/voice-phrases.tsRuntime per-language phrase storage/merge (custom overrides + English fallback)
lib/cached-fetch.tsGeneric TTL cache with in-flight request deduplication
lib/usage-tracker.tsPersistent token usage high water mark tracking
lib/tts-config.tsTTS voice configuration file management
lib/openclaw-bin.tsResolves openclaw binary path (env → sibling of node → common paths → PATH)

Services

FilePurpose
services/openai-tts.tsOpenAI TTS API client (gpt-4o-mini-tts, tts-1, tts-1-hd)
services/replicate-tts.tsReplicate API client for hosted TTS models (Qwen3-TTS). WAV→MP3 via ffmpeg
services/edge-tts.tsMicrosoft Edge Read-Aloud TTS via WebSocket protocol. Free, zero-config. Includes Sec-MS-GEC token generation
services/tts-cache.tsLRU in-memory TTS cache with TTL expiry (100 MB budget)
services/openai-whisper.tsOpenAI Whisper transcription client
services/whisper-local.tsLocal whisper.cpp STT via @fugood/whisper.node. Singleton model context, auto-download from HuggingFace, GPU detection
services/claude-usage.tsClaude Code CLI usage/limits parser via node-pty

Updater (server/lib/updater/)

Self-update system invoked via npm run update (entrypoint: bin/nerve-update.tsbin-dist/bin/nerve-update.js).

FilePurpose
orchestrator.tsState machine: lock → preflight → resolve → snapshot → update → build → restart → health → rollback
preflight.tsValidates git, Node.js, npm versions and git repo state
release-resolver.tsFinds latest semver tag via git ls-remote --tags, falls back to local tags
snapshot.tsSaves current git ref, version, and .env backup to ~/.nerve/updater/
installer.tsgit fetch + checkout --force, npm install, npm run build + build:server
service-manager.tsAuto-detects systemd or launchd, provides restart/status/logs
health.tsPolls /health and /api/version with exponential backoff (60s deadline)
rollback.tsRestores snapshot ref, clean rebuilds, restarts service
lock.tsPID-based exclusive lock file (wx flag) with stale detection
reporter.tsFormatted terminal output with stage progress, colors, and dry-run markers
types.tsShared types, exit codes, UpdateError class

Compiled separately via config/tsconfig.bin.json to avoid changing the server's rootDir.


Data Flow

WebSocket Proxy

Browser WS → /ws?target=ws://gateway:18789/ws → ws-proxy.ts → OpenClaw Gateway
  1. Client connects to /ws endpoint on Nerve server
  2. When auth is enabled, the session cookie is verified on the HTTP upgrade request (rejects with 401 if invalid)
  3. Proxy validates target URL against WS_ALLOWED_HOSTS allowlist. This matters in hybrid and split-host deployments where the gateway is not on loopback
  4. Proxy opens upstream WebSocket to the gateway
  5. On connect.challenge event, proxy intercepts the client's connect request and injects Ed25519 device identity (device block with signed nonce)
  6. If the gateway rejects the device (close code 1008), proxy retries without device identity (reduced scopes)
  7. After handshake, all messages are transparently forwarded bidirectionally
  8. Pending messages are buffered (capped at 100 messages / 1 MB) while upstream connects

Server-Sent Events (SSE)

Browser → GET /api/events → SSE stream (text/event-stream)

Events pushed by the server:

  • memory.changed — File watcher or memory API detects MEMORY.md / daily file changes, tagged with agentId
  • file.changed — File watcher detects a workspace file change, tagged with agentId
  • tokens.updated — Token usage data changed
  • status.changed — Gateway status changed
  • ping — Keep-alive every 30 seconds

SSE is excluded from compression middleware to avoid buffering.

REST API

REST endpoints serve two purposes:

  1. Proxy to gateway — Routes like /api/crons, /api/memories (POST/DELETE), /api/gateway/* invoke gateway tools via invokeGatewayTool()
  2. Local server data — Routes like /api/tokens, /api/agentlog, /api/server-info read from local files or process info

Gateway RPC (via WebSocket)

The frontend calls gateway methods via GatewayContext.rpc():

MethodPurpose
statusGet current agent model, thinking level
sessions.listList active sessions
sessions.deleteDelete a session
sessions.resetClear session context
sessions.patchPatch session metadata and settings, including rename/model/thinking flows the gateway supports
chat.sendSend a message (with idempotency key)
chat.historyLoad message history
chat.abortAbort current generation
connectInitial handshake with auth/device identity

Event Types (Gateway → Client)

EventPayloadPurpose
connect.challenge{ nonce }Auth handshake initiation
chat{ sessionKey, state, message?, content? }Chat state changes: started, delta, final, error, aborted
agent{ sessionKey, state, stream, data? }Agent lifecycle: lifecycle.start/end/error, tool.start/result, assistant stream
cron{ name }Cron job triggered
exec.approval.requestExec approval requested
exec.approval.resolvedExec approval granted
presencePresence updates

Kanban Subsystem

The kanban board provides task management with agent execution, drag-and-drop reordering, and a proposal system for agent-initiated changes.

Store Design

${NERVE_DATA_DIR:-~/.nerve}/kanban/tasks.json   -- single JSON file (tasks + proposals + config)
${NERVE_DATA_DIR:-~/.nerve}/kanban/audit.log    -- append-only audit log (JSONL)

All data lives in one JSON file (StoreData). Every mutation acquires an async mutex, reads the file, applies the change, and writes back atomically via temp-file rename. This guarantees consistency under concurrent requests without a database. On first startup, the store migrates legacy data from server-dist/data/kanban/ or server/data/kanban/ into the canonical runtime directory if needed.

FilePurpose
server/lib/kanban-store.tsKanbanStore class -- mutex-protected CRUD, workflow transitions, CAS versioning, proposal management
server/routes/kanban.tsHono routes, Zod validation, gateway session spawning, poll loop
server/lib/parseMarkers.tsRegex-based [kanban:create]/[kanban:update] marker extraction

The store schema is versioned (meta.schemaVersion). A migrate() function runs on every read to backfill new fields transparently.

State Machine

                    execute          complete (success)        approve
  backlog --------+                  +---- review ------------ done
                  v                  |       |
  todo --------- in-progress -------+       | reject
                  |                          v
                  | abort / error         todo (run cleared)
                  +---------------------> todo
TransitionFromToTrigger
Executebacklog, todoin-progressPOST .../execute
Complete (success)in-progressreviewPoller or POST .../complete
Complete (error)in-progresstodoPoller or POST .../complete with error
ApprovereviewdonePOST .../approve
RejectreviewtodoPOST .../reject (clears run + result)
Abortin-progresstodoPOST .../abort

The cancelled status exists in the schema but has no automatic transitions -- tasks are moved there manually via PATCH.

CAS Versioning

Every task has a version field (starts at 1, incremented on every mutation). Mutating endpoints (PATCH, reorder, workflow actions) require the client to send the current version. If it doesn't match, the server returns 409 with the latest task so the client can retry:

{ "error": "version_conflict", "serverVersion": 5, "latest": { "..." } }

This prevents stale overwrites from concurrent editors (drag-and-drop, API clients, agent completions).

Agent Execution Flow

1. POST /api/kanban/tasks/:id/execute
   +-- withMutex(`kanban-execute:${id}`) prevents double-launch races
   +-- if task already in-progress: return 409 duplicate_execution
   +-- if task has an assignee root:
   |    +-- resolve assignee root -> agent:<assignee>:main
   |    +-- gatewayRpcCall('sessions.list', ...) confirms the parent root exists
   |    +-- store.executeTask(..., { sessionKey }) -> status = in-progress, run.status = running
   |    +-- launchKanbanFallbackSubagentViaRpc({ label, task, parentSessionKey, model?, thinking? })
   |         +-- gatewayRpcCall('sessions.create', { key: childSessionKey, parentSessionKey, label, model? })
   |         +-- gatewayRpcCall('sessions.send', { key: childSessionKey, message: task, thinking?, idempotencyKey })
   |         +-- if send fails after create: best-effort gatewayRpcCall('sessions.delete', { key: childSessionKey, deleteTranscript: true })
   |         +-- return correlationKey + childSessionKey + runId?
   |         +-- attach childSessionKey / runId immediately when available
   |         +-- start pollFallbackSessionCompletion(taskId, { correlationKey, parentSessionKey, childSessionKey?, expectedChildLabel, knownSessionKeysBefore, runId? })
   |
   +-- else if task is unassigned / operator:
   |    +-- on macOS: return 409 invalid_execution_target
   |    +-- otherwise use invokeGatewayTool('sessions_spawn', { task, mode:'run', label: runSessionKey, model?, thinking? })
   |    +-- attach childSessionKey / runId when available
   |    +-- start pollSessionCompletion(taskId, { correlationKey: runSessionKey, childSessionKey?, runId? })
   |
   +-- if an assigned parent root is missing: return 409 invalid_execution_target
   +-- on launch failure: store.completeRun(taskId, sessionKey, undefined, 'Spawn failed: ...')

2. pollSessionCompletion() / pollFallbackSessionCompletion()
   +-- sessions_spawn path polls gateway subagents by correlation key / childSessionKey / runId
   +-- assignee-root path polls gateway RPC sessions.list every 5s (max 720 attempts / 60 min)
   +-- assignee-root path prefers the known childSessionKey; otherwise it discovers the new child beneath the parent root and attaches it
   +-- if the child completes successfully:
       |   fetch child history via sessions.get / sessions_history
       |   parseKanbanMarkers(resultText) -> create proposals
       |   stripKanbanMarkers(resultText) -> clean result
       |   store.completeRun(taskId, sessionKey, cleanResult)
       +-- gatewayRpcCall('sessions.send', { key: parentSessionKey, message: completionReport })
   +-- if status=error/failed:
       |   store.completeRun(taskId, sessionKey, undefined, errorMsg)
       +-- gatewayRpcCall('sessions.send', { key: parentSessionKey, message: failureReport })
   +-- if task/run no longer matches the active session key:
       +-- stop polling as stale
   +-- otherwise:
       +-- schedule next poll

3. store.completeRun()
   |-- success -> run.status = done, task.status = review
   +-- error   -> run.status = error, task.status = todo

Assigned-root execution now uses real session primitives instead of synthetic marker-message spawn conventions. The model cascade is: execute request model -> task model -> board config defaultModel -> OpenClaw's configured default model. Thinking follows the same pattern with defaultThinking.

Marker Parsing

When agent output arrives (via poller or POST .../complete), it's scanned for kanban markers:

[kanban:create]{"title":"Fix login bug","priority":"high"}[/kanban:create]
[kanban:update]{"id":"abc","status":"done"}[/kanban:update]

Each valid marker creates a proposal in the store. The markers are then stripped from the result text before it's saved on the task. See Agent Markers for the full format.

Proposal System

Proposals let agents suggest task changes without directly modifying the board:

  1. Agent emits [kanban:create] or [kanban:update] markers in its output
  2. Backend parses markers -> creates proposals with status: "pending"
  3. Frontend polls proposals every 5 seconds -> shows inbox notification
  4. Operator approves or rejects each proposal

The proposalPolicy config controls behavior:

  • "confirm" (default) -- proposals stay pending until the operator acts
  • "auto" -- proposals are applied immediately on creation

Polling Architecture

WhatWhoIntervalPurpose
Task listFrontend5sSync board state across tabs/users
ProposalsFrontend5sShow new proposals in inbox
Gateway subagentsBackend5sDetect when agent runs complete

Backend polling for each running task is independent -- each executeTask call starts its own poll loop (capped at 720 attempts = 60 minutes). Stale runs are reconciled by reconcileStaleRuns().


Build System

Development

npm run dev          # Vite dev server (frontend) — port 3080
npm run dev:server   # tsx watch (backend) — port 3081

Vite proxies /api and /ws to the backend dev server.

Production

npm run prod         # Builds frontend + backend, then starts
# Equivalent to:
npm run build        # tsc -b && vite build → dist/
npm run build:server # tsc -p config/tsconfig.server.json → server-dist/
npm start            # node server-dist/index.js

Vite Configuration

  • Plugins: @vitejs/plugin-react, @tailwindcss/vite
  • Path alias: @/./src/
  • Manual chunks: react-vendor, markdown (react-markdown + highlight.js), ui-vendor (lucide-react), utils (clsx, tailwind-merge, dompurify)
  • HTTPS: Auto-enabled if certs/cert.pem and certs/key.pem exist

TypeScript Configuration

Project references with four configs:

  • config/tsconfig.app.json — Frontend (src/)
  • config/tsconfig.node.json — Vite/build tooling
  • config/tsconfig.server.json — Backend (server/) → compiled to server-dist/
  • config/tsconfig.scripts.json — Setup scripts
  • config/tsconfig.bin.json — CLI tools (bin/) → compiled to bin-dist/

Testing

Framework: Vitest with jsdom environment for React tests.

npm test              # Run all tests
npm run test:coverage # With V8 coverage

Test Coverage Areas

The Vitest suite spans server code, client features, hooks, and shared utilities. Representative areas:

AreaFilesCoverage
Server libconfig, device-identity, env-file, gateway-client, mutex, voice-language-coverage, ws-proxyAuth, config resolution, WS proxy relay, device identity signing
Server routesauth, events, files, gateway, health, sessions, skillsAPI endpoints, error handling, gateway proxy
Server middlewareauth, error-handler, rate-limit, security-headersRequest pipeline
Chat operationssendMessage, streamEventHandler, loadHistory, mergeRecoveredTailMessage lifecycle, streaming, history recovery
Client featuresextractCharts, extractImages, edit-blocks, MarkdownRenderer, ContextMeter, ErrorBoundary, sessionTreeRendering, parsing, UI components
HooksuseAuth, useInputHistory, useKeyboardShortcuts, useServerEvents, useTTSClient-side state and interaction
Voice/audioaudio-feedback, useVoiceInput, voice-prefixVoice input, TTS, wake/stop phrase parsing
Utilitiesconstants, formatting, sanitize, unreadSessionsShared logic

Configuration

  • Environment: jsdom (browser APIs mocked)
  • Setup: src/test/setup.ts
  • Exclusions: node_modules/, server-dist/ (avoids duplicate compiled test files)
  • Coverage: V8 provider, text + HTML + lcov reporters