Long-lived human-in-the-loop step in Python with an HTTP gateway

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-23

The 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 uses group="gateway"; the worker uses group="worker". src/gateway.py:8, src/worker.py:4.

@resonate.register() — registers foo as 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 the worker group via the long-poll transport. Returns a Handle immediately. Deduplicates by id: same id reconnects rather than starts a second run. src/gateway.py:19.

ctx.promise() — creates a durable promise inside the workflow and returns an RFI. Yielding it returns a Promise(id, cid) dataclass handle whose .id is the durable promise id (the id is minted client-side by Context._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. Runs send_email as 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 same workflow_id is 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 no timeout kwarg, so the SDK defaults the promise's timeout to 31536000 seconds — roughly one year (resonate/resonate.py:1219 at resonate-sdk-py v0.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 worker group 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-workflow calls resonate.promises.resolve(id=promise_id, ikey=promise_id) (src/gateway.py:37). The SDK sends ikey=promise_id as the idempotency-key header on the underlying PATCH /promises/{id} (resonate/stores/remote.py:213-239 at 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_rpc is keyed on workflow_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, the target="poll://..." string, the .promises.resolve low-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 — the foo workflow generator

src/worker.py:7-12 — send_email step that prints the unblock link

src/gateway.py:11-38 — the start and unblock HTTP routes

src/gateway.py:8, src/worker.py:4 — client construction with Resonate.remote

pyproject.toml:12 — SDK pin

README.md:65 — kill-one-worker recovery instruction

SDK reference for ctx.promise: resonate/resonate.py:1161-1224 in resonate-sdk-py at tag v0.6.7 (default timeout at line 1219; id generated by Context._next at resonate/resonate.py:1226-1228)

SDK reference for PromiseStore.resolve: resonate/stores/remote.py:213-239 in resonate-sdk-py at tag v0.6.7