The most valuable program in my agent setup is about forty lines of shell and Python that decides not to run anything. It has no model call, no network call, no dependencies beyond a hash function, and it finishes in tens of milliseconds. Everything downstream of it — the token spend, the browser session, the messages to a person — happens only if it says yes.
I wrote about the interface of that program before: a check exits 0 to wake the worker, 1 for "nothing new", 2 for "I have lost sight of the thing I was watching". That article was about the wake, the run, and the log. This one is about the layer underneath, which took me longer to get right and which nobody writes about: what the guard is allowed to read, what state it compares against, and what a skip has to record so that "nothing happened" stays provable for weeks instead of rotting into "I stopped seeing".
The reason this matters is arithmetic. A scheduled agent's cost is not one number. It is tokens per wake, multiplied by a cadence you chose when the world seemed noisier than it is, plus the human minutes to read a run log and confirm that a run which produced output produced useful output. In the five days I have been operating one, most wakes found exactly the document they found six hours earlier. Cheap in dollars, expensive in the only currency that matters at 04:00: a log that has to be read to learn that nothing happened.
Two prices for the same question
"Has anything changed?" is answerable in two places, and they differ by three orders of magnitude:
| model reads the source | guard reads a projection | |
|---|---|---|
| latency | seconds to minutes | tens of milliseconds |
| cost per decision | a context window | zero |
| determinism | a generation, so it varies | a comparison, so it does not |
| failure mode | plausible summary of an unchanged page | a crash, which is visible |
| what it can judge | meaning | equality |
The model is not worse at the question. It is answering a different question — "what does this mean?" — and I was paying for that answer every twenty minutes to find out that a page had not moved. Splitting the two is the whole trick: the guard answers equality, and the model is handed a fact ("the status changed from A to B at 14:02") so it drafts instead of rediscovers.
The rest of this article assumes that split and asks what the guard side needs in order to be trustworthy.
The guard is a program, not a prompt
Constraints I now treat as non-negotiable for anything that runs before a paid wake:
- No model. A guard that needs a model to decide whether to spend money has inverted the economics.
- No network by default. If the guard must fetch, then the fetch is the observation, and the comparison is local. A guard that calls three endpoints to decide whether to call a fourth is a distributed system with no test.
- Deterministic. Same bytes in, same decision out. No sampling, no time-relative phrasing, no randomness.
- Bounded. A hard timeout shorter than the scheduler's patience, so a hung socket cannot outlive the due time and turn into a phantom run.
- Read-only. The guard observes and writes exactly one thing: its decision, to the journal. A guard that fixes state on the way through cannot be trusted to report state.
- A decision, not a summary. Exit code plus one line. If I want prose about the observation, that is the worker's job, and it costs money, which is the point.
The last constraint is the one people violate first, because a chatty guard feels helpful. It is not: every sentence a guard prints is a sentence someone has to read before they know whether the run mattered.
Hash guards: hash the projection, not the page
The naive version — hash the whole response — works for a week and then wakes the worker every run because the page gained a timestamp, a session id, a CSRF token, a relative time ("2 hours ago"), a view counter, an ad slot, or a reordered JSON key. Each of those is a real byte difference and a fake change, and each one you discover by paying for a wake.
So the guard hashes a projection: the subset of fields that would change what the worker does.
import hashlib, json
PROJECTION_VERSION = 3 # bump when the field list changes
def project(obs: dict) -> dict:
return {
# only fields whose change would alter the action
"status": (obs.get("status") or "").strip().lower(),
"price": normalize_money(obs.get("price")), # "₩12,000" -> "12000"
"open": sorted(obs.get("open_days") or []), # order is not information
"slots": len(obs.get("slots") or []), # a count, not the list
}
def fingerprint(obs: dict) -> str:
canon = json.dumps(project(obs), sort_keys=True, ensure_ascii=False,
separators=(",", ":"))
return hashlib.sha256(f"{PROJECTION_VERSION}|{canon}".encode()).hexdigest()[:16]
Four decisions hide in those ten lines.
Exclusions are the design. Timestamps, ids minted per request, ordering, formatting, counters, marketing text. If a field can change without changing what I would do, it does not belong in the projection. The test is a sentence, not a judgement call: if this changed, would I act differently?
Normalise before hashing. Case, whitespace, currency formatting, null versus "", a date as 2026-11-15 versus 15/11/2026. Two observations that mean the same thing must hash the same, or the guard reports change where there is none. Normalisation belongs in one function used by the guard, by the worker when it re-reads, and by any test that asserts they agree.
Version the projection, and store the version with the hash. Without this, every improvement to the field list changes every hash in your state file and wakes the worker on all of them — the exact cost the guard exists to avoid, triggered by fixing the guard. With it, an old hash and a new hash are simply not comparable, and the first run after an upgrade compares 3|abc against 3|def, not 2|... against 3|.... The invariant is precise: same projection version and same hash means no relevant change. It says nothing across versions, and pretending otherwise produces a fake "changed" or, worse, a fake "unchanged".
Hash what you compared, keep what you compared. A fingerprint alone tells you that something moved and nothing about what. Store the small projection alongside it — four fields, a few dozen bytes — so the run log can say "price 12000 → 13500" without re-fetching. When a guard's worker later reports an unexplained action, the stored projection is the only evidence of what the guard actually saw.
mtime is not an observation
File-based guards deserve their own warning. mtime changes on touch, on rsync, on a restore from backup, on an editor save with no edits, and does not change reliably when a tool writes a slightly different timezone. Size misses same-length edits. For a document, hash the content, or hash the section that matters — a heading-to-heading slice, a table's rows as a canonical list. For something too large to hash every run, keep a rolling hash of size plus the first and last block, and accept that the guard's claim is narrower ("the edges did not change"), which is different from "nothing changed" and should be written in the skip contract as such.
For lists — a directory of products, a folder of deliverables — use two levels: a set hash for "did anything change", plus a per-item hash so the run knows which item. One level forces the worker to re-read everything; two levels let it read the one row that moved.
State files: the file that decides whether money is spent
The guard's comparison target lives in a state file, and that file is now load-bearing in a way that ordinary config is not. If it is wrong in the "unchanged" direction, work silently stops. If it is wrong in the "changed" direction, money is spent answering a question already answered.
What it must hold:
| field | why |
|---|---|
schema_version |
so an old file is upgraded, not misread as current |
projection_version |
a hash is meaningless without it |
last_ok_fingerprint |
the comparison target |
last_projection |
evidence, and what the next log line quotes |
last_run_at (UTC) |
to compute real cadence and detect a stalled schedule |
last_decision + code
|
the skip is an output; the state records which one |
consecutive_skips |
the counter that turns a stuck skip into a message |
last_effect_id |
the worker's receipt, so a repeat is detectable |
stale_after |
when the stored observation stops being evidence |
Three rules about writing it.
Write atomically or not at all. A truncated JSON file is worse than a missing one, because a partial {"last_ok_fingerprint": "8f3a may parse as absent, and absent means "no baseline" — which is fine if you handle it, and a silent full-rehash if you do not. Write to a temporary file in the same directory, flush, os.replace(). Never overwrite in place.
Derive it, don't accumulate it. The durable record is an append-only journal of one line per decision: timestamp, code, fingerprint, projection, and — when a run happened — the effect id. state.json is a cache over that journal. This gives you three things an updating-in-place file cannot: a rebuild test (delete the state, rebuild it from the journal, assert it is identical), an audit trail for "when did we stop seeing", and forgiveness for a corrupt write. If you cannot rebuild the state from the journal, then the state is the truth and it has no history, which is the same as having no evidence.
Keep it next to the guard and review them together. A field list in the guard and a state file that stores one fewer field is a bug that only appears on the next upgrade. One directory, one commit, and a test that asserts set(state.keys()) == set(project(obs).keys()) | meta_keys.
Two smaller things that bit me: store UTC only, and store the observation time from the source when the source provides one — a guard that stamps now() on an observation it cached an hour ago is lying about freshness. And never store a secret or a secret's fingerprint in the state file; a hash of a token is still a credential if the space is small.
Skip contracts: an enumerated list, with words
A skip is not the absence of a run. It is a decision with a cause, and the cause determines what happens next. So the contract is a closed table, not a comment.
| code | exit | meaning | expected duration | escalation |
|---|---|---|---|---|
NO_CHANGE |
1 | observation equals baseline | until the world moves | none; this is success |
GATE_CLOSED |
11 | outside the window (hours, region, release gate) | hours to days | heartbeat daily |
BACKOFF |
12 | recently attempted, retry after T | minutes to hours | none unless it repeats N times |
RATE_LIMITED |
13 | receiver said slow down | minutes | none; never wake to discover a 429 |
QUIET_HOURS |
14 | action would be visible at a bad local hour | until the window opens | none |
CREDENTIAL_MISSING |
21 | we cannot authenticate | should be zero | message after 1 skip |
UPSTREAM_UNREADABLE |
22 | we cannot see (5xx, changed shape, timeout) | unknown | message after 2 consecutive |
NEEDS_HUMAN |
23 | a person must decide or act | unknown | message once, then heartbeat |
BUDGET_EXHAUSTED |
24 | the spend cap for today is reached | until reset | log only |
NOTHING_TO_GUARD |
25 | the target does not exist (yet) | unknown | after 7 days, message |
Five rules that make this table worth having.
-
Every skip prints exactly one line:
2026-11-15T07:43:31Z skip=NO_CHANGE key=a3f1 obs=a3f1 stored=a3f1 pv=3. Greppable, comparable, cheap. No prose. - A skip without a code is a bug, not a convenience. The code is how the operator knows whether to wait, retry, or repair.
-
"Cannot see" is never
NO_CHANGE. This is the single most expensive conflation in scheduled automation. A login wall, a changed HTML shape, an expired token, and a 500 are all absences of observation, and folding them into "nothing new" means the system reports health while being blind. - The table is closed. A new reason gets a new code, reviewed and added deliberately. Free-text reasons drift into forty spellings of "not now" and the skip rate becomes unmeasurable.
- Exit codes are grouped, not unique. 1x means "wait, this is normal", 2x means "blocked, a human is needed", 3x is reserved for guard failure. The scheduler only needs to know which group, and grouping survives adding codes.
The exit-code grouping matters because it decides alerting, and alert fatigue is how operators stop reading. Normal waiting must be silent; blocked states must be loud exactly once; guard failure must be loud every time, because it means the guard no longer knows anything.
The skip that never ends
Codes are a snapshot; the interesting behaviour is in time. Each code needs a maximum age, after which silence is no longer acceptable:
-
NO_CHANGE— unbounded. It re-decides every run against a fresh observation. It cannot get stuck; it can only be wrong about the baseline. -
BACKOFF— its own duration, andconsecutive_skipsmust reset on any real observation, not on any run. -
GATE_CLOSED— okay for days, but it needs a heartbeat that proves the gate is still being read, not remembered. A gate check that reads a cached "closed" is not a gate. -
CREDENTIAL_MISSING/NEEDS_HUMAN— zero tolerance for repetition. The first skip sends one message that states exactly what would unblock it; subsequent skips are silent for a day, then one reminder with the count. A system that says "needs a human" every six hours trains its human to ignore it. -
UPSTREAM_UNREADABLE— the guard's own alert. Consecutive, not cumulative: two in a row is an incident; one in a week is a flake.
I learned this the expensive way on the third day of a five-day run: a session expired, the guard caught the login wall honestly, and the handler for that code appended needs_human to a state file nobody was reading during the night. Six wakes later the same line was there. The action had not been missed — nothing was due — but I could not have known that from the log, because a stuck skip and a quiet world look identical unless the code and its age are printed together.
The discipline that follows: for every guard, be able to say what it will print on the day the world moves. If the answer is "I would have to read the state file to tell", the guard is not finished.
Fail-open or fail-closed: choose per action, in writing
When the guard itself breaks — a syntax error, a missing interpreter, a state file that will not parse, a timeout — the system has to do something, and the right choice depends entirely on what the action can do to the world.
| action class | guard failure behaviour | why |
|---|---|---|
| read-only observation, cheap, idempotent | run (fail-open) | a broken guard that stays silent is an outage nobody notices |
| writes to your own store, resumable | run, with a note in the run log | the effect is bounded and re-checkable |
| sends something to a person, spends money, deletes | skip (fail-closed) + alert every run | a broken guard that runs anyway re-sends, re-books, re-charges |
| anything irreversible | skip + alert + require a fresh approval | the guard is the last cheap checkpoint |
Then write the choice into the contract, because the default is never obviously correct. A guard with a database error that answers "skip" protects a payment endpoint and blinds a monitoring job. The same behaviour in the wrong class is either a double charge or a week of silence, and both are found late.
Two supporting rules. Never let the guard depend on the same service it guards: if the site is down, "cannot see" must be reportable without the site. And keep the last good observation with its timestamp, so a guard that cannot fetch can still say "last seen 3h ago, stale" instead of "unknown".
Prove the decisions off-line: replay
The guard's decisions are the thing you are trusting, so they need tests that do not depend on the world. Which means recording the world.
Every guard run appends the raw observation (or its hash plus the projection, if size matters) to the journal. A week of that is a replay corpus. Then:
- Take each recorded observation and the state that preceded it.
- Run the new guard against the pair, in order, in a temporary state directory.
- Compare the decision to what the run actually did at the time — including runs that were skipped and later found to have had work waiting.
- Read the confusion matrix.
| work actually existed | no work existed | |
|---|---|---|
| guard said run | correct run | wasted wake — cost, no damage |
| guard said skip | missed work — damage | correct skip |
The target is a hard zero on the bottom-left cell and a tolerable rate on the top-right. That asymmetry is the whole design: a skip is a claim about the world, and a false claim hides work, while a false wake merely spends. Everything about the guard should bias toward the cheap error — which is why an unparseable state means "run", why UPSTREAM_UNREADABLE is not a skip-and-forget, and why NO_CHANGE requires an actual observation rather than a remembered one.
Two tests catch almost everything. Idempotence of the projection: the same observation, projected twice, hashes equal; any two observations equal under the projection must produce the same decision (a property test, not an example test). Upgrade safety: bump PROJECTION_VERSION, replay the corpus, assert the new version generates no CHANGED decisions caused only by the version change.
The numbers, if you want to know whether it is working
| metric | what it tells you |
|---|---|
| skip rate by code | whether the cadence matches the world; a 95% NO_CHANGE rate across days means the schedule is too eager |
| wasted-wake rate | runs that found nothing to do — the cost of a bad projection or a noisy field |
| missed-work count | should be zero and is the only metric worth alerting a human about |
| guard p95 latency | whether a guard is quietly becoming a job of its own |
| detect latency | world changed → run started; the real quality of a schedule |
| escalations per week | whether the system asks for a human at a rate a human will answer |
| state rebuild result | whether your records can be reconstructed at all |
Cost per confirmed effect is the one I actually watch: (wakes × price per wake + human minutes reading logs) / effects that changed something. It is a ratio that improves when you guard harder and worsens when a projection gets noisy, which is exactly the feedback an operator needs.
Where not to put a guard
- Interactive requests. When a person asks, the person is the trigger. Adding a change-check to a live request adds latency and a reason to say no.
- Jobs that run twice a month. The guard will be wrong and unmaintained; the two runs are cheaper than the bug.
- Anything where a wrong skip is unrecoverable. Guard the check, not the action: let it run and verify the effect afterwards instead.
- "Should this exist?" A guard answers "is it different". "Is it right" is a person's question and the reason approval gates exist.
- A guard larger than what it saves. Roughly: guard lines × change frequency should be less than the wakes it prevents. Once a guard grows branches, config, and its own tests, it is a subsystem, and it deserves the same review as the worker it is protecting.
Checklist
- The guard needs no model, makes no network call of its own, and writes exactly one artefact.
- It returns a decision with a code and one greppable line, and never a paragraph.
- The projection is the fields that would change the action; every excluded field has a reason.
- Normalisation happens before hashing, in one shared function.
- The projection carries a version, the hash is stored with it, and the invariant is stated for one version only.
- Fingerprint and projection are stored, so the log can say what moved.
- mtime is not used as an observation; large documents get a narrower claim that is written down.
- Lists use a set hash plus per-item hashes.
- The state file is written atomically, holds the version fields, and can be rebuilt from the journal.
- Every skip code has an expected duration and an escalation rule; "cannot see" has its own code.
-
consecutive_skipsdrives escalation, and repeating codes get one reminder, not a stream. - Fail-open versus fail-closed is chosen per action class and written into the contract.
- The guard's decisions are replayed against a recorded week, and the confusion matrix has an empty missed-work cell.
- The cadence is justified by the skip rate, not by how nervous the first week felt.
None of this makes an agent smarter. It makes the cheap half of its judgement deterministic and auditable, and it keeps the expensive half from being spent on a question whose answer was a hash comparison. In a system that wakes on its own, the honest measure of maturity is how confidently you can say that nothing happened — with a timestamp, a code, and a number.
Disclosure: I build and write about small automation tools, and I run an English guide for an AI assistant app whose invite system I use as a working example of these ideas: Pickle invite code — what the app does and how to enter it and the invite link pickle.com/i/RDKXK77G9ZRT. If someone joins through that link, I receive in-app usage credit; it costs the person joining nothing. Nothing in the article depends on that product, and the guard and skip-contract design described is generic.
Top comments (0)