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-156The 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-77The 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-84The durable primitives in play
Resonate()— constructs the Resonate client in the worker process. With no arguments, the client falls back toLocalStore()(in-memory only).schedule-reminder.py:9. SDK:resonate-sdk-py/resonate/resonate.py:119-124.@resonate.register— registersschedule_reminderas a top-level workflow callable viaschedule_reminder.run(id, ...). The decorator returns aFunctionwrapper.schedule-reminder.py:119. SDK:resonate-sdk-py/resonate/resonate.py:317-399.ctx.lfc(fn, args)— Local Function Call. Runsfnas 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-invokingfn. Used for the LLM call atschedule-reminder.py:130and for the tool dispatch atschedule-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 aRemoteStorepointed 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 siblingFunction.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 completedctx.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 defaultLocalStore, 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 cachedmessagedict instead of re-paying for the request. - Replay between
schedulereturning andreminderbeing invoked. The cached"The current time is ..."string fromschedulelives in the promise store. The generator replays up to the point after theschedulestep completes, then re-enters the loop, which feeds the cached tool-result back into the LLM and produces theremindertool 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 onFunction.begin_runatresonate-sdk-py/resonate/resonate.py:1393-1416.
Known gaps the example does not handle
- LLM produces an unknown tool name. The
match/casefalls through tocase _: handler = None(schedule-reminder.py:149-150);ctx.lfc(None, tool_args)then raisesValueError("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 callshandle = schedule_reminder.run(...)and thenhandle.result().Function.runis 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, sohandleisNoneand.result()raisesAttributeError. The agent loop itself completes before the crash. The intended pattern is eitherhandle = schedule_reminder.begin_run(...)followed byhandle.result()(non-blocking + handle) orresult = schedule_reminder.run(...)(blocking, value). - Hardcoded 10-second sleep (
schedule-reminder.py:76). The production form is the commented line above it; substitutectx.sleep(seconds_until(args["timestamp"]))to honor the timestamp the LLM picked. - Stale lockfile.
pyproject.toml:7-10pinsresonate-sdk>=0.6.7; the committeduv.lockresolves toresonate-sdk==0.5.3(itsrequires-distmetadata still reads>=0.5.3). A freshuv syncagainst PyPI will install0.6.7. All SDK surfaces the example calls (Resonate(),@resonate.register,ctx.lfc,ctx.sleep,Function.run) exist and work at0.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_stepsbudget, 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—scheduletool withctx.sleepschedule-reminder.py:79-84—reminderandcurrent_timetoolsschedule-reminder.py:87-95—seconds_untilhelperschedule-reminder.py:119-156—schedule_reminderagent loopschedule-reminder.py:162-163— workflow invocation under promise id"remindme.1"(entrypoint bug)pyproject.toml:7-10— SDK pinresonate-sdk>=0.6.7resonate-sdk-py/resonate/resonate.py:119-124— default-store fallback toLocalStoreresonate-sdk-py/resonate/resonate.py:151-155—Resonate.local()docstring (no external persistence)resonate-sdk-py/resonate/resonate.py:195—Resonate.remote(...)factoryresonate-sdk-py/resonate/resonate.py:317-399—@resonate.registerresonate-sdk-py/resonate/resonate.py:1393-1416— duplicate-id idempotency docstring onFunction.begin_runresonate-sdk-py/resonate/resonate.py:1020-1034—Context.lfc(raisesValueErroron non-function)resonate-sdk-py/resonate/resonate.py:1137—Context.sleepresonate-sdk-py/resonate/resonate.py:1368-1391—Function.run(blocking, returnsT)
