Skill v1.1.0
currentAutomated scan100/100+2 new
name: cortex-mailbox-poll description: "Use when wiring the canonical cortex inbox+outbox polling loop into Claude Code's /loop. This is the orchestration spine — every empirica claude polls Cortex on a fast adaptive cadence (30s base, 5m max) for proposals addressed to itself + status changes on its own outgoing proposals. Self-throttles when an empirica transaction is open (the AI is already busy; no need to interrupt). The canonical loop catalog (empirica/core/cockpit/canonical_loops.py) auto-installs this when the TUI cockpit toggles L on an instance that has no loops registered. This skill is the body the AI runs each fire." version: 1.1.0
Cortex mailbox-poll cron loop wiring
The Phase 1 canonical orchestration loop. Every empirica claude polls Cortex inbox+outbox on a fast adaptive cadence so peer proposals and status changes route in seconds, not on the next user prompt.
This skill is a thin wrapper over /loop-cron — same self-scheduling template, with cortex_inbox_poll + cortex_outbox_poll MCP calls plugged in as the body.
Harness portability — flat tool names are the Claude Code form
Every mcp__cortex__cortex_* name in this skill is the Claude Code calling convention, where each MCP tool is exposed as its own flat tool. Namespace-aggregating harnesses (codex / ecodex, the OpenAI Responses-API) instead collapse a whole MCP server's toolset into ONE namespace tool — mcp__cortex — driven by an operation + params interface. There you invoke the cortex operation through that namespace tool (operation=cortex_inbox_poll, params={…}); a flat mcp__cortex__cortex_inbox_poll call parses to a non-namespaced tool name, matches nothing, and returns unsupported call.
Rule of thumb: read every mcp__cortex__<op> below as "the cortex operation `<op>`" and call it however your harness surfaces cortex tools — a flat tool in Claude Code, the mcp__cortex namespace tool + operation param in codex/ecodex. The operations and their params are identical across harnesses; only the invocation shape differs.
Simpler: the empirica mailbox poll CLI (harness-agnostic receive path)
The whole namespace-shape problem above evaporates if you poll via the CLI instead of the MCP tool. empirica mailbox poll is a thin wrapper over the same GET /v1/orchestration/inbox that cortex_inbox_poll hits — a plain shell command every harness runs identically, no namespace gymnastics:
empirica mailbox poll --ai-id <your-canonical-3-form> --output json# receive side, symmetric with `empirica mailbox reply` (the send/ack side)# --outbox poll YOUR emissions' status changes instead# --status accepted,changed default wake-react set (NOT eco_review — the CLI# exists to react to ECO-decided wakes)# --since <ISO8601> incremental polling# --limit 20 · --related match cortex_inbox_poll defaultsempirica mailbox show <proposal_id> --output json # one proposal's full bodyempirica mailbox archive <proposal_id> # soft-delete from inbox view
Prefer the CLI on tool-aggregating harnesses (codex/ecodex). A woken idle practitioner told to run empirica mailbox poll as its FIRST action succeeds with no open transaction — the mailbox verbs are Sentinel-whitelisted (reads → Tier 1, reply/archive → Tier 2), so they flow pre-transaction. The MCP cortex_inbox_poll remains valid everywhere; the CLI is the reliable receive path when the namespace call shape is fragile.
When to Use
Register the canonical mailbox-poll cron when:
- You're setting up a new empirica claude instance and want it to join
the orchestration mesh (react to proposals routed via cortex_propose)
- The TUI cockpit auto-installed
cortex-mailbox-pollvia the canonical
catalog (see empirica/core/cockpit/canonical_loops.py) and surfaced a pending install request — this skill is the body
- You want fast collaboration cadence (30s base) without the overhead
of polling when you're already busy (self-throttle)
If your project has a custom inbox poll already (e.g. outreach-inbox-poll, eco-inbox-poll in .empirica/project.yaml), use that instead — the project-specific config takes precedence over this canonical default.
AI_ID convention
Your ai_id is the exact name of your project (directory basename, empirica- prefix KEPT). The full convention lives in ~/.claude/empirica-system-prompt.md (canonical) + empirica-org-prompt.md (org-specific aliases) + wire-level detail in docs/architecture/EVENT_LISTENER.md. Quick reference:
| Project root | ai_id | |
|---|---|---|
~/empirical-ai/empirica | empirica | |
~/empirical-ai/empirica-cortex | empirica-cortex | |
~/empirical-ai/empirica-outreach | empirica-outreach |
Shorter human aliases (cortex, outreach, etc.) live in the org-prompt layer and are used in chat — but not on the wire.
Read your canonical id from .empirica/project.yaml's ai_id field (set by setup-claude-code at project init). Use it in session-create --ai-id <id>. For target_claudes and source_claude, use the canonical 3-form <org>.<tenant>.<exact-project-name> (e.g. empirica.david.empirica-cortex) — bare basenames bounce via delivery_failed.
Reaction Protocol — content events (push-primary)
The SessionStart session-monitor-arm.py hook arms a Monitor on empirica loop listen --instance <your-id> — the push-primary listener that holds an ntfy stream to Cortex and emits one stdout line per ECO-decided proposal event. Each line is a <task-notification> wake event into this session.
Content event (the wake signal you should usually act on)
{"event_type": "proposal_event","proposal_id": "prop_abc","proposal_title": "Surface project block on daemon HTTP","status": "accepted","action_category": "TACTICAL","eco_actor": "eco-phone","change_kind": "new","instance_id": "<you>","loop": "cortex-mailbox-poll","ts": "..."}
The empirica loop tick body polled Cortex and diffed against last-seen state. This event IS the content — you don't need to poll inbox/outbox yourself. Each event represents one ECO-decided proposal (accepted / changed / declined).
ECO-gated autonomy property: every action you take ultimately traces back to eco_actor's decision. Even if the timer or fires log were compromised, your re-verification via cortex_inbox_poll(status="accepted") against the proposal_id is the auth boundary — Cortex only returns ECO-decided state. Hijacking the wake signal cannot widen your authority.
Step 0 — Recipient gate (always check this first)
Before any of the below, verify the event is addressed to YOU. The shared loop_fires.log carries events for every AI on the mesh; the Monitor that bridges those fires into your session is supposed to grep '"instance_id": "<your-ai-id>"', but a session armed before that filter existed — or one set up wide for debugging — will see the whole stream. Defense in depth: check before you act.
`target_claudes` is the authoritative recipient list, not `instance_id`. instance_id identifies which AI's loop emitted the event into the shared log; target_claudes (on the underlying proposal) identifies who the proposal is FOR. These often agree but they are not the same field — confusing them is the most common way real messages get silently dropped.
Branching logic, in order:
- Read your own `ai_id` from
.empirica/project.yamlai_id:
field, or fall back to the exact directory basename (prefix kept, strict-canonical — empirica-cortex stays empirica-cortex).
- If `event["instance_id"] == your ai_id` — fast path. The event
came through your own loop, so it's already targeted to you. Proceed to the direction-specific handler below. No proposal fetch needed just for the gate.
- If `event["instance_id"] != your ai_id` — DO NOT silently ignore.
Your Monitor is in broadcast mode (visible in its description: phrases like "all events", "not filtered by instance_id", "corrected" are tell-tales). Fall through to step 4 — target_claudes decides, not instance_id.
- Resolve `target_claudes` for this event. Cheap path: if you've
recently polled your inbox you already know the proposal_id and can look it up locally. Otherwise fetch with cortex_get_proposal( proposal_id=event["proposal_id"]). Then:
- `your ai_id IN target_claudes` → proceed to the
direction-specific handler below. This is a real message for you that came in via a broadcast or peer-emitted Monitor.
- `your ai_id NOT IN target_claudes` → silently ignore. The event
is observable noise from other AIs' loops, no action.
- If you keep hitting step 4 repeatedly (more than ~3 events per
session), your Monitor is set up wrong. Re-arm with the correctly-filtered command via empirica setup-claude-code --force or empirica listener on --output json (whose next_step.args.command pins the grep filter for your ai_id). Fixing the Monitor at the source is cheaper than per-event cortex_get_proposal calls.
Catch-up safety net: at session start, after long pauses, or any time you suspect Monitor drops, run cortex_inbox_poll(ai_id=<you>, status="accepted,changed") directly. The Monitor is the push path for liveness; the inbox poll is the pull path for correctness. Both should agree; the poll wins on disagreement.
Authoritative delivery model (David-ratified 2026-06-21). Themailbox is the source of truth; push is liveness only. A wake eventis a low-latency nudge that something actionable landed — never theauthority for what happened. Never act irreversibly on a push alone: thedurable proposal state (poll /cortex_get_proposal) is what youreconcile against. Consequences:- You don't need a reaction branch for every status. A dropped wake,a failed completion, an un-acked emission — all are reconciled on yournext poll, not chased through the push stream.- Cortex does not babysit delivery. There is no reminder / retry /escalation nag (that chain is retired — see/inbox-listener).Send-side reliability is the sender's job: refire on adelivery_failedbounce (see/cortex-mailbox-send).- Autonomy is the systemic crack-net. Anything that genuinely slipped(a completion that failed, an item that landed but was never picked up,a send that bounced and wasn't refired) is swept by the canonicalautonomy watch-layer — one quiet sweep with judgment, not N noisyper-message timers. You are not individually responsible for chasingevery dropped signal; the sweep covers the tail.
What to do — depends on `direction`:
direction: "inbox" — proposal is FOR you (ECO-gated)
The proposal targets this AI. ECO has decided. Authorization to act is verified by the status field (accepted/changed/declined — never eco_review).
- If mid-transaction: log a goal using the exact convention so the
POSTFLIGHT deferred-proposals nudge can surface it later: ``bash empirica goals-create \ --objective "Process proposal <proposal_id>: <title>" \ --description "From <source_claude>. Direction=<direction>, status=<status>. \ Original ask: <summary>. \ Complete via cortex_complete_proposal + goals-complete." ` The literal token prop_` (in objective OR description) is what the POSTFLIGHT retrospective greps for. Pick up at the next natural break (EWM pattern). Do not interrupt the in-flight work.
Before POSTFLIGHT of the in-flight transaction, scan for open proposal-derived goals (empirica goals-list + grep prop_) and either action them now or explicitly roll forward as planned for the next transaction. The retrospective will surface them in the POSTFLIGHT response — don't let them slip past after you close the window.
- If idle:
- status
accepted→ fetch full proposal via
cortex_inbox_poll(ai_id=<you>, status="accepted") and find this proposal_id, then execute the payload (code change, follow-up emit). When done, mark it `completed` via the cortex completion primitive so the source AI gets the ack — this is the AI-to-AI handshake.
- status
changed→ ECO requested refinement of a proposal SOMEONE ELSE
sent that targets you. Read eco_decision.note and proceed with the adjusted scope.
- status
declined→ ECO said no to something pointed at you. Update
mental model; no action needed.
- Archive via
cortex_archive_proposalafter handling.
direction: "outbox" — proposal is FROM you (ack-style, no ECO gate)
A proposal you emitted earlier just transitioned state. ECO already decided when it went out — these events are informational acks for the source AI.
- status
completed/shipped
→ Target AI finished your work successfully. Event carries commit_sha so you can trace the landing. Log a finding (empirica finding-log) noting the completion + commit. If there's a next-step you were waiting on, chain to it now.
- status
failed/wont_fix
→ Target AI did NOT land the work (tried and couldn't / declined to). These are honest completion outcomes, not error events — first-class results cortex emits verbatim (since fix dbc1de5). Do not chain the next step as if it shipped — that leg is dead. Read the reply note, reconcile, decide (re-scope / re-propose / drop). No frantic handling: it's informational and poll-visible — if you were asleep when it fired, your next outbox poll shows it and the autonomy watch-sweep catches any that slip. (Per the authoritative delivery model above: failure is a reconcile-on-poll state, not a push-reaction branch.)
- status
changed
→ ECO sent your emission back for refinement. Read eco_decision.note and emit a parent_id-linked refined proposal via cortex_propose.
- status
declined
→ ECO rejected your proposal. Update mental model. Optionally log a decision artifact noting why it didn't fly so you don't re-propose.
Outbox accepted is NEVER surfaced (informational — target will act on the next tick of their inbox poll). Saves chat noise.
event_type=ser_escalation — SER re-ping (LIVE; env-gated)
A separate wake shape rides the same proposal_event channel when an SER you're a required-tier participant of has been idle past its escalation interval and you haven't acked since the last transition. Cortex emits these from system:ser-escalation (env-gated by CORTEX_SER_ESCALATION_ENABLED; interval CORTEX_SER_ESCALATION_INTERVAL_S, default 600s). The listener delivers them two ways: the live ntfy doorbell is relayed straight from the push body (via: push_relay), and a dropped doorbell is recovered on the next catch-up by reconciling from /v1/sers (via: catchup_reconcile). Both paths are needed because ser_escalation has no proposal-store row, so the proposal-only catch-up can't reconstruct it.
The shape that lands in your session (what the relay writes) — note event_type is the shape itself, the cortex-side event key is promoted into it and dropped, and via tags the delivery path:
{"ts": "...", "instance_id": "<you>", "loop": "cortex-mailbox-poll","event_type": "ser_escalation","ser_id": "ser_xxx", "ser_state": "in_progress","source_claude": "system:ser-escalation","target_claudes": ["<your_canonical_id>"],"escalation": true,"idle_for_seconds": 14400,"via": "push_relay"}
Discriminator: event_type == "ser_escalation" (NOT "proposal_event") routes this as a non-proposal wake — proposal handlers keyed on event_type=="proposal_event" correctly skip it. escalation=true distinguishes re-pings from first delivery; source_claude=system:ser-escalation identifies the cortex internal emitter. via tells you the delivery path (push_relay = live doorbell; catchup_reconcile = recovered from /v1/sers after a dropped doorbell).
What to do:
- Verify it's for you —
your ai_id IN target_claudes. Cortex
currently relies on the SER caller to set target_claudes correctly (caller-controlled per v1; see cortex prop_ealogh defer flag).
- Re-engage with the SER. Fetch the SER projection:
``python GET /v1/sers/{ser_id} ` Read coordination_state, last_transition_at, last_transition_actor, and your row in participants[] (your last_action_at, last_ack_at`). The escalation fires because either:
- You haven't acted (your
last_action_at < last_transition_at),
and the SER needs you to transition / ack / contribute, OR
- You haven't even acked the last transition (
last_ack_at IS NULL
or < last_transition_at)
- Act, then ack to silence the next tick. Either:
- Take the substantive action (transition the SER via
cortex_propose(payload.action='transition_ser', transition_spec=...)), OR
- If just acknowledging receipt without state change is the right
posture (e.g. an SER you're observing but not blocking on), emit cortex_propose(payload.action='ser_ack', ack_spec={...}) — this stamps your last_ack_at and suppresses the next escalation tick for you until the SER's next transition (spec §5.3 — read-and-waiting participants don't get spammed).
- Closed SERs never escalate. If
coordination_state=closedwhen
you fetch, the tick was racing closure — no action needed.
If mid-transaction, follow the same defer-as-goal pattern as inbox proposals: log a goal "Process ser_escalation: <ser_id>" and pick up at the next natural break. SER escalations are designed to wait — the next tick fires another 10min later, not immediately.
Heartbeat event (content-free)
{"ts": "...", "instance_id": "<you>", "loop": "cortex-mailbox-poll"}
Some loops emit content-free heartbeats. If you receive one (no event_type field), fall through to the Cron Prompt Template below — poll inbox/outbox manually via MCP. This path costs more tokens; prefer the content-event path when available.
Adaptive cadence model
| Signal | Effect | |
|---|---|---|
| Self has open empirica transaction | Skip poll (return paused result) — caller is busy, no interruption | |
| Poll returns new items | Reset streak → next fire at base (30s) | |
| Poll returns nothing | Advance streak → next fire grows toward max (5m) | |
| Poll fails | Reset streak — retry at base, don't compound delay |
This is the same --backoff exponential pattern as /loop-cron, with base 30s and max 5m. The faster floor reflects that orchestration is interactive — 15m is too slow when a peer Claude is waiting.
Resolving ai_id for the poll
Cortex inbox/outbox are scoped to a specific AI identity (e.g. empirica-outreach, empirica-cortex, empirica-extension, empirica-autonomy, empirica). The body resolves ai_id from project context, in this order:
- Project's `.empirica/project.yaml` — read the
ai_id:field
(canonical per-project value, set by setup-claude-code).
- Project name fallback — use the directory basename as-is
(keep the empirica- prefix).
- `EMPIRICA_AI_ID` env var — last-resort explicit override (for
environments that don't have a project file).
If none resolves, the body emits a fail heartbeat with the unresolved-ai_id error and lets the user fix the project's CLAUDE.md.
Cron Prompt Template
When invoking /loop in cron mode, prepend these CLI lines to your task prompt. Variables already filled — the canonical preset is fixed by the catalog entry (30s base, 5m max).
At start (idempotent — safe to call every fire):empirica loop register --name cortex-mailbox-poll --kind interval \--interval 30s \--description "Poll Cortex inbox + outbox for orchestration messages (canonical)" \--backoff exponential --base-interval 30s --max-interval 5mCheck pause — exit silently AND don't schedule next fire if paused:PAUSED=$(empirica loop status cortex-mailbox-poll --output json | jq -r .paused)if [ "$PAUSED" = "true" ]; thenempirica loop heartbeat cortex-mailbox-poll --status ok --result paused \--message "skipped, paused"exit 0fiSelf-throttle — if an empirica transaction is open, the caller isalready engaged. Don't interrupt; just freeze the streak.TX_OPEN=$(python3 -c "from empirica.utils.session_resolver import InstanceResolver as Rtx = R.transaction_read()print('true' if tx and tx.get('status') == 'open' else 'false')")if [ "$TX_OPEN" = "true" ]; thenempirica loop heartbeat cortex-mailbox-poll --status ok --result empty \--message "self-throttle: transaction open"NEXT_CRON=$(empirica loop schedule-next cortex-mailbox-poll --output json | jq -r .cron_one_shot)# CronCreate(cron=$NEXT_CRON, recurring=false, prompt='<this template again>')exit 0fiResolve self ai_id from project context:AI_ID=$(python3 -c "import os, refrom pathlib import Path# 1. Project's CLAUDE.mdfor parent in [Path.cwd()] + list(Path.cwd().parents):claude_md = parent / 'CLAUDE.md'if claude_md.exists():text = claude_md.read_text()m = re.search(r'(?:^|\n)\*?\*?AI_ID:?\*?\*?\s*[\`\"]?([a-z0-9_-]+)[\`\"]?', text, re.IGNORECASE)if m:print(m.group(1)); raise SystemExitbreak# 2. Project name fallback (use directory basename as-is, keep prefix)project_path = os.getcwd()name = Path(project_path).nameif name:print(name); raise SystemExit# 3. Env var overrideai_id = os.environ.get('EMPIRICA_AI_ID')if ai_id:print(ai_id); raise SystemExitraise SystemExit(1) # unresolved")if [ -z "$AI_ID" ]; thenempirica loop heartbeat cortex-mailbox-poll --status fail --result fail \--message "unresolved ai_id (no CLAUDE.md AI_ID line, no project name fallback, no EMPIRICA_AI_ID env)"NEXT_CRON=$(empirica loop schedule-next cortex-mailbox-poll --output json | jq -r .cron_one_shot)exit 0fiPoll inbox via MCP — react to new proposals addressed to self.The api_key for cortex_* MCP tools is read by the MCP server itselffrom ~/.empirica/credentials.yaml; no need to pass it explicitly.Call mcp__cortex__cortex_inbox_poll(ai_id=$AI_ID)INBOX_NEW=<number of new items returned>For each new item:- If type=collab_brief: AUTO-REACT — read the payload, log afinding-log for durability, and post a reply via`empirica mailbox reply --parent-id <pid> --result shipped` (theatomic propose+complete verb closes the loop). Do NOTsurface-and-wait. Collab is noetic/ungated — the human is thegate ONLY for ECO-gated typed proposals (see below) and foryour own returning outbox state changes (status=changed/declined).If a collab asks you a question, answer it directly; theAI-to-AI substrate exists so the human doesn't have to dispatch.- If type=spec_updated: ack with cortex_archive_proposal once you'veconsumed the change- If type=architecture_decision / code_change_request / publish /trust_escalation_request: these are ECO-gated typed proposals.Surface to the user — they need explicit human Accept/Declinebefore action. Do not auto-execute the underlying work.- For any item with parent_id: link your follow-up via parent_idPoll outbox via MCP — emit follow-ups for proposals that came backas 'changed' (peer/user requested a refinement).Call mcp__cortex__cortex_outbox_poll(ai_id=$AI_ID, status=changed)OUTBOX_CHANGED=<number of changed proposals>For each changed proposal:- Read the refinement note- Compose an updated proposal with parent_id pointing to the original- Submit via cortex_propose (parent_id linking back closes the loop)Determine result for backoff signaling:if [ "$INBOX_NEW" -gt 0 ] || [ "$OUTBOX_CHANGED" -gt 0 ]; thenRESULT=foundSUMMARY="ai_id=$AI_ID inbox=+$INBOX_NEW outbox-changed=+$OUTBOX_CHANGED"elseRESULT=emptySUMMARY="ai_id=$AI_ID no activity"fiAt end — heartbeat with result, schedule + install the next fire:empirica loop heartbeat cortex-mailbox-poll --status ok --result $RESULT \--message "$SUMMARY"NEXT_CRON=$(empirica loop schedule-next cortex-mailbox-poll --output json | jq -r .cron_one_shot)# CronCreate(cron=$NEXT_CRON, recurring=false, prompt='<this whole template again>')# Heartbeat back the scheduler-returned job_id so pause can cancel:empirica loop heartbeat cortex-mailbox-poll --status ok --result $RESULT \--next-scheduled-job-id "$JOB_ID" --scheduler-kind cron-createOn MCP failure (network, auth, unexpected error):empirica loop heartbeat cortex-mailbox-poll --status fail --result fail \--message "{error message}"# Failure retries at base — schedule-next still returns base interval.
Self-throttle
If R.transaction_read() returns an open transaction, the body returns empty (not paused) — streak grows toward max_interval. Resumes on next fire after POSTFLIGHT.
Handling received items
The body shape above lists the rough decision tree. Detailed handling per proposal type:
| type | Gate | Default action | |
|---|---|---|---|
collab_brief | None (noetic, ungated) | Auto-react: read payload, log finding-log, post a reply via empirica mailbox reply --parent-id <pid> --result shipped (atomic propose+complete). If the collab asks a question, answer it; if it shares context, ack/integrate it; if it converges, graduate to cortex_propose per the send-side discipline. Do NOT surface-and-wait — the human-as-dispatcher pattern breaks the AFK/ambassador model. | |
spec_updated | None | Read spec at payload.path, log finding-log "consumed spec X", archive via cortex_archive_proposal | |
architecture_decision | ECO-gated at the proposal layer | This is an inbound typed proposal targeting YOU as an executor. You DO the work after ECO accepts; ECO's Accept/Decline IS the gate, not a separate "ask the human if I can act" step. If status=accepted and you're in target_claudes, the human already authorized you — proceed. | |
code_change_request | ECO-gated at the proposal layer | Same: ECO already gated it. On status=accepted, do the code work + ack via empirica mailbox reply. | |
investigation_request | None (auto-reflex) | Run the investigation via cortex_research or local tools, post results via reply collab_brief | |
publish | ECO-gated at the proposal layer | On status=accepted, compose + dispatch per the spec | |
trust_escalation_request | ECO-gated at the proposal layer | On status=accepted, apply the trust change |
ECO gating IS the proposal's Accept/Change/Decline status. Auto-emit on convergence; act on status=accepted for typed proposals targeting you. Do NOT re-ask the human at either end.
Surface to user only:
direction=outbox, status=changed— read the change-note + emit refinement withparent_id. Only surface if the note needs clarification you can't infer.direction=outbox, status=declined— surface so the user can correct the model. Update beliefs; don't re-emit without new evidence.
For any proposal requiring action, open an empirica transaction (PREFLIGHT) to record the work. The poll itself is lightweight — detect + route, don't do the work inline.
Conceptual context: empirica/docs/human/end-users/MESH_CONCEPTS.md.
Visibility
Once registered, the loop appears in:
empirica status # current instance — cortex-mailbox-poll: 30s, last fire X agoempirica status --all # every Claude across every terminal
…showing the adaptive interval (current streak position), last fire result, and pause state. From the TUI cockpit, press L on the instance to toggle pause/resume globally.
Related
/loop-cron— the underlying registry-wiring template this skill wrapsempirica/core/cockpit/canonical_loops.py— catalog entry that auto-installs this loopdocs/architecture/COCKPIT.md— full state-file layoutcortex_propose,cortex_inbox_poll,cortex_outbox_poll,cortex_archive_proposal—
the MCP tools this body wires up