Use case
Stop a scheduled agent running twice
The problem Junkt was built for. A scheduled task starts from nothing every time, so it cannot tell the difference between the first run of the day and the third.
The problem
Every invocation of a scheduled agent is a fresh context. It has your instructions and its tools, and no recollection of ever having run before. That is fine until the schedule fires twice — a retry, a queue redelivery, an infrastructure hiccup — and both runs do the job in full, because neither one could have known about the other.
It also breaks the other way. An agent that crashed halfway through has no way to tell you it got as far as step three, so a manual re-run starts from the beginning.
The pattern
One key holds a marker. The agent reads it at the top of the run, decides, does the work, and writes it back at the bottom:
get_value("cowork:daily-digest:last_run")
→ { exists: false }
# nothing recorded, so do the work, then:
set_value("cowork:daily-digest:last_run", "ok")
→ { created: true, updated_at: "2026-08-15T09:00:00+00:00" }
On the second run of the same day:
get_value("cowork:daily-digest:last_run")
→ { exists: true, value: "ok", updated_at: "2026-08-15T09:00:00+00:00" }
# updated_at is today, so stop.
Do not store the timestamp
The obvious version of this stores today's date as the value and compares it to today's date on the next run. Do not. That asks the model to work out what today is, format it, parse it back and compare it, and models are notably shaky about the current date.
Junkt already records updated_at on every write. Write anything you like
as the value and read the timestamp back. If the agent genuinely needs to know the
current time to compare against, the time tool gives it the real answer
rather than a guess — and kind: "same_day" does the whole
“was that today?” comparison for it, in whatever timezone you name.
Tell your agent
Something along these lines, adjusted for the job:
Before doing anything, use Junkt to get_value the key
"cowork:daily-digest:last_run".
If it exists, pass its updated_at to the time tool with
kind "same_day". If that returns true, stop and report
that today's digest has already been sent.
Otherwise: send the digest, then set_value the same key
to "ok".
The instruction to stop needs to be explicit. Left to its own judgement a model will often decide the digest is worth sending again.
Every hour instead of every day
For anything more frequent than daily, reach for a TTL and skip the date comparison entirely. Write the marker with a lifetime that matches the interval, and its mere existence is the answer:
set_value("cowork:inbox-sweep:ran", "ok", ttl: 3600)
Now get_value returning exists: false means "more than an
hour has passed, go ahead". There is no arithmetic and nothing to clean up. That is
the cooldown pattern, and it is the
better shape whenever the window is shorter than a day.
Worth knowing
- Write the marker after the work, not before. Writing first means a run that fails halfway leaves a marker saying it succeeded.
- Unless the work is expensive. If a duplicate run is worse than a skipped one — sending money, posting publicly — write the marker first and accept that a crash may skip a day.
- One key per job. Namespacing by job (
cowork:daily-digest:,cowork:weekly-report:) meanslist_keyswith a prefix shows you the state of everything at once. - Deleting the key re-arms the job. Useful when you want to force a run without waiting for the schedule.
Keys you end up with
cowork:daily-digest:last_run value, no expiry
cowork:weekly-report:last_run value, no expiry
cowork:inbox-sweep:ran value, ttl 3600
Three keys out of twenty-five, and the free tier covers a fair number of scheduled jobs before you have to think about it.
Give your scheduled tasks a memory
One key is usually all it takes. Free tier, 25 keys, no card, and about ten minutes from signup to your agent's first successful call.