6 min readResonate HQJust published

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

How a generator-style workflow blocks indefinitely on a ctx.promise() and resumes the moment an external HTTP handler calls resonate.promises.resolve.

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

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 handlesYou 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-processThe 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 sideThe 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 handleThe 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 workflowThe return value at the end of foo
Polling the server for new tasks and routing resumptions to the right groupresonate.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-pythe 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-12send_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