Use case
Resume where the last run stopped
A cursor is the smallest possible answer to “what have I already seen?” — and it is the difference between a job that processes ten new items and one that processes ten thousand old ones.
The problem
A job that sweeps your inbox, or a feed, or a list of pull requests, has to decide what counts as new. Without a memory of the last run there are only bad options: process everything every time and deduplicate downstream, or pick an arbitrary window like “the last 24 hours” and quietly lose anything that arrives during an outage.
Asking the model to work out the boundary from the data is worse. It will read the first page, decide it has seen enough, and stop somewhere non-deterministic.
The pattern
One key holds the position. Read it at the start, use it to bound the query, write the new position at the end:
get_value("inbox:sweep:cursor")
→ { exists: true, value: "1934812", updated_at: "..." }
# fetch everything after id 1934812, process it, then:
set_value("inbox:sweep:cursor", "1934977")
→ { created: false, updated_at: "..." }
On a brand new account exists: false comes back instead, which is the
signal to do whatever your first-run behaviour should be — usually process a small
recent window rather than all of history.
What to put in it
Whatever the source gives you that is monotonic and comparable. In rough order of preference:
- An opaque pagination token from the API. Best option — the provider guarantees it means something.
- The highest ID processed. Reliable when IDs increase.
- A timestamp from the data, not from the clock. The created-at of the newest item you handled, so an item that arrives late is not skipped.
Values cap at 2KB, which is a lot of cursor. If yours does not fit, you are storing results rather than a position — keep the position here and put the results somewhere built for them.
More than one field
Values are strings, so if you need a couple of fields, store JSON as a string and let the model parse it:
set_value("inbox:sweep:cursor",
'{"last_id":"1934977","page":3,"run":"2026-08-15"}')
This is fine and common. It is also the point where you should check you have not started building a database — if the JSON is growing fields every week, the shape of the problem has changed.
Tell your agent
Start by using Junkt to get_value the key
"inbox:sweep:cursor".
If it exists, only look at messages newer than that ID.
If it does not exist, look at the last 20 messages only —
do not process the whole history.
After you have finished processing, set_value that key to
the highest message ID you handled. Do this last, so a
failure part way through means the next run picks the same
messages up again.
Write the cursor last
The order matters more here than anywhere else. Writing the cursor before the work means a crash loses everything between the old position and the new one, permanently and silently. Writing it after means a crash causes some items to be processed twice on the next run, which is nearly always the better failure.
If processing twice is genuinely harmful, pair the cursor with a per-item marker —
a key like inbox:handled:1934977 with a TTL — and check it before acting.
That burns keys quickly, so keep the TTL short.
Worth knowing
- Deleting the cursor replays everything. Useful deliberately, painful accidentally. It is worth telling the agent it may never delete this key.
- Do not put a TTL on a cursor. An expired cursor looks exactly like a first run, and your job silently reprocesses.
- Keep the source in the key name.
inbox:sweep:cursorandgithub:prs:cursorrather than one sharedcursor, so two jobs cannot tread on each other. - Check the activity log if a run behaves oddly — it records every write to the cursor with the time and whether it came from MCP or the dashboard.
Somewhere to keep your place
One key per job, and the sweep only ever looks at what it has not seen. Free tier, 25 keys, no card.