* chore(release): prepare 1.5.2 * docs(changelog): include kanban assignee picker in 1.5.2 * fix(sessions): preserve status timers across resubscribe * fix(sessions): use latest gateway for delayed refreshes * fix(sessions): refresh unknown sessions via latest gateway
44 KiB
Architecture
Nerve is a web interface for OpenClaw — chat, voice input, TTS, and agent monitoring in the browser. It connects to the OpenClaw gateway over WebSocket and provides a rich UI for interacting with AI agents.
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 │
│ (ws://127.0.0.1: │
│ 18789) │
└─────────────────────┘
Frontend Structure
Built with React 19, TypeScript, Vite, and Tailwind CSS v4.
Entry Point
| File | Purpose |
|---|---|
src/main.tsx |
Mounts the React tree: ErrorBoundary → StrictMode → AuthGate. The auth gate checks session status before rendering the app |
src/App.tsx |
Root 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:
| Context | File | Responsibilities |
|---|---|---|
| GatewayContext | src/contexts/GatewayContext.tsx |
WebSocket connection lifecycle, RPC method calls, event fan-out via pub/sub pattern, model/thinking status polling, activity sparkline |
| SettingsContext | src/contexts/SettingsContext.tsx |
Sound, TTS provider/model, wake word, panel ratio, theme, font, font size, telemetry/events visibility. Persists to localStorage |
| SessionContext | src/contexts/SessionContext.tsx |
Session 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) |
| ChatContext | src/contexts/ChatContext.tsx |
Thin 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.
| File | Purpose |
|---|---|
AuthGate.tsx |
Top-level component — shows loading spinner, login page, or the full app depending on auth state |
LoginPage.tsx |
Full-screen password form matching Nerve's dark theme. Auto-focus, Enter-to-submit, gateway token hint |
useAuth.ts |
Auth state via useSyncExternalStore. Module-level fetch checks /api/auth/status once on load. Exposes login/logout callbacks |
index.ts |
Barrel export |
features/chat/
The main chat interface.
| File | Purpose |
|---|---|
ChatPanel.tsx |
Full chat view — message list with infinite scroll, input bar, streaming indicator, search |
InputBar.tsx |
Text input with voice recording, image attachment, tab completion, input history |
MessageBubble.tsx |
Renders individual messages (user, assistant, tool, system) with markdown |
ToolCallBlock.tsx |
Renders tool call blocks with name, arguments, and results |
DiffView.tsx |
Side-by-side diff rendering for file edits |
FileContentView.tsx |
Syntax-highlighted file content display |
ImageLightbox.tsx |
Full-screen image viewer |
SearchBar.tsx |
In-chat message search (Cmd+F) |
MemoriesSection.tsx |
Inline memory display within chat |
edit-blocks.ts |
Parses edit/diff blocks from tool output |
extractImages.ts |
Extracts image content blocks from messages |
image-compress.ts |
Client-side image compression before upload |
types.ts |
Chat-specific types (ChatMsg, ImageAttachment) |
utils.ts |
Chat utility functions |
useMessageSearch.ts |
Hook 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.
| File | Purpose |
|---|---|
SessionList.tsx |
Hierarchical session tree with parent-child relationships |
SessionNode.tsx |
Individual session row with status indicator, context menu |
SessionInfoPanel.tsx |
Session detail panel (model, tokens, thinking level) |
SpawnAgentDialog.tsx |
Dialog for spawning top-level agents and subagents with task, model, thinking, and subagent After run cleanup config |
sessionTree.ts |
Builds tree structure from flat session list using parentId |
statusUtils.ts |
Maps agent status to icons and labels |
features/file-browser/
Full workspace file browser with tabbed CodeMirror editor.
| File | Purpose |
|---|---|
FileTreePanel.tsx |
Collapsible file tree sidebar with directory expand/collapse |
FileTreeNode.tsx |
Individual file/directory row with icon and indent |
EditorTabBar.tsx |
Tab bar for open files with close buttons |
EditorTab.tsx |
Single editor tab with modified indicator |
FileEditor.tsx |
CodeMirror 6 editor — syntax highlighting, line numbers, search, Cmd+S save |
TabbedContentArea.tsx |
Manages chat/editor tab switching (chat never unmounts) |
editorTheme.ts |
One Dark-inspired CodeMirror theme matching Nerve's dark aesthetic |
hooks/useFileTree.ts |
File tree data fetching and directory toggle state |
hooks/useOpenFiles.ts |
Open file tab management, save with mtime conflict detection |
utils/fileIcons.tsx |
File extension → icon mapping |
utils/languageMap.ts |
File extension → CodeMirror language extension mapping |
types.ts |
Shared 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.
| File | Purpose |
|---|---|
WorkspacePanel.tsx |
Container for workspace tabs, scoped by the current top-level workspace agent |
WorkspaceTabs.tsx |
Tab switcher (Memory, Config, Crons, Skills) |
tabs/MemoryTab.tsx |
View/edit the selected top-level agent's MEMORY.md and daily files |
tabs/ConfigTab.tsx |
Edit scoped workspace files (SOUL.md, TOOLS.md, USER.md, etc.) for the selected top-level agent |
tabs/CronsTab.tsx |
Cron job management (list, create, toggle, run). Remains global |
tabs/CronDialog.tsx |
Cron creation/edit dialog |
tabs/SkillsTab.tsx |
View installed skills for the selected top-level agent workspace |
hooks/useWorkspaceFile.ts |
Fetch/save scoped workspace files via REST API + agentId |
hooks/useCrons.ts |
Cron CRUD operations via REST API |
hooks/useSkills.ts |
Fetch scoped skills list via agentId |
workspaceScope.ts |
Derives workspace scope and localStorage keys from the owning top-level agent |
workspaceSwitchGuard.ts |
Pure 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.
| File | Purpose |
|---|---|
SettingsDrawer.tsx |
Slide-out drawer container. Includes logout button when auth is enabled |
ConnectionSettings.tsx |
Gateway URL/token, reconnect |
AudioSettings.tsx |
TTS provider, model, voice, wake word |
AppearanceSettings.tsx |
Theme, font family, font size selection |
features/tts/
Text-to-speech integration.
| File | Purpose |
|---|---|
useTTS.ts |
Core TTS hook, speaks text via server /api/tts endpoint. Supports OpenAI, Replicate, Edge (default), and Xiaomi MiMo providers |
useTTSConfig.ts |
Server-side TTS voice configuration management for Qwen, OpenAI, Edge, and Xiaomi MiMo settings |
features/voice/
Voice input and audio feedback.
| File | Purpose |
|---|---|
useVoiceInput.ts |
Web Speech API integration for voice-to-text with Whisper fallback |
audio-feedback.ts |
Notification sounds (ping on response complete) |
features/markdown/
Markdown rendering pipeline.
| File | Purpose |
|---|---|
MarkdownRenderer.tsx |
react-markdown with remark-gfm, syntax highlighting via highlight.js |
CodeBlockActions.tsx |
Copy/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.
| File | Purpose |
|---|---|
InlineChart.tsx |
Chart router — dispatches [chart:{...}] markers to the correct renderer based on type |
extractCharts.ts |
Bracket-balanced parser for [chart:{...}] markers. Validates chart data by type (bar, line, pie, area, candle, tv) |
LightweightChart.tsx |
Renders line, area, and candlestick charts using lightweight-charts (TradingView). Dark theme, gradient fills, crosshair, percentage change badges |
TradingViewWidget.tsx |
Embeds TradingView Advanced Chart widget via official script injection for real financial tickers (e.g. TVC:GOLD, BITSTAMP:BTCUSD) |
Chart type routing:
| Type | Renderer | Use case |
|---|---|---|
tv |
TradingView Widget | Live financial tickers (stocks, crypto, forex, commodities) |
line, area |
Lightweight Charts | Custom time-series data |
candle |
Lightweight Charts | Custom OHLC candlestick data |
bar, pie |
Recharts | Category comparisons, proportions |
features/command-palette/
Cmd+K command palette.
| File | Purpose |
|---|---|
CommandPalette.tsx |
Fuzzy-search command list |
commands.ts |
Command definitions (new session, reset, theme, TTS, etc.) |
features/connect/
| File | Purpose |
|---|---|
ConnectDialog.tsx |
Initial gateway connection dialog. Uses official gateway URL + serverSideAuth metadata from /api/connect-defaults to decide whether the token field is needed |
features/activity/
| File | Purpose |
|---|---|
AgentLog.tsx |
Scrolling agent activity log (tool calls, lifecycle events) |
EventLog.tsx |
Raw gateway event stream display |
features/dashboard/
| File | Purpose |
|---|---|
TokenUsage.tsx |
Token usage and cost display |
MemoryList.tsx |
Memory listing component |
useLimits.ts |
Claude Code / Codex rate limit polling |
features/memory/
| File | Purpose |
|---|---|
MemoryEditor.tsx |
Inline memory editing |
MemoryItem.tsx |
Individual memory display with edit/delete |
AddMemoryDialog.tsx |
Dialog for adding new memories |
ConfirmDeleteDialog.tsx |
Delete confirmation |
useMemories.ts |
Memory CRUD operations |
Shared Components
| Path | Purpose |
|---|---|
components/TopBar.tsx |
Header with agent log, token data, event indicators |
components/StatusBar.tsx |
Footer with connection state, session count, sparkline, context meter |
components/ResizablePanels.tsx |
Draggable split layout (chat left, panels right) |
components/ContextMeter.tsx |
Visual context window usage bar |
components/ConfirmDialog.tsx |
Reusable confirmation modal |
components/ErrorBoundary.tsx |
Top-level error boundary |
components/PanelErrorBoundary.tsx |
Per-panel error boundary (isolates failures) |
components/NerveLogo.tsx |
SVG logo component |
components/skeletons/ |
Loading skeleton components (Message, Session, Memory) |
components/ui/ |
Primitives: button, card, dialog, input, switch, scroll-area, collapsible, AnimatedNumber, InlineSelect |
Hooks
| Hook | File | Purpose |
|---|---|---|
useWebSocket |
hooks/useWebSocket.ts |
Core WebSocket management — connect, RPC, auto-reconnect with exponential backoff |
useConnectionManager |
hooks/useConnectionManager.ts |
Auto-connect logic, official gateway resolution, serverSideAuth gating, and credential persistence in localStorage |
useDashboardData |
hooks/useDashboardData.ts |
Fetches memories and token data via REST + SSE |
useServerEvents |
hooks/useServerEvents.ts |
SSE client for /api/events |
useInputHistory |
hooks/useInputHistory.ts |
Up/down arrow input history |
useTabCompletion |
hooks/useTabCompletion.ts |
Tab completion for slash commands |
useKeyboardShortcuts |
hooks/useKeyboardShortcuts.ts |
Global keyboard shortcut registration |
useGitInfo |
hooks/useGitInfo.ts |
Git branch/status display |
Libraries
| File | Purpose |
|---|---|
lib/constants.ts |
App constants: context window limits (with dynamic getContextLimit() fallback), wake/stop/cancel phrase builders, attachment limits |
lib/themes.ts |
Theme definitions and CSS variable application |
lib/fonts.ts |
Font configuration |
lib/formatting.ts |
Message formatting utilities |
lib/sanitize.ts |
HTML sanitization via DOMPurify |
lib/highlight.ts |
Syntax highlighting configuration |
lib/utils.ts |
cn() classname merge utility (clsx + tailwind-merge) |
lib/progress-colors.ts |
Color scales for progress indicators |
lib/text/isStructuredMarkdown.ts |
Detects structured markdown for rendering decisions |
Backend Structure
Built with Hono (lightweight web framework), TypeScript, running on Node.js ≥22.
Entry Point
| File | Purpose |
|---|---|
server/index.ts |
Starts HTTP + HTTPS servers, sets up WebSocket proxy, file watchers, graceful shutdown |
server/app.ts |
Hono app definition — middleware stack, route mounting, static file serving with SPA fallback |
Middleware Stack
Applied in order in app.ts:
| Middleware | File | Purpose |
|---|---|---|
| Error handler | middleware/error-handler.ts |
Catches unhandled errors, returns consistent JSON. Shows stack in dev |
| Logger | Hono built-in | Request logging |
| CORS | Hono built-in + custom | Whitelist of localhost origins + ALLOWED_ORIGINS env var. Validates via URL constructor. Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS |
| Security headers | middleware/security-headers.ts |
Standard security headers (CSP, X-Frame-Options, etc.) |
| Body limit | Hono built-in | Configurable max body size (from config.limits.maxBodyBytes) |
| Auth | middleware/auth.ts |
When 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 |
| Compression | Hono built-in | gzip/brotli on all routes except SSE (/api/events) |
| Cache headers | middleware/cache-headers.ts |
Hashed assets → immutable, API → no-cache, non-hashed static → must-revalidate |
| Rate limiting | middleware/rate-limit.ts |
Per-IP sliding window. Separate limits for general API vs TTS/transcribe. Client ID from socket or custom header |
API Routes
| Route | File | Methods | Purpose |
|---|---|---|---|
/health |
routes/health.ts |
GET | Health check with gateway connectivity probe |
/api/auth/status |
routes/auth.ts |
GET | Check whether auth is enabled and current session validity |
/api/auth/login |
routes/auth.ts |
POST | Authenticate with password, set signed session cookie |
/api/auth/logout |
routes/auth.ts |
POST | Clear session cookie |
/api/connect-defaults |
routes/connect-defaults.ts |
GET | Returns the official gateway WS URL plus authEnabled / serverSideAuth metadata. token is always null; trusted flows use server-side token injection |
/api/events |
routes/events.ts |
GET, POST | SSE stream for real-time push (memory.changed, tokens.updated, status.changed, ping). POST for test events |
/api/tts |
routes/tts.ts |
POST | Text-to-speech with provider auto-selection (OpenAI → Replicate → Edge). LRU cache with TTL |
/api/tts/config |
routes/tts.ts |
GET, PUT | TTS voice configuration per provider (read / partial update) |
/api/transcribe |
routes/transcribe.ts |
POST | Audio transcription via OpenAI Whisper or local whisper.cpp (STT_PROVIDER). Multipart file upload, MIME validation |
/api/transcribe/config |
routes/transcribe.ts |
GET, PUT | STT runtime config (provider/model/language), model readiness/download status, hot-reload updates |
/api/language |
routes/transcribe.ts |
GET, PUT | Language preference + Edge voice gender management (NERVE_LANGUAGE, EDGE_VOICE_GENDER) |
/api/language/support |
routes/transcribe.ts |
GET | Full provider × language compatibility matrix + current local model multilingual state |
/api/voice-phrases |
routes/voice-phrases.ts |
GET | Merged recognition phrase set (selected language + English fallback) |
/api/voice-phrases/status |
routes/voice-phrases.ts |
GET | Per-language custom phrase configuration status |
/api/voice-phrases/:lang |
routes/voice-phrases.ts |
GET, PUT | Read/save language-specific stop/cancel/wake phrase overrides |
/api/agentlog |
routes/agent-log.ts |
GET, POST | Agent activity log persistence. Zod-validated entries. Mutex-protected file I/O |
/api/tokens |
routes/tokens.ts |
GET | Token usage statistics — scans session transcripts, persists high water mark |
/api/memories |
routes/memories.ts |
GET, POST, DELETE | Agent-scoped memory management — reads MEMORY.md + daily files, stores/deletes via gateway tool invocation |
/api/memories/section |
routes/memories.ts |
GET, PUT | Read/replace a specific memory section by title, scoped via agentId |
/api/gateway/models |
routes/gateway.ts |
GET | Config-backed model catalog from the active OpenClaw config. Returns { models, error, source: "config" } |
/api/gateway/session-info |
routes/gateway.ts |
GET | Current session model/thinking level |
/api/gateway/session-patch |
routes/gateway.ts |
POST | HTTP fallback for model changes. Thinking changes belong on WS sessions.patch |
/api/server-info |
routes/server-info.ts |
GET | Server time, gateway uptime, agent name |
/api/version |
routes/version.ts |
GET | Package version from package.json |
/api/version/check |
routes/version-check.ts |
GET | Check whether a newer published version is available |
/api/channels |
routes/channels.ts |
GET | List configured messaging channels from OpenClaw config |
/api/gateway/restart |
routes/gateway.ts |
POST | Restart the OpenClaw gateway service and verify readiness |
/api/sessions/hidden |
routes/sessions.ts |
GET | List hidden cron-like sessions from stored session metadata |
/api/sessions/:id/model |
routes/sessions.ts |
GET | Read the actual model used by a session from its transcript |
/api/workspace |
routes/workspace.ts |
GET | List allowlisted workspace files for the selected agent workspace |
/api/workspace/:key |
routes/workspace.ts |
GET, PUT | Read/write allowlisted workspace files (soul, tools, identity, user, agents, heartbeat) via agentId |
/api/crons |
routes/crons.ts |
GET, POST, PATCH, DELETE | Cron job CRUD via gateway tool invocation |
/api/crons/:id/toggle |
routes/crons.ts |
POST | Toggle cron enabled/disabled |
/api/crons/:id/run |
routes/crons.ts |
POST | Run cron job immediately |
/api/crons/:id/runs |
routes/crons.ts |
GET | Cron run history |
/api/skills |
routes/skills.ts |
GET | List skills for the selected agent workspace via a scoped OpenClaw config |
/api/keys |
routes/api-keys.ts |
GET, PUT | Read API-key presence and persist updated key values to .env |
/api/files |
routes/files.ts |
GET | Serve local image files (MIME-type restricted, directory traversal blocked) |
/api/files/tree |
routes/file-browser.ts |
GET | Agent-scoped workspace directory tree (excludes node_modules, .git, etc.) |
/api/files/read |
routes/file-browser.ts |
GET | Read scoped file contents with mtime for conflict detection |
/api/files/write |
routes/file-browser.ts |
PUT | Write scoped file contents with optimistic concurrency (409 on conflict) |
/api/files/rename |
routes/file-browser.ts |
POST | Rename a file or directory within the selected workspace |
/api/files/move |
routes/file-browser.ts |
POST | Move a file or directory within the selected workspace |
/api/files/trash |
routes/file-browser.ts |
POST | Trash a file or directory, or permanently delete when using FILE_BROWSER_ROOT |
/api/files/restore |
routes/file-browser.ts |
POST | Restore a trashed file or directory |
/api/files/raw |
routes/file-browser.ts |
GET | Serve scoped image previews from the selected workspace |
/api/claude-code-limits |
routes/claude-code-limits.ts |
GET | Claude Code rate limits via PTY + CLI parsing |
/api/codex-limits |
routes/codex-limits.ts |
GET | Codex rate limits via OpenAI API with local file fallback |
/api/kanban/tasks |
routes/kanban.ts |
GET, POST | Task CRUD -- list (with filters/pagination) and create |
/api/kanban/tasks/:id |
routes/kanban.ts |
GET, PATCH, DELETE | Get, update (CAS-versioned), and delete tasks |
/api/kanban/tasks/:id/reorder |
routes/kanban.ts |
POST | Reorder/move tasks across columns |
/api/kanban/tasks/:id/execute |
routes/kanban.ts |
POST | Spawn agent session for task |
/api/kanban/tasks/:id/complete |
routes/kanban.ts |
POST | Complete a running task (auto-called by poller) |
/api/kanban/tasks/:id/approve |
routes/kanban.ts |
POST | Approve task in review -> done |
/api/kanban/tasks/:id/reject |
routes/kanban.ts |
POST | Reject task in review -> todo |
/api/kanban/tasks/:id/abort |
routes/kanban.ts |
POST | Abort running task -> todo |
/api/kanban/proposals |
routes/kanban.ts |
GET, POST | List and create proposals |
/api/kanban/proposals/:id/approve |
routes/kanban.ts |
POST | Approve pending proposal |
/api/kanban/proposals/:id/reject |
routes/kanban.ts |
POST | Reject pending proposal |
/api/kanban/config |
routes/kanban.ts |
GET, PUT | Board configuration |
Server Libraries
| File | Purpose |
|---|---|
lib/config.ts |
Centralized configuration from env vars — ports, keys, paths, limits, auth settings. Validated at startup |
lib/session.ts |
Session token creation/verification (HMAC-SHA256), password hashing (scrypt), cookie parsing for WS upgrade requests |
lib/ws-proxy.ts |
WebSocket proxy — client→gateway with session cookie auth on upgrade and Ed25519 device identity injection |
lib/device-identity.ts |
Ed25519 keypair generation/persistence (~/.nerve/device-identity.json). Builds signed connect blocks for gateway auth |
lib/gateway-client.ts |
HTTP client for gateway tool invocation API (/tools/invoke) |
lib/file-watcher.ts |
Discovers 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.ts |
File browser utilities — path validation, directory exclusions, binary file detection |
lib/files.ts |
Async file helpers (readJSON, writeJSON, readText) |
lib/mutex.ts |
Async mutex for serializing file read-modify-write. Includes keyed mutex variant |
lib/env-file.ts |
Mutex-protected .env key upserts for hot-reload settings writes (writeEnvKey) |
lib/language.ts |
Language/provider compatibility helpers (isLanguageSupported, fallback metadata) |
lib/voice-phrases.ts |
Runtime per-language phrase storage/merge (custom overrides + English fallback) |
lib/cached-fetch.ts |
Generic TTL cache with in-flight request deduplication |
lib/usage-tracker.ts |
Persistent token usage high water mark tracking |
lib/tts-config.ts |
TTS voice configuration file management |
lib/openclaw-bin.ts |
Resolves openclaw binary path (env → sibling of node → common paths → PATH) |
Services
| File | Purpose |
|---|---|
services/openai-tts.ts |
OpenAI TTS API client (gpt-4o-mini-tts, tts-1, tts-1-hd) |
services/replicate-tts.ts |
Replicate API client for hosted TTS models (Qwen3-TTS). WAV→MP3 via ffmpeg |
services/edge-tts.ts |
Microsoft Edge Read-Aloud TTS via WebSocket protocol. Free, zero-config. Includes Sec-MS-GEC token generation |
services/tts-cache.ts |
LRU in-memory TTS cache with TTL expiry (100 MB budget) |
services/openai-whisper.ts |
OpenAI Whisper transcription client |
services/whisper-local.ts |
Local whisper.cpp STT via @fugood/whisper.node. Singleton model context, auto-download from HuggingFace, GPU detection |
services/claude-usage.ts |
Claude Code CLI usage/limits parser via node-pty |
Updater (server/lib/updater/)
Self-update system invoked via npm run update (entrypoint: bin/nerve-update.ts → bin-dist/bin/nerve-update.js).
| File | Purpose |
|---|---|
orchestrator.ts |
State machine: lock → preflight → resolve → snapshot → update → build → restart → health → rollback |
preflight.ts |
Validates git, Node.js, npm versions and git repo state |
release-resolver.ts |
Finds latest semver tag via git ls-remote --tags, falls back to local tags |
snapshot.ts |
Saves current git ref, version, and .env backup to ~/.nerve/updater/ |
installer.ts |
git fetch + checkout --force, npm install, npm run build + build:server |
service-manager.ts |
Auto-detects systemd or launchd, provides restart/status/logs |
health.ts |
Polls /health and /api/version with exponential backoff (60s deadline) |
rollback.ts |
Restores snapshot ref, clean rebuilds, restarts service |
lock.ts |
PID-based exclusive lock file (wx flag) with stale detection |
reporter.ts |
Formatted terminal output with stage progress, colors, and dry-run markers |
types.ts |
Shared 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
- Client connects to
/wsendpoint on Nerve server - When auth is enabled, the session cookie is verified on the HTTP upgrade request (rejects with 401 if invalid)
- Proxy validates target URL against
WS_ALLOWED_HOSTSallowlist - Proxy opens upstream WebSocket to the gateway
- On
connect.challengeevent, proxy intercepts the client'sconnectrequest and injects Ed25519 device identity (deviceblock with signed nonce) - If the gateway rejects the device (close code 1008), proxy retries without device identity (reduced scopes)
- After handshake, all messages are transparently forwarded bidirectionally
- 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 detectsMEMORY.md/ daily file changes, tagged withagentIdfile.changed— File watcher detects a workspace file change, tagged withagentIdtokens.updated— Token usage data changedstatus.changed— Gateway status changedping— Keep-alive every 30 seconds
SSE is excluded from compression middleware to avoid buffering.
REST API
REST endpoints serve two purposes:
- Proxy to gateway — Routes like
/api/crons,/api/memories(POST/DELETE),/api/gateway/*invoke gateway tools viainvokeGatewayTool() - Local server data — Routes like
/api/tokens,/api/agentlog,/api/server-inforead from local files or process info
Gateway RPC (via WebSocket)
The frontend calls gateway methods via GatewayContext.rpc():
| Method | Purpose |
|---|---|
status |
Get current agent model, thinking level |
sessions.list |
List active sessions |
sessions.delete |
Delete a session |
sessions.reset |
Clear session context |
sessions.patch |
Patch session metadata and settings, including rename/model/thinking flows the gateway supports |
chat.send |
Send a message (with idempotency key) |
chat.history |
Load message history |
chat.abort |
Abort current generation |
connect |
Initial handshake with auth/device identity |
Event Types (Gateway → Client)
| Event | Payload | Purpose |
|---|---|---|
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.request |
— | Exec approval requested |
exec.approval.resolved |
— | Exec approval granted |
presence |
— | Presence 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.
| File | Purpose |
|---|---|
server/lib/kanban-store.ts |
KanbanStore class -- mutex-protected CRUD, workflow transitions, CAS versioning, proposal management |
server/routes/kanban.ts |
Hono routes, Zod validation, gateway session spawning, poll loop |
server/lib/parseMarkers.ts |
Regex-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
| Transition | From | To | Trigger |
|---|---|---|---|
| Execute | backlog, todo |
in-progress |
POST .../execute |
| Complete (success) | in-progress |
review |
Poller or POST .../complete |
| Complete (error) | in-progress |
todo |
Poller or POST .../complete with error |
| Approve | review |
done |
POST .../approve |
| Reject | review |
todo |
POST .../reject (clears run + result) |
| Abort | in-progress |
todo |
POST .../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:
- Agent emits
[kanban:create]or[kanban:update]markers in its output - Backend parses markers -> creates proposals with
status: "pending" - Frontend polls proposals every 5 seconds -> shows inbox notification
- 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
| What | Who | Interval | Purpose |
|---|---|---|---|
| Task list | Frontend | 5s | Sync board state across tabs/users |
| Proposals | Frontend | 5s | Show new proposals in inbox |
| Gateway subagents | Backend | 5s | Detect 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.pemandcerts/key.pemexist
TypeScript Configuration
Project references with four configs:
config/tsconfig.app.json— Frontend (src/)config/tsconfig.node.json— Vite/build toolingconfig/tsconfig.server.json— Backend (server/) → compiled toserver-dist/config/tsconfig.scripts.json— Setup scriptsconfig/tsconfig.bin.json— CLI tools (bin/) → compiled tobin-dist/
Testing
Framework: Vitest with jsdom environment for React tests.
npm test # Run all tests
npm run test:coverage # With V8 coverage
Test Files
48 test files, 692 tests. Key areas:
| Area | Files | Coverage |
|---|---|---|
| Server lib | config, device-identity, env-file, gateway-client, mutex, voice-language-coverage, ws-proxy |
Auth, config resolution, WS proxy relay, device identity signing |
| Server routes | auth, events, files, gateway, health, sessions, skills |
API endpoints, error handling, gateway proxy |
| Server middleware | auth, error-handler, rate-limit, security-headers |
Request pipeline |
| Chat operations | sendMessage, streamEventHandler, loadHistory, mergeRecoveredTail |
Message lifecycle, streaming, history recovery |
| Client features | extractCharts, extractImages, edit-blocks, MarkdownRenderer, ContextMeter, ErrorBoundary, sessionTree |
Rendering, parsing, UI components |
| Hooks | useAuth, useInputHistory, useKeyboardShortcuts, useServerEvents, useTTS |
Client-side state and interaction |
| Voice/audio | audio-feedback, useVoiceInput, voice-prefix |
Voice input, TTS, wake/stop phrase parsing |
| Utilities | constants, formatting, sanitize, unreadSessions |
Shared 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