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¶
- Cloud environment variable
JARVIS_BRAIN_MCP_TOKEN— the committed.mcp.jsondeclares thejarvis-brainserver with"Authorization": "Bearer ${JARVIS_BRAIN_MCP_TOKEN}", and Claude Code expands${VAR}in.mcp.jsonheaders 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"
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:
uuidabove 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_toolsis deliberately narrow:Read/Grep/Globfor the routine to readPROMPT.mdand nothing else in the repo, plus every tool under thejarvis-brainMCP server.allowed_toolsis a pre-approval list, not a denylist — the first live run (2026-09-01) calledBashseveral times although it was not listed. The hard boundary isdisallowed_tools: withBash,Write,Edit,NotebookEdit,WebFetch,WebSearch,AgentandPushNotificationdenied 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 typedjarvis-brainwrite tool, which is the actual security boundary (see the design spec's threat model). Verified on a manual run on 2026-09-04: zeroBashcalls across 192 turns. Never dropdisallowed_toolsfrom this definition, and read it back (RemoteTriggerget→session_request.config.disallowed_tools) after any edit.- Routines do not push.
PushNotificationjoined 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_toolsnever 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. messageis a pointer, not the operator prompt itself — the real instructions live inops/brain-routine/PROMPT.mdso they can be reviewed and versioned in this repo without editing the routine definition every time. If the two ever drift,PROMPT.mdis the source of truth for what the routine actually does.enabled: falseis deliberate: this canonical routine is the one that ends up running hourly against prod with fulljarvis-brainwrite 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_logrow withprovider='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 callquery_recordsoncap_itemsfilteredstate='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_recordsoncap_reply_drafts(status='proposed') from the same session to sample drafts. Nothing should be sent —statuscan only ever beproposedcoming out ofpropose_reply_draft. - Owner updates (phase 3). After a run with pending updates: every row in
owner_updatesshould bedistilledorfailed, never leftpendingunless the 20-per-run budget was hit. Adistilledrow'sproduced.notenames a file that exists in the vault. A second run must not create a second note for the same update —complete_owner_updatekeys filing onexternal_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.
- In the cloud environment's settings, add an environment variable
JARVIS_BRAIN_MCP_TOKENwith the token value (UI only — never in a file). - The
jarvis-brainentry in the repo's committed.mcp.jsonreferences 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:
- No
file_notein that window actually created a note. Afiledrow 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 nextfile_notewould 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'srun_vault_syncreportedcreated: 0; confirm against the vault itself (expect0):
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.
Recovery: nested wikilinks on person pages¶
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:
- Disable the routine at
claude.ai/code/routines— stops future runs immediately; nothing in flight is force-killed, but no new hourly fire happens. - 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:
- The endpoint: the three
curlchecks in brain-mcp.md → step 4:/healthz→ 200,/mcpwithout a token → 401, with the token →tools/list. - 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-braincall (list_untriaged,list_followups,query_records) returns data. All three prompts tell the model to stop and say so in one line when nojarvis-braintool is available, so a run that lost the server does nothing and ends with that line. - What the routine sees, reproduced locally: in a fresh clone with
JARVIS_BRAIN_MCP_TOKENexported,claude mcp listshould show onlyjarvis-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.