Nerve uses APScheduler for in-process async job scheduling. Jobs can run in isolated sessions (fresh each time) or persistent sessions (context preserved across runs) and deliver output to configured channels.
Cron jobs live in two YAML files under ~/.nerve/cron/:
| File | Purpose | Managed by |
|---|---|---|
system.yaml |
Built-in crons (core + productivity) | nerve init — safe to regenerate |
jobs.yaml |
Your custom crons | You — Nerve never touches this file |
Both files use the same format. On startup, CronService loads and merges both:
- If a job ID appears in both files, the user version wins (with a warning in the log).
- Old installs with everything in
jobs.yamlstill work — ifsystem.yamldoesn't exist, all jobs load fromjobs.yaml.
Running nerve init on an existing install regenerates system.yaml (e.g., to pick up updated prompts from a Nerve update) without touching jobs.yaml.
# ~/.nerve/cron/jobs.yaml (or system.yaml — same format)
jobs:
- id: morning-briefing
schedule: "30 11 * * *" # 11:30 AM daily
prompt: "Give me a morning briefing..."
description: "Daily morning summary"
model: claude-sonnet-4-6 # Optional model override
target: telegram # Delivery channel
session_mode: isolated # "isolated", "persistent", or "main"
enabled: true
- id: system-monitor
schedule: "30m" # Every 30 minutes
prompt: "Check system health and report changes since your last check."
session_mode: persistent # Keeps context across runs
context_rotate_hours: 48 # Fresh context every 48h
enabled: true
- id: task-reminder
schedule: "0 */2 * * *" # Every 2 hours
prompt: "Check for overdue tasks..."
target: telegram
enabled: true| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Unique job identifier |
schedule |
string | yes | Crontab expression or interval (2h, 30m) |
prompt |
string | yes | Message sent to the agent |
description |
string | no | Human-readable description |
model |
string | no | Override model (default: agent.cron_model) |
target |
string | no | Delivery channel (default: telegram) |
session_mode |
string | no | isolated (new session per run), persistent (reuse context), or main |
context_rotate_hours |
int | no | Hours before persistent context resets (default: 24, 0 = never) |
reminder_mode |
bool | no | Persistent only: send short reminder instead of full prompt on subsequent runs (default: false) |
catchup |
bool | no | Fire once on startup if the job missed a run while the server was down (default: true) |
enabled |
bool | no | Whether the job is active (default: true) |
lock |
bool | no | Prevent concurrent runs of this job — the next fire waits for the previous one (default: false) |
run_if |
list | no | Run gates — preconditions that must all hold for the job to fire. See Run Gates |
A run gate is a precondition evaluated right before a job fires. It answers one question: should this cron run right now? Gates let a job stay idle until there is actually something to do — no agent session is spawned (and nothing is logged beyond a skip line) when a gate is unsatisfied.
Declare gates with the run_if key — a list of gate specs. All gates must be
satisfied (logical AND) for the job to run:
jobs:
- id: task-planner
schedule: "0 */4 * * *"
prompt: "Review open tasks and propose plans..."
run_if:
- type: tasks # only when there's something to plan
status: pendingWhen multiple gates are listed, the job runs only if every one passes:
- id: triage
schedule: "30m"
prompt: "Triage incoming work..."
run_if:
- type: tasks # there is an open task AND
status: pending
- type: messages # a source has unread mail
sources: [gmail, github]Gates are fail-open: if a gate errors while checking (e.g. a transient DB issue), the run proceeds rather than being skipped — an occasional wasted run beats a cron that silently never fires.
Satisfied when enough tasks match a status/tag filter. The canonical use is "only run the planner when there is something to plan."
| Field | Type | Default | Description |
|---|---|---|---|
status |
string | list | "all" |
omitted = any open (non-done) task | Status name(s) to count. A list counts across all of them; "all" counts every task regardless of status |
tag |
string | — | Optional tag filter |
min_count |
int | 1 | Minimum number of matching tasks required to run |
run_if:
- type: tasks
status: [pending, in_progress] # any of these statuses
tag: backend # ...tagged "backend"
min_count: 3 # ...and at least 3 of themSatisfied when monitored sync sources have unread messages (compares each source's max ingested rowid against the consumer cursor; never advances it).
| Field | Type | Default | Description |
|---|---|---|---|
sources |
list | — (required) | Source names to check (e.g. gmail, github) |
consumer |
string | inbox |
Consumer cursor name used for the unread check |
run_if:
- type: messages
sources: [gmail, github]
consumer: inboxLegacy shorthand. The older
skip_when_idle: [<sources>]/idle_consumer: <name>fields still work — they are translated into an equivalentmessagesgate at load time. Preferrun_iffor new jobs.
Gates live in nerve/cron/gates.py. To add one: subclass CronGate, set its
type, implement is_satisfied, describe, and from_config, then register
the class in GATE_REGISTRY. It becomes usable from run_if immediately.
Each run creates a fresh session (cron:{job_id}:{timestamp}). The agent has no in-context memory of previous runs. This is best for self-contained jobs like daily briefings or cleanup tasks.
Jobs with session_mode: persistent maintain SDK conversation context across runs:
- First trigger: Creates a fresh session (
cron:{job_id}) and runs the prompt. - Subsequent triggers: Resumes the same SDK session and sends the prompt as a new message. The agent sees all prior runs in-context.
- Context rotation: Every
context_rotate_hours(default: 24), the context is reset. Old messages remain in the database and are searchable via memU, but the agent starts with a clean slate.
This is useful for jobs that benefit from accumulated context:
- Monitoring jobs that track changes over time
- Summary jobs that should remember what was already reported
- Multi-step workflows that build on previous results
Between runs, the SDK client subprocess is freed (no resource leak). On the next trigger, the SDK resumes the session from its stored state.
Persistent jobs with reminder_mode: true avoid resending the full prompt on every trigger. Instead:
- First run (or after context rotation): The full prompt is sent, with a note explaining that subsequent runs will use a short reminder.
- Subsequent runs: A short message ("Scheduled run — continue with the same task as before.") is sent instead of the full prompt. The agent already has the original instructions in-context from the first run.
This significantly reduces token usage for frequently-triggered persistent jobs (e.g., every 15 minutes).
Jobs with session_mode: main run in the main user session instead of an isolated one.
# List available jobs (shows source and status)
nerve cron
# [system] memory-maintenance: Daily memory cleanup (enabled)
# [system] inbox-processor: Polls sources every 30 min (enabled)
# [user ] my-custom-monitor: Checks CI status (enabled)
# Run a specific job manually
nerve cron morning-briefing
# Check cron status
nerve doctor
# [OK] System crons: ~/.nerve/cron/system.yaml (3/5 enabled)
# [OK] User crons: ~/.nerve/cron/jobs.yaml (1 jobs)These ship in ~/.nerve/cron/system.yaml and are managed by nerve init. Running nerve init regenerates this file (e.g., to pick up updated prompts from a Nerve update) without touching your custom jobs.yaml.
| Job | Schedule | Session Mode | Description | Personal | Worker |
|---|---|---|---|---|---|
memory-maintenance |
Daily 5 AM | isolated | Dedup, prune stale entries, improve memory wording. Runs silently. | ✅ always | ✅ always |
inbox-processor |
Every 30 min | persistent (24h rotation, reminder mode) | Polls all sync sources (email, GitHub, Telegram). Triages, creates tasks, memorizes facts, sends notifications for urgent items. | ✅ default | — |
task-planner |
Every 4 hours | persistent (168h rotation) | Reviews open tasks, explores codebases, proposes implementation plans via plan-approve workflow. Gated on tasks (status pending) — stays idle when there's nothing to plan. |
✅ default | ✅ default |
skill-extractor |
Every 12 hours | persistent | Identifies repeated workflows from recent conversations, memory, and completed tasks. Proposes new skills via task+plan system. | ✅ optional | ✅ default |
skill-reviser |
Weekly (Sun 3 AM) | persistent | Reviews existing skills for accuracy (outdated paths, credentials), completeness (missing steps), and quality (trigger phrases, examples). Proposes revisions via task+plan. | ✅ optional | ✅ default |
Mode defaults:
- Personal —
memory-maintenance(always on) +inbox-processor+task-plannerenabled by default.skill-extractorandskill-reviserare presented as optional duringnerve init. - Worker —
memory-maintenance(always on) +task-planner+skill-extractor+skill-reviserenabled by default.inbox-processoris not included (workers don't have sync sources).
Both skill jobs use source="skill-extractor" or source="skill-reviser" on created tasks. When their plans are approved, the plan approval handler creates/updates the skill directly from the plan content (which is a full SKILL.md file) instead of spawning an implementation session.
Cron schedules survive server restarts. On startup, the cron service queries cron_logs for each job's last successful run and uses that to restore correct timing.
For interval schedules (e.g. 4h), the trigger is anchored to the last run time. If a job last ran 2.5 hours ago and the interval is 4 hours, the next fire is in 1.5 hours — not 4 hours from now.
If a job should have fired while the server was down, it fires once on startup — regardless of how many runs were missed. This applies to both interval and crontab schedules.
- First-ever run: No catch-up (no history to compare against).
- Multiple missed fires: Coalesced into a single catch-up run.
- Catch-up runs concurrently: All overdue jobs fire in parallel, in the background (doesn't block startup).
Set catchup: false on jobs where a late run doesn't make sense:
- id: morning-briefing
schedule: "0 12 * * *"
catchup: false # no point running a morning briefing at 3pmInterval alignment still applies even with catchup: false — only the startup catch-up fire is skipped.
In addition to YAML-defined cron jobs, the cron service auto-registers source runners from the sync: config. Each enabled source becomes an APScheduler job with ID source:<name> (e.g., source:gmail, source:github).
Source runners:
- Run on the schedule defined in their config (
sync.<source>.schedule) - Use
SourceRunnerto fetch → process → advance cursor - Are logged in both
cron_logsandsource_run_logtables - Appear in
list_jobs()alongside regular cron jobs
See sources.md for full documentation.
Every cron and source run is logged in the cron_logs SQLite table:
job_id— Which job ran (e.g.,morning-briefingorsource:gmail)started_at/finished_at— Timestampsstatus—successorerroroutput— First 2000 chars of response / summaryerror— Error message if failed
Source runs also log to source_run_log with per-source diagnostics (records fetched/processed, errors).
View logs via API: GET /api/cron/logs?job_id=morning-briefing&limit=10