Pollers — Event-Driven Monitoring
Pollers are lightweight scripts that check external services on a schedule and report back when something needs attention. They live inside skills and are discovered automatically by the scheduler.
Contract
Trigger: Authoring a new poller (a pollers.json manifest + script), debugging
why an existing poller isn't firing, or extending an existing one (new emit type,
schedule change, new pass-through env var). Distinct from world-scanning (which
catalogs what to poll) and from async-tasks (one-shot subprocess via
bash_async, not recurring).
Requires: A clear answer to "what state am I checking?"; the cron expression for
the check cadence; the ability to write the script in any language (the framework
cares about JSONL stdout shape, not language). A skill directory at
<home>/skills/<name>/ to host both pollers.json and the script — the framework
discovers <home>/skills/<name>/pollers.json automatically.
Guarantees:
- New poller registers automatically via
reload_pollers(or process restart) — no manualscheduler.yamledit. - Emitted events route to a synthetic channel
poller:<name>so the agent treats each as a fresh trigger. - Silence is the filter — a poller that has nothing to report outputs nothing and the agent stays asleep. Only state-changes wake the agent.
- Subprocess isolation — a poller crash can't take down the parent agent. Stderr
surfaces as
poller_stderrevents for debugging.
Does not: Decide what to poll (that's world-scanning); manage in-process
state changes (use scheduler callables for those — saga-consolidate,
oauth-usage-poll, viability-report, etc. — they're not subprocess-isolated and
can mutate mimir-internal state directly); replace async-tasks for one-shot
async work; guarantee a successful emit (poller bugs surface as poller_stderr /
poller_nonzero_exit algedonic events, not as silent failures).
How It Works
- A skill includes a
pollers.jsonfile alongside itsSKILL.md - The scheduler discovers all
pollers.jsonfiles at startup and whenreload_pollersis called - On each cron tick, the scheduler runs the poller command as a subprocess
- Each line of stdout is parsed as JSON and delivered to the agent as an event
- If there's nothing to report, the poller outputs nothing — silence is the filter
Creating a Poller
1. Write the poller script
The script runs with the skill directory as its cwd. It receives these environment variables automatically:
| Variable | Description |
|---|---|
STATE_DIR |
Persistent cursor/state directory at <home>/state/pollers/<poller_name>/ — created lazily on first run, on the home volume so cursor files survive container rebuilds even when the skill itself ships in the image. |
POLLER_NAME |
The poller's name from pollers.json (matches the STATE_DIR subpath). |
Plus any literal env vars from the env field, plus any pass-throughs declared in pass_env (see field docs below), plus the deny-filtered allowlist of the agent's process environment.
Why STATE_DIR is separate from the skill dir: skills are deployable artifacts (resettable via seed_skills, image-shippable, optionally reset on container rebuild). Cursor files are persistent runtime data — losing them means re-emitting the entire backlog of "events since cursor=0" on next run, which for a github-poller would be every PR comment in every watched repo. Mimir's filing rules separate these — skills under skills/, runtime state under state/.
Command parsing (pollers.json command field): parsed by /bin/sh -c via asyncio.create_subprocess_shell. Shell features (env-var expansion $FOO, pipes, redirection) are available — and you're responsible for quoting args containing whitespace or shell metacharacters: "python scripts/poller.py 'arg with spaces'" not "python scripts/poller.py arg with spaces".
Output contract:
stdout: JSONL (one JSON object per line). Two record shapes:
Event records —
{"poller": "<name>", "prompt": "<text>", ...extras}. Each becomes oneAgentEvent(= one turn the agent runs). Other keys flow intoAgentEvent.extraso platform metadata (URLs, IDs,source_platform) carries through to your prompt rendering.Signal records —
{"poller": "<name>", "signal": "<event_type>", ...payload}. These do NOT spawn anAgentEvent. The framework writes them toevents.jsonlvialog_event(event_type, poller=<name>, **payload)— recognized signal event types (below) surface in the next turn's algedonic block as negative signals (pain). Use for external-state health that the agent should see but that shouldn't each fire a turn of their own: OAuth token expiry, upstream 5xx outage, rate-limit cliffs.Recognized signal event types (
feedback._EVENT_RULES):event_type When to emit poller_oauth_expiredOAuth token expired or revoked (refresh failed) poller_auth_failedNon-OAuth auth failure (invalid API key, 401 from upstream) poller_service_outageUpstream service unreachable (5xx, DNS failure, connection refused) poller_rate_limitedUpstream rate-limit hit (429 / explicit retry-after) poller_signalGeneric / unclassified pain signal poller_turn_gave_upFramework gave up recovering a failed poller turn after the retry cap ( recover_failed_turns). Recognized via the*_gave_upsuffix convention rather than an explicit_EVENT_RULESentry — any*_gave_upevent classifies as a negativegave_upsignal.A record with neither
promptnorsignalis silently dropped. A record with BOTH is treated as signal-only (thepromptis ignored — emit a separate record per shape).stderr: Free-form diagnostic logging. Captured and emitted as a
poller_stderrevent inevents.jsonlfor observability — not forwarded as a turn-prompt, but greppable frommimir introspection/ log scraping.Exit 0: Success. Non-zero: Error — the framework drops stdout entirely (events AND signals from this run are NOT processed) and auto-emits
poller_nonzero_exit(negative algedonic) so the operator sees recurring failures. The next cron tick retries.Circuit-breaker (chainlink #94): after 3 consecutive failures (non-zero exit, timeout, or subprocess launch error) the framework suspends the poller for 5 minutes. A
poller_circuit_trippedevent fires once at trip time (negative algedonic — operator-visible). Subsequent suppressed runs emitpoller_circuit_openwith aremaining_secondscountdown. The circuit resets automatically on the first clean (exit-0) run after the backoff expires. This protects the scheduler from a persistently broken poller spinning up costly turns.Implication for skill authors: to surface a signal (e.g. "OAuth token expired"), emit the signal record then
return 0— that's a successful detection, not a runtime failure. Reserve non-zero exit for catastrophic failures (script crashed, can't parse own config) where dropping stdout is the right move. The common pattern:if oauth_token_expired: emit({"signal": "poller_oauth_expired", "account": acct, "detail": "..."}) return 0 # we successfully detected the problem
Example poller script:
#!/usr/bin/env python3
"""Check for new items since last poll."""
import json, os, sys
from pathlib import Path
STATE_DIR = Path(os.environ.get("STATE_DIR", "."))
CURSOR_FILE = STATE_DIR / "cursor.json"
def load_cursor():
if CURSOR_FILE.exists():
return json.loads(CURSOR_FILE.read_text())
return {}
def save_cursor(cursor):
CURSOR_FILE.write_text(json.dumps(cursor, indent=2))
def main():
cursor = load_cursor()
# ... check your service, compare against cursor ...
new_items = [] # your logic here
for item in new_items:
event = {
"poller": os.environ.get("POLLER_NAME", "my-poller"),
"prompt": f"New item: {item['title']}"
}
print(json.dumps(event))
# Update cursor so next run skips these items
save_cursor(cursor)
if __name__ == "__main__":
main()
2. Create pollers.json in the skill directory
{
"pollers": [
{
"name": "my-service-check",
"command": "python scripts/poller.py",
"cron": "*/5 * * * *",
"authority": {
"profile": "research",
"tier": "scoped-with-provenance",
"capabilities": ["memory_store", "saga_feedback", "write_file", "send_message"],
"scoped_roots": ["state"]
},
"env": {
"SERVICE_URL": "https://example.com/api"
}
}
]
}
Top-level must be a dict with a pollers key (not a bare array).
| Field | Required | Description |
|---|---|---|
name |
yes | Unique identifier. Used in logs and event routing. |
command |
yes | Shell command, relative to the skill directory. |
cron |
yes | Cron expression (5-field, UTC). |
authority |
no | The sole authority declaration for this instance. It must contain exactly profile, tier, capabilities, and scoped_roots. Missing authority means an empty capability set. Profiles are research, github, or custom; tiers are scope-contained, scoped-with-provenance, or code-execution. Capability names are checked against the executable tier table. Skill pollers cannot declare unbounded capabilities. state resolves only to state/pollers/<name>; wiki:<name> resolves only to an existing direct child of state/wiki. Unknown values, missing/extra authority fields, tier violations, missing roots, and path escapes reject the whole poller. operator_alert grants send_message only to the single MIMIR_OPERATOR_ALERT_CHANNEL destination. Each event carries the resulting immutable poller:<name> principal through continuations. |
env |
no | Additional environment variables for the script. Values are literal — no shell expansion. Use this for fixed config (URLs, feature flags) declared in pollers.json itself. Do not put secrets here — env is a static config surface (values are literal strings, no shell expansion), not a secret-forwarding path. If a key's name matches a deny-list pattern (*_API_KEY, *_TOKEN, *_SECRET, *_PASSWORD, MIMIR_*), the framework emits a poller_env_secret_reintroduced algedonic warning. Use pass_env to forward live secrets from os.environ. |
pass_env |
no | List of env var names to pass through from mimir's process environment to the subprocess, bypassing the deny-suffix/deny-prefix filter (see security.md). This is the supported path for getting secrets (GITHUB_TOKEN, ANTHROPIC_API_KEY) and MIMIR_*-prefixed knobs into a poller subprocess — the global allowlist MIMIR_POLLER_ENV_ALLOWLIST does NOT bypass the deny filter, so it can't be used for *_TOKEN keys. Keys not set in os.environ are silently skipped; keys whose names match a deny pattern emit a poller_env_passthrough_named_secret event (visibility, not blocking). |
batch_size |
no | Coalesce up to N items per emitted AgentEvent (= per turn the agent sees). Default 1 (per-item-per-turn, matches open-strix). Use >1 for bursty pollers (github-poller, RSS) so the agent sees one turn per cron tick instead of one per item. Items beyond batch_size overflow into additional batches with batch_index / batch_count set in extra so the agent can tell it's seeing part of a multi-batch fire. |
recover_failed_turns |
no | Opt into framework recovery of turns whose triggered turn failed (chainlink #262). When true, the framework stashes each enqueued event by source_id, then each cycle reads turn_failed / turn_completed outcomes to re-enqueue (capped) the ones whose turn died — emitting a one-shot poller_turn_gave_up signal when the cap is hit. Closes the "poll advanced the cursor but the triggered review/processing turn died" drop (#299) for pollers with no live state to reconcile against (gmail, github issue/comment turns). Default false. Leave off for pollers that recover another way — github-poller reconciles against requested_reviewers, so framework re-enqueue on top would double-fire turns. |
priority |
no | low | normal (default) | high — how much resource pressure this poller rides through before the scheduler sheds its fires. The homeostat grades quota/cost pressure into a severity ladder (CLEAR / ELEVATED / TIGHT / BLOCKED): low sheds at ELEVATED, normal at TIGHT, high keeps firing until the provider actively refuses (recorded 429 → BLOCKED sheds everything). A suppressed fire skips the subprocess entirely — the cursor stays frozen, so events are delayed, not lost; the next tick after recovery catches up. Each shed fire emits poller_fire_suppressed with priority, severity, and the deciding reason. Use high for near-interactive feeds the operator actively waits on, low for nice-to-have ambient monitoring. |
Operator overrides (<home>/pollers-overrides.yaml)
Skill-shipped pollers.json files are code; per-deployment tuning is config —
and the two used to share a file, so mimir skills update --apply could
silently reset operator tuning (observed live: a priority: high reverted by
an update). The overrides file separates them for good. It lives in the agent
HOME (never the skill dir), so updates can't touch it and drift detection
never sees it — the same pattern as scheduler.yaml overriding callable crons.
# <home>/pollers-overrides.yaml — poller name → overridden fields
github-activity:
cron: "*/5 * * * *" # poll faster than the shipped */15
priority: high # ride through quota pressure (see priority docs)
some-feed:
batch_size: 10
recover_failed_turns: true
Overridable fields: cron, priority, batch_size, recover_failed_turns,
env, pass_env, deliver, budget — tuning only. Authority fields including
authority, capabilities, tier, profile, principal_id, scoped_roots,
and operator_alert are rejected by the strict write path and dropped during
discovery. Environment values never add capabilities.
Anything else (notably command) is refused with a warning: overrides tune
behavior, they don't redefine what runs. Field-level fail-safety: an invalid
value (typo'd cron, unknown priority) warns and keeps the manifest value —
an override mistake degrades to shipped behavior, never to a dead poller.
Unknown poller names warn so a rename/uninstall can't silently orphan tuning.
Applied at discovery time (startup + reload_pollers).
Built-in heartbeat and session-boundary triggers use server-owned profiles,
not skill manifests. Those profiles are authoritative; a manifest declaration
using a built-in profile is validated only as a narrowing subset and cannot add
capabilities or destinations. Resolution is deterministic: built-in profile,
then optional narrowing; skill pollers resolve only from their manifest.
On batch_size: the poller script always emits per-item JSONL lines (clean contract). The framework collects all items, then emits ceil(N/batch_size) AgentEvents, each carrying a rendered prompt summarizing up to batch_size items + per-item metadata in extra.items. Single-item batches (default) render the prompt verbatim — no header. Multi-item batches render with a header (<poller-name> reported N items plus a (batch X of Y) suffix on multi-batch fires) and a numbered list of per-item prompts.
3. Register the pollers
After creating or updating pollers.json, call the reload_pollers tool. This re-scans <home>/skills/**/pollers.json and registers any new pollers with the scheduler. Removed pollers (skill uninstalled, manifest deleted) get dropped on the same call.
reload_pollers()
# → "reload_pollers ok: 2 poller(s) registered — github-activity, bluesky-mentions"
Pollers are also loaded automatically at startup, so a fresh container restart picks up any new skills without an explicit reload call.
Ready-built poller skills
Mimir ships ready-built poller skills under mimir/optional-skills/ — opt-in, NOT auto-installed (most installs don't need them). Each is a standalone skill directory with its own SKILL.md documenting the env vars and what it watches:
| Skill | Watches |
|---|---|
github-poller |
New issues, PRs, comments, PR reviews, and inline diff comments on configured GitHub repos |
worklink-tool-pins |
Worklink tool_pins: version drift; files/reuses low-priority Chainlink bump issues, never auto-bumps |
To install one:
cp -r mimir/optional-skills/<name> <home>/skills/
# (set the skill's required env vars — see its SKILL.md)
reload_pollers
Removing a skill: delete the directory under <home>/skills/ and call reload_pollers to drop the cron job.
File Layout
skills/my-monitor/
├── SKILL.md
├── pollers.json ← declares pollers
├── scripts/
│ └── poller.py ← the script
├── cursor.json ← poller state (managed by script)
└── events.jsonl ← optional local event log
Design Patterns
See design-patterns.md for detailed guidance on:
- State management — cursor pattern, timestamp vs URI cursors, external service state, recovery on first run
- Filtering — selecting actionable notification types, avoiding shared
is_readtraps - Prompt quality — including URIs/CIDs so the agent can act, not just observe
- Error handling — fail silently (exit non-zero), never emit on error
- Anti-patterns — common mistakes and how to avoid them
Security & Privacy
See security.md for guidance on:
- Trust tiers — the follow-gate pattern for sorting trusted vs unknown sources
- Operator in the loop — keeping the human informed without locking everything down
- Credential handling — env vars, per-agent accounts, what not to log
- Prompt injection — honest reporting with context, not sanitization
Key Constraints
- 60-second timeout. If a poller doesn't finish in 60s, it's killed and the cycle is skipped. The framework reaps the subprocess on every exit path so long-lived mimir processes don't accumulate zombies.
- Silence means nothing to report. Only output lines when there's something actionable.
- One JSON object per line. Each line must parse independently.
promptis the only required field. Lines missing it are silently dropped (a poller can emit metadata-only diagnostic lines without firing turns). Other keys flow into the AgentEvent'sextrafor prompt rendering.- Pollers are dumb. No LLM calls. Check a service, output what changed, exit. Keep them fast and pure.
- State management is the poller's job. Use
STATE_DIRto store cursors, history, or any persistent state. The scheduler doesn't track state for you, but it does provide a persistent path under<home>/state/pollers/<name>/(see above). - 16 KB prompt cap. Each emitted event's
promptis capped at ~16 KB. Larger payloads get truncated with a marker — emit multiple events or stash to a file + send a path reference instead. Protects against chatty pollers blowing the prompt-build cache. - Back-pressure surfaces in events.jsonl. When the dispatcher refuses an event (queue cap hit, channel saturated), it lands as a
poller_event_rejectedevent; the run'spoller_completecarries bothevents_emittedandevents_rejectedcounts so a mismatch is grep-able.
Debugging
If a poller isn't working:
- Check it was discovered:
reload_pollersreports the count and names - Run it manually:
cd skills/my-monitor && STATE_DIR=. POLLER_NAME=test python scripts/poller.py - Check stderr: Poller stderr is logged as
poller_stderrevents - Check exit code: Non-zero exits are logged as
poller_nonzero_exit - Check JSON format: Each stdout line must be valid JSON with at least a
promptkey
Available Tool
| Tool | Description |
|---|---|
reload_pollers |
Re-scan <home>/skills/**/pollers.json and register pollers. Call after installing or updating a skill. Also picks up skills with their pollers.json deleted (drops the corresponding cron jobs). |