Human-in-the-loop summarization agent in Python on Resonate

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:13

The 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 :53

The 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; group decides which poll:// target a process listens on. src/app.py:8, src/gateway.py:8.

@resonate.register — registers downloadAndSummarize as a top-level durable function the worker will pick up off the worker poll queue. src/app.py:13.

ctx.lfc(download, ...).options(durable=False, non_retryable_exceptions=(NetworkResolutionError,)) — local function call. durable=False means the SDK does not checkpoint this call's result (the scraped page is large and reproducible from the URL); non_retryable_exceptions marks 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, then src/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 the worker group; returns a Handle immediately. Same workflow_id reconnects 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's yield promise unblocks 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 summarize and send_email resumes by re-invoking download (not durable, so the function runs again — though its os.path.exists guard short-circuits before re-driving Selenium) and replaying summarize from 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 the worker group.

Applies the default retry policy (exponential) to lfc calls, except where non_retryable_exceptions opts out.

Deduplicates: begin_rpc(f"downloadAndSummarize-{usable_id}", ...) with the same usable_id will 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 download and summarize. On restart, the workflow replays. download is re-invoked (because .options(durable=False) skipped the checkpoint), but the if os.path.exists(filename): return filename early-return at src/app.py:47-49 short-circuits before Selenium runs, so the URL is not re-scraped; summarize then runs as if it were the first time. src/app.py:20, src/app.py:23.

Worker crashes between summarize and send_email. The summary is checkpointed (default durable=True), so summarize is not re-invoked against Ollama; send_email runs 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 worker group polls again the workflow resumes at the yield promise point 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 at src/app.py:62 raises NetworkResolutionError on src/app.py:63, and that exception type is listed in non_retryable_exceptions on the lfc options, 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's confirmed = yield promise returns False, the if confirmed: break is skipped, and the while True loop runs summarize again on the same filename. src/gateway.py:53, src/app.py:32.

Same URL submitted twice while the first run is still pending. begin_rpc is keyed by f"downloadAndSummarize-{usable_id}", so a duplicate POST to /summarize reconnects 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-sdk v0.6.7, pinned in pyproject.toml): https://github.com/resonatehq/resonate-sdk-py

SDK source for the primitives used:

Resonate.remote and begin_rpc: https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/resonate.py

ctx.lfc, ctx.promise: same file, methods on Context

resonate.promises.resolve: https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/stores/remote.py

Options.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