6 min readResonate HQJust published

Schedule-and-act reminder agent in Python on Resonate

How an autonomous agent loop with a long-lived sleep step collapses to straight-line generator code when each yield is a durable checkpoint.

Resonate brand card on a dark background with a plum spectrum wave at the bottom and the post headline in white Sansation.

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 handlesYou write
Checkpointing each ctx.lfc(...) return value in the durable promise store so completed LLM calls and tool calls are not re-issued on replayThe 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 weeksA 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 orderA 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 idempotentThe one-line call schedule_reminder.run("remindme.1", "...") (schedule-reminder.py:162)
Walking the durable log to rebuild generator stateNo 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-9from resonate import Resonate + resonate = Resonate()
    • schedule-reminder.py:73-77schedule tool with ctx.sleep
    • schedule-reminder.py:79-84reminder and current_time tools
    • schedule-reminder.py:87-95seconds_until helper
    • schedule-reminder.py:119-156schedule_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-155Resonate.local() docstring (no external persistence)
    • resonate-sdk-py/resonate/resonate.py:195Resonate.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-1034Context.lfc (raises ValueError on non-function)
    • resonate-sdk-py/resonate/resonate.py:1137Context.sleep
    • resonate-sdk-py/resonate/resonate.py:1368-1391Function.run (blocking, returns T)