Skip to content

The Matrix Daemon

The Matrix daemon is Neo's always-on background process — the substrate from VISION.md §Matrix that "processes, prepares, and notices" while you aren't actively talking to Neo. Without it, every neo invocation boots, does one thing, and exits. With it, one long-lived process owns the scheduler, runs memory maintenance on a cadence, fires your scheduled workflows, and can (at higher capability tiers, with your explicit opt-in) take pre-authorized actions and reach back into a live session.

It is opt-in and default-off. Nothing below runs unless you set NEO_DAEMON=on.


Quick start

bash
# Enable the daemon for this shell + run it in the foreground (Ctrl-C to stop).
NEO_DAEMON=on neo daemon run

# …or register it as an OS service that starts at login (see Autostart below):
NEO_DAEMON=on neo daemon install

# Check health any time:
neo daemon status

# Ask it to drain and exit:
neo daemon stop

By default the daemon runs at Tier 0 — memory maintenance and read-only tools only. It cannot run bash or take any external action until you raise the tier in your settings (see Capability tiers).


Commands

CommandWhat it does
neo daemon runBecome the daemon in this process (or exit 0 if another instance already owns the host lock). Used by autostart and self-heal; can also be run in the foreground.
neo daemon stopWrite the stop sentinel, wait for a graceful drain, escalate to a hard kill only if it doesn't drain in time.
neo daemon statusPrint health (HEALTHY / STALE / DEAD / FOREIGN), owner PID, version, heartbeat age, today's token spend, capability tier, and the log/audit paths. Exit code is non-zero when not healthy/stale, so scripts can gate on it.
neo daemon installPrint a first-run consent (the tier the daemon will run at) and register an OS autostart service.
neo daemon uninstallRemove the OS autostart service. A currently-running daemon keeps running until neo daemon stop.

Capability tiers

The daemon runs unattended, so there is no human to answer a permission prompt. The security model is therefore fail-closed: ambiguity = deny. What the daemon may do is governed entirely by a user-configured capability tier, set in your settings under daemon.capabilities:

jsonc
{
  "daemon": {
    "capabilities": {
      "tier": 1,
      "allowTools": ["read-file", "grep"],     // tier ≥ 1: named tool allowlist
      "allowBash": ["git status", "git log *"] // tier ≥ 1: named bash patterns
      // "allowMcpTools": ["gmail-send"]        // tier 2 only
    }
  }
}
TierGrants
0 (default, fail-closed floor)Memory maintenance + read-only tools (read-file, grep, glob, recall-memory) and writes confined to the global memory directory. No bash. No external actions.
1Tier 0 + your declared allowTools allowlist and named allowBash patterns.
2Tier 1 + named allowMcpTools (e.g. email/web/messaging). Irreversible Tier-2 actions are additionally fronted by a workflow approval step.

Fail-closed guarantees:

  • An absent daemon block ⇒ Tier 0.
  • A malformed daemon block ⇒ Tier 0, with the parse error surfaced in neo daemon status (never a silent partial grant).
  • An unknown capability key is a parse error (the schema is strict), never a silently-ignored typo you think granted something.
  • Dangerous bash code-exec patterns are stripped unconditionally — naming python or node in allowBash does not open an interpreter escape hatch.

Under the hood, daemon-hosted workflow sub-agents run in a dedicated closed-world (restricted) permission mode: anything not explicitly allowed is denied, the bypass killswitch is force-disabled, and the tool registry is a minimal safe core (read floor + memory write) rather than the full interactive surface — so a tool the daemon should never touch is absent, not merely denied.


What the daemon does

  • Owns the scheduler. Exactly one CronScheduler per host runs inside the daemon. Interactive sessions detect a live daemon and stand down, so cron tasks never double-fire.

  • Runs Tier-0 memory maintenance (the dreaming/consolidation pass) on a cadence. Default-ON (Wave 1) — runs unless you set NEO_MEMORY_DREAMING=off — under cheap gates and a daily token budget.

  • Reflects across your sessions (the "noticing" pass) on a daily cadence. Default-ON (Wave 1) — runs unless you set NEO_REFLECTION=off. It spots stale goals you haven't revisited and themes that recur across conversations, records them to REFLECTIONS.md in your global memory, and — when a session is live — surfaces a short proactive notice. Also Tier-0 (memory reads + one cheap-model call, no tools); cooldown-deduped so it never nags. See Reflection below.

  • Fires your scheduled workflows. workflow:<id> schedule rows finally execute, under the restricted posture + your tier's capability scope.

  • Two-way IPC with sessions. Sessions can submit signals (e.g. "dream now", "resume this session"); the daemon can leave notifications in a per-session outbox that the session surfaces — including asyncRewake (the daemon asking a live session to resume work).

  • Hosts the shared interactive engine (ADR-0002). Beyond background work, the daemon listens on two owner-only local IPC endpoints (named pipes on Windows), both backed by one shared engine, so connectors drive agent runs against it instead of each spawning a private neo --json child. The connector stays a separate network-facing process that sends only typed NDJSON run requests (the security boundary is unchanged); the engine multiplexes connections by sessionId. The two endpoints differ only in permission posture, decided by which one accepted the connection — never a request field:

    • ~/.neo/daemon/engine.sock — channel endpoint (untrusted). Telegram, WhatsApp, and any future channel connector attach here. Every run is clamped to the mobile-safe floor (clampToMobileSafeAutoAllow) so a connector can't widen its ceiling, whatever toolPolicy it sends.
    • ~/.neo/daemon/engine-owner.sock — owner endpoint (trusted). neo attach connects here and runs unclamped, under your normal interactive permission settings. A channel connector is never wired to this path, so it cannot acquire the owner posture — see the ADR-0002 owner-posture addendum for the threat model.

    Both endpoints are owner-only (0o600 on POSIX; per-NEO_HOME named pipes on Windows). See Channels. Disable a connector's use of the shared engine with NEO_ENGINE=isolated (per connector), which falls back to a private child. neo attach makes your terminal a connector too — an interactive REPL (or neo attach "msg" one-shot) against the shared engine, with streaming and y/N tool approvals for anything your settings mark ask (same as plain neo).


Channel supervision (ADR-0003)

The daemon can also bring up your channel connectors (Telegram, later WhatsApp) so you no longer launch neo-tg by hand. List the channels to supervise in ~/.neo/config/settings.json:

json
{ "channels": { "supervise": ["telegram"] } }

Absent ⇒ the daemon supervises nothing (fail-safe); a malformed channels block is ignored, never fatal.

When neo daemon run starts, its channel-supervisor service (registered after the engine server) spawns each configured connector as a child process after the engine socket is listening — so the connector attaches to the shared engine. Each connector is:

  • auto-restarted on crash with exponential backoff and a crash-loop cap (mirrors the daemon's own breaker — a connector that keeps crashing is given up on and logged, never taking the daemon down);
  • credential-isolated — the daemon holds NO bot token. The connector resolves its own token + owner id from the keychain (service='neo-cli.channels') or ~/.neo/.env. The supervisor only probes presence: a channel whose credentials are missing is skipped with a logged reason;
  • non-detached, so it dies with the daemon (no orphan connectors). On shutdown, connectors are drained FIRST (SIGTERM → SIGKILL after a budget), then the engine.

Store credentials with the new CLI:

neo channels login telegram     # prompts for the bot token + owner id
neo channels list               # show which channels are configured
neo channels logout telegram    # remove stored credentials

Credentials go to the OS keychain when available, else ~/.neo/.env (0o600). See ADR-0003 for the design rationale and the rejected alternatives.

Watching supervision health. neo daemon status prints a per-channel line (state, pid, cumulative restarts, current uptime, last exit reason). Every transition also emits a greppable {type:'decision'} log line (channel_connector_spawned / _attached / _exited / _restart_scheduled / _crash_loop_tripped / _skipped_no_creds / _drained_on_shutdown) and records metrics on the daemon's telemetry sink (channel_connector_restart_total, channel_connector_crash_loop_total, channel_connector_uptime_ms, channel_connector_time_to_restart_ms, …, all labelled by channel) — so you can judge stability over time and decide whether to keep daemon supervision or move a channel to an external process manager instead.

If you prefer OS-level supervision (systemd / launchd / a Windows service / pm2), simply leave the channel out of channels.supervise and run its connector (neo-tg) under your own unit — the connector still resolves its own keychain credentials and attaches to the shared engine exactly the same way.


Webhook receiver (vision-waves W2.2 — "senses")

The daemon can also receive external signals over HTTP — a generic webhook endpoint any outside system (GitHub, Zapier, a home-automation hub, a script of your own) can POST to, so events that happen outside any Neo session can still reach Neo. Every accepted POST is normalized into an ExternalSignal hook event and emitted onto the daemon's hook-event queue, where a workflow with a kind: event trigger on ExternalSignal can subscribe to it — the same mechanism any other hook event uses, no separate webhook-specific trigger type.

Configure it under settings.daemon.webhook:

jsonc
{
  "daemon": {
    "webhook": {
      "enabled": true,        // default: true
      "port": 7777,           // default: 7777
      "bind": "127.0.0.1",    // default: 127.0.0.1 (loopback-only)
      "token": "REPLACE_ME"   // bearer token; required if bind is non-loopback
    }
  }
}

Absent block ⇒ the service still starts with the defaults above (loopback-only, no token — safe because nothing off-box can reach it).

Request contract:

  • POST /hook/:source only. Any other method → 405; any other path → 404.
  • If token is set, every request must carry Authorization: Bearer <token> (compared in constant time). Missing/wrong token → 401.
  • Body must be JSON, at most 64KB. Malformed JSON → 400; oversize → 413.
  • Success → 202 {"ok": true, "id": "<signal-id>"}.
bash
curl -X POST http://127.0.0.1:7777/hook/github \
  -H "Authorization: Bearer $NEO_WEBHOOK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"kind": "push", "id": "abc123", "repo": "me/repo"}'

This normalizes to an ExternalSignal payload:

jsonc
{
  "source": "github",       // the :source path segment
  "kind": "push",           // body.kind, default "event"
  "id": "abc123",           // body.id, default a generated UUID
  "receivedAt": "2026-07-02T12:00:00.000Z",
  "data": { "kind": "push", "id": "abc123", "repo": "me/repo" } // the raw body
}

Security posture — fail-closed on exposure: if bind is anything other than loopback (127.0.0.1 / ::1 / localhost) and no token is configured, the service refuses to start (logs a warning, does not crash the daemon) rather than listen unauthenticated off-box. Binding off-box always requires a token.

Wiring note. The webhook receiver is intentionally NOT part of the shared interactive engine (ADR-0002) — it is its own plain HTTP listener that validates and normalizes untyped network input into a narrow, typed ExternalSignalbefore anything downstream (including the engine) ever sees it. The Gmail / Calendar poller (a separate daemon job) emits the same ExternalSignal shape (source: 'gmail' | 'gcal'), so workflows can subscribe once and receive signals from either "sense".


Gmail/Calendar signal poller (vision-waves W2.3 — "senses")

The daemon can also PULL external signals, on a fixed cadence, from Gmail and Google Calendar via the gws CLI (Google Workspace CLI — the same tool the gmail-via-gws skill uses interactively). This is the poller half of "senses"; the webhook above is the push half.

  • Capability flag: NEO_SIGNALS — default-on (Wave 1 polarity: only the literal NEO_SIGNALS=off disables it). See capability-flags.ts.
  • Cadence: every 15 minutes by default. Override with NEO_SIGNALS_CRON (standard 5-field cron). The job also has its own 10-minute in-memory re-entry throttle (NEO_SIGNALS_MIN_INTERVAL_MS) independent of the cron.
  • What it does: asks gws for unread Gmail messages and upcoming Calendar events since the last poll (a durable cursor in signal-state.json — a capped ring of seen Gmail message ids + a Calendar id→updated map), and emits an ExternalSignal (source: 'gmail' or 'gcal') for each new or changed item — the exact same event shape and hook-event queue the webhook receiver uses, so a workflow subscribes once and gets both "senses".
  • High-importance items (subject/summary matching urgent/asap/ important/deadline) also route a direct notice, subject to the interruption budget below.
  • No Gmail/Calendar connected yet: the poller degrades quietly. gws missing OR unauthenticated both surface as "not usable"; the daemon pushes exactly one notice, ever (state-flagged in signal-state.json), pointing at the connect-gmail skill, then polls silently every cycle until the user connects it.
  • Read-only: the poller never sends mail, modifies labels, or writes to Calendar — it is a sense, not an actuator. Sending stays behind the interactive gmail-via-gws skill's draft→confirm flow.

Interruption budget (vision-waves W2.4)

Three daemon jobs can each independently decide "this is worth pushing to the user": the heartbeat, the reflection pass, and the signal poller above. Without a shared cap, a bad day could mean three separate nudges that were each individually reasonable but collectively noisy. settings.daemon. interruptionBudget caps this across all sources:

jsonc
{
  "daemon": {
    "interruptionBudget": {
      "maxPerDay": 6   // default: 6
    }
  }
}

One InterruptionBudget instance is built at daemon boot (cli/commands/ daemon.ts) and threaded through scheduler-host.ts into all three jobs' routeNotices, so they draw against the same day-counter rather than each getting their own allowance. A notice that clears the day's budget (or scores above the priority floor — approval/rewake-kind notices always go through, since those gate something the user is actively waiting on) is pushed immediately; everything else is deferred into a once-daily digest. The heartbeat job — since it fires most often — flushes that digest (if anything is waiting) once per local calendar day.

Existing per-job cooldowns/throttles are unaffected — the budget is an additional cross-source cap layered on top, not a replacement for them.


Notifications & asyncRewake

When the daemon needs to reach a session, it enqueues a notification into that session's outbox. A live session:

  • drains at boot (anything left while you were away), and
  • polls continuously while running, surfacing new notifications as they arrive.

A notification's body is always treated as untrusted display data — escaped and defanged before it can reach a model or your terminal.

asyncRewake: a rewake-kind notification fires your session's DaemonRewake hook (via the normal hook mechanism), so you can automate what happens when the daemon asks a session to resume. Configure it like any hook:

jsonc
{ "hooks": { "DaemonRewake": [{ "type": "command", "command": "notify-send 'Neo wants to resume'" }] } }

Notification display controls

jsonc
{
  "daemon": {
    "notifications": {
      "mute": false,                 // true ⇒ suppress surfacing (hooks still fire)
      "kinds": ["notice", "rewake"]  // which kinds to surface; default: all
    }
  }
}

Muting affects only what you seeDaemonRewake hooks still fire, so muting is UI silence, not an automation killswitch.


Lifecycle hooks (§12)

The daemon emits job-lifecycle hook events you can subscribe to like any other hook — useful for observability and for building your own "noticing" automation:

EventWhen
DaemonJobStarteda job began
DaemonJobCompleteda job finished successfully (carries duration + tokens)
DaemonJobFaileda job threw (carries duration + reason)
DaemonJobBudgetExhausteda job was skipped because its token reservation would exceed the daily budget
DaemonRewakethe daemon asked this session to resume (see asyncRewake)

These fire for both internal maintenance passes (e.g. the dream job) and dispatched action jobs — the workflows the daemon runs from a workflow:<id> schedule row or a run-action signal. Every action job is reserved against the daily budget, recorded in audit.jsonl, and reported through these events; its jobTag is action:<workflow-id>. The budget is settled on the run's real provider token actuals (summed across its sub-agent steps); a run that does no LLM work falls back to a flat per-run estimate.

The job detail rides to command hooks JSON-encoded via the TOOL_INPUT env var.


Autostart

neo daemon install resolves the absolute node + entry paths (a service's PATH/working dir is not your shell) and bakes them plus the env block (NEO_DAEMON=on and whichever dreaming/reflection/attunement/heartbeat/bypass flags are live at install time) into the unit, so the service environment can't drift from what you opted into. Since Wave 1 the soul-capability flags are default-ON, so the value worth forwarding is usually an explicit =off opt-out — see Default-on capabilities in env-vars.md. Per OS:

  • Linux — a systemd user unit at ~/.config/systemd/user/neo-daemon.service, enabled with loginctl enable-linger (without lingering, a logged-out box never runs the user daemon) and systemd StartLimit* rate-limiting.
  • macOS — a launchd LaunchAgent at ~/Library/LaunchAgents/com.neo.daemon.plist with RunAtLoad + KeepAlive + ThrottleInterval.
  • Windows — a .cmd launcher (carrying the env block, which schtasks /tr can't) behind a hidden VBS wrapper, registered as an ONLOGON scheduled task. The wrapper waits for the daemon and returns its exit code, so Task Scheduler owns the process for its full lifetime and RestartOnFailure can restart a crashed daemon. Re-run neo daemon install after upgrading to refresh the baked paths and task definition.

neo daemon install prints the active tier as a first-run consent before registering, so you see the autonomy you're granting before an unattended process can act on your behalf.

Self-healing

When NEO_DAEMON=on, every interactive session boot calls ensureDaemonRunning(): if no current-version daemon is live it spawns one (detached, fire-and-forget); if a live daemon is running an older version it drains and respawns it (version handoff). A crash-loop breaker refuses to respawn a daemon that has failed ≥5 times within 120s, so a fast-failing daemon can't become a fork storm.


Single-host only (v1)

The daemon is single-host. It refuses to autostart on a synced/shared ~/.neo (Dropbox / OneDrive / iCloud / Google Drive / NFS / SMB) because two hosts sharing one daemon directory would corrupt each other's lock and ledgers. Override the path/FS heuristics (but never a live foreign-host lock) with NEO_DAEMON_SINGLE_HOST=1. Multi-device coordination is a later wave.


On-disk layout (~/.neo/daemon/)

FilePurpose
daemon.lockSingle-instance ownership (O_EXCL, host-fingerprinted, multi-signal staleness).
heartbeat.jsonHigh-frequency liveness only (deliberately separate from the budget ledger).
state.jsonDurable: schema/daemon version + crash-loop ledger.
budget.jsonDurable per-day token ledger.
audit.jsonlAppend-only forensic record of autonomous actions (single-writer, rotated).
daemon.log[.1..5]Rotated process log (the daemon runs detached).
signals/Inbound IPC: sessions submit signals; the daemon drains them.
notifications/<sessionId>/Outbound IPC: daemon → a specific session.
engine.sockChannel (untrusted) engine endpoint — connectors attach here; runs are mobile-clamped (ADR-0002). Named pipe on Windows.
engine-owner.sockOwner (trusted) engine endpoint — neo attach connects here and runs unclamped (ADR-0002 addendum). Named pipe on Windows.

Reflection (noticing)

VISION.md §Reflection is the daemon's "thinking between interactions." Default-ON (Wave 1) — the daemon runs a daily reflection pass (0 3 * * *, one hour after the weekly dream slot) unless you set NEO_REFLECTION=off. Each pass:

  1. Gathers candidates deterministically (no model): goals/intentions (type: project / user) you noted but haven't revisited past a staleness threshold, and themes that recur across two memories or across a memory and a recent conversation.
  2. Records them to REFLECTIONS.md in your global memory — the durable home, written on every pass regardless of whether anything is surfaced. Each entry carries [[memory-name]] provenance links.
  3. Surfaces ≤3 proactive notices — but only when a model + budget headroom + a live session all exist. One cheap-model call phrases the top candidates into short heads-ups, routed to a live session's notification inbox.

It is Tier-0 (memory reads + one provider call, no tools), budget-bounded (reserve→settle under NEO_DAEMON_DAILY_TOKEN_BUDGET), and failure-soft: no provider, exhausted budget, no live session, or a phrasing error all degrade to "logged the candidates, surfaced nothing" — never a crash. A content-hash cooldown ledger (reflection-state.json, 14-day window) makes re-runs over unchanged memory idempotent, so it never nags about the same thing twice.

Reflection writes a disjoint surface from the dream pass (REFLECTIONS.md vs INSIGHTS.md) and never mutates memory — the two passes share the daemon's one scheduler and budget but take no shared lock.

Activity gate (idle-skip)

Both Tier-0 passes fire on a fixed cron, but a host left idle shouldn't keep spending provider calls re-processing inputs that never changed. Before the budget reserve — and before any provider call — each pass consults a shared activity gate (activity-gate.ts): it compares the newest session's lastModifiedAt against a per-pass watermark persisted in activity-watermark.json (the lastActivityAt the last successful pass already processed). The pass runs only when there is activity newer than its watermark. The check is deterministic and provider-free — it reads session metadata only.

The clean property: after the user goes idle, exactly one trailing pass runs (to digest the last session), records the watermark, and every later cron fire is a cheap no-op until the user returns. Set NEO_DAEMON_ACTIVITY_GATE=off to force the legacy always-run behaviour (debugging / forcing a pass). The watermark is monotonic and degrades to "process the backlog once" on a corrupt/missing file.

Wave 9 (emotional attunement) calibrates how these notices sound. Default-ON (Wave 1) — unless NEO_ATTUNEMENT=off, the reflection phraser folds a bounded tone brief — derived from the user's user-profile.md + settings.tone — into its system prompt, so notices match the user's preferred register (directness vs. gentleness) while a hard guardrail keeps Neo from performing emotion. Notices use the user's durable tone only; the in-the-moment "reads the room" read applies to the live interactive voice, not background notices. Wave 8 still decides what to notice.


Heartbeat (proactive checklist)

Where reflection digests what already happened, the heartbeat is the "reasons-to-push" pass: it scans your standing checklist against the current moment and decides whether anything is worth a nudge right now. It is the complement to the channel proactive bridge — the heartbeat decides there's a reason; the bridge delivers the nudge to Telegram/WhatsApp.

Naming: this is the proactive heartbeat. It is unrelated to the daemon's liveness heartbeat.json (process ts/pid), which shares only the word.

Default-ON (Wave 1) — the daemon registers a recurring pass (default */30 * * * *, every 30 minutes) unless you set NEO_HEARTBEAT=off. Each beat:

  1. Reads ~/.neo/HEARTBEAT.md — your hand-authored checklist of standing "watch for these and nudge me" instructions (plain Markdown; # headings and <!-- comments --> are ignored). Absent file ⇒ nothing to scan, no model call.
  2. Makes one cheap-model call deciding, against the checklist + a snapshot of recent memories/sessions + the current local time, whether any item warrants surfacing. Most beats are silent — the model answers HEARTBEAT_OK.
  3. Surfaces ≤3 nudges to a live session's notification inbox, each with a stable dedup key so a recurring reminder isn't repeated every beat.

It reuses the reflection job's exact operational envelope — Tier-0 (one provider call, no tools), budget-bounded (reserve→settle), failure-soft (no provider / budget / live session / parse error ⇒ surface nothing), and the same content-hash cooldown ledger (reflection-state.json, keys namespaced under hb: so heartbeat and reflect can't suppress each other). Two differences:

  • Active-hours gate, not the idle-skip activity gate. The heartbeat is meant to fire on time/checklist reasons while you're idle ("standup in 5"), so the activity gate (which skips when no session changed) would defeat it. Instead it honors NEO_HEARTBEAT_ACTIVE_HOURS (e.g. 8-22, local 24h, end-exclusive; wraps past midnight like 22-6). Absent ⇒ always active.
  • Throttle floor (HEARTBEAT_JOB_MIN_INTERVAL_MS, default 30m, durable across restarts via the audit log) keeps the cron from over-firing.

Trust posture. The checklist is your own standing instructions — trusted by design (defanging it would break its purpose), handed to the model like the user profile. The heartbeat has no tool surface to hijack (Tier-0 chat-only), and every surfaced nudge body is escaped as untrusted display data at the inbox read side. When a future wave lets a checklist item pull in external content (an inbox, a web page), that content is the untrusted surface — gated by the daemon's existing Tier-0 capability policy (runtime/daemon/policy.ts) and the memory-only fork whitelist (core/agent-loop/background-fork.ts), not by anything heartbeat-specific.

HEARTBEAT.md example

markdown
# My heartbeat checklist
- If it's a weekday past 8:45am and I haven't been active, remind me standup is at 9.
- Nudge me if a memory marked `type: project` has gone stale (untouched > 2 weeks).
- Around 5pm, if I have unreviewed PRs noted in memory, give me a heads-up.

Environment variables

Since Wave 1 ("Waking the Soul"), NEO_MEMORY_DREAMING, NEO_REFLECTION, NEO_ATTUNEMENT, and NEO_HEARTBEAT are default-ON — set to off to disable; any other value (including unset) means enabled. NEO_DAEMON itself is unaffected and stays an explicit opt-in. See Default-on capabilities in env-vars.md for the rationale.

VariableEffect
NEO_DAEMON=onMaster switch. Nothing daemon-related runs without it.
NEO_MEMORY_DREAMING=offDisable the Tier-0 dreaming/consolidation pass in the daemon (default-on).
NEO_REFLECTION=offDisable the Tier-0 reflection ("noticing") pass in the daemon (default-on).
NEO_REFLECTION_CRONOverride the reflect cron (default 0 3 * * *, daily 03:00).
NEO_HEARTBEAT=offDisable the Tier-0 proactive heartbeat pass (default-on) — scans ~/.neo/HEARTBEAT.md and nudges.
NEO_HEARTBEAT_CRONOverride the heartbeat cron (default */30 * * * *, every 30 min).
NEO_HEARTBEAT_ACTIVE_HOURSRestrict beats to a local-hour window, e.g. 8-22 (end-exclusive; 22-6 wraps midnight). Absent ⇒ always active.
NEO_HEARTBEAT_MIN_INTERVAL_MSOverride the heartbeat re-entry throttle floor (default 30 min).
NEO_ATTUNEMENT=offDisable emotional attunement (default-on) — calibrate Neo's tone (interactive voice + notices) from user-profile.md + settings.tone. Off ⇒ no tone section, notices keep their default register.
NEO_ATTUNEMENT_LIVE=llmOpt into a cheap-model "reads the room" classifier on the live path (adds one per-turn call). Default heuristic floor uses no model and is failure-soft. This one stays a separate, narrower opt-in — NOT default-on.
NEO_SIGNALS=offDisable the Gmail/Calendar signal poller (default-on, vision-waves W2.3) — polls via the gws CLI and emits ExternalSignal events.
NEO_SIGNALS_CRONOverride the signal-poll cron (default */15 * * * *, every 15 min).
NEO_SIGNALS_MIN_INTERVAL_MSOverride the signal-poll re-entry throttle floor (default 10 min).
NEO_DAEMON_DAILY_TOKEN_BUDGETInteger daily token cap for daemon jobs (unset ⇒ unlimited).
NEO_DAEMON_ACTIVITY_GATE=offDisable the idle-skip gate so dream/reflect run on every cron fire regardless of activity (default: gate on).
NEO_DAEMON_SINGLE_HOST=1Override the synced-FS heuristics (not a live foreign lock).
NEO_ENGINE=isolatedPer-connector: skip attaching to the shared daemon engine and spawn a private neo --json child instead (ADR-0002). Default (unset) attaches when a daemon is available.
NEO_DISABLE_BYPASSForce-disable bypass permission mode (also forced on for the daemon regardless).

See env-vars.md for the full Neo environment reference.


Troubleshooting

  • neo daemon status says DEAD / STALE. Check ~/.neo/daemon/daemon.log (the path is printed by status). A STALE daemon's heartbeat is older than the staleness threshold (a hung or suspended process); the next run/self-heal reclaims the lock.
  • It won't start on a synced folder. That's the single-host refusal — see Single-host only.
  • status shows "config invalid → fell back to Tier 0". Your daemon.capabilities block has a typo or an unknown key; the printed parse error says which. Fix it and the daemon picks up the new tier on its next job (policy changes apply to the next job, never an in-flight one).
  • LLM jobs are disabled. If daemon.requireSecureStorage is set and the OS keychain is unavailable, the daemon refuses to use plaintext credentials and disables LLM-using jobs (it still owns the scheduler and fires non-LLM workflows). Unlock your keychain or unset the requirement.
  • A crash loop. If the daemon exits abnormally ≥5 times within 120s, the crash-loop breaker stops respawning it (status shows the crash ledger). Fix the underlying error in the log, then start it again.