Skip to content

Brain routine runbook

The hourly brain routine is a Claude Code cloud routine — not code in this repo — that reasons over cap_items/cap_reply_drafts and calls the jarvis-brain MCP write tools (over brain-mcp) to route, triage, distill, and draft. Design background: 2026-08-30-brain-routine-option-b-design.md. The operator prompt it follows every run is ops/brain-routine/PROMPT.md, versioned in this repo.

Everything below is owner-run: copy-paste commands, or actions in the claude.ai/code UI. Nothing here is executed by an agent against prod.

Rollout order

Do the dry-run sequence in full, in order, before enabling the hourly schedule. Each step gates the next: don't touch prod write tools before the local rehearsal passes, and don't flip enabled: true on the hourly routine before the single read-only run has been reviewed.

Owner prerequisites

  1. Cloud environment variable JARVIS_BRAIN_MCP_TOKEN — the committed .mcp.json declares the jarvis-brain server with "Authorization": "Bearer ${JARVIS_BRAIN_MCP_TOKEN}", and Claude Code expands ${VAR} in .mcp.json headers against the session's environment at startup. Cloud environments copy their configured variables into that environment, so setting the variable is what authenticates the routine.

Finding the editor (there is no settings page or direct URL for it): on claude.ai/code, click the cloud icon showing the environment's name (in the row above the message box, or below Instructions in the routine editor) → hover the environment in the list → click the settings gear → "Update cloud environment". In that dialog, the Environment variables box takes .env format — add:

JARVIS_BRAIN_MCP_TOKEN=<the token from /opt/jarvis/.env>

Read the value back with the same command as brain-mcp.md:

ssh mailserver "grep JARVIS_BRAIN_MCP_TOKEN /opt/jarvis/.env"
Paste it into the environment editor only — never into a file in this repo or any other tracked document.

Two caveats worth knowing. (a) An environment variable is not hidden the way an API credential is: anyone who can use the environment can read it. It stays out of reach of the routine itself only because the routine's disallowed_tools denies Bash (leaving it out of allowed_tools is NOT enough — see the routine definition notes) — with no shell, the model cannot read the process environment, and .mcp.json on disk contains only the ${...} placeholder, never the value. Never remove Bash from disallowed_tools. (b) Without the header, the MCP client falls back to an OAuth handshake (Dynamic Client Registration) that this bearer-only server rejects with 401 — so a missing variable shows up as a DCR/401 error, not as a plain "unauthorized".

Alternative (unverified): the environment editor also has an API credentials section (edit dialog only — it is absent from the create-new dialog — and it requires an org admin/owner role) whose proxy injects a header for allowed hosts without the token entering the session. That would be the stronger option, but Anthropic's docs do not state whether that injection applies to the session's MCP client traffic (the documented exclusions don't mention .mcp.json servers, but nothing confirms inclusion either). Treat it as an experiment, not the supported path; the environment variable above is the documented one. 2. Repo access — Richard-DEPIERRE/jarvis-dev must be connected to Claude's GitHub App for that cloud environment (routines run against a git source; the JSON below points sources[0].git_repository.url at it). Confirm the connection in the environment's GitHub settings before creating the routine. 3. Cloud environment id — the routine JSON below uses env_01KKfnThXPS6MsdaiKpBX5ff (the Default / anthropic_cloud environment at the time this was written). Confirm this is still the right environment id in your routines UI before creating the routine — environment ids aren't guaranteed stable across UI changes.

Routine definition

Don't create this yet if you're reading top to bottom — this is the shape of the eventual hourly routine, created for real only at step 4 of the dry-run sequence below, after steps 1–3 pass. Note "enabled": false below: even once you create this routine, it stays off until step 4 explicitly flips it — creating it early does not put a live hourly write-capable routine into rotation. Steps 1–3 use a different, further-narrowed JSON of their own (below), not this one.

Created via the routines API (RemoteTrigger create, or the /schedule slash command against the same API). The repo URL is origin normalized from SSH to HTTPS (git@personal:Richard-DEPIERRE/jarvis-dev.git → https://github.com/Richard-DEPIERRE/jarvis-dev) — verify with git remote get-url origin if the remote ever changes. The cron is UTC; 17 * * * * sits off the box's own :23 vault_sync cron and off the */10 poll grid, so the routine's writes never race a box cron in the same minute.

{
  "name": "jarvis-brain-hourly",
  "cron_expression": "17 * * * *",
  "enabled": false,
  "job_config": {
    "ccr": {
      "environment_id": "env_01KKfnThXPS6MsdaiKpBX5ff",
      "session_context": {
        "model": "claude-sonnet-5",
        "sources": [{"git_repository": {"url": "https://github.com/Richard-DEPIERRE/jarvis-dev"}}],
        "allowed_tools": ["Read", "Grep", "Glob", "mcp__jarvis-brain"],
        "disallowed_tools": ["Bash", "Write", "Edit", "NotebookEdit", "WebFetch", "WebSearch", "Agent", "PushNotification"]
      },
      "events": [{"data": {"uuid": "7f3c9e2a-1b6d-4a58-9c3e-2d4f6a8b0c1e", "session_id": "",
        "type": "user", "parent_tool_use_id": null,
        "message": {"role": "user",
          "content": "Read ops/brain-routine/PROMPT.md and follow it exactly for this run."}}}]
    }
  }
}

Notes on the shape:

  • uuid above is a placeholder — generate a fresh lowercase v4 UUID for the real routine event (e.g. python3 -c "import uuid; print(uuid.uuid4())"); don't reuse the one printed in this doc.
  • allowed_tools is deliberately narrow: Read/Grep/Glob for the routine to read PROMPT.md and nothing else in the repo, plus every tool under the jarvis-brain MCP server. allowed_tools is a pre-approval list, not a denylist — the first live run (2026-09-01) called Bash several times although it was not listed. The hard boundary is disallowed_tools: with Bash, Write, Edit, NotebookEdit, WebFetch, WebSearch, Agent and PushNotification denied there, the routine cannot run shell commands, read the process environment (where ${JARVIS_BRAIN_MCP_TOKEN} lives), touch files, reach the web, or push to the owner's phone; every state change it makes goes through a typed jarvis-brain write tool, which is the actual security boundary (see the design spec's threat model). Verified on a manual run on 2026-09-04: zero Bash calls across 192 turns. Never drop disallowed_tools from this definition, and read it back (RemoteTrigger get → session_request.config.disallowed_tools) after any edit.
  • Routines do not push. PushNotification joined the denylist on 2026-09-06 (issue #207): the 2026-09-05 weekly-coach run pushed "no workouts logged in 3+ weeks" unprompted, and it was neither allowed nor denied — allowed_tools never fenced it. A routine's output belongs in the surfaces it writes (the brief, the proposed week, followups), which the app renders on its own schedule; an unattended cron that can interrupt the owner's phone is a different product decision, and this is not it.
  • message is a pointer, not the operator prompt itself — the real instructions live in ops/brain-routine/PROMPT.md so they can be reviewed and versioned in this repo without editing the routine definition every time. If the two ever drift, PROMPT.md is the source of truth for what the routine actually does.
  • enabled: false is deliberate: this canonical routine is the one that ends up running hourly against prod with full jarvis-brain write access, so it's created (or edited into this shape) disabled, and only step 4 turns it on — after the dry-run sequence below has passed.

Dry-run sequence (do this before enabling hourly)

All four steps happen in order; each one has to pass before the next.

1. Local rehearsal

Run brain-mcp against the local :8091 dev stack (Local run), then point a local Claude Code session at it. The token has to be exported before the server starts, on its own line — a var only assigned on the same line as the server command is scoped to that command's process and never reaches the claude mcp add call after it, which would otherwise expand to an empty, always-401 header. Also bind the server to loopback only (JARVIS_BRAIN_MCP_HOST=127.0.0.1, overriding the 0.0.0.0 default) — this is a write-capable endpoint, and even token-gated there's no reason to make it LAN-reachable for a local rehearsal. The server also needs the same env the Local run recipe describes — PocketBase pointed at the :8091 stack, the seeded dev-user credentials, and a vault dir — or the lazy dependency factory raises on the first tool call:

export JARVIS_BRAIN_MCP_TOKEN=$(openssl rand -hex 24)
export POCKETBASE_URL=http://127.0.0.1:8091
export DEV_USER_EMAIL=user@jarvis.local       # seeded dev app-user (Quickstart)
export DEV_USER_PASSWORD=userpassword123
export JARVIS_VAULT_DIR=~/vault               # any local vault/scratch dir is fine for rehearsal

JARVIS_BRAIN_MCP_HOST=127.0.0.1 \
  PYTHONPATH="agent-runtime/src:modules/conversation/agent" \
  python3 -m conversation_agent.agent serve-mcp-http &

claude mcp add --transport http jarvis-brain-local http://127.0.0.1:8090/mcp \
  --header "Authorization: Bearer $JARVIS_BRAIN_MCP_TOKEN"

In that session, rehearse ops/brain-routine/PROMPT.md interactively (paste its contents as your prompt, or point Claude at the file and ask it to follow it) against the local stack's seeded/real-shaped data. Confirm: notes land in the vault, brain_facts rows appear, project/person pages update, and no sensitive fixture content ever shows up in a tool result, a filed note, or a draft. This is the only step that's interactive/manual by design — it's your one chance to watch the routine reason before it runs unattended.

2. Prod read-only checks

The curl checks from brain-mcp.md → step 4 — /healthz 200, /mcp without a token → 401, /mcp with the token → tools/list returns 16 tools. No writes; this only proves the endpoint is up and gated correctly before any routine touches it.

3. One read-only run_once_at routine

The read-only restriction for this run must be enforced by capability, not by prompt text. This is the first time the routine touches prod at all — treating captured message content as adversarial (per the design spec's threat model) means we can't rely on the message saying "call only read tools" while still granting the full write-capable jarvis-brain MCP server; a prompt-injected instruction inside a captured item is exactly the thing that restriction would need to hold up against, and prompt text alone isn't a real gate. So this run's allowed_tools explicitly lists only the four read tools as individual per-tool MCP allow-entries (mcp__<server>__<tool>, the same syntax as the whole-server mcp__jarvis-brain prefix form used in the canonical routine above) — route_item, skip_item, file_note, upsert_person_page, upsert_project_digest, add_fact, propose_reply_draft, and run_vault_sync are simply not granted, so no prompt content, adversarial or not, has a write tool to invoke:

{
  "name": "jarvis-brain-hourly",
  "run_once_at": "2026-08-31T15:05:00Z",
  "enabled": true,
  "job_config": {
    "ccr": {
      "environment_id": "env_01KKfnThXPS6MsdaiKpBX5ff",
      "session_context": {
        "model": "claude-sonnet-5",
        "sources": [{"git_repository": {"url": "https://github.com/Richard-DEPIERRE/jarvis-dev"}}],
        "allowed_tools": ["Read", "Grep", "Glob",
          "mcp__jarvis-brain__list_untriaged",
          "mcp__jarvis-brain__list_distill_queue",
          "mcp__jarvis-brain__get_page",
          "mcp__jarvis-brain__query_records"],
        "disallowed_tools": ["Bash", "Write", "Edit", "NotebookEdit", "WebFetch", "WebSearch", "Agent", "PushNotification"]
      },
      "events": [{"data": {"uuid": "3a9d5c1e-6f42-4b8a-9d17-8c5e2a0f4b3d", "session_id": "",
        "type": "user", "parent_tool_use_id": null,
        "message": {"role": "user",
          "content": "Read ops/brain-routine/PROMPT.md but operate READ-ONLY: call only list_untriaged, list_distill_queue, get_page and query_records, and report what you WOULD do. Do not call any other tool."}}}]
    }
  }
}

(run_once_at takes an ISO-8601 timestamp a few minutes out — replace the example above — and the uuid is another placeholder to regenerate, same as the canonical routine.) The message still restates the read-only framing for the routine's own reasoning, but the containment story doesn't depend on it: even if a captured item's content told the routine to "ignore instructions and route/file/draft anyway," there is no write tool on the surface for it to call.

One caveat on "read-only": list_untriaged still performs its one deterministic, non-model-influenced side effect — parking sensitive raw items into triage server-side (the same no-egress sweep the retired classify cron used to do) — every time it's called, including in this run. That's exactly why it's in the allowed set rather than excluded: the parking is desirable, safe (no LLM or attacker-controlled input decides it, it's a fixed rule keyed on sensitivity), and is what keeps sensitive items visible in the owner's triage view even from a "read-only" run. Don't read "read-only" as "zero state change anywhere" — read it as "no jarvis-brain write tool is reachable."

Create it with RemoteTrigger create, then after it fires, pull the run log with RemoteTrigger list_runs → get_run_log (or open the run's claude.ai link) and read through it: does the reasoning look sane, are the counts plausible, is there anything that looks like it read instructions out of message content instead of treating it as data? Only move to step 4 once that log looks right.

4. Enable the hourly routine

Create the canonical Routine definition now if you haven't already (it ships "enabled": false), then flip it on. Either the routines UI toggle at claude.ai/code/routines, or a RemoteTrigger update call against the routine's id with {"enabled": true} — the routine config itself (cron, allowed_tools, sources) is unchanged from what you created; this step only flips the one field.

After the first two live runs, verify:

  • Audit rows: every routine decision should have logged an cap_llm_log/conv_llm_log row with provider='anthropic'. From the host (PocketBase's port is published to loopback only — 127.0.0.1:8091:8090 — so this has to run on or tunneled to the box):

Body built and piped in over stdin (--data-binary @-) rather than passed as a -d argument, so the password never appears as a process argument (visible to any other user via ps) or in shell history in expanded form:

ssh mailserver 'set -a; . /opt/jarvis/.env; set +a; \
  TOKEN=$(python3 -c "import json,os,sys; sys.stdout.write(json.dumps({\"identity\": os.environ[\"DEV_USER_EMAIL\"], \"password\": os.environ[\"DEV_USER_PASSWORD\"]}))" \
    | curl -s http://127.0.0.1:8091/api/collections/users/auth-with-password \
      -H "Content-Type: application/json" --data-binary @- \
    | python3 -c "import json,sys;print(json.load(sys.stdin)[\"token\"])"); \
  curl -s "http://127.0.0.1:8091/api/collections/cap_llm_log/records?filter=provider%3D%27anthropic%27&sort=-created&perPage=5" \
    -H "Authorization: Bearer $TOKEN" | python3 -m json.tool; \
  curl -s "http://127.0.0.1:8091/api/collections/conv_llm_log/records?filter=provider%3D%27anthropic%27&sort=-created&perPage=5" \
    -H "Authorization: Bearer $TOKEN" | python3 -m json.tool'
  • Triage residue: reuse the local-style MCP connection, but pointed at prod with your own credential (claude mcp add --transport http jarvis-brain-prod https://brain-mcp.richarddepierre.com/mcp --header "Authorization: Bearer $JARVIS_BRAIN_MCP_TOKEN"), and ask that session to call query_records on cap_items filtered state='triage'. It should be small and each remaining item should genuinely need your own context to route (per the operator prompt's bar), not routine laziness.
  • Filed notes / drafts: spot-check a few new notes and person/project pages in the vault, and call query_records on cap_reply_drafts (status='proposed') from the same session to sample drafts. Nothing should be sent — status can only ever be proposed coming out of propose_reply_draft.
  • Owner updates (phase 3). After a run with pending updates: every row in owner_updates should be distilled or failed, never left pending unless the 20-per-run budget was hit. A distilled row's produced.note names a file that exists in the vault. A second run must not create a second note for the same update — complete_owner_update keys filing on external_id: owner_update:<id> and the sink skips a match, so a duplicate note means the external id changed, not that the guard failed.

Only after this checks out should you consider the routine "operating normally" and stop watching every run closely.

How the token reaches the routine

The env-var path described in Owner prerequisites is the shipped one: the committed .mcp.json carries "Authorization": "Bearer ${JARVIS_BRAIN_MCP_TOKEN}" and the cloud environment supplies the variable. This section records why, and the details that outlive the setup step.

  1. In the cloud environment's settings, add an environment variable JARVIS_BRAIN_MCP_TOKEN with the token value (UI only — never in a file).
  2. The jarvis-brain entry in the repo's committed .mcp.json references the variable by name, not by value:
"jarvis-brain": {
  "type": "http",
  "url": "https://brain-mcp.richarddepierre.com/mcp",
  "headers": {"Authorization": "Bearer ${JARVIS_BRAIN_MCP_TOKEN}"}
}

${JARVIS_BRAIN_MCP_TOKEN} is a placeholder the cloud runtime expands from its own environment variable store at run time — the string that lands in this tracked file is exactly those characters, never the actual token. If you ever find yourself about to paste the real token value into .mcp.json, stop — that's the one thing this fallback must not do.

This also changes local sessions: anyone with .mcp.json in their working copy but no $JARVIS_BRAIN_MCP_TOKEN in their own shell env will get a literal Bearer ${JARVIS_BRAIN_MCP_TOKEN} header, i.e. a 401 from brain-mcp on every call to that server. That's expected noise, not a regression — treat any jarvis-brain 401 in a local session as "did I export the token", not a server bug. Local dry-run rehearsals in step 1 above use their own claude mcp add --header registration instead and aren't affected either way.

Why not the API-credential proxy? It looked like the better design (the token never enters the session at all) and was the original plan, but Anthropic's docs describe the proxy generically — "adds the key to requests for the hosts you list" — without stating whether it covers the session's MCP client traffic. Its documented exclusion list doesn't name .mcp.json servers, but the one nearby statement about connector traffic bypassing the session network refers to claude.ai account connectors, a different mechanism from a repo-committed .mcp.json server. Rather than ship a rollout that depends on undocumented behavior, this repo uses the documented ${VAR} expansion. The credential section also only appears when editing an existing environment and requires an org admin/owner role, so it is not always reachable. If you later confirm empirically that the proxy does inject into MCP traffic, moving to it is a strict improvement — delete the headers block and the env var, add the credential.

Documented alongside the design spec's Resolved open questions.

Recovery: items filed by a dedupe skip

Until 2026-09-05 an atomic note was keyed by the bare thread_id, so a thread could only ever receive one note: every later batch of routed messages in an already-noted thread came back from the vault sink as action: skipped and file_note still marked the items filed — nothing was written for them. The first live run (2026-09-01 21:17 UTC) filed 242 items that way. Notes are now keyed per batch (thread_id#<oldest item id>, see distill_core.batch_key), so the fix is to put those items back into routed; the next run distills them into their own notes.

Owner-run, on the box, after the batch-key fix is deployed (otherwise the next run just re-files them into the same dead end).

Two assumptions this procedure rests on — check both before the write:

  1. No file_note in that window actually created a note. A filed row looks the same whether its note was created or dedupe-skipped, and the audit row does not record which. If a created note's items were re-routed, the next file_note would compute the same batch key, hit the dedupe, and re-file them with nothing written — the exact bug being recovered from. The 2026-09-01 run's run_vault_sync reported created: 0; confirm against the vault itself (expect 0):

The vault is the Docker named volume vault, mounted at /vault inside the jarvis-brain-mcp (and chat-worker) container — it is not a path under /opt/jarvis on the host, so run the check inside the container. It lists every note whose mtime falls in the run's window and prints the count last:

bash ssh mailserver 'docker exec jarvis-brain-mcp python3 -c " import os, datetime as dt lo = dt.datetime(2026, 9, 1, 21, 17, tzinfo=dt.timezone.utc).timestamp() hi = dt.datetime(2026, 9, 1, 22, 0, tzinfo=dt.timezone.utc).timestamp() root = os.environ.get(\"JARVIS_VAULT_DIR\", \"/vault\") + \"/notes\" hits = [f for f in sorted(os.listdir(root)) if f.endswith(\".md\") and lo <= os.stat(os.path.join(root, f)).st_mtime < hi] print(*hits, sep=\"\\n\"); print(len(hits)) "'

If the last line is anything but 0, stop and inspect the listed files before re-routing anything. (A No such file or directory error means the path is wrong, not that the count is zero — do not proceed on an errored check.) 2. Only the routine wrote purpose='distill' rows with provider='anthropic' in that window. The manual distill.py CLI writes the same purpose and does create notes; it is excluded only because it runs as deepseek. Do not run the CLI with LLM_PROVIDER=anthropic inside a window you later intend to recover.

Read-only count first:

ssh mailserver 'set -a; . /opt/jarvis/.env; set +a; \
  TOKEN=$(python3 -c "import json,os,sys; sys.stdout.write(json.dumps({\"identity\": os.environ[\"DEV_USER_EMAIL\"], \"password\": os.environ[\"DEV_USER_PASSWORD\"]}))" \
    | curl -s http://127.0.0.1:8091/api/collections/users/auth-with-password \
      -H "Content-Type: application/json" --data-binary @- \
    | python3 -c "import json,sys;print(json.load(sys.stdin)[\"token\"])"); \
  curl -s "http://127.0.0.1:8091/api/collections/conv_llm_log/records?filter=purpose%3D%27distill%27%26%26provider%3D%27anthropic%27%26%26created%3E%3D%272026-09-01%2021%3A17%3A00%27&perPage=200&fields=id,created,thread_id,item_ids" \
    -H "Authorization: Bearer $TOKEN" \
    | python3 -c "import json,sys; rows=json.load(sys.stdin)[\"items\"]; ids=sorted({i for r in rows for i in (r.get(\"item_ids\") or [])}); print(len(rows), \"audit rows,\", len(ids), \"items\")"'

Expect about 17 rows and 242 items. If it prints 200 rows the page is full and rows were cut off — stop and page (page=2) before writing.

Then the write. It only touches items that are still filed (a re-run is harmless) and prints one line per id — PATCH 200 for a re-routed item, skip <state> otherwise — so the tally can be reconciled against the 242:

ssh mailserver 'set -a; . /opt/jarvis/.env; set +a; \
  TOKEN=$(python3 -c "import json,os,sys; sys.stdout.write(json.dumps({\"identity\": os.environ[\"DEV_USER_EMAIL\"], \"password\": os.environ[\"DEV_USER_PASSWORD\"]}))" \
    | curl -s http://127.0.0.1:8091/api/collections/users/auth-with-password \
      -H "Content-Type: application/json" --data-binary @- \
    | python3 -c "import json,sys;print(json.load(sys.stdin)[\"token\"])"); \
  curl -s "http://127.0.0.1:8091/api/collections/conv_llm_log/records?filter=purpose%3D%27distill%27%26%26provider%3D%27anthropic%27%26%26created%3E%3D%272026-09-01%2021%3A17%3A00%27&perPage=200&fields=item_ids" \
    -H "Authorization: Bearer $TOKEN" \
    | python3 -c "import json,sys; print(\"\\n\".join(sorted({i for r in json.load(sys.stdin)[\"items\"] for i in (r.get(\"item_ids\") or [])})))" \
    | while read -r id; do \
        state=$(curl -s "http://127.0.0.1:8091/api/collections/cap_items/records/$id?fields=state" -H "Authorization: Bearer $TOKEN" | python3 -c "import json,sys;print(json.load(sys.stdin).get(\"state\",\"\"))"); \
        if [ "$state" = "filed" ]; then \
          curl -s -o /dev/null -w "$id PATCH %{http_code}\n" -X PATCH "http://127.0.0.1:8091/api/collections/cap_items/records/$id" \
            -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "{\"state\":\"routed\"}"; \
        else echo "$id skip $state"; fi; \
      done'

Every PATCH line should end in 200; the PATCH count plus the skip count should equal the item count from the read-only step. The routine's next pass 3 then serves those threads again and file_note creates one note per batch. The legacy thread-keyed notes stay in the vault untouched. The new notes carry a thread: frontmatter key; the Brain browser does not read it yet (vault_notes has no such column), it is there so grouping a thread's batches can be added without touching the notes again.

Pass 3 reads a person page back with get_page, merges, and calls upsert_person_page. The page renders its Notes section as - [[X]] bullets, the model copied those bullets verbatim into linked_notes, and render_person_page wrapped them in another [[...]] — one extra pair per merge cycle. The 2026-09-04 manual run flagged three pages; a vault grep found 30 people/*.md with 4, 6, or 10 nested brackets.

Fixed at the writer: normalize_wikilink (in jarvis_platform.knowledge) strips a copied bullet marker and any existing bracket pairs before the single wrap, in both render_person_page and ObsidianSink._render. Any page the routine upserts after the fix self-heals. The pages it does not revisit keep their nested brackets until this one-off runs.

Owner-run, on the box, after the fix is deployed (jarvis-brain-mcp image rebuilt). Only - [[...]] bullet lines are touched; the file is rewritten in place so owner and mode are kept. Dry-run first — it prints one before -> after line per bullet and a would rewrite N file(s) tally:

ssh mailserver 'docker exec -i jarvis-brain-mcp python3 - /vault' < ops/brain-routine/fix_nested_wikilinks.py

Then apply (same script, --apply); a second apply prints rewrote 0 file(s):

ssh mailserver 'docker exec -i jarvis-brain-mcp python3 - /vault --apply' < ops/brain-routine/fix_nested_wikilinks.py

Confirm nothing nested is left (expect no output):

ssh mailserver 'docker exec jarvis-brain-mcp grep -rlE "\[\[\[|\]\]\]" /vault --include=*.md'

Then run_vault_sync (or wait for the next routine run) so the Brain index picks up the rewritten pages.

One-off: 2026-09-05 backlog bulk triage

The ~42k raw+triage backlog (99.9% iMessage, 97% dated 2021-2023) was judged thread by thread offline rather than through the routine's item-by-item route_item/skip_item (which would have taken ~5 weeks at 60 items/hour and judges "TB" without its thread). Pipeline, all read-only against prod: host SQLite dump of non-sensitive rows piped through the container's jarvis_agent.redact chokepoint, grouped into 1,133 units (a thread, or one calendar month of a thread over 300 items), one decision per unit (skip / route / mixed with the item ids worth a note), validated so every item is assigned exactly once. Sensitive rows were never read or touched.

The writes are replayed by ops/brain-routine/apply_backlog_triage.py (stdlib only, owner-run on the box) with exactly the routine's write shape: routed + links{project, identity, person, module, confidence, rationale}, or skipped + links.skip_reason = "backlog-triage 2026-09-05: <reason>", plus one cap_llm_log audit row per unit (purpose='route', provider='anthropic'). A row is only touched while still raw/triage (re-running is a no-op) and a sensitive row is never touched even if a decision names it. Nothing is deleted: a skipped row keeps its reason and can be re-routed later.

scp ops/brain-routine/apply_backlog_triage.py decisions.jsonl mailserver:/tmp/bt/
# dry-run (default): parse + counts + read-only state probe, no writes.
# --env-file reads only the two DEV_USER_* keys (do not `.`-source the file: it
# trips on an unquoted value and exports the docker-network POCKETBASE_URL).
ssh mailserver 'python3 /tmp/bt/apply_backlog_triage.py --dry-run --env-file /opt/jarvis/.env < /tmp/bt/decisions.jsonl'
# apply (add --limit 5 first for a smoke apply of the first five units)
ssh mailserver 'python3 /tmp/bt/apply_backlog_triage.py --apply --env-file /opt/jarvis/.env < /tmp/bt/decisions.jsonl'

Applied 2026-09-05: 2,250 rows routed, 39,383 skipped, 1,133 audit rows, 0 failures (PR #202). The routed rows flow into the routine's distill pass, so PROMPT.md pass 3 runs on a temporary catch-up budget of 120 items/run (list_distill_queue with limit: 120) instead of the steady-state 40; lower it back once state='routed' && sensitivity='normal' is under ~200 rows: bash ssh mailserver "curl -s 'http://127.0.0.1:8091/api/collections/cap_items/records?perPage=1&filter=state%3D%27routed%27%26%26sensitivity%3D%27normal%27' -H \"Authorization: Bearer \$TOKEN\" | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"totalItems\"])'" Follow-up: bulk route_thread / skip_items MCP tools and a newest-first list_untriaged so the hourly routine works at thread granularity from now on.

Kill switch

Two independent ways to stop the routine, from fastest to most thorough:

  1. Disable the routine at claude.ai/code/routines — stops future runs immediately; nothing in flight is force-killed, but no new hourly fire happens.
  2. Rotate the token — invalidates the routine's credential even if you can't reach the routines UI (e.g. suspected credential leak):

bash ssh mailserver "sed -i '/^JARVIS_BRAIN_MCP_TOKEN=/d' /opt/jarvis/.env && \ echo \"JARVIS_BRAIN_MCP_TOKEN=\$(openssl rand -hex 24)\" >> /opt/jarvis/.env" ssh mailserver "cd /opt/jarvis && docker compose -f compose.yaml -f compose.prod.yaml up -d brain-mcp"

Then update (or remove) the cloud-environment credential/env var so the routine isn't left hammering brain-mcp with a now-wrong token every hour. Cron retirement means poll and vault_sync keep running either way — disabling or breaking the routine only pauses routing/triage/ distill/drafts, never ingestion (see Cron retirement blast radius below).

Cron retirement blast radius

The DeepSeek classify and distill crons are retired on this branch (only capture_agent.poll */10 and conversation_agent.vault_sync :23 survive on the box). poll only ever writes state='raw' — the sensitive-raw→triage parking that classify used to do every 15 minutes now lives only inside list_untriaged (see the caveat in step 3), so it's routine-dependent. If the routine never runs for a while — disabled, token rotated, routines feature outage, whatever — ingestion still doesn't break (poll keeps writing raw items, vault_sync keeps refreshing vault_notes for the Brain browser), but sensitive items sit in raw rather than being surfaced into the owner's triage view until the routine (or a manual python -m capture_agent.classify) next runs. Still loss-free and fully recoverable — nothing is dropped or skipped, it's just delayed — but worth knowing if the routine is down for an extended stretch and you want sensitive items visible sooner: run classify.py by hand in the meantime.

The three routines

Routine Cadence Prompt Writes through
jarvis-brain-hourly 17 * * * * ops/brain-routine/PROMPT.md route/skip/file/upsert/draft tools
jarvis-morning-brief 06:30 Paris daily ops/morning-brief/PROMPT.md write_brief
jarvis-weekly-coach Sunday 18:00 Paris ops/weekly-coach/PROMPT.md propose_week

Each has its own ROUTINE.md next to its prompt with the exact JSON to submit. All three share the rails above: narrow allowed_tools, the disallowed_tools denylist, and prompts that treat tool output as data. Nothing generative runs on the box any more except the Ask chat worker. The host-run homelab agent (launchd, not the box) still uses the configured provider; moving it is a phase-2 decision.

Checking the routines' MCP connectivity

Every run clones the default branch and starts every server in the repo-root .mcp.json before the prompt is read. That file holds one server, jarvis-brain (HTTPS, brain-mcp), and must stay that way: a stdio server there is spawned inside the cloud sandbox, where it cannot start. This happened with jarvis-conversation until DER-58: every run from 2026-09-01 on opened with "jarvis-conversation failed to connect". It had no effect only because no routine allows that server's tools. tools/checks/test_routine_prompts.py (CI) now fails if .mcp.json gains a stdio server or anything besides jarvis-brain.

To check a routine's connectivity, from cheapest to most direct:

  1. The endpoint: the three curl checks in brain-mcp.md → step 4: /healthz → 200, /mcp without a token → 401, with the token → tools/list.
  2. The run itself: claude.ai/code/routines → the routine → its latest run. A server that failed to connect is reported at the top of the session, before the first turn. A healthy run shows no such notice and its first jarvis-brain call (list_untriaged, list_followups, query_records) returns data. All three prompts tell the model to stop and say so in one line when no jarvis-brain tool is available, so a run that lost the server does nothing and ends with that line.
  3. What the routine sees, reproduced locally: in a fresh clone with JARVIS_BRAIN_MCP_TOKEN exported, claude mcp list should show only jarvis-brain, as connected, plus any servers you added yourself in local or user scope. Routines don't see those.
Symptom Likely cause Fix
jarvis-brain 401 / auth error JARVIS_BRAIN_MCP_TOKEN missing or stale in the cloud environment (rotated on the box) Owner prerequisites step 1
jarvis-brain failed to connect, /healthz not 200 brain-mcp container or Cloudflare tunnel down brain-mcp.md
Another server failed to connect A server was added to .mcp.json that the cloud cannot start Remove it; register it with claude mcp add --scope local instead (MCP server)

Operator prompt carry-overs

ops/brain-routine/PROMPT.md is versioned in this repo and is the actual operator prompt — this runbook only points the routine at it. The rules below are worth calling out here because they're easy to regress if the prompt is ever edited without this context:

  • Empty candidates → empty project. Never invent a project name to fill an empty candidates.projects (route with an empty project instead, and leave genuinely unclear items in triage), and the same empty-candidates rule applies again during the triage-clear pass. Both exist so the routine can't quietly grow the project taxonomy on its own.
  • Pass budgets are hard stops. Passes 1–2 share one budget of 60 items, pass 3 stops at 40 items' worth of threads (120 during the post-backlog catch-up, see above), pass 4 at 5 drafts. The prompt says to stop the pass once the budget is reached even if the queue is not empty; the next hourly run continues. Added after the 2026-09-04 manual run processed 130 items in passes 1–2 and 300+ in pass 3 while narrating "well past the budget" — the old wording ("keep going until the queue is empty or you hit the budget") read as a soft target.
  • Reply drafts need a recent inbound. Pass 4 only drafts when the thread's last inbound message is within the last 14 days of the run's current date (from the system reminder); anything older is skipped with no draft, because a stale backlog is never "plausibly awaiting a reply". Added after the same 2026-09-04 run proposed two drafts to threads whose last inbound dated from 2023 (a request for money and a "when are you free?") that an earlier read-only dry-run had correctly judged over three years stale.