curl -fsSL https://raw.githubusercontent.com/ClickHouse/nerve/main/install.sh | bashThe installer puts the dependencies in place (Python, Node.js, uv), clones
the repository and runs nerve init. From a checkout the same thing is:
git clone https://github.com/ClickHouse/nerve.git nerve
cd nerve
uv sync # creates .venv from uv.lock
source .venv/bin/activate # puts `nerve` on PATH
nerve inituv sync creates .venv but does not activate it, so nerve is not on PATH
until you do. If you'd rather not activate, prefix commands with uv run instead —
uv run nerve init.
nerve init asks three things in the terminal — how Nerve runs here, where
its files live, which port and address it listens on — writes the
machine-local files, builds the web UI if it is not built yet, starts Nerve,
and prints the address of the setup page together with a setup token:
┌──────────────────────────────────────────────────────────────┐
│ Finish setup in your browser │
│ │
│ Setup page http://127.0.0.1:8900/setup │
│ Setup token <token> │
│ (copied to your clipboard) │
│ │
│ The token is a one-time credential. It is pasted into │
│ the page and never part of the URL. To see it again: │
│ nerve status │
└──────────────────────────────────────────────────────────────┘
Open the setup page in your browser now? [Y/n]:
Everything else — your account, personal assistant or worker, the model provider, your time zone, memory, Telegram or Slack, sources, scheduled jobs — is decided on that page. Three decisions get you to a working assistant: claim the account, choose a mode, connect a model. The rest can be skipped and finished later.
Prefer to answer everything in the terminal? nerve init --terminal is the
full wizard, unchanged. Have no terminal at all? See
Unattended installation.
- Python 3.13+
- Node.js 22.12+ (to build the web UI;
nerve initbuilds it for you) - A way to reach Claude: an Anthropic API key, a Claude subscription, or AWS Bedrock — asked for on the setup page, not in the terminal
- Docker with Compose V2 (
docker compose) - The same choice of credential, asked for on the setup page
Only what the browser cannot: the shape of the install on this machine.
Nerve setup
Most of the setup happens in your browser. This terminal only decides what the
browser cannot: how Nerve runs on this machine and where it keeps its files.
Ctrl+C is safe; answers are saved.
Step 1 of 3 · How do you want to run Nerve?
server Run directly on this machine. Recommended on a server or your own laptop.
docker Run in a container. Isolated, easy to remove. Needs Docker.
Choose [server/docker] (server):
Step 2 of 3 · Where things live
Config files /home/alice/nerve (config.yaml, config.local.yaml)
State /home/alice/.nerve (database, logs; set NERVE_HOME before running nerve to move it)
Workspace — the agent's files: memory, skills, settings [~/nerve-workspace]:
Step 3 of 3 · Network
Gateway port [8900]:
Who should be able to open the web UI?
localhost Only this computer. Open it here, or through an SSH tunnel from elsewhere.
lan Other devices on your network too. You will set a password on the setup page.
Reachable from [localhost]:
- Deployment —
serverordocker. Docker adds one question, see Docker. - Paths — the config directory and the state directory are shown, not
asked: they are decided by
-c/NERVE_CONFIG_DIRandNERVE_HOMEbeforenerve initruns. The workspace is asked, default~/nerve-workspace. - Port — default 8900. If it is already taken,
nerve initsays so and proposes the first free port between 8901 and 8999 as the new default. - Reachable from —
localhostbinds the gateway to127.0.0.1;lanbinds to0.0.0.0. The default islocalhost: nothing listens beyond this machine until you decide it should, and the setup page is where you set the password that makeslansafe. (nerve init --terminaland--non-interactivekeep their previous default of0.0.0.0.)
Answers are saved after every question (NERVE_HOME/init-state.json), so
Ctrl+C and a later nerve init resumes where you stopped. The file is removed
once the configuration is written.
What it writes:
| File | Keys |
|---|---|
config.yaml (machine-local) |
workspace, deployment, telegram.enabled: false, sync.gmail.accounts: []; Docker also gets docker.extra_mounts: [] and gateway.host/port so host-side commands know the published port |
<workspace>/config/settings.yaml (tracked) |
gateway.host/port; every sync source explicitly enabled: false |
config.local.yaml (owner-only, 0600) |
the session-signing secret auth.jwt_secret — written only if the file does not exist, so a re-run never signs anybody out; Docker also gets a forwarded credential, see below |
<workspace>/ |
directories, the config/ scaffold, the bundled skills, config/cron/gates/ |
Deliberately not written here: the mode templates (SOUL.md and friends),
config/cron/system.yaml, time zone, model, memory settings. Those depend on
decisions made in the browser, and the matching step writes them.
Then nerve init:
- Builds the web UI if
web/distis missing and Node.js is installed (npm ci && npm run build; first time only, about a minute). A failed or skipped build is a warning, not an error: the gateway serves a plain page at/and/setupthat says how to build it, and/api/*works regardless. - Starts Nerve (
nerve start --foreground, detached, logging toNERVE_HOME/nerve.log) and waits up to 60 s for/health. - Prints the setup page and the setup token, once. The token is copied to the
clipboard when
pbcopy,wl-copy,xcliporxselis onPATH(always through stdin, never as a command argument), and a terminal offers to open the page.
If Nerve does not answer within 60 s: Nerve did not answer within 60 s. Logs: nerve logs. Re-run nerve init after fixing the cause.
| Flag | Effect |
|---|---|
--deployment server|docker, --workspace PATH, --port N, --bind localhost|lan |
Answer a question from the command line instead of the prompt |
--yes, -y |
Accept every default without asking |
--no-start |
Write the files but do not start Nerve (prints what to do next) |
--no-browser |
Do not offer to open the setup page |
--no-clipboard |
Do not copy the setup token to the clipboard |
--terminal |
The full terminal wizard — see The terminal wizard |
--non-interactive |
Read everything from the environment — see Unattended installation |
--if-needed |
Exit silently unless this is a fresh install (used by the Docker entrypoint) |
--memory-backend memu|loom |
Word the workspace templates, bundled skills and built-in jobs written now for this memory backend. Init-only: it does not set memory.backend, which a platform switching the instance to Loom writes into config.local.yaml afterwards. Without it, nerve init uses what an existing configuration names, else memu. Works with every path above |
A non-TTY stdin still answers the prompts line by line; only --yes skips
them. Scripted example:
nerve init --deployment server --workspace ~/nerve-workspace --port 8900 \
--bind localhost --no-browser --no-clipboard --yesOn an already configured install, nerve init changes nothing. It prints
Nerve is already configured., whether Nerve is running, the setup page and
token while the instance is still unclaimed, Setup checklist: N steps left — http://…/setup while the browser checklist is unfinished, and
Reconfigure everything: nerve init --terminal. Exit code 0. The signing
secret in config.local.yaml is never regenerated by this path.
nerve init --terminal on a configured install asks before regenerating; see
the terminal wizard for what it
overwrites and what it keeps.
/setup is one wizard with a progress rail on the left, Back / Continue /
Skip for now in the footer, and a Review step at the end. It works on a phone.
Each step is saved as you continue; forms open on what the instance already
has, and a stored secret is shown as "Configured · …ab12 — leave blank to
keep", never read back.
| Step | Asks | Writes | Takes effect |
|---|---|---|---|
| Claim (local mode only) | username, password or "Keep this installation passwordless", display name, the setup token | the account and the setup-complete record in nerve.db; deletes the token |
immediately; you are signed in |
| Mode | Personal assistant (works for you: your own inboxes, notes about you) or Worker (works for a team, with its own memory, task list and schedule). For a worker, what it should do: checkboxes from the work catalog, by group (code: review pull requests, triage issues, fix issues and open pull requests, keep CI healthy, dependency and security updates; operations: first response to alerts, which needs an alert source; communication: channel digests and answers; knowledge: research and reports, docs and release notes), Custom… for work in your own words (at most 4000 characters), and an optional "Anything it must know or never do?" (at most 2000). Nothing ticked is fine: the conversation then asks with one form | the mode's templates (SOUL.md, personal: IDENTITY.md, USER.md, …); a worker's TASK.md from its template with what you chose under "Task description (as given)" (the onboarding conversation fills the other sections); mode defaults in settings.yaml only where absent; config/cron/system.yaml with the mode's default jobs if missing. With memory.backend: loom the templates, bundled skills and jobs name Loom's memory tools |
memory.categories is read at startup → restart; the rest is live |
| Model | Anthropic API key · Claude subscription token (claude setup-token) · the Claude Code login found on this server · AWS Bedrock (region, optional profile); "Test connection"; then a model and effort picker |
anthropic_api_key / claude_oauth_token → config.local.yaml; provider.type, provider.aws_region, agent.model, agent.cron_model (the picked model, so scheduled jobs run on it too), agent.effort → settings.yaml; provider.aws_profile → config.yaml |
new sessions; Bedrock's memory model ids need a restart |
| About you (worker: Operator) | your name, time zone (prefilled from the browser), quiet hours as an on/off switch with two times (personal: on, prefilled 22:00–07:00; worker: off), response language, the agent's name and emoji, a sentence about you or your work | timezone, quiet_start, quiet_end → settings.yaml (both empty when quiet hours are off); the matching lines of USER.md and IDENTITY.md (personal; quiet hours off reads none) — only lines still at their template value are touched, your edits are never overwritten; in local mode the display name on your account |
timezone → restart (the scheduler carries the old zone); the rest is live |
| Memory | an OpenAI key for vector search, or "No embeddings" (Claude ranks memories at recall time). With memory.backend: loom nothing: the step is done and says memory is kept in Loom |
openai_api_key → config.local.yaml |
the key is live; embeddings are built at the next restart |
| Channels | a Telegram bot token (verified against Telegram before it is saved), pairing; Slack bot and app tokens (verified the same way), who may talk to it | tokens → config.local.yaml; telegram.enabled / slack.enabled → config.yaml; Slack allow-lists → settings.yaml |
Telegram → restart (the bot is built with its token at startup); Slack is live |
| Sources (personal) | GitHub, Gmail (addresses), Telegram message sync (API id and hash, then a one-time sign-in to your own Telegram account: phone, the code Telegram sends, your two-step password if you have one) as toggle cards, each with a server-side check of its tool (gh, gog). On an instance that keeps no credentials only the GitHub toggle, and only when the operator provides the token from the environment |
sync.*.enabled → settings.yaml; sync.gmail.accounts → config.yaml; Telegram API credentials → config.local.yaml; the Telegram session → NERVE_HOME/telegram_sync.session (owner-only, written by Telethon) |
live |
| Automation | which optional built-in jobs run; the core set is always on; schedules shown in your time zone. Each job shows its effective state: a job with the same id in config/cron/jobs.yaml replaces the built-in job, and the step shows it read-only. For a worker task-planner starts off: the onboarding conversation turns it on when the worker should plan through the approval loop |
enabled flags in config/cron/system.yaml (never for a job that jobs.yaml replaces) |
live (the scheduler reloads) |
| Agents (personal) | which coding agents on this server (Codex, Claude Code) get a connection to Nerve; what to do with an existing config | the agents' own config files; external_agents.* and mcp_endpoint.enabled → config.yaml |
restart |
| Review and finish | a server check-up (Claude CLI, Node, gh, gog, disk, time zone, web build), what was written where, and the apply button |
nothing new | hot reload, or a restart when something above needs one |
A few things worth knowing per step:
- Mode is chosen once. Afterwards the step is read-only with the reason:
changing it means a new workspace (stop Nerve, point
workspace:inconfig.yamlat an empty directory, run setup again). Steps that depend on the mode (About you, Sources, Automation, Agents) are shown but locked until it is chosen. - Model. "Test connection" runs on the server (not in your browser) and
reports precisely: a rejected key, a region where Claude is not enabled, no
network, a timeout. Subscription tokens cannot be tested against the API and
say so. A subscription token drives chat only: memory, titles and scheduled
jobs call the API directly and need an Anthropic API key. Paste one on the
Model step instead (it replaces the token), set
ANTHROPIC_API_KEYin the server's environment, or use the proxy fromnerve init --terminal, the one route that runs those jobs on the subscription; the page says so when it applies. "Claude Code login on this server" is offered only when the server actually has one; on a Mac, opening the Model step may show a Keychain prompt on the server's screen once — Deny keeps the option hidden. Choosing it copies that credential intoconfig.local.yaml, exactly as the terminal wizard does. The model you pick — or confirm with Continue after saving a credential, the preselected default included — also becomesagent.cron_model, so scheduled jobs run on it; to run them on a cheaper model, setagent.cron_modelinsettings.yamlafterwards (a later save that picks nothing leaves it alone; a switch into or out of Bedrock resets both to the installer's defaults, as before). - About you prefills the time zone from your browser. Quiet hours are
optional: on a personal install the switch starts on with 22:00–07:00 while
the instance still has the defaults; a worker's starts off. The agent's name
is prefilled from the name the environment gives the agent
(
NERVE_DISPLAY_NAME, which a hosted deployment seeds from its own record) whileIDENTITY.md's name line is still the template's; Continuing writes it there like a typed name, and from then on the file is the source. A name the step would refuse — over 40 characters, or not a single line — is not offered, and nothing is offered under lockdown. Continuing accepts what the form shows; the step counts as decided. Nerve records quiet hours for the agent and does not hold messages back itself (see Configuration → Quiet Hours). - Channels. A Telegram token is checked with Telegram before anything is
written: a well-formed but revoked token would otherwise take the bot — and
the next start — down. The bot itself starts with the new token after the
restart at the end of setup, which is why pairing is offered on the final
"Nerve is ready" screen: it shows the
t.me/<bot>link and a pairing code, and notices when your/pair <code>arrives. You can also paste your numeric Telegram user id in the step. Slack behind a shared channel gateway (slack.mode: hosted) shows only the on/off switch. - Sources that are turned on while their tool is missing on the server are saved and stay idle until the tool is set up; the card says so.
- Telegram message sync reads the chats of your own Telegram account,
so API credentials alone do nothing: after the API id and hash are saved,
the card offers a sign-in — your phone number, the code Telegram sends,
and your two-step password if you have one. The sign-in stays on this
server: Telethon writes the session file under
NERVE_HOME, owner-only; the page shows the phone masked and never stores the code or the password. One sign-in is pending at a time, for ten minutes; a new one replaces it. Sign out revokes the session and removes the file. On the server,nerve setup-telegramruns the same sign-in in the terminal. While the sync is on but nobody is signed in, the Sources summary, the Ready screen andnerve doctorsay so. - Automation lists the core jobs as always-on and the optional ones as
checkboxes with their schedule rendered in the chosen time zone. A worker's
task-planneris unchecked by default; leave it for the onboarding conversation, which turns it on (scoped to the worker's tasks) when the worker should plan through the approval loop. A job thatjobs.yamlreplaces shows that file's state and cannot be changed here. - Review and finish shows an HTTPS warning when the page is served over plain HTTP from an address other than loopback, and a warning when the instance is passwordless and listening beyond localhost.
Step status is derived, never stored: a step is done because a fact in the
configuration, the workspace or the database says so. That is why an instance
configured at the terminal, from environment variables or from a config
repository shows the same checklist as one configured here, with the parts
already decided marked done. The only stored decisions are skipped and
"answered with a value that equals the default", kept in
NERVE_HOME/setup-state.json — a file that never contains a configuration
value or a secret.
Several people or tabs can have the wizard open. State lives on the server only; every save returns the whole checklist and the page refreshes rather than trusting what it had. The last writer wins per step.
The Review step's button reads Finish when everything you changed applies live, or Restart Nerve when something is read at startup (the time zone, a Telegram token, Bedrock's memory models, external agents). The restart reasons are listed before you click. Then:
- Finish reloads the configuration in place, opens the onboarding conversation and shows Nerve is ready with Start the conversation. When onboarding is already done or skipped, a personal install offers "Say hello" (a new chat with a suggested first message) and a worker offers "Open chat".
- Restart Nerve restarts the daemon and waits for the new process to answer (see Restarts from the browser). The page stays where it is, reconnects on its own and ends the same way. The new process opens the onboarding conversation when it starts.
After setup, the agent is set up by talking to it. Finish opens one web chat
session titled Onboarding for the person who finished setup, and the agent
speaks first: its first message is the answer to a hidden instruction, which
the chat does not show. Its first message always opens with the same
sentence: "Hi Alice, nice to meet you! I'm Scout, and you can configure me by
talking to me." The person's name is the display name of the person who
finished setup. The agent's name is the name in IDENTITY.md (the About you
step), else NERVE_DISPLAY_NAME (the name a hosting platform gives the
agent). Without a name, the greeting is "Hi, nice to meet you!" and the agent
is "your Nerve agent". What comes next depends on what a worker's Mode step
recorded:
- With work chosen (checkboxes, custom work, or both) it offers no menu and starts on that work in the same message. It explains before it asks: after the opening, it restates the work in one sentence, says what it studies and sketches the setup it has in mind, and only then asks — first only for what the explorer still lacks, the rest once its findings are in. It works from the follow-up questions of each item you ticked (which repositories, what a review may do, the label vocabulary, and so on), asks the questions the items share once (the account it acts as, what waits for your approval, where results go), and skips what setup or its explorer can tell. What does not fit into two rounds of questions becomes a default in its proposal, for you to change. An item whose connection the instance lacks (first response to alerts without an alert source) appears under what it needs from you.
- With nothing chosen (a personal install, or a worker with nothing ticked) the opening is followed by "Let's start with a few questions so I understand how I can help you." and one form with one question, "What should I help you with?", with a checkbox for each of: review pull requests, fix issues and open pull requests, keep CI healthy, research and reports, and, on a personal install, personal assistant. Other… takes work in your own words; the agent knows the full work catalog and uses it to understand that answer, but does not list it. A worker with only "Anything it must know or never do?" filled in gets the same form.
- With a note from an older setup page, or from
nerve init --terminal(free text), a note that describes the work is handled like chosen work; a note with only rules ("never merge anything") or only repositories gets the form.
In the agent's first turn, the chat does not show the thinking that comes before its first words or its first tool call. Thinking after that shows as in any other chat.
Whatever you wrote under "Anything it must know or never do?" (or as rules in a note) becomes hard rules and approval boundaries in its proposal, and an answer in the conversation never loosens one without asking.
The agent runs no tool before its first message, so the hidden instruction
also gives it what Nerve knows about the instance when the conversation
opens: the mode and the workspace (cloned repositories go to
<workspace>/repos/), whether configuration arrives by pull request, the
model and memory, and each connection, with what is configured and where it
comes from (a file on this machine, the environment, the configuration
repository, or the platform): the GitHub token, the GitHub CLI's login when
gh auth status says it is authenticated, the GitHub sync source and its
repositories, Slack (in hosted mode, with the platform gateway's status),
Telegram and Gmail, and the time zone and quiet hours. It never includes a
secret. The agent takes these as facts: it does not ask about them or check
them again before its first message, and it asks only about what is marked
unknown. When the GitHub login is known, it recommends acting as that account
and asks you only to confirm it or name another, and the explorer is told the
same access. Reading the facts adds a moment to Finish: gh auth status,
eight seconds at most.
Then:
- It explores in the background. As soon as it knows what to study
(repositories, documentation, data or alert sources, topics), it starts
one explorer, a background sub-agent, and says in one line what it
studies; the explorer's progress shows on the agent line below the chat.
The explorer only reads. It clones repositories into
<workspace>/repos/<name>(withghwhen it is signed in, else from the public URL) and is told to write nothing else, install nothing, build nothing and run no tests: it learns how to build and test from the CI configuration, scripts and docs, works for about ten minutes and returns a short findings report: how to build and test, CI, contribution and review rules, the volume of the work, risks and missing access, a suggested first task. If it fails or finds only part, the proposal says so. With nothing to clone or fetch (a personal assistant for the inbox and reminders) there is no explorer. - It asks after the explorer reports, and only what neither the
explorer nor setup can tell. A first form, when needed, asks only for the
targets the explorer cannot start without (which repositories, which
channels); the rest waits for the findings, so the options come from what
was found and nothing the findings settle is asked. It explains first, in
the form's intro (the text above the questions, because the chat does not
show its thinking as a message), then shows one form of up to six
questions per round, in at most two rounds, each saying why it is asked
and each in the shape its answer needs (the Nerve tool
ask_form): repositories asowner/nameentries, decisions as a few options that say what they lead to with its recommendation first and Other… for your own words, channels and topics as a list, anything else as free text. Typical questions: the account it acts as on the code host (the login it knows is its recommendation), what waits for your approval (its recommendation: drafts you approve for anything public, on its own for reading, cloning and local checks), and where results go. It never asks what setup recorded: the mode, your name, time zone and quiet hours, the connected channels and sources. Work that arrives through a connected source (GitHub notifications, mail, Slack; pull request activity too where the GitHub source is hosted) is consumed by a job gated on new messages from that source rather than by a timer that polls the service; on a hosted instance thenerve-cloudskill says how each source reaches the agent, and the agent reads it first. - It proposes the setup as a plan once the findings and your answers
are in, in Claude Code's plan mode. The plan has fixed headings: what it
understood, what it found, the
TASK.mdsections (a worker), the cron jobs (schedule, gate, session mode and what one run does, plus the built-in jobs it turns on or off, such as whethertask-plannerplans through the approval loop), task statuses and tags (the built-inbacklogandplan_reviewand any extra waits), skills, what it will remember, what it needs from you, the approval boundaries, and at most three optional extras under "Later, if you want". Nerve features appear only where this work needs them. The chat shows the plan in a decision card: Apply setup; Change something… with what should change, and the agent revises the plan and asks again, as often as it takes, without asking what you already wrote (a change request never widens what you allowed earlier: when it seems to, the agent keeps the narrower choice and asks); or Edit to change the plan text yourself and Apply edited plan, which tells the agent the setup was approved with your edits. - It applies what you approved: cron jobs in
config/cron/jobs.yaml(thennerve reload, so the instance loads them; on an instance whose configuration arrives by pull request, it proposes the change instead), statuses, skills, tasks for what only you can do, notes in memory and, for a worker, theTASK.mdsections. It is instructed to change nothing before you approve; the runtime does not enforce that (plan mode does not block file writes here). Then it summarizes every change with numbered next steps and calls theonboarding_donetool.
The state is one row in nerve.db (onboarding): pending, running (with the
session id), done or skipped, and the kind: the conversation, or the
autonomous onboarding. A worker
onboarded by an earlier version (the TASK.md marker is gone) counts as done.
An install from before the onboarding state that still has its marker, and a
personal install from before, count as pending: the banner offers them the
conversation once setup is complete. Nothing opens without a model credential.
When the agent's first turn fails (a model error) before you saw its form,
onboarding goes back to pending, and the next Finish or daemon start opens a
new conversation. When it fails after the form, the conversation stays open
and you continue in the same chat. Until onboarding is done or skipped:
- the Ready screen leads into the conversation;
- a banner on every page except the conversation says that the agent waits
for you, with Continue. While the agent waits for an answer it says
what for: "Your agent has a question for you" with Answer, or "Your
agent proposed a setup" with Review (
onboarding.awaitinginGET /api/setup). Its close button hides it until the next page. When setup is complete and the conversation has not started (nobody pressed Finish, or an install from before), the banner offers Start, which does what Finish does and opens the conversation. Every signed-in person sees it, because the conversation sets up the shared agent and anyone who can use setup can continue or skip it; - the setup page shows the conversation and Skip onboarding, behind a confirmation. A skip keeps the session and ends the reminders.
nerve init --terminal and nerve init --non-interactive (NERVE_TASK) take
the worker's task description. With a description, the worker sets itself up
without a person: at the first start of the daemon with a model credential,
one session titled Onboarding (with the model of scheduled jobs) reads the
description and the worker guide, researches, fills TASK.md, creates
skills, cron jobs and the tasks only you can do, notifies you with a summary
and the next steps, and calls onboarding_done. It runs once: a restart
during the session does not start it again, and the banner in the web UI
links to the session while it runs. When its first turn fails, the next start
runs it again. Finish on the setup page opens the conversation instead, while
the session has not started.
With an empty task description, the worker waits for its onboarding conversation in the web UI: open the web UI after the first start, and the banner leads you to it.
The wizard is re-enterable. Setup sits in the navigation until setup is
finished. After that, a gear button labelled Setup at the bottom of the
navigation rail opens it. On a phone, Setup stays in the More menu.
An instance that has never chosen a mode opens on the setup page: whoever
signs in lands on /setup once per visit (a link straight into the wizard
is left where it points) and is free to go anywhere else from there. A partially set-up
instance — Mode chosen, steps left — opens as usual, with a dismissible
banner that reads "Finish setting up Nerve — N steps left" (or "Restart
Nerve to apply your setup" when only the restart is left). The count
includes the steps that are waiting for the Mode step, so it only ever goes
down; steps a lockdown makes read-only are never counted. The
chat page's empty state points at the Model step until a provider is
configured. On the Overview every done step of your own has a Change
link; a skipped step offers Undo skip. A done step that you cannot change
here, because the operator configured it, is listed under Your operator has
configured, with its summary and no Change link. A step that a lockdown or
the instance's policy closed is not shown. The CLI reports the same checklist: nerve status
prints Setup checklist: N steps left — http://…/setup, and nerve doctor
prints [OK] Setup checklist complete or
[WARN] Setup checklist incomplete (steps: provider, profile) — finish at /setup.
The first browser step — claiming the account — is the only write Nerve
accepts without a signed-in person, so it is guarded by the setup token
nerve init prints:
- It is generated once, kept in
nerve.dbacross restarts, and deleted the moment the claim succeeds. - It is sent only in the body of the claim request. Nerve never puts it in a
URL, a response, a server log or browser storage, and
nerve initnever passes it as a command-line argument (the clipboard copy goes through stdin). nerve statusprints it again while the instance is unclaimed.nerve doctordeliberately never does: that report is also relayed by chat commands, and a live credential does not belong in a chat log.- It is a bearer credential. For a remote claim, use HTTPS or a protected tunnel; the Review step warns when the page is served over plain HTTP from a non-loopback address.
"Keep this installation passwordless" is an explicit choice: anyone who can
reach the gateway is then the owner. It is safe only when access is already
restricted — the localhost bind, or a tunnel. A password can be added later
on the Accounts page; it cannot be removed.
Every other setup endpoint requires a signed-in person. System and MCP tokens
are refused (403): setup is driven by a human, never by an agent talking to
its own API. Until the claim, those endpoints answer 409. See
Accounts and identity for the account model.
Most of what the wizard writes is applied by a hot reload, the same one
nerve reload performs. A handful of keys are read only at startup —
timezone, telegram.*, memory.*, external_agents.enabled,
mcp_endpoint.* — and the full list is the restart table in
Config; the wizard adds no entry to
it. Whether a restart is pending is computed by comparing the configuration
the running process started with against what is on disk now, so a value you
change and change back disappears from the list, and the list is empty after
a restart by construction.
When a restart is needed, the Review step's Restart Nerve calls
POST /api/system/restart, which does what nerve restart does, from inside
the daemon:
| Where Nerve runs | What happens |
|---|---|
plain nerve start |
a detached helper stops the old process and starts a new one; the page polls /health until its generation changes, up to 2 minutes |
| the generated Docker container | the process exits and Compose brings it back (restart: unless-stopped); up to 3 minutes. If you had stopped the container with docker compose stop, run docker compose up -d |
| systemd | refused, on purpose — the browser cannot see whether the unit says Restart=. The page shows the exact command (systemctl restart nerve.service, with the unit name read from the process's own cgroup leaf; systemctl --user restart … when a systemd user manager started it) and keeps waiting up to 5 minutes for the operator to run it |
NERVE_SUPERVISOR=exit or NERVE_SUPERVISOR=refuse in the daemon's
environment overrides the detection for other supervisors. The wait is never
unbounded: the overlay offers Not now after ten seconds, and "did not
come back" with Keep waiting and a nerve logs / nerve start hint at
the end. Your session survives the restart — the signing secret is pinned and
persisted, and nothing in the wizard rotates it.
The previous nerve init, unchanged: it asks everything the browser would —
deployment, credential, mode, OpenAI key, workspace, password or passwordless,
your name and time zone, Telegram, sources, cron jobs, a worker's task,
external agents — and writes it all at the end. It never starts the daemon;
run nerve start afterwards. For a Docker deployment it continues inside the
container (docker compose run nerve nerve init --inside-docker). Its network
default is still 0.0.0.0.
Use it when no browser can reach the gateway, or to reconfigure an install
from scratch. Two differences from before: your name is now actually written
into USER.md (it used to leave the placeholder), and it is the only path
that regenerates files. On an existing install it asks first:
What gets overwritten:
config.yamlandconfig.local.yaml— regenerated from your choices (each is copied to*.bakfirst unless it holds nothing)<workspace>/config/cron/system.yaml— regenerated (picks up new built-in cron prompts from Nerve updates)
What's preserved:
- All workspace files (
SOUL.md,IDENTITY.md,USER.md,MEMORY.md, skills, tasks, etc.) <workspace>/config/cron/jobs.yaml— your custom crons are never touched<workspace>/config/settings.yaml— only the keys the wizard generates are rewritten; anything else you added stays~/.nerve/nerve.dband~/.nerve/memu.sqlite— databases are preserved
The browser checklist reads a terminal-configured install exactly as it reads its own work: the steps you answered there are done.
For cloud-init, Ansible, image builds and other places with no terminal, run the installer with --non-interactive (or NERVE_NON_INTERACTIVE=1). It implies NERVE_YES=1, never prompts, and finishes with nerve init --non-interactive, which reads its configuration from the environment.
curl -fsSL https://raw.githubusercontent.com/ClickHouse/nerve/main/install.sh \
| NERVE_NON_INTERACTIVE=1 \
NERVE_INSTALL_DIR=/home/agent/nerve \
NERVE_MODE=worker \
NERVE_WORKSPACE=/home/agent/nerve-workspace \
NERVE_TIMEZONE=UTC \
NERVE_PROVIDER=bedrock \
NERVE_AWS_REGION=eu-central-1 \
bashDependency installation adapts to the environment: as root (a plain ubuntu:24.04 container, for example) packages are installed directly with no sudo needed, and on Debian and Ubuntu DEBIAN_FRONTEND=noninteractive is set so tzdata and friends cannot open a debconf prompt. As a non-root user, sudo -n is used, so a missing password rule fails immediately instead of waiting.
The daemon is not started: unattended installs are normally followed by a service manager that owns the process (see systemd Service). Set NERVE_START=1 to start it from the installer instead.
Anything the setup wizard would ask comes from the environment. Authentication is required — set NERVE_PROVIDER=bedrock (IAM, no key), or ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN or NERVE_USE_PROXY=1. Everything else has a default. NERVE_PASSWORD is read once here and stored only as a bcrypt hash in config.local.yaml, so it does not need to stay in the environment afterwards. NERVE_PASSWORDLESS is also read once here: the choice is recorded in nerve.db, and changing the variable later has no effect.
| Variable | Default | Purpose |
|---|---|---|
NERVE_MODE |
personal |
personal or worker — this is how an unattended install chooses the mode |
NERVE_WORKSPACE |
~/nerve-workspace |
Workspace directory |
NERVE_TIMEZONE |
America/New_York |
Schedule timezone, written to settings.yaml. An empty value or a value that is not a time zone name gives America/New_York. When the variable is also in the environment of the Nerve process, it is the default time zone (Configuration) |
NERVE_PROVIDER |
anthropic |
anthropic or bedrock |
NERVE_AWS_REGION |
$AWS_REGION, else us-east-1 |
Bedrock region; sets the model geo-prefix |
NERVE_PASSWORD |
unset | Web UI password. Setting it completes setup |
NERVE_PASSWORDLESS |
unset | 1 completes setup without a password: anyone who can reach the gateway acts as the owner (see Accounts and identity). Not allowed with NERVE_PASSWORD. With neither, setup stays incomplete until the browser claim (nerve status prints the token once the gateway runs) |
NERVE_TASK |
unset | Worker mode task description. With it, the worker sets itself up at its first start; without it, the worker waits for its onboarding conversation in the web UI (see A worker set up without the browser) |
NERVE_EXTERNAL_AGENTS |
unset | Personal mode, e.g. codex,claude-code |
GH_TOKEN |
unset | GitHub integration |
This path writes the same files it always has and starts no daemon; only a
worker's defaults changed: quiet hours off (quiet_start and quiet_end
empty) and task-planner disabled until onboarding enables it.
The browser checklist afterwards shows what the environment decided as done —
the mode, the provider, the time zone, a Telegram token — and leaves the rest
to skip or fill in. A time zone that is equal to NERVE_TIMEZONE in the
environment of the Nerve process is the default, not an answer: About you
stays pending.
A re-run over an existing install keeps the configuration and only upgrades the code.
Choose docker at the first question. The workspace question then reads
"Workspace on this computer (mounted into the container)", and one more
question follows when this machine has a Claude Code login:
Found a Claude Code login in this machine's keychain (macOS Keychain (OAuth)). Forward it to the container? [Y/n]
Yes writes it into config.local.yaml as claude_oauth_token or
anthropic_api_key (the same classification the terminal wizard uses). A
gh auth token gets the same question (Found a GitHub login (gh CLI). Forward it to the container? [Y/n]) and is written as github_token only on
yes; --yes accepts both. Each forward is announced (✓ Forwarding the GitHub token (gh CLI) as github_token in config.local.yaml) — the values themselves
are never printed. The Model step in the browser then shows the credential as
configured. Nothing from ~/.claude is mounted into the container.
On the host, config.yaml names the container workspace path
(/root/nerve-workspace). nerve status and nerve doctor derive the setup
checklist from the same files at their bind-mounted host path, read from the
volume in docker-compose.yml; when that file or the mount cannot be read,
status prints Setup checklist: open http://…/setup instead of a count.
Then nerve init generates Dockerfile, docker-compose.yml,
docker-entrypoint.sh and .dockerignore — carrying the port you chose
(EXPOSE, the health check, and the compose ports: line, which publishes
on 127.0.0.1: only when you chose localhost) — runs
docker compose up -d --build, waits up to 300 s for /health, and prints
the same setup block. Existing Docker files are never overwritten; if they
publish a different port, the two lines to edit are printed.
✓ Nerve started in Docker — ready in 12.0 s
Container restarts are automatic (restart: unless-stopped). nerve stop is docker compose down.
config.yaml records the container paths (workspace: /root/nerve-workspace)
and, for Docker only, the published gateway.host/port, so nerve status
on the host prints the right address. The tracked settings.yaml and the
workspace skeleton are written under the host directory that is bind-mounted
into the container.
Subsequent starts:
docker compose up # Start
docker compose up -d # Start in background
docker compose down # Stop
docker compose logs -f # Follow logsThe entrypoint is unchanged: nerve init --if-needed --non-interactive, then
exec nerve start -f. With the files the host wrote, --if-needed is a
no-op and the container boots straight into an instance that waits for the
browser.
Non-interactive Docker setup (CI / automation):
cp .env.example .env
# Edit .env with your ANTHROPIC_API_KEY (or set NERVE_USE_PROXY=1)
docker compose upThe entrypoint's nerve init --if-needed --non-interactive then configures
the instance from .env, exactly as before.
Using CLIProxyAPI instead of an API key:
Set NERVE_USE_PROXY=1 in your environment (no ANTHROPIC_API_KEY required). The proxy authenticates via Claude Code's OAuth — requires a Claude Max/Pro subscription at claude.ai. See config.md for details. The proxy is enabled by nerve init --terminal or by hand, not from the browser.
Volumes:
| Mount | Purpose |
|---|---|
.:/nerve |
Application code (bind mount) |
<NERVE_HOME>:/root/.nerve |
Databases, logs, PID, sessions (~/.nerve by default) |
<NERVE_HOME>/claude:/root/.claude |
The container's own Claude Code conversation files, so sessions survive docker compose down; kept apart from the host user's ~/.claude |
<workspace>:/root/nerve-workspace |
Workspace files (SOUL.md, tasks, skills, config/) |
The container paths are not conventions — the generated Dockerfile sets
NERVE_HOME=/root/.nerve and NERVE_WORKSPACE=/root/nerve-workspace
explicitly. Point them elsewhere and the mounts must follow.
A restart asked for from the browser makes the container exit, and Compose
starts it again; the page waits for the new process. nerve restart on the
host runs docker compose restart.
nerve init resolves a credential on the host and writes it into
config.local.yaml for the container, using a priority waterfall:
- macOS Keychain —
Claude Code-credentials— extracts the OAuth access token from the JSON stored by Claude Code - macOS Keychain —
Claude Code— raw API key CLAUDE_CODE_OAUTH_TOKENenv var~/.claude/.credentials.jsonfile — where Linux stores Claude credentialsANTHROPIC_API_KEYenv var
The first match wins. The slim flow asks before forwarding; nerve init --terminal passes the same credential to docker compose run as an
environment variable and writes it inside the container. Either way the
~/.claude directory is NOT mounted into the container — this avoids file
permission issues and macOS Keychain access from within Docker.
The browser's "Claude Code login on this server" option looks in a different order — the environment variables and the credentials file first, the Keychain last — because it runs on the gateway host at the click of a button and a Keychain lookup can raise a prompt there (see the Model step notes above).
An operator can provide a connection before anyone opens the setup page —
from the service environment, from config.local.yaml written by
provisioning, from a settings.yaml that arrives from a configuration
repository, or by running Slack behind a shared channel gateway. The setup
page then shows that connection as done and read-only: the card reads
"Configured by your operator (environment)", "Managed from the configuration
repository" or "Managed by your organisation's channel gateway", with no
Replace or Remove. The page never asks for a value the operator already
provided, and a save that would overwrite one is refused (409, naming the
field).
| Connection | Setting(s) and layer | Environment-reference example | How the setup page reports it |
|---|---|---|---|
| Model provider | anthropic_api_key or claude_oauth_token in config.local.yaml; or ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN in the service environment; Bedrock: provider.type, provider.aws_region in settings.yaml |
anthropic_api_key: "${ANTHROPIC_API_KEY}" in settings.yaml from a configuration repository, or Environment=ANTHROPIC_API_KEY=… in the service unit |
Model: done · "Configured by your operator (environment)"; the model and effort picker stays open |
| Memory (vector search) | openai_api_key in config.local.yaml |
openai_api_key: "${OPENAI_API_KEY}" |
Memory: done, read-only |
| Telegram bot | telegram.bot_token in config.local.yaml; telegram.enabled in config.yaml |
telegram: {bot_token: "${TELEGRAM_BOT_TOKEN}"} in settings.yaml, or config.local.yaml written by provisioning |
Channels: done; the Telegram card is read-only, pairing still works |
| Slack with your own app | slack.bot_token, slack.app_token in config.local.yaml; slack.enabled in config.yaml; allow-lists in settings.yaml |
slack: {bot_token: "${SLACK_BOT_TOKEN}", app_token: "${SLACK_APP_TOKEN}"} |
Channels: done; token fields read-only |
| Slack behind a shared channel gateway | slack.mode: hosted and channels.hosted.* in settings.yaml (config.md) — no tokens on this machine |
— | Channels: done · "Managed by your organisation's channel gateway · connected" or "· waiting for the gateway"; the on/off switch is the only control |
| Telegram message sync | sync.telegram.api_id, sync.telegram.api_hash in config.local.yaml; sync.telegram.enabled in settings.yaml. The signed-in session is runtime state (NERVE_HOME/telegram_sync.session), created on the setup page or with nerve setup-telegram |
sync: {telegram: {api_id: "${TG_API_ID}", api_hash: "${TG_API_HASH}"}} |
Sources: the credentials read as managed; the sign-in is still offered until a session exists |
| GitHub | gh auth login on the server; or github_token in config.local.yaml (forwarded to a container as GH_TOKEN); or GH_TOKEN / GITHUB_TOKEN in the environment |
GH_TOKEN in the service environment |
Sources: the card shows the login the server-side check found; a token from the environment reads "(environment)" — and on an instance that keeps no credentials it is what keeps the GitHub toggle on offer |
| Gmail | sync.gmail.accounts in config.yaml; sync.gmail.enabled in settings.yaml; gog gmail setup <account> on the server |
sync: {gmail: {accounts: "${GMAIL_ACCOUNTS}"}} |
Sources: done; the addresses are read-only |
How the origin is detected: Nerve reads the configuration files as written,
before ${VAR} references are resolved, and judges the layer the loader
takes the value from (config.local.yaml over config.yaml over
settings.yaml; under lockdown settings.yaml alone). Every masked value in
GET /api/setup carries a source: file (a file on this machine — the
page may replace or remove it), environment (a variable Nerve reads
directly, or a ${VAR} reference in any layer), config-repo (the tracked
settings.yaml under lockdown) or gateway (held by the
channel gateway). Secrets stay masked whatever their source.
nerve doctor prints the source of every configured connection:
[OK] Anthropic API key: configured (environment)
[OK] Telegram bot token: ...ab12 (environment)
[OK] Slack hosted by the channel gateway: agent …, 2 gateway key(s) from …
[OK] Telegram sync: API credentials configured (config-repo)
[WARN] Telegram sync: no signed-in session at ~/.nerve/telegram_sync.session — sign in on the setup page (Sources → Telegram) or run 'nerve setup-telegram' on the server
Nothing is appended for a value that came from a file on this machine.
When a front gateway signs people in and names them to Nerve
(NERVE_AUTH_MODE=external, see Accounts and identity),
the setup page works the same way with these differences:
- There is no claim step. There are no local accounts, no password and no
setup token in this mode; the gateway deletes any token left from an earlier
local mode at startup.
nerve initin a shell withNERVE_AUTH_MODE=externalprints the setup page without a token block — "Sign in through your gateway." / "No setup token is used in external mode." — and its last line reads "Later:nerve statusprints the page again." - The Claude Code login on the server is not offered. That login is the
personal credential of whoever runs the server, and here every signed-in
person may run setup, so the Model step does not offer to import it (the
server does not even look it up). Paste an API key or a subscription token
instead, or provision the credential from the environment. An operator who
wants the option anyway sets
NERVE_SETUP_HOST_LOGIN=1in the service environment. - The wizard starts at Mode, with an overview that says who you are ("Signed in as Alice Example through your organisation's sign-in"), what the wizard does, and what your operator has configured. It configures the instance for everyone who uses it; nothing in it is personal to your account.
- Any signed-in person may run setup. There is no owner. Several people may have it open; the state is server-side, every save returns the whole checklist, and the last writer wins per step.
- About you takes your name from the gateway's sign-in, read-only for the
account, and uses what you type only for the workspace notes (
USER.md). No account or password control is shown, and nothing in the wizard calls an account endpoint. - Step status comes from configuration, workspace files and the state
file alone — the same facts as in local mode. An instance provisioned from
the environment (
nerve init --non-interactive, a credential in the process environment) or from a config repository reads as done where those decided; a connection the operator provided shows as configured and managed, read-only, and the page never asks for it again — see Pre-configuring connections for the settings per connection and how each is reported. Slack behind your organisation's channel gateway shows the gateway's connection state and an on/off switch only. - Finish prefers a hot reload. When a restart is unavoidable, the page says what will happen for the supervisor it detects — the process exits and the supervisor starts it again, or an operator must run the command shown — and reconnects through the gateway's own sign-in; no login form can appear. The wait is time-boxed with a clear message and a retry.
- The Setup entry and the "Finish setting up" banner render in this mode too, and so does the landing: an instance that has never chosen a mode opens on the setup page once the gateway has signed the person in; a partially set-up one shows the banner.
Under lockdown configuration
arrives by pull request, so the wizard is read-only for anything it would
write to configuration or to a reviewed workspace file. GET /api/setup
marks such a step blocked, with the reason ("Configuration is managed
from the config repository on this instance; changes arrive by PR and sync.
Configure this step from the config repository."), and does not count it as
"left to do". The setup page does not show it. A locked instance that has
everything provisioned reads as finished and the banner stays quiet. A step
that is done is read-only, and the Overview lists it under Your operator
has configured; the Mode step and your account stay in your own list. When
no step is left for you, the Overview says that the operator configured every
step. What still works: the claim (it writes to nerve.db), a
worker's TASK.md once the templates have arrived from the repository, your
display name (local mode), skipping, finishing and restarting.
Some instances never hold a credential on the machine Nerve runs on: the model
credential arrives from the service environment, Slack runs through a channel
gateway, and nothing a person pastes into the setup page should end up in
config.local.yaml. For those, set setup.local_secrets: false in the
tracked workspace/config/settings.yaml:
setup:
local_secrets: falseor NERVE_SETUP_LOCAL_SECRETS=0 in the service environment. The variable
works like NERVE_LOCKDOWN, one way only: it can turn the storage off over a
file that allows it, never back on over a file that forbids it. The value must
be true or false: anything else (a typo, a ${VAR} that resolves to
something else) stops the configuration from loading, as it does for
lockdown, and a bare local_secrets: reads as false. The setting is read
on every request, so a reload applies it. The wizard then says so instead of
offering the fields:
- Memory, Sources and Agents are closed, with the reason: This instance does not store credentials entered here. Connections are provided by the operator of this deployment. The setup page does not show them. They are not counted as left to do, so the checklist can finish without them. Recall is LLM-ranked, as it is on any instance without an OpenAI key.
- Sources stays open for one toggle when the operator provides the
GitHub token from the environment (
GH_TOKENorGITHUB_TOKENin the service environment, orgithub_token: "${VAR}"in a configuration file): switching the GitHub source on stores nothing here. The step then offers GitHub alone, with the note GitHub is provided by the operator of this deployment; Gmail and Telegram are not available on this instance. It counts as left to do until it is answered or skipped, and reads done — with the switch still open, like the hosted Slack's — once GitHub is on or the answer was "no". A Gmail or Telegram field in the same save is refused (409, naming the field).GET /api/setupsays which sources are on offer in the step's detail (available, withunavailable_reason). - Channels reads the same way — unless Slack runs through a channel
gateway (
slack.mode: hosted), which stores no token on this machine: the step is done and the on/off switch stays its one control, as before. - Model still has to be answered. A credential from the environment (or a
${VAR}reference) reads as configured and managed, exactly as under Pre-configuring connections; AWS Bedrock can be chosen (an IAM role, no secret on disk); the model and effort picker stays open. Pasting an API key or a subscription token is refused (409, naming the field), and so is copying the Claude Code login found on the server. - A step the operator provided — an OpenAI key or a bot token as a
${VAR}reference, a source switched on in the tracked settings — stays done and read-only, and the Overview lists it under Your operator has configured. - The writes behind those steps answer
409with the same two sentences: the Memory, Channels, Sources and Agents saves (the Slack switch in hosted mode and the GitHub switch with a token from the environment excepted), the OpenAI key test and the bot-token tests, Telegram pairing, the Telegram sign-in, and skipping one of the closed steps (there is nothing to decide). The Model step's connection test is not among them: Bedrock and a credential from the environment are still worth testing.
What stays available: the model provider from the environment, Slack through
a channel gateway, the GitHub source when its token comes from the
environment, the model and effort choice, About you, Automation, the account
(local mode), skipping the steps that are offered, finishing and restarting.
GET /api/setup carries local_secrets: false, and nerve doctor prints one
line about it — naming the Gmail and Telegram sources rather than the whole
Sources step when the GitHub token comes from the environment.
Unlike lockdown, this does not change which configuration files
are read or what the agent may edit; it only governs what the setup page
stores. nerve init --non-interactive is not affected either: what the
environment provides at install time is written as before.
The wizard handles all of this automatically, but you can also configure manually:
# Create secrets file (gitignored)
cat > config.local.yaml << 'EOF'
anthropic_api_key: sk-ant-...
# claude_oauth_token: sk-ant-oat01-... # alternative: a Claude subscription token (chat only)
openai_api_key: sk-... # Optional — enables vector-based memory search
telegram:
bot_token: "123456:ABC..."
auth:
password_hash: "$2b$12$..." # Generate below — see the note under this block
jwt_secret: "..." # Optional; if omitted, generated into nerve.db on first start
jwt_expiry_hours: 720 # Optional — web-session idle timeout (default 30 days)
EOFauth.password_hash here is a starting password, not where the password
lives. The first start copies it onto the local account and removes the key from
this file; after that, passwords are managed on the accounts screen in the web
UI and this key does nothing. If you leave it out, setup is not complete until
you claim the instance from the browser (the first step of /setup).
See Accounts and identity.
jwt_expiry_hours is an idle timeout, not a cap on a working session: the
gateway re-mints the token whenever a request arrives past half its lifetime,
so a tab in continuous use is never logged out. Only a tab left untouched for
the whole window comes back to a password prompt. Lower it if the browser is
somewhere you don't fully trust.
# Password hash
python -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())"
# JWT secret
python -c "import secrets; print(secrets.token_hex(32))"nerve status # Running? Setup page and token while unclaimed; checklist while unfinished
nerve doctor # Verify everything is set up (never prints the token)
nerve logs # Tail the daemon logThe gateway boots with nothing configured — no provider, no mode — and stays
idle rather than failing: memory warm-up and sync sources wait for their
configuration, the chat page shows "Connect a model to start a conversation",
and a Telegram bot whose token is rejected at startup is logged (token
scrubbed), reported on the Channels step and by nerve doctor, and never
stops the gateway from starting.
# Install mkcert
sudo apt install libnss3-tools
curl -L https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-arm64 -o mkcert
chmod +x mkcert && sudo mv mkcert /usr/local/bin/
# Create certificates
mkdir -p ~/.nerve/certs
mkcert -install
mkcert -cert-file ~/.nerve/certs/cert.pem -key-file ~/.nerve/certs/key.pem \
localhost 127.0.0.1 "$(hostname)" "$(hostname).local"Update config.yaml:
gateway:
ssl:
cert: ~/.nerve/certs/cert.pem
key: ~/.nerve/certs/key.pem# On Pi: copy the CA cert
cat "$(mkcert -CAROOT)/rootCA.pem"
# On Mac: save to file and trust
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain rootCA.pemNerve has built-in daemon management. No systemd required for basic usage.
nerve start # Start as background daemon (nerve init already did this once)
nerve stop # Stop the daemon (graceful: gateway.drain_timeout_seconds + 15s)
nerve restart # Stop + start
nerve status # Show PID, memory, uptime, setup state
nerve status -f # Show status then tail logs
nerve logs # Tail the daemon log
nerve start -f # Run in foreground (for debugging)PID file: ~/.nerve/nerve.pid
Lock file: ~/.nerve/nerve.lock (the daemon locks it while it runs)
Log file: ~/.nerve/nerve.log
nerve restart and the setup page's Restart Nerve use the same detached
helper, so a restart typed here and one clicked in the browser are the same
restart.
For auto-start on boot, create /etc/systemd/system/nerve.service:
[Unit]
Description=Nerve Personal AI Assistant
After=network.target
[Service]
Type=simple
User=YOUR_USER
WorkingDirectory=/home/YOUR_USER/nerve
Environment=PATH=/home/YOUR_USER/nerve/.venv/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/home/YOUR_USER/nerve/.venv/bin/nerve start --foreground
Restart=always
RestartSec=10
RestartPreventExitStatus=65
[Install]
WantedBy=multi-user.targetNote: Use --foreground with systemd since it manages the process lifecycle.
If you set up the instance with nerve init first, stop the daemon it
started (nerve stop) before enabling the unit. RestartPreventExitStatus=65
keeps systemd from restarting a Nerve that refused a database newer than its
code, where every restart would refuse again (see
Going back to an older Nerve).
sudo systemctl daemon-reload
sudo systemctl enable nerve
sudo systemctl start nerve
# Check status
sudo systemctl status nerve
journalctl -u nerve -fUnder systemd the setup page does not restart Nerve itself; it shows
systemctl restart <unit> for you to run (systemctl --user restart <unit>
when a systemd user manager started it) and waits for the new process.
nerve restart on the box still stops the process and lets the unit respawn it.
uv.lock pins the exact version of every dependency, transitive ones included.
uv sync installs from it, so the same commit resolves to the same versions
whenever and wherever you install. That is why uv sync is the documented install
rather than uv pip install -e ..
Two honest limits on "reproducible": the lock is universal but its entries carry platform and Python markers, so different platforms legitimately get different files for the same pinned versions; and artifacts are not vendored, so an air-gapped install still needs a populated uv cache or a local wheelhouse.
uv sync # runtime dependencies
uv sync --extra test # ...plus the test extrauv sync creates and manages .venv itself, installs Nerve editable, and
removes anything not in the lock — so the environment matches the lock exactly
rather than accumulating leftovers.
uv pip install -e .does not readuv.lock. uv's pip-compatible layer has no lockfile awareness, so that command resolves against the bounds inpyproject.tomland can install different versions. It still works, and it is what you want when deliberately testing against current upstream — but it is not a reproducible install.
nerve upgrade runs the updater code that is already loaded in memory, then
pulls. So the first upgrade across the commit that introduced uv.lock still uses
the old, unpinned installer — the lock only takes effect from the second upgrade
onward. Two consequences worth knowing before you run it:
- That first upgrade resolves dependencies fresh, so it can pick up something newer than the lock intends.
- If you are on Python 3.12, it will fail after
git pullhas already advanced the checkout, because the newpyproject.tomlrequires 3.13+. You are left with new source and an old environment.
Either is straightforward to recover from — do the install step yourself, on 3.13+:
cd <your nerve checkout>
uv sync --locked --inexact
nerve restartSubsequent nerve upgrade runs install from the lock automatically.
Going back to an older Nerve after an upgrade is refused by default: the
database schema is then newer than the code, and older code on a newer schema
fails quietly later rather than loudly at start. The check covers each
migration track on its own, so a Nerve without a fork's migrations also
refuses a database that fork migrated (see
Migrations). The refusal is one line, naming the
track and both versions. nerve start --foreground, which is what a
supervisor runs, and every command that opens the database then exit with
status 65. A supervisor can key on it, for example with systemd's
RestartPreventExitStatus=65, as in the unit above. The daemonizing
nerve start does not pass the status on: it checks the process it started
once, a second later, and only if that process has exited by then does it
print Nerve failed to start (exit code 65) and exit with status 1. The
refusal line is in the log file either way. Restore the backup taken before
the upgrade instead, or set NERVE_ALLOW_NEWER_SCHEMA=1 to start anyway and
accept that risk.
Edit pyproject.toml, then relock and commit uv.lock alongside it:
uv lockCI runs uv sync --locked, which fails if uv.lock and pyproject.toml have
drifted apart — so a dependency change without a relock is caught rather than
silently re-resolved.
Relocking preserves existing pins, so it won't sweep in unrelated upgrades. To move one deliberately:
uv lock --upgrade-package mcp # one dependency
uv lock --upgrade # everythingci.yml installs with uv sync --locked, which does two jobs at once:
- A PR is never broken by an upstream release that has nothing to do with it — the versions come from the lock.
- A dependency change cannot merge without a relock.
--lockedfails ifuv.lockdisagrees withpyproject.tomlfor any reason: a dependency added, removed, or simply re-bounded. Since the relock then moves the pins, the versions CI tests are always the ones the change actually selects.
That second property is why no separate unpinned CI job is needed. Widen a bound and the lock is invalidated; relock and the pin moves; CI tests the moved pin.
| Path | Installs from uv.lock? |
|---|---|
uv sync (quick start, server install, install.sh) |
yes |
nerve upgrade |
yes — uv sync --locked --inexact when a uv.lock is present |
CI (ci.yml) |
yes — uv sync --locked |
| Docker | yes — the image installs from the lock at build time and the entrypoint syncs the project |
uv pip install -e . / plain pip install -e . |
no — resolves from pyproject.toml bounds |
Docker keeps its environment at /opt/nerve-venv, outside the /nerve bind mount,
because a .venv under /nerve would be shadowed by the host's checkout (and a
host-created one may not be Linux-compatible).
Existing Docker deployments need one manual step.
nerve initdoes not overwrite Docker files that already exist, so an install generated before this change keeps its old pip-basedDockerfileand entrypoint. To pick up the locked build, delete them and regenerate:rm Dockerfile docker-entrypoint.sh nerve init --terminal # choose "docker" again; regenerates both docker compose build --no-cacheKeep
docker-compose.yml— it is unchanged. Check any local edits you made to the old files before deleting them.
nerve upgrade uses --inexact, so it won't uninstall optional extras you added
yourself, and --frozen, so it never rewrites uv.lock as a side effect of
upgrading. If uv is missing, or the checkout has no uv.lock, it falls back to the
previous pip install -e . behaviour.
I lost the setup token. nerve status prints it again while the instance
is unclaimed. Nothing else does.
/setup says the web UI has not been built. Node.js was missing or the
build failed when nerve init ran. On the server: cd web && npm ci && npm run build (or nerve upgrade), then reload the page. The API — and the rest of
the checklist, once the UI exists — was working the whole time.
"Port 8900 is already in use by another program." Accept the proposed
port, or pass --port. The address nerve init prints carries the port it
actually used; nerve status repeats it.
"Nerve did not answer within 60 s." Read nerve logs, fix the cause, and
run nerve init again — it resumes with your saved answers, or prints the
status block if the files were already written.
"Telegram bot failed to start at the last boot." The saved token was
rejected by Telegram when the gateway started. The gateway is running without
the bot; replace the token on the Channels step (it is verified before it is
saved) or in config.local.yaml, then restart.
The page says "Nerve did not come back". The restart was requested but no
new process answered within the time box. On the server, check nerve logs
and run nerve start if nothing is running, then Keep waiting or reload
the page.
A Keychain prompt appeared on the server's screen (macOS). Opening the Model step looks for a Claude Code login once per process; Deny keeps the option hidden and nothing else changes.
Nerve auto-migrates the SQLite database on startup. If a migration fails or the schema gets out of sync, you can inspect and fix it manually.
Check current schema version:
sqlite3 ~/.nerve/nerve.db "SELECT version FROM schema_version"Verify sessions table columns:
sqlite3 ~/.nerve/nerve.db "PRAGMA table_info(sessions)"Expected columns (as of V3): id, title, created_at, updated_at, source, metadata, status, sdk_session_id, parent_session_id, forked_from_message, connected_at, last_activity_at, archived_at, message_count, total_cost_usd, last_memorized_at.
Add a missing column manually:
sqlite3 ~/.nerve/nerve.db "ALTER TABLE sessions ADD COLUMN last_memorized_at TEXT"After any manual schema fix, restart Nerve: nerve restart.