skills/marketing-loops/references/loop-state.md
Idempotency is only real if the loop can remember what it already did between runs. This reference defines where that state lives and how to log runs — so loops don't double-act, re-nag the same people, or re-alert the same issue.
Persist each loop's state in a file under .agents/loops/ — the same .agents/ convention this repo uses for product-marketing.md and listening-sources.md. One state file per loop:
.agents/loops/<loop-name>.json # the loop's memory
.agents/loops/<loop-name>.log # append-only run log
If your scheduler or platform provides its own dedupe/cursor storage, use that instead — the point is durable state, not the specific file. Never keep state only in memory; a loop that forgets on restart will repeat itself.
A state file holds whatever the loop needs to not repeat itself:
{
"loop": "churn-signal",
"last_run": "2026-07-01T09:00:00Z",
"cursor": "2026-06-30T23:59:59Z", // watermark — only process items newer than this
"handled": ["acct_1042", "acct_1077"], // dedupe keys already acted on
"cooldowns": { // entity -> next-eligible timestamp
"acct_1042": "2026-07-15T00:00:00Z"
},
"in_flight": ["exp_pricing_v3"], // actions/tests currently open
"counters": { "acct_1042_attempts": 2 } // e.g. dunning/win-back attempt counts
}
Keep state small and prune it: expire old handled/cooldown entries once they're past their window.
cursor; advance cursor at the end of a successful run. Safe to re-run — it won't reprocess.handled; add it after acting.cooldowns[entity]; set it after contact.in_flight.Append one line per run, whether or not it acted. This is the audit trail and the vanity-loop detector.
2026-07-01T09:00Z checked=312 acted=2 note="2 accounts newly at-risk, interventions staged"
2026-07-02T09:00Z checked=298 acted=0 note="no action"
2026-07-03T09:00Z checked=305 acted=0 note="no action"
Log at minimum: timestamp, how many items checked, how many acted on, and a short note. Use it to answer two questions:
acted=0 for weeks and nobody misses it — or it acts every run (a sign it's chasing noise) — reconsider it.cursor/handled — but keep cooldowns so a reset doesn't spam people who were recently contacted.loop-guardrails.md).