Skip to content
🎓 Find your path Subscribe

The Loops Doctrine

Tier 2 · Building 8 min read

Before this, read:


“I’ll check back on that” is a promise a chat reply can’t keep. Sessions end, context rotates, attention moves. Every future obligation that lives only in a conversation will eventually be dropped. The loops doctrine is the rule that forces the right primitive in the same moment you create the obligation.

Whenever you create a future obligation — “watch for X,” “check back on Y,” “Z expires on a date,” “waiting on a reply,” “verify after deploy” — you wire it to the right loop primitive before moving on. A promise in a reply is a dropped promise.

Four primitives, four different shapes:

ShapePrimitiveWhen
Fixed cadence forevercron”Run every hour/day/week”
Condition that fires oncewatcher”When this email lands,” “when this file appears,” “when this URL changes”
In-flight work in this sessionsession loopCI run, long deployment, “keep going until this job is done”
Date-based, one-shotdurable one-shot”Remind me on Jun 13,” “run this once at 3pm”

Use cron when the cadence is the point — “every hour at :03” regardless of whether anything has changed. Domain heartbeats, daily briefings, weekly reviews, database backups.

Crons blast on schedule whether or not anything changed. This makes them the wrong choice for “check if X changed and notify me” — that pattern should be a watcher with change-gating.

The 4-hour open-loops check is a real example of this mistake in the system: the same open-loop list was being blasted 6 times a day, unchanged. No content-hash. No “only notify if the list changed.” JD named this his #1 noise complaint. The fix: move condition-checkable items from cron blast to change-gated watchers.

A watcher checks a condition on a cadence and fires once when the condition becomes true, then stops.

Terminal window
# Register a watcher
python3.12 -m agents.ai_os.watchers add \
--title "HydroJug reply" \
--kind gmail_search \
--cadence 30 \
--escalate "HydroJug replied — handle the thread" \
--expires 30d

Four check kinds:

  • gmail_search — true when a search query returns results
  • file_exists — true when a file appears at a path
  • file_mtime — true when a file’s modification time changes
  • shell — true when a shell command exits 0

Every watcher requires a mandatory expiry — --expires 30d by default. No immortal watchers. An obligation that has no clear end-condition either has the wrong primitive or needs a human to make the call.

The watcher registry lives at ~/agent-system/state/watchers.json. The sweep cron runs every 15 minutes.

Direction-check on gmail watchers. The first HydroJug watcher false-triggered on JD’s own outbound mail because the search wasn’t pinned to from:. Gmail search queries for watchers should include from:<expected sender> to prevent this.

Session loops: in-flight work this session

Section titled “Session loops: in-flight work this session”

Session loops are for work that needs to stay alive during a single session but doesn’t need to persist across sessions. A CI run you’re waiting on, a long build job, a “keep retrying this until it passes” pattern.

These don’t need a cron or a watcher — they’re just Bash loops or Agent tool calls with run_in_background: true, where the parent session monitors completion via the notification event.

The key property: session loops die with the session. Use a watcher or cron if the obligation must survive a session rotation.

Durable one-shots: date-based future obligations

Section titled “Durable one-shots: date-based future obligations”

For “do X on date Y” — a reminder, a scheduled run, a one-time future action:

Terminal window
# Via the watchers system's date_reached kind
python3.12 -m agents.ai_os.watchers add \
--title "LinkedIn token expiry" \
--kind date_reached \
--date 2026-06-10 \
--escalate "LinkedIn OAuth token expires today — run reauth script"

The date_reached kind shipped 2026-06-08. Its first real use: a watcher that fired on June 10 with the LinkedIn reauth command, three days before the token would have died silently.

Every loop must name when it stops. For watchers, that’s the expiry or the trigger. For crons, that’s the calendar (or a manual delete). For session loops, it’s the end of the session. For durable one-shots, it’s the single fire.

A loop without an off-condition is noise waiting to happen. The watcher’s default 30-day expiry is there because most conditions either resolve or become irrelevant within a month. If your condition genuinely needs longer, set it explicitly — but name it.

How this plays out: the June 2026 loop-adoption audit

Section titled “How this plays out: the June 2026 loop-adoption audit”

In June 2026, a loop-adoption audit swept the system and found 18 candidates for conversion to watchers or durable one-shots. 5 watchers were registered. 3 were self-rejected as noise (the condition would generate more alerts than value). The remaining 10 were either already in crons or folded into the open-loops ledger.

Two important findings from that audit:

  1. Open-loop entries that are machine-checkable conditions should have a paired watcher (tracked in the ledger as linked_open_loop). The ledger records the human promise; the watcher does the checking.

  2. The zombie-cron class — cron jobs whose Python modules had been deleted but whose crontab entries remained, running to guaranteed failure on every tick — was caught by the loop audit (and by the CI import-smoke job). Two such zombies were found: jitai_sensor and linkedin_approval_gate. Neither produced errors anyone noticed because their failures were silent. The remedy: delete the crontab entry the moment you delete the Python module.

Whenever you finish a piece of work and there’s a pending “and we should follow up on…” at the end, stop before leaving the session and ask: what shape is this obligation?

  • Needs to happen on a fixed schedule? Add the crontab line.
  • Needs to fire when a condition changes? Register the watcher.
  • Needs to happen on a specific date? Register the date_reached watcher.
  • Only relevant for this session? A session loop or a plain reminder is fine.

That pause — five minutes to wire the obligation to the right primitive — is the whole doctrine. It’s what makes “I’ll check back on that” true instead of aspirational.


Next: Watchers in depth — the four check kinds, the direction-check pattern, how open-loop pairing works. Watchers: condition-until-it-fires.