Nerve is a single-process Python application that serves as a personal AI assistant. All components run within one process using async Python (asyncio).
┌──────────────────────────────────────────────────────────┐
│ nerve (Python) │
│ │
│ ┌─────────┐ ┌──────────┐ ┌────────┐ ┌────────┐ │
│ │ Gateway │ │ Agent │ │Sources │ │ Cron │ │
│ │ (FastAPI)│──│ Engine │ │ Layer │ │ Service│ │
│ │ WS + HTTP│ │ (Claude) │ │(cursor)│ │ │ │
│ └────┬─────┘ └────┬─────┘ └────┬───┘ └───┬────┘ │
│ │ │ │ │ │
│ ┌────┴──────────────┴─────────────┴───────────┴──┐ │
│ │ SQLite + File Store │ │
│ │ sessions, tasks, sync state │ markdown memory │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ Interfaces: Telegram Bot │ React Web UI │
│ │
│ Optional: CLIProxyAPI (OAuth proxy, no API key needed) │
└──────────────────────────────────────────────────────────┘
Nerve supports two modes, chosen once — on the Mode step of the browser setup, in nerve init --terminal, or by NERVE_MODE for an unattended install. The mode is not a config key: it is read back from the workspace templates (SOUL.md's first line, then the other files), and until it is chosen the gateway runs with no templates and the setup page asks for it.
Personal — a full-featured assistant for one human. Ships with sync sources (Telegram, Gmail, GitHub), life-oriented memory categories, identity files (IDENTITY.md, USER.md), and the inbox-processor cron. The agent develops personality over time.
Worker — a task-focused autonomous agent. Ships with operational memory categories, a TASK.md mission brief, no sync sources, and a self-configuring onboarding flow. Operates in a plan-approve-execute loop.
Mode affects which workspace templates are generated, which cron jobs are enabled, and which memory categories are seeded — but the underlying engine is identical. Both modes run the same agent, tools, and infrastructure.
How to operate each mode well is covered in Personal Best Practices (brain files, memory tiers, approval classes) and Worker Best Practices (TASK.md anatomy, plan-approve-execute, the task state machine).
Two deployment options:
Server (bare metal) — Nerve runs directly as a Python process. Uses nerve start for daemon management, nerve stop / nerve restart for lifecycle.
Docker — nerve init generates Dockerfile, docker-compose.yml, and docker-entrypoint.sh, carrying the chosen port and bind address. The code is bind-mounted (editable), and the state directory and workspace are bind-mounted from the host. CLI commands (nerve start, nerve logs, etc.) automatically proxy to docker compose when deployment: docker is set in config. A Claude Code credential found on the host (macOS Keychain, env vars, credential files) is forwarded into config.local.yaml for the container — ~/.claude is NOT mounted. A restart asked for from the browser makes the container exit; Compose (restart: unless-stopped) brings it back.
FastAPI application serving:
- REST API for sessions, tasks, memory files, diagnostics
- The setup API (
/api/setup/*): the first-account claim (local mode, guarded by the setup token) and the setup checklist, whose step status is derived from configuration, workspace files and the database (nerve/setup_checklist.py) and whose writes go through the same writer asnerve init(nerve/setup_writer.py); plusPOST /api/system/restart, which shares its detached restart helper withnerve restart(nerve/daemon.py) - WebSocket endpoint for real-time chat streaming
- Static file serving for the built React web UI — or, when
web/distis missing, a plain page at/and/setupsaying how to build it GET /healthwith a per-process bootgeneration, so a client can tell the new process from the one shutting down- JWT authentication
Claude Agent SDK wrapper providing:
- Agent configuration (model, tools, system prompt)
- Agentic loop execution (tool calls → results → continuation)
- Interactive tool handler —
can_use_toolcallback replacesbypassPermissions; pauses the agent mid-turn for tools that need user input (AskUserQuestion,ExitPlanMode,EnterPlanMode), auto-approves everything else. Answers routed via WebSocketanswer_interactionmessages. Seeinteractive.py. - File snapshot hooks —
PreToolUsehook onEdit|Write|NotebookEditcaptures original file content tosession_file_snapshotstable before the tool executes. Enables session-scoped unified diffs without git. Seeengine.py::_build_snapshot_hooks(). - Diff computation —
gateway/diff.pycomputes structured unified diffs (hunks with line numbers) usingdifflib.unified_difffrom stored snapshots vs current file on disk. - Streaming bridge to broadcast events to all interfaces (bounded buffers, max 10k events)
- Session lifecycle management — explicit states (created → active → idle → stopped → archived → error) with transitions logged to
session_eventstable - Session resume via SDK
--resumeflag (survives server restarts;sdk_session_idstored as a first-class DB column) - Session forking — branch conversations via SDK
fork_session=True, exposed through REST API and WebSocket - Session stop — scoped: Stop interrupts only the orchestrator's turn (the task that holds the session's run lock) and spares background agents; one agent stops through the runtime's per-task stop; stop-all and force are separate actions. A turn is cancelled only when no agent would die with it, or by force
- Turn queue — runs that wait for a session's run lock are listed (
queue_update) and can be cancelled by their sender;client_msg_idmakes a resend run once - Persistent channel mappings — channel-to-session mapping stored in
channel_sessionstable (survives restarts) - Orphan recovery — on startup, sessions marked
activein DB but with no live client are transitioned toidle(resumable) orstopped - Automatic cleanup — periodic task (every 6h) archives stale sessions (default 30 days) and enforces max session count (default 500)
- Per-run cron sessions: each cron run gets a unique session ID (
cron:{job_id}:{run_id}, the start time and a random suffix) to prevent unbounded message accumulation - AI-generated session titles via lightweight Haiku API call
- Custom MCP tools (tasks, memory recall, conversation history, sync status, skills CRUD, notifications)
- API calls routed through configurable base URL (direct Anthropic API or local CLIProxyAPI proxy)
Sub-agents and background tasks (nerve/agent/agents/). The runtime
streams a sub-agent's work in the parent's stream, tagged with
parent_tool_use_id. The engine routes every such event (and every
sub-agent model call's usage) to the agent registry instead of the
current turn, so a sub-agent's work never opens, extends or persists into an
orchestrator turn, and background agents alone never make a session look
running. The registry also takes the spawning tool calls (Agent, Task,
Workflow, Monitor, background Bash) and the CLI's task_* and
background_tasks_changed frames, and keeps one record per agent: status,
brief, progress, deduplicated usage and an estimated cost, and a per-agent
transcript with a gap-free seq. Records and transcript items are written
behind to session_agents / session_agent_events (batched in one
transaction per session about once a second; status changes are written at
once) and broadcast through a single pump task
(agent_update to the session, agent_event only to sockets that follow
that agent), so ingestion never waits on the database or a socket while it
holds the run lock. Every teardown of a session's runtime goes through
SessionManager.remove_client(reason=), which marks its live agents
interrupted with the reason; shutdown and boot reconcile what bypasses it.
See agents.md.
Abstract communication layer with three components:
-
BaseChannel — abstract interface with capability declarations (
ChannelCapabilityflags:SEND_TEXT,STREAMING,MARKDOWN,INTERACTIVE,TYPING_INDICATOR) and streaming protocol (send_placeholder,edit_message). Channels declare capabilities; the router checks them before calling optional methods. -
ChannelRouter — centralized session resolution, streaming adapter lifecycle, interactive tool routing, and cron output delivery. Replaces per-channel session management.
-
StreamAdapter — translates
StreamBroadcasterevents into channel-appropriate output (edit-in-place for Telegram, accumulated send for simple channels). Created per inbound message. -
Access matching (
access.py) — transport-neutral identity aliases and fail-closed allow/deny pattern matching. Channels compose these primitives into their own policy before creating anInboundMessage. -
Archives (
archives.py) — bounded one-level ZIP unpacking shared by Telegram and Slack. The download cap is on compressed bytes, so entry count, per-entry and aggregate uncompressed size, and compression ratio are all checked against the archive directory before an entry is read.
Implementations:
- Telegram — python-telegram-bot v21+ with partial message streaming (edit-in-place, 1.5s rate limit), inline keyboard buttons for notification questions,
/replycommand for free-text answers - Slack — slack_sdk Socket Mode (outbound WebSocket, no public URL) with partial streaming via
chat.update, Block Kit buttons,/nerveslash command, and per-thread sessions.SlackAccessPolicy(slack_access.py) composes user, channel, and DM guardrails; in channels the bot answers only on mention or in a thread it already owns. - Hosted (
hosted/): provider traffic that a shared channel gateway holds (slack.mode: hosted). Nerve has no provider tokens. Each gateway replica opens a WebSocket to/_internal/channel/v1/stream, authenticated with a token that the gateway signs (ES256, issuernerve-gateway, audiencenerve-channel, subject naming this exact tenant and agent, at most 300 seconds of lifetime). One inbox reader pulls admitted events over the preferred stream: read, process, acknowledge, and read again until a page is empty.HostedChannelregisters under the provider's name, so the router and tools seeslackas with Socket Mode, and uses the same session keys. The gateway decides which events invoke the agent: a direct message, a mention of the agent, a thread reply, a reaction, an interaction, or a command. A mention of the agent carries theselfflag, so intake needs no capabilities. Outbound messages are contract operations:OperationRunner(hosted/operations.py) sends each on a stream that advertised it for the connection, within the stream and connection in-flight limits, and never sends a side-effecting operation again after its result is lost. File uploads and attachment reads use transfers on the stream of their operation; a read is sent only while its length fits Nerve's stream memory limit. Notification cards carry buttons, and a press arrives as aninteractionevent that answers the notification.hosted/contract/holds the version 1 channel contract frames that Nerve reads and sends. The gateway checks the contract; Nerve checks only the members and values that its code uses, and its size limits. - Web — Passive channel using gateway WebSocket
Adding a new channel (Discord, WhatsApp, etc.) requires implementing ~5 methods and zero session/routing logic.
Async notification system for agent→user communication:
notifytool — fire-and-forget notifications (status updates, alerts, reminders)ask_usertool — questions with predefined options (rendered as buttons) + free-text input. Supports blocking mode (wait=true) and async mode (answer injected as session message)- NotificationService — centralized fanout to configurable channels (web + Telegram by default), answer routing, periodic expiry
- Multi-channel delivery — web UI via
__global__WebSocket broadcast channel, Telegram via direct bot API with inline keyboard buttons, Slack via Block Kit action buttons - Answer routing: answers from any channel (web UI, Telegram inline button, Slack button,
/replycommand) are persisted and either unblock a waiting tool or injected as a user message into the originating session. A web answer records the person inanswered_by_actor_id, and the relayed message is written by that person; the agent's work in that turn has the session's cause. A channel answer keeps the provider user ID in metadata and records no actor. An approval dispatcher (nerve/notifications/handlers.py) receives the person who answered as itsactorkeyword argument,Nonefor a channel answer A dispatcher that asks for a snooze can return anafter_snoozecallback; the service awaits it with the result of its snooze write (False when the card was no longer pending) - Web UI —
/notificationspage with status/type filters, inline answer buttons, dismiss, dismiss-all; real-time toast overlay for new notifications; NavRail badge for pending count
APScheduler-based job scheduler:
- Crontab and interval triggers
- Isolated sessions per job
- Persistent logging
Cursor-based data ingestion with agent processing:
- Pull-based source adapters (Telegram/Telethon, Gmail/gog CLI, GitHub/gh CLI)
- Records routed through processors:
agent(LLM review),memorize(direct memU),notify(channel forward) - Opaque cursor per source in SQLite — advances only after successful processing
- Auto-registered as APScheduler jobs alongside cron jobs
- See sources.md for details
Dual-layer memory:
- File-based — MEMORY.md, identity files (curated source of truth, loaded into system prompt)
- memU — Semantic index over conversations and files (SQLite-persisted at
~/.nerve/memu.sqlite) - Knowledge quality filtering — Custom extraction prompt + post-extraction Haiku filter + semantic deduplication prevent generic CS/DevOps facts from polluting memory
- Session rotation — Main session rotates daily; conversations are indexed into memU on close
- Session resume — SDK session IDs stored as dedicated DB columns; sessions resume with full context via
--resumeflag - Session forking — Fork conversations from any point; new session branches via SDK
fork_session=True - Crash recovery — On startup, sessions marked
activein DB with no live client are recovered: those withsdk_session_idbecomeidle(resumable), others becomestopped
Filesystem-based skill system (Claude SDK compatible):
- Skills stored as
workspace/skills/<name>/SKILL.mdwith YAML frontmatter - Optional
references/,scripts/,assets/subdirectories - SQLite index for metadata + usage statistics (
skills,skill_usagetables) - Progressive disclosure: name+description in system prompt, full content loaded on demand via
skill_gettool - Agent can create/update skills dynamically via
skill_create/skill_updateMCP tools - Automated extraction:
skill-extractorcron identifies repeated workflows and proposes new skills via task+plan system - Automated revision:
skill-revisercron reviews existing skills for accuracy, completeness, and quality - Plan approval handler creates/updates skills directly when approving skill-extractor/skill-reviser proposals
The houseofagents multi-agent runtime was retired in favor of workflow runs (nerve/workflows/, the workflow_run_* tools) — budget-capped multi-agent runs with journals, live spend tracking, and run-scoped kill. See workflow-runs.md. The hoa_* tool names remain registered as deprecation stubs (gated by houseofagents.enabled) that point callers at workflow_run_start.
Optional local HTTP proxy that routes Anthropic API calls through Claude Code's OAuth authentication:
- Eliminates the need for a direct Anthropic API key
- Uses CLIProxyAPI binary (auto-downloaded on first start)
- OAuth tokens refreshed automatically
- Configured via
proxy.*settings in config — see config.md
Markdown + SQLite task system:
- Task files in
workspace/memory/tasks/ - SQLite index for queries
- Escalation reminders (soft → medium → urgent)
The engine is backend-agnostic: per-session clients implement the
AgentBackend/AgentClient protocols (nerve/agent/backends/) and
translate their native streams into normalized events
(backends/events.py) — the only vocabulary the engine consumes.
backends/claude.py wraps the Claude Agent SDK (options, hooks,
can_use_tool, idle-stream drains, hardened disconnect);
backends/codex/ speaks JSON-RPC to a per-session codex app-server
subprocess (threads/turns, approval server-requests routed to the
interaction hub, per-turn pricing). Backend choice is sticky per session
(sessions.backend); capabilities (cumulative vs per-turn cost,
idle-stream support, cache-TTL policy) gate engine behavior — never
isinstance. Details: docs/plans/codex-backend.md.
- User sends message via Telegram
TelegramChannelreceives it, resolves active sessionAgentEngine.run()is called with the message- System prompt is built (SOUL.md + IDENTITY.md + MEMORY.md — identical across sessions); the session id and pre-recalled memories are prepended to the first message
- Claude Agent SDK processes the message with tools
- Streaming events broadcast to Telegram (edit-in-place) and any WebSocket clients
- Final response stored in SQLite
- User sends message via WebSocket
- Gateway's WebSocket handler receives it — the connection's actor was resolved once, at accept
AgentEngine.run()called in background task, carrying that actor- The user message is stored under it; the assistant's reply is stored unattributed
- Streaming events sent back via WebSocket
- Client renders tokens in real-time
- APScheduler triggers at schedule
- Isolated per-run session created (
cron:{job_id}:{run_id}) - Agent runs with job prompt and cron model, acting as the system principal — the session and the prompt are attributed to it, not to whoever wrote the schedule
- Agent uses
notify/ask_usertools to communicate with user - Run logged in
cron_logstable
The database layer lives in nerve/db/ as a Python package with domain-specific modules:
nerve/db/
├── __init__.py # Re-exports Database, init_db, close_db, SCHEMA_VERSION
├── base.py # Database class (connection, _atomic, FTS check)
├── migrations/ # File-based migration system
│ ├── runner.py # Discovers and applies vNNN_*.py files in order
│ ├── v001_initial.py
│ ├── ...
│ └── v016_mcp_servers.py
├── sessions.py # SessionStore mixin
├── messages.py # MessageStore mixin
├── tasks.py # TaskStore mixin
├── plans.py # PlanStore mixin
├── notifications.py # NotificationStore mixin
├── sources.py # SourceStore mixin (inbox, sync cursors, consumer cursors)
├── cron.py # CronStore mixin
├── skills.py # SkillStore mixin
├── mcp.py # McpStore mixin
├── agents.py # AgentStore mixin (session_agents, session_agent_events)
└── audit.py # AuditStore mixin
The Database class in base.py inherits all domain mixins, preserving a single Database interface. All existing imports (from nerve.db import Database) work unchanged via __init__.py re-exports.
Migrations are individual Python files in nerve/db/migrations/ named vNNN_description.py. Each exports an async def up(db) function. The runner discovers them by scanning the directory, applies pending ones in version order, and wraps each in a transaction. SCHEMA_VERSION is derived dynamically from the highest migration file number.
To add a new migration: create nerve/db/migrations/vNNN_your_feature.py with an up() function, numbered above the highest version on main: the runner skips versions at or below the database's current one, and CI (scripts/check_migration_numbers.py) fails a PR whose new migration is not.
SQLite with WAL mode (schema version 16):
sessions— Session metadata with lifecycle columns (status,sdk_session_id,connected_at,parent_session_id,forked_from_message,last_activity_at,archived_at,message_count,total_cost_usd) andcreated_by_actor_id→actor_refs: who caused the session to existmessages— Conversation messages with tool call data and orderedblocksJSON column (preserves interleaving of text/thinking/tool_call blocks across page reloads), plusactor_id→actor_refs: whose input the row records. NULL means attribution was not recorded (legacy history, unidentified external people, and assistant/tool output). Nothing is backfilled — see Accounts and identitysession_events: append-only lifecycle audit log (created, started, idle, stopped, archived, error, and the changes listed in Session changes), withactor_idandcaused_by_actor_id→actor_refssession_deletions: one row for each deleted session: last title, time,actor_idandcaused_by_actor_id. Written in the transaction of the delete, which removes the session's eventschannel_sessions— Persistent channel-to-session mapping (survives restarts)session_file_snapshots— Pre-modification file content captured viaPreToolUsehook for session-scoped diff computation. Keyed by(session_id, file_path), first-touch only. Cleaned up on session delete.session_agents— One row per sub-agent or background task of a session: kind, status and end reason, brief, progress, usage and estimated cost, stop attribution (stopped_by_actor_id,stopped_caused_by_actor_id→actor_refs). Deleted with the session (see agents.md)session_agent_events— Each agent's transcript items (agent_id, gap-freeseq,kind, JSONpayload), capped per item; follows telemetry retention when it is enabledtasks— Task index (mirrors markdown files)tasks_fts— FTS5 full-text search index for taskssync_cursors— Opaque cursor positions per sourcesource_run_log— Per-source run diagnostics (records fetched/processed, errors)source_messages— Source inbox with TTL-based expiryconsumer_cursors— Kafka-like consumer positions per sourcecron_logs— Job execution history (includes source runs assource:<name>)memu_audit_log— memU operation audit trailplans— Plans proposed by planner agent for async human reviewskills— Skill registry (id, name, description, version, enabled, metadata)skill_usage— Skill invocation tracking (skill_id, session_id, invoked_by, duration, success/error)notifications— Async notifications and questions (id, session_id, type, title, body, priority, status, options, answer, delivery tracking, expiry)mcp_servers— MCP server registry (config is source of truth, DB tracks metadata)mcp_tool_usage— MCP tool invocation trackingactor_refs: attribution identity: stable id,kind(human/system), display name (see Accounts and identity)accounts: local login state, one per human actor: username,credential_source(config/local/none), credential, enabledinstance_secrets: machine-local secrets that are state, not configuration (the generated JWT signing secret)
memU SQLite (~/.nerve/memu.sqlite):
memu_resources— Indexed source files/conversationsmemu_memory_items— Extracted facts with embeddingsmemu_memory_categories— Topic categories with rolling summariesmemu_category_items— Category-item links
- Load config (
settings.yaml+config.yaml+config.local.yamldeep merge). The loaded object is remembered as the startup configuration: the setup page later compares it with what is on disk to say whether a restart is pending. The previous process's setup notes (restart debts, start-up errors) are retired - Run database migrations (file-based migration system in
nerve/db/migrations/); bootstrap the owner account and the setup token (local mode) or delete a leftover token (external mode) - Initialize memory system (memU SQLite, load categories). With no model credential yet the warm-up is skipped and recall answers empty; nothing calls out
- Start CLIProxyAPI proxy if enabled (download binary if missing, authenticate, start subprocess)
- Recover orphan sessions (active in DB but no live client → idle or stopped)
- Start cron service (load system.yaml + jobs.yaml, register source runners, catch up missed jobs). Sources the installer left
enabled: falseare not scheduled - Start gateway (FastAPI + uvicorn, WebSocket, static files or the "web UI not built" page)
- Start Telegram bot if configured. A token Telegram rejects does not stop the boot: the failure is logged with the token scrubbed, recorded for the setup page and
nerve doctor, and the gateway runs without the bot - Worker onboarding — if worker mode and TASK.md lacks
## Mission, run the setup agent session (the setup page's Finish also starts it when no restart is needed)
The gateway boots and serves /setup with nothing configured — no mode, no provider — and stays idle rather than failing, so the browser setup can do its work against a running instance.
- JWT authentication for all API/WebSocket access. The signing secret is
auth.jwt_secretor, when unset, one generated on first start and kept innerve.db. There is no unauthenticated mode. The one unauthenticated write — the first-account claim — requires the setup token, which never appears in a URL, response, log, state file or browser storage; every other setup endpoint needs a signed-in person, and system or MCP tokens cannot drive setup - Channel gateway streams (
/_internal/channel/v1/stream) have their own authentication: a gateway-signed token in theAuthorizationheader, verified against the public keys in a local key file (channels.hosted.gateway_jwks_file). Nerve fetches no keys over the network. The web session token never opens a stream, a stream token never reaches the web UI or the API, and an upgrade with anOriginheader (every browser sends one) is refused - bcrypt password hashing
- Path traversal prevention on file operations
- Self-signed HTTPS (mkcert)
- Local accounts with a stable actor identity per account (one owner account, created at first start; see Accounts and identity). No multi-tenancy