A website summarization agent has to scrape a page, call an LLM, surface the draft to a human, and either return the approved summary or regenerate when rejected — and any one of those steps can fail or outlive a worker process. On Resonate, the whole flow is one generator workflow: scrape and LLM calls are wrapped in ctx.run (durable checkpoints with automatic retry), the human approval is a ctx.promise() (a latent durable promise the gateway settles from outside), and rejection drives a while (true) loop that regenerates the summary against the cached scrape. The gateway entry uses resonate.beginRpc — an RFI (Remote Function Invocation) — to kick the workflow off without holding the connection open. The example-website-summarization-agent-ts repo demonstrates the pattern in two files — worker.ts registers the workflow, gateway.ts exposes HTTP endpoints for invocation and approval — pinned to @resonatehq/sdk ^0.10.0.
The shape of the solution
function* downloadAndSummarize(
ctx: Context,
url: string,
email: string
): Generator<any, string, any> {
console.log("running downloadAndSummarize");
const content = yield* ctx.run(download, url);
while (true) {
const humanApproval = yield* ctx.promise();
const summary = yield* ctx.run(summarize, content);
yield* ctx.run(sendEmail, summary, email, humanApproval.id);
const approval = yield* humanApproval;
if (approval) {
console.log("APPROVED");
return summary;
}
console.log("REJECTED, regenerating...");
}
}
resonate.register("download-and-summarize", downloadAndSummarize);
// from example-website-summarization-agent-ts/worker.ts:70-90The gateway side is two Express handlers — one starts the workflow as an RFI (beginRpc), one settles the latent approval promise — with a clean() helper between them that derives the workflow ID:
app.post("/summarize", async (req: Request, res: Response) => {
try {
const { url, email } = req.body ?? {};
const id = clean(url);
const func = "download-and-summarize";
const options = { target: "poll://any@worker" };
await resonate.beginRpc(id, func, url, email, resonate.options(options));
res.status(200).send("workflow started.");
} catch (e) {
console.error(e);
}
});
function clean(url: string) {
return crypto.createHash("sha256").update(url).digest("hex");
}
app.get("/confirm", async (req: Request, res: Response) => {
try {
const promiseId = req.query.promiseId as string;
const confirm = req.query.confirm as string;
assert(promiseId, "promiseId is required");
assert(confirm, "confirm is required");
await resonate.promises.settle(promiseId, "resolved", { data: confirm });
res.status(200).send("promise resolved.");
} catch (e) {
console.error(e);
return res.status(500).send(e);
}
});
// from example-website-summarization-agent-ts/gateway.ts:16-45The durable primitives in play (and the control flow that wires them)
new Resonate({ url, group })— connects to the Resonate Server athttp://localhost:8001and joins the named group. The worker process joins"worker"; the gateway joins"client". Group membership is whatpoll://any@workerroutes against.worker.ts:7-11,gateway.ts:10-14.resonate.register("download-and-summarize", downloadAndSummarize)— registers the generator workflow under a string name so remote callers can invoke it without holding a function reference.worker.ts:90.resonate.beginRpc(id, func, ...args, resonate.options({ target: "poll://any@worker" }))— fire-and-forget RFI (Remote Function Invocation). Creates a durable promise on the server and routes the invocation to any process in theworkergroup via thepoll://any@<group>address. The gateway returns immediately after the server accepts the invocation.gateway.ts:22.ctx.run(download, url)— durable local function call for the Puppeteer + Cheerio scrape. The returned text is persisted against the workflow's promise store; on replay it returns from cache without re-launching a browser. On thrown error, the SDK retries against the same step.worker.ts:76.ctx.promise()— creates a latent durable promise on the Resonate server with no function backing it. The workflow's continuation is gated on its later settlement; the.idfield is the server-side ID external systems target.worker.ts:78.ctx.run(summarize, content)— durable local function call for the Ollamallama3.1call. Cached on success, retried on thrown error.worker.ts:79.ctx.run(sendEmail, summary, email, humanApproval.id)— durable local function call for the notification side-effect, which carries the latent promise's ID into the approval/rejection links it logs. Checkpointed so it does not re-fire on replay.worker.ts:80.yield* humanApproval— suspends the generator until the latent promise settles. If the worker dies while suspended, the promise stays PENDING on the server and another process in theworkergroup recovers the workflow.worker.ts:81.resonate.promises.settle(promiseId, "resolved", { data: confirm })— settles the latent promise from outside any workflow context. This is what unblocks the workflow atyield* humanApproval.gateway.ts:39.while (true) { ... }(plain JavaScript, not a Resonate primitive) — the control flow that wires the primitives. The loop regenerates the summary by creating a freshctx.promise()and a freshctx.run(summarize, ...)on each iteration; the scrape step (outside the loop) is not redone.worker.ts:77-87.
What the SDK handles vs. what you write
| You write | The SDK handles |
|---|---|
The downloadAndSummarize generator (one ctx.run for scrape, one ctx.promise per approval cycle, two ctx.runs inside the loop, one yield* to suspend on approval) at worker.ts:70-88 | Recording each ctx.run result against the workflow's promise store and returning it from cache on replay without re-invoking download / summarize / sendEmail |
download, summarize, sendEmail as plain async functions with no retry loops, no backoff, no idempotency tokens (worker.ts:13-68) | Catching thrown errors out of ctx.run, persisting the failure, scheduling a retry, and re-invoking the same step against the same arguments |
The workflow ID crypto.createHash("sha256").update(url).digest("hex") derived from the input URL (gateway.ts:19,29-31) | Deduplicating concurrent beginRpc calls with the same ID — a second POST /summarize for the same URL reconnects to the running workflow instead of starting a second one |
target: "poll://any@worker" in resonate.options (gateway.ts:21) | Routing the invocation to any reachable process in the worker group; if the picked worker dies mid-flight, recovery routes it to another |
The Express /confirm handler that forwards confirm and promiseId to resonate.promises.settle (gateway.ts:33-45) | Persisting the settled value to the latent promise, unblocking the suspended yield* humanApproval in the workflow, and surviving across deploys and worker restarts while the promise is PENDING |
while (true) regeneration loop with if (approval) return summary (worker.ts:77-87) | Treating each loop iteration's ctx.promise() as a distinct durable promise with its own server-side ID, so the previous iteration's settled promise has no bearing on the new one |
The workflow body is thirteen lines (worker.ts:75-87). Each ctx.run result is cached, the approval suspension survives worker death, and the workflow ID dedupes triggers — all of that is handled by ctx.run, ctx.promise, yield*, and the workflow ID, not by code in the workflow body.
Failure modes covered
- The Puppeteer launch or
page.gotofails transiently.downloadthrows and the SDK catches it out ofctx.run, persists the failure, and retries the step. The browser is launched fresh each retry; thetry/catch/finallyaround the browser session (worker.ts:17-42) wraps and re-throws the error in the catch (worker.ts:37-39) and closes the browser in the finally (worker.ts:40-42), so the previous attempt's browser is released before the next attempt runs. - The page returns no readable text.
downloadthrows"No readable content found on ${url}"(worker.ts:34) and the SDK retriesctx.run(download, url)against the same step. The retry semantics are identical to any other thrown error insidectx.run. - The Ollama call fails.
summarizethrows and the SDK retriesctx.run(summarize, content)(worker.ts:79) without re-running the scrape —contentis the cached return value of the earlierctx.run(download, url), so retries reuse it. - The worker crashes while waiting on human approval. The latent promise lives on the Resonate server (
worker.ts:78), not in the worker's memory. After the worker dies, the promise stays PENDING; another worker in theworkergroup recovers the workflow and waits on the samehumanApproval.id. The approval link printed bysendEmailstill works against the new worker. - The worker crashes between
summarizereturning andsendEmailrunning. On recovery the SDK replays from checkpoints: thectx.run(summarize, content)result is cached and returned immediately;ctx.run(sendEmail, ...)(worker.ts:80) runs exactly once. The notification side-effect (the example logs the summary and approval/rejection links to stdout —worker.ts:60-67) does not fire twice per regenerated summary. - The same URL is POSTed to
/summarizetwice. The workflow ID issha256(url)(gateway.ts:19,29-31). TwobeginRpccalls with the same ID resolve to the same durable promise on the server; the second call reconnects to the in-flight execution instead of starting a parallel one. - The human rejects the summary. The
if (approval)branch is not taken; the loop continues, creates a newctx.promise()for the next approval cycle, callsctx.run(summarize, content)again to regenerate (against the cached scrape), and sends a fresh email with the new promise ID (worker.ts:82-86). - The Lambda-style execution time limit on a worker process. Because the workflow is reconnectable by ID and the approval gate is a server-side latent promise, an indefinite human wait does not require any one process to stay alive.
When to reach for this pattern
- If you have an agent loop with one or more LLM calls and a human-in-the-loop gate on the output, and you want crash recovery for both the LLM steps and the human wait without writing your own state machine.
- If the agent's deterministic steps (scrape, prepare, call LLM, notify) are naturally expressible as a sequence of
ctx.runcheckpoints whose return values feed each other. - If you want regeneration-on-rejection to be a
while (true)loop in workflow code rather than a manually-coordinated set of records in a database. - If multiple workers in a group should be able to pick up any in-flight agent run, not just the one that started it — including across deploys.
- If callers should be able to safely retry the trigger request (here:
POST /summarizefor the same URL) without spawning duplicate workflows. - If the approval/reject signal arrives over HTTP, a webhook, or any other transport that can issue a
resonate.promises.settlecall by ID.
Sources
- Example repo: https://github.com/resonatehq-examples/example-website-summarization-agent-ts
- Resonate TypeScript SDK: https://github.com/resonatehq/resonate-sdk-ts
ctx.run()(LFC): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts#L208ctx.promise(): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts#L531resonate.beginRpc(): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts#L380resonate.promises.settle(): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/promises.ts#L98- Related example (minimal HITL without the LLM steps): https://github.com/resonatehq-examples/example-human-in-the-loop-ts
