A workflow needs to scrape a URL, hand the text to a local LLM, present the summary to a human, and not move on until the human approves — and if the human rejects, re-summarize and ask again. Resonate models this as a single generator function that yields a durable promise to suspend on the human's decision, and a separate gateway process resolves that promise from an HTTP confirmation route. The example wires a Flask gateway, a Resonate worker that drives Selenium + Beautiful Soup + Ollama, and a Resonate Server in between.
The shape of the solution
@resonate.register
def downloadAndSummarize(ctx, params):
url = params["url"]
usable_id = params["usable_id"]
email = params["email"]
print(f"beginning work on {url}")
# Download the content from the URL and save it to a file
filename = yield ctx.lfc(download, usable_id, url).options(durable=False,non_retryable_exceptions=(NetworkResolutionError,))
while True:
# Summarize the content of the file
summary = yield ctx.lfc(summarize, filename)
# Create Durable Promise to block on confirmation
promise = yield ctx.promise()
# Send email with summary and confirmation/rejection links
yield ctx.lfc(send_email, summary, email, promise.id)
# Wait for summary to be confirmed or rejected / wait for the promise to be resolved
confirmed = yield promise
if confirmed:
break
print("summary was rejected, re-summarizing")
print("summary confirmed, workflow complete.")
return summary
# from example-website-summarization-agent-py/src/app.py:13The matching gateway route invokes the workflow over async RPC and the confirm route resolves the durable promise:
handle = resonate.options(target="poll://any@worker").begin_rpc(
f"downloadAndSummarize-{params['usable_id']}",
"downloadAndSummarize",
params,
)
# ...
resonate.promises.resolve(
id=promise_id,
data=json.dumps(confirm),
)
# from example-website-summarization-agent-py/src/gateway.py:27 and :53The durable primitives in play
Resonate.remote(group="worker")/Resonate.remote(group="gateway")— instantiates a Resonate client wired to a remote Resonate Server as both promise store and message source;groupdecides whichpoll://target a process listens on.src/app.py:8,src/gateway.py:8.@resonate.register— registersdownloadAndSummarizeas a top-level durable function the worker will pick up off theworkerpoll queue.src/app.py:13.ctx.lfc(download, ...).options(durable=False, non_retryable_exceptions=(NetworkResolutionError,))— local function call.durable=Falsemeans the SDK does not checkpoint this call's result (the scraped page is large and reproducible from the URL);non_retryable_exceptionsmarks DNS failures as terminal so the default exponential retry policy gives up immediately rather than burning retries on a permanently broken URL.src/app.py:20.ctx.lfc(summarize, filename)— local function call to the Ollama summarizer; checkpointed by default so the summary text survives a worker crash before the email step runs.src/app.py:23.ctx.promise()— creates a durable promise with no explicit id; the SDK derives one from the workflow context counter ({workflow_id}.{counter}) on the worker and registers it with the Resonate Server. Yielding it (yield promise) suspends the workflow until something outside resolves it.src/app.py:26, thensrc/app.py:32.ctx.lfc(send_email, summary, email, promise.id)— local function call that emits the confirm/reject URLs containing the promise ID. The example prints them; in production this is where you'd send an actual email.src/app.py:29.resonate.options(target="poll://any@worker").begin_rpc(workflow_id, "downloadAndSummarize", params)— gateway-side async RPC. Routes the invocation to any process in theworkergroup; returns aHandleimmediately. Sameworkflow_idreconnects to an existing PENDING execution instead of starting a new one.src/gateway.py:27.resonate.promises.resolve(id=promise_id, data=json.dumps(confirm))— low-level promise-store call that resolves the durable promise from outside the workflow process. The workflow'syield promiseunblocks with the JSON-decoded value.src/gateway.py:53.
What the SDK handles vs. what you write
You write the generator function, the Selenium/BS4 scrape, the Ollama chat call, and the two Flask routes. The SDK handles everything between yields.
Specifically, the SDK does:
- Persists the workflow's state across yields, so a worker crash between
summarizeandsend_emailresumes by re-invokingdownload(not durable, so the function runs again — though itsos.path.existsguard short-circuits before re-driving Selenium) and replayingsummarizefrom its checkpoint (durable, so the saved result returns without re-calling Ollama). - Maintains the durable promise as a row in the Resonate Server. The workflow process can crash, the gateway process can crash, and the promise survives — when both come back, the workflow either picks up where it left off or, if it had already received the resolution, completes.
- Routes invocations via the
target="poll://any@worker"selector to whichever worker is currently polling theworkergroup. - Applies the default retry policy (exponential) to
lfccalls, except wherenon_retryable_exceptionsopts out. - Deduplicates:
begin_rpc(f"downloadAndSummarize-{usable_id}", ...)with the sameusable_idwill not start a second execution; it returns a handle to the existing one.
You do not write: a job queue, a database row to track which step the workflow is on, a polling loop on the gateway side to check whether the human has clicked yet, or retry logic for the LLM call.
Failure modes covered
- Worker crashes between
downloadandsummarize. On restart, the workflow replays.downloadis re-invoked (because.options(durable=False)skipped the checkpoint), but theif os.path.exists(filename): return filenameearly-return atsrc/app.py:47-49short-circuits before Selenium runs, so the URL is not re-scraped;summarizethen runs as if it were the first time.src/app.py:20,src/app.py:23. - Worker crashes between
summarizeandsend_email. The summary is checkpointed (defaultdurable=True), sosummarizeis not re-invoked against Ollama;send_emailruns with the recovered summary.src/app.py:23,src/app.py:29. - Worker crashes while blocked on the human. The durable promise lives in the Resonate Server. The workflow process can be killed entirely; when a worker in the
workergroup polls again the workflow resumes at theyield promisepoint and either keeps waiting or proceeds with the already-resolved value.src/app.py:32. - Permanent DNS failure on the URL. The
if "net::ERR_NAME_NOT_RESOLVED" in str(e):branch atsrc/app.py:62raisesNetworkResolutionErroronsrc/app.py:63, and that exception type is listed innon_retryable_exceptionson thelfcoptions, so Resonate stops retrying and the workflow fails fast instead of consuming the full exponential-backoff budget.src/app.py:20,src/app.py:41-42. - Human rejects the summary. The confirm route resolves the promise with
json.dumps(False); the workflow'sconfirmed = yield promisereturnsFalse, theif confirmed: breakis skipped, and thewhile Trueloop runssummarizeagain on the same filename.src/gateway.py:53,src/app.py:32. - Same URL submitted twice while the first run is still pending.
begin_rpcis keyed byf"downloadAndSummarize-{usable_id}", so a duplicate POST to/summarizereconnects to the in-flight execution rather than spawning a second scrape + summarize.src/gateway.py:27.
When to reach for this pattern
- If a workflow contains an LLM step whose output a human needs to approve before the workflow continues, and the human may take seconds, hours, or days to respond.
- If "rejected" should mean "redo this step with the same inputs and ask again," not "abort the workflow."
- If the approval signal arrives over HTTP from a process that is not the workflow worker (a Flask gateway, a webhook receiver, a button click in a separate UI).
- If you want the LLM call's result to be checkpointed so a crash after the LLM call doesn't re-bill you, but you don't want to checkpoint a multi-megabyte scrape that's cheap to redo.
- If the trigger arrives over HTTP and the caller should not hold the connection open while the LLM runs — the gateway returns immediately after
begin_rpc.
Sources
- Example repo: https://github.com/resonatehq-examples/example-website-summarization-agent-py
- Python SDK repo (
resonate-sdkv0.6.7, pinned inpyproject.toml): https://github.com/resonatehq/resonate-sdk-py - SDK source for the primitives used:
Resonate.remoteandbegin_rpc: https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/resonate.pyctx.lfc,ctx.promise: same file, methods onContextresonate.promises.resolve: https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/stores/remote.pyOptions.durable,Options.non_retryable_exceptions: https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/options.py
- Resonate docs — human-in-the-loop pattern: https://docs.resonatehq.io/get-started/examples/human-in-the-loop
