A workflow has to pause until a human takes an action — clicking a link in an email — and the wait can be seconds or weeks. The Resonate shape of the solution is to create a durable promise inside the workflow with ctx.promise(), yield it to suspend the generator, and resolve that promise id from outside the workflow through an HTTP gateway that calls resonate.promises.resolve(id=...). The example shows a Flask gateway that starts the workflow over RPC, a worker that prints an unblock URL and waits on the promise, and the same gateway's second route resolving the promise so the workflow returns.
The shape of the solution
@resonate.register()
def foo(ctx, workflow_id):
blocking_promise = yield ctx.promise()
yield ctx.lfc(send_email, blocking_promise.id)
print(f"workflow {workflow_id} blocked, waiting on human interaction")
# wait for the promise to be resolved
yield blocking_promise
print(f"workflow {workflow_id} unblocked, promise resolved")
return {"message": f"workflow {workflow_id} completed"}
# from example-human-in-the-loop-py/src/worker.py:15-23The workflow is a generator. yield ctx.promise() creates a durable promise (the id is generated client-side by the SDK's Context counter and then registered with the Resonate server) and returns a Promise handle whose .id is the durable promise id; yield ctx.lfc(send_email, ...) runs the email step as a Local Function Call so its execution is also a durable step; yield blocking_promise is where the generator suspends. Nothing in the worker holds the suspension in memory — the worker's task is freed when the generator yields the promise, and the Resonate server holds the suspended state until the resolve arrives. (The worker's long-poll loop keeps running to pick up later work; it just is not pinned to this suspension.)
The gateway side starts and unblocks the workflow:
@app.route("/start-workflow", methods=["POST"])
def start_workflow_route_handler():
# ...
data = request.get_json()
if "workflow_id" not in data:
return jsonify({"error": "workflow_id is required"}), 400
handle = resonate.options(target="poll://any@worker").begin_rpc(data["workflow_id"], "foo", data["workflow_id"])
# ...
if handle.done():
# ...
return jsonify({"message": handle.result()})
# ...
return jsonify({"message": f"workflow {data["workflow_id"]} started"}), 200
@app.route("/unblock-workflow", methods=["GET"])
def unblock_workflow_route_handler():
# ...
promise_id = request.args.get("promise_id")
if not promise_id:
return jsonify({"error": "promise_id is required"}), 400
# ...
resonate.promises.resolve(id=promise_id, ikey=promise_id)
return jsonify({"message": "workflow unblocked"}), 200
# from example-human-in-the-loop-py/src/gateway.py:11-38 (docstrings + inline comments elided with `# ...`)The begin_rpc call uses the caller-supplied workflow_id as the promise id, so a duplicate POST returns the existing handle rather than starting a parallel run. The unblock-workflow route is a plain HTTP handler — it does not need to know which worker is holding the suspended generator, only the promise id.
The durable primitives in play
Resonate.remote(host=..., group=...)— constructs a client that talks to the Resonate server as both promise store and message source. The gateway usesgroup="gateway"; the worker usesgroup="worker".src/gateway.py:8,src/worker.py:4.@resonate.register()— registersfooas a remotely-invocable workflow. The worker's poller can claim tasks for this name.src/worker.py:15.resonate.options(target="poll://any@worker").begin_rpc(id, "foo", ...)— starts the workflow on any worker in theworkergroup via the long-poll transport. Returns aHandleimmediately. Deduplicates byid: same id reconnects rather than starts a second run.src/gateway.py:19.ctx.promise()— creates a durable promise inside the workflow and returns anRFI. Yielding it returns aPromise(id, cid)dataclass handle whose.idis the durable promise id (the id is minted client-side byContext._next()and then registered with the server). The durable promise lives in the server until resolved.src/worker.py:17.ctx.lfc(fn, *args)— Local Function Call. Runssend_emailas a durable step on the same worker.src/worker.py:18.yield blocking_promise— suspends the workflow generator on the durable promise. The worker frees the task; the server holds the suspension.src/worker.py:21.resonate.promises.resolve(id=..., ikey=...)— resolves the promise from outside the workflow via the promise store. The server then routes the resumption to a worker that can satisfy the original call graph.src/gateway.py:37.handle.done()/handle.result()— read whether the workflow has completed and read the return value. Used in the start route to short-circuit when the sameworkflow_idis replayed after completion.src/gateway.py:21-23.
What the SDK handles vs. what you write
| SDK handles | You write |
|---|---|
Creating the durable promise in the server when ctx.promise() is yielded (id minted by the SDK's Context counter and registered with the server) | The single yield ctx.promise() line |
Suspending the generator on yield blocking_promise and persisting the suspension off-process | The yield blocking_promise line |
Routing the initial invocation to a worker in the worker group, and routing the resumption after promises.resolve(...) based on the call graph — without you re-stating any routing on the resolve side | The poll target string poll://any@worker on the initial begin_rpc call |
Deduplicating duplicate begin_rpc calls on the same workflow_id and returning the existing handle | The choice to key begin_rpc on the human's workflow_id |
Storing the return value so a later begin_rpc with the same id can return it without re-running the workflow | The return value at the end of foo |
| Polling the server for new tasks and routing resumptions to the right group | resonate.start() and Event().wait() in the worker's main |
The workflow body reads as four straight-line statements with three yield points: yield ctx.promise() (src/worker.py:17), yield ctx.lfc(send_email, blocking_promise.id) (src/worker.py:18), and yield blocking_promise (src/worker.py:21). The persistence of the suspension, the routing of the resume, and the deduplication on workflow id are not in the code you write — they are in the SDK plus server.
Failure modes covered
- The human never clicks the link. The workflow stays suspended; the server holds the durable promise. The worker process is free to execute other workflows in the meantime — it is not pinned to this generator.
ctx.promise()is called with notimeoutkwarg, so the SDK defaults the promise's timeout to31536000seconds — roughly one year (resonate/resonate.py:1219atresonate-sdk-pyv0.6.7). Suspensions longer than that expire; suspensions inside that window do not pin process or thread state. - The worker crashes while suspended. The task was already freed when the generator yielded the promise. When a worker in the
workergroup is up and the promise is later resolved, the server routes the resumption to it — the generator is reconstructed from the durable state. Killing one of multiple workers mid-suspension is exactly what the README demonstrates under "Load balancing and recovery" (README.md:65). - The unblock link is clicked twice. The second
GET /unblock-workflowcallsresonate.promises.resolve(id=promise_id, ikey=promise_id)(src/gateway.py:37). The SDK sendsikey=promise_idas the idempotency-key header on the underlyingPATCH /promises/{id}(resonate/stores/remote.py:213-239at v0.6.7); a duplicate resolve with the same(id, ikey)is the standard idempotent path and does not re-resume the workflow. - The start request is sent twice with the same
workflow_id.begin_rpcis keyed onworkflow_id(src/gateway.py:19); the second call reconnects to the existing handle. If the workflow has already completed,handle.done()is true and the route returns the stored result (src/gateway.py:21-23). - The gateway process restarts between start and unblock. The unblock route only needs the promise id from the query string and a Resonate client pointed at the server — it does not hold workflow state. A fresh gateway resolves the promise just as well.
The example does not implement email delivery, signed unblock URLs, or per-human authentication — send_email prints the link to stdout (src/worker.py:7-12). Those are application concerns that sit on top of the durable shape.
When to reach for this pattern
- If a workflow needs to block on arbitrary human response time — minutes to weeks, within the promise's configured (or defaulted) timeout — without holding a process, thread, or open connection.
- If the action that unblocks the workflow arrives over an HTTP webhook, an email link, or a callback from a third party, rather than over an in-process queue.
- If duplicate start requests on the same external id (
workflow_id,order_id,case_id) must deduplicate to a single run and replay the stored result after completion. - If you want multiple workers to load-balance and recover suspended workflows for free, so killing one worker does not lose any in-flight step.
- If the only way to resolve the wait is from outside the workflow process (a different service, a different machine, a different person).
Sources
- Example repo: https://github.com/resonatehq-examples/example-human-in-the-loop-py
- Python SDK repo: https://github.com/resonatehq/resonate-sdk-py — the example pins
resonate-sdk>=0.6.7(pyproject.toml:12). The construction surface (Resonate.remote, thetarget="poll://..."string, the.promises.resolvelow-level path) is the area to re-verify first on a newer SDK release. - Resonate documentation: https://docs.resonatehq.io
- Human-in-the-Loop pattern page: https://docs.resonatehq.io/get-started/examples/human-in-the-loop
- Files cited in this post:
src/worker.py:15-23— thefooworkflow generatorsrc/worker.py:7-12—send_emailstep that prints the unblock linksrc/gateway.py:11-38— the start and unblock HTTP routessrc/gateway.py:8,src/worker.py:4— client construction withResonate.remotepyproject.toml:12— SDK pinREADME.md:65— kill-one-worker recovery instruction- SDK reference for
ctx.promise:resonate/resonate.py:1161-1224inresonate-sdk-pyat tagv0.6.7(default timeout at line 1219; id generated byContext._nextatresonate/resonate.py:1226-1228) - SDK reference for
PromiseStore.resolve:resonate/stores/remote.py:213-239inresonate-sdk-pyat tagv0.6.7
