Schedule-and-act reminder agent in Python on Resonate

A reminder agent needs to interpret an ambiguous natural-language request ("first thing tomorrow, remind me to check Resonate"), compute a target timestamp, wait until that timestamp — possibly hours, days, or weeks later — and then send the reminder. The Resonate shape of the solution is a generator workflow that yields each LLM call and each tool call as a durable checkpoint, with the wait expressed as yield ctx.sleep(...) so the pending wake-up lives in the durable promise store and resumes after replay. The example registers one generator function, schedule_reminder, runs an agent loop of at most max_steps iterations, and dispatches three GPT-4 tool calls (schedule, reminder, current_time) as local function calls (ctx.lfc).

The shape of the solution

from resonate import Resonate

resonate = Resonate()
# from example-schedule-reminder-agent-py/schedule-reminder.py:7-9

@resonate.register
def schedule_reminder(ctx, question, max_steps=5):

messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": question}
]

for step in range(max_steps):

# Prompt the LLM
message = yield ctx.lfc(prompt, messages)
message = ChatCompletionMessage.model_validate(message)

messages.append(message)

if message.tool_calls:
for tool_call in message.tool_calls:
tool_name = tool_call.function.name
tool_args = json.loads(tool_call.function.arguments)

handler = None

match tool_name:
case "schedule":
handler = schedule
case "reminder":
handler = reminder
case "current_time":
handler = current_time
case _:
handler = None

result = yield ctx.lfc(handler, tool_args)

messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": result})
else:
break
# from example-schedule-reminder-agent-py/schedule-reminder.py:119-156

The schedule tool is the load-bearing one — it is the tool the LLM picks to express "wait until this timestamp":

def schedule(ctx, args):
print("Scheduling reminder:", args["timestamp"])
#yield ctx.sleep(seconds_until(args["timestamp"]))
yield ctx.sleep(10)
return "The current time is {}".format(args["timestamp"])
# from example-schedule-reminder-agent-py/schedule-reminder.py:73-77

The committed body hardcodes a 10-second sleep for demo convenience; the production form is the commented line (ctx.sleep(seconds_until(args["timestamp"]))), with the helper at schedule-reminder.py:87-95 converting an ISO-8601 timestamp into a total_seconds() delta from now.

The two other tools are plain functions that the agent loop also dispatches through ctx.lfc:

def reminder(ctx, args):
print("Sending reminder:", args["message"])
return "The reminder has been sent successfully"

def current_time(ctx, args):
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# from example-schedule-reminder-agent-py/schedule-reminder.py:79-84

The durable primitives in play

Resonate() — constructs the Resonate client in the worker process. With no arguments, the client falls back to LocalStore() (in-memory only). schedule-reminder.py:9. SDK: resonate-sdk-py/resonate/resonate.py:119-124.

@resonate.register — registers schedule_reminder as a top-level workflow callable via schedule_reminder.run(id, ...). The decorator returns a Function wrapper. schedule-reminder.py:119. SDK: resonate-sdk-py/resonate/resonate.py:317-399.

ctx.lfc(fn, args) — Local Function Call. Runs fn as a durable child step in the same worker; the return value is checkpointed in the promise store, so on generator replay the SDK returns the cached value rather than re-invoking fn. Used for the LLM call at schedule-reminder.py:130 and for the tool dispatch at schedule-reminder.py:152. SDK: resonate-sdk-py/resonate/resonate.py:1020-1034.

ctx.sleep(secs) — durable timer. The pending wake-up is held in the configured promise store; with a RemoteStore pointed at a Resonate server, the timer survives worker restarts. schedule-reminder.py:76. SDK: resonate-sdk-py/resonate/resonate.py:1137.

schedule_reminder.run(id, ...) — invokes the registered workflow under the caller-supplied promise id ("remindme.1"); a second invoke with the same id resolves against the existing promise instead of starting a parallel run. Duplicate-id idempotency is documented on the sibling Function.begin_run (SDK: resonate-sdk-py/resonate/resonate.py:1393-1416). schedule-reminder.py:162. SDK: resonate-sdk-py/resonate/resonate.py:1368-1391.

Store configuration caveat

Resonate() with no arguments defaults to LocalStore() (resonate-sdk-py/resonate/resonate.py:119-124), and Resonate.local(...) documents this explicitly: "There is no external persistence — all state is stored in local memory" (resonate-sdk-py/resonate/resonate.py:151-155). With this configuration, generator replay still works inside the running worker process, but a process restart loses the durable timer and the checkpointed step results. Cross-process recovery of an in-flight reminder requires Resonate.remote(host=..., ...) (SDK: resonate-sdk-py/resonate/resonate.py:195) or an explicit RemoteStore pointed at a Resonate server. The example does not configure either; the durability claims below apply only when the example is rewired to use a remote store.

What the SDK handles vs. what you write

SDK handles | You write
Checkpointing each ctx.lfc(...) return value in the durable promise store so completed LLM calls and tool calls are not re-issued on replay | The two yield ctx.lfc(...) sites (one for the LLM prompt at schedule-reminder.py:130, one parameterised tool-dispatch site at :152 that handles all three tools) and the four step bodies (prompt, schedule, reminder, current_time)
Holding the wait as a durable timer (server-backed when configured against a Resonate server), regardless of duration — seconds, hours, or weeks | A single yield ctx.sleep(...) inside the schedule tool (schedule-reminder.py:76)
Replaying the generator and returning cached results for completed steps, so the messages list rebuilds in the same order | A plain Python list[dict] named messages, initialized as a literal at schedule-reminder.py:122-125 and appended at :133, 154
Holding the workflow's identity under the promise id "remindme.1" so a re-invoke is idempotent | The one-line call schedule_reminder.run("remindme.1", "...") (schedule-reminder.py:162)
Walking the durable log to rebuild generator state | No replay code — the agent loop is written as if it never crashes

The author writes a for loop, a match/case over tool names, a Python list of chat messages, and a sleep that is shaped like time.sleep but is durable. Everything that makes the workflow survive replay (and, when configured against a Resonate server, a worker restart) is in the SDK and the promise store.

Failure modes covered

The bullets below describe what the pattern handles when the example is configured against a Resonate server (Resonate.remote(...)). As committed, Resonate() uses LocalStore, so the same bullets describe in-process generator replay only.

Worker crash between the LLM picking a timestamp and the sleep expiring (server-backed only). ctx.sleep (schedule-reminder.py:76) registers a durable timer in the promise store. On worker restart the generator replays, the SDK returns the cached results for completed ctx.lfc(prompt, ...) and the previous tool dispatch, and the sleep resumes for whatever time remains rather than restarting from zero or firing immediately. With the default LocalStore, this recovery happens inside the same process only.

Generator replay mid-LLM-call. The yield ctx.lfc(prompt, messages) (schedule-reminder.py:130) returns a durable promise. If replay starts before the call completes, the step re-enters and re-issues the OpenAI request. If replay starts after the response is checkpointed, the SDK returns the cached message dict instead of re-paying for the request.

Replay between schedule returning and reminder being invoked. The cached "The current time is ..." string from schedule lives in the promise store. The generator replays up to the point after the schedule step completes, then re-enters the loop, which feeds the cached tool-result back into the LLM and produces the reminder tool call again.

Duplicate invocation of the workflow. schedule_reminder.run("remindme.1", ...) (schedule-reminder.py:162) supplies the promise id at the call site. A second invocation with the same id resolves against the existing promise rather than starting a parallel agent loop — idempotency documented on Function.begin_run at resonate-sdk-py/resonate/resonate.py:1393-1416.

Known gaps the example does not handle

LLM produces an unknown tool name. The match/case falls through to case _: handler = None (schedule-reminder.py:149-150); ctx.lfc(None, tool_args) then raises ValueError("provided callable must be a function") (resonate-sdk-py/resonate/resonate.py:1029-1031). The example does not catch this — it is a known gap, not a feature.

Entrypoint bug (schedule-reminder.py:162-163). The script calls handle = schedule_reminder.run(...) and then handle.result(). Function.run is blocking and returns the workflow's return value directly (resonate-sdk-py/resonate/resonate.py:1368-1391); the generator falls off the end with no explicit return, so handle is None and .result() raises AttributeError. The agent loop itself completes before the crash. The intended pattern is either handle = schedule_reminder.begin_run(...) followed by handle.result() (non-blocking + handle) or result = schedule_reminder.run(...) (blocking, value).

Hardcoded 10-second sleep (schedule-reminder.py:76). The production form is the commented line above it; substitute ctx.sleep(seconds_until(args["timestamp"])) to honor the timestamp the LLM picked.

Stale lockfile. pyproject.toml:7-10 pins resonate-sdk>=0.6.7; the committed uv.lock resolves to resonate-sdk==0.5.3 (its requires-dist metadata still reads >=0.5.3). A fresh uv sync against PyPI will install 0.6.7. All SDK surfaces the example calls (Resonate(), @resonate.register, ctx.lfc, ctx.sleep, Function.run) exist and work at 0.6.7.

When to reach for this pattern

If you have an agent loop that must block on a long-lived wait (a scheduled time, a calendar event, an external timeout) and you want the wait expressed as straight-line code rather than as cron, a job queue, or a database-backed timer table.

If you want per-step replay across LLM calls and tool calls so a replay does not re-pay for completed OpenAI requests or re-trigger external side effects that have already been recorded.

If the gap between "pick a target time" and "fire the action" can be hours, days, or weeks, and you cannot rely on a Lambda-style execution that times out in minutes — and you are willing to run a Resonate server so the durable timer survives worker restarts.

If you want idempotent re-invocation of the same reminder under a stable id so retries from the caller (or a UI) do not spawn duplicates.

If the agent is simple enough to fit one generator function — one outer loop, a fixed max_steps budget, and a small fixed toolset — and you do not yet need multi-worker fan-out or remote function invocation.

Sources

Example repo: https://github.com/resonatehq-examples/example-schedule-reminder-agent-py

Python SDK repo: https://github.com/resonatehq/resonate-sdk-py

Resonate server: https://github.com/resonatehq/resonate

Resonate documentation: https://docs.resonatehq.io

Files cited in this post:

schedule-reminder.py:7-9 — from resonate import Resonate + resonate = Resonate()

schedule-reminder.py:73-77 — schedule tool with ctx.sleep

schedule-reminder.py:79-84 — reminder and current_time tools

schedule-reminder.py:87-95 — seconds_until helper

schedule-reminder.py:119-156 — schedule_reminder agent loop

schedule-reminder.py:162-163 — workflow invocation under promise id "remindme.1" (entrypoint bug)

pyproject.toml:7-10 — SDK pin resonate-sdk>=0.6.7

resonate-sdk-py/resonate/resonate.py:119-124 — default-store fallback to LocalStore

resonate-sdk-py/resonate/resonate.py:151-155 — Resonate.local() docstring (no external persistence)

resonate-sdk-py/resonate/resonate.py:195 — Resonate.remote(...) factory

resonate-sdk-py/resonate/resonate.py:317-399 — @resonate.register

resonate-sdk-py/resonate/resonate.py:1393-1416 — duplicate-id idempotency docstring on Function.begin_run

resonate-sdk-py/resonate/resonate.py:1020-1034 — Context.lfc (raises ValueError on non-function)

resonate-sdk-py/resonate/resonate.py:1137 — Context.sleep

resonate-sdk-py/resonate/resonate.py:1368-1391 — Function.run (blocking, returns T)