Resilient website summarization agent in TypeScript on Resonate

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

The 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-45

The durable primitives in play (and the control flow that wires them)

new Resonate({ url, group }) — connects to the Resonate Server at http://localhost:8001 and joins the named group. The worker process joins "worker"; the gateway joins "client". Group membership is what poll://any@worker routes 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 the worker group via the poll://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 .id field is the server-side ID external systems target. worker.ts:78.

ctx.run(summarize, content) — durable local function call for the Ollama llama3.1 call. 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 the worker group 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 at yield* 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 fresh ctx.promise() and a fresh ctx.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.goto fails transiently. download throws and the SDK catches it out of ctx.run, persists the failure, and retries the step. The browser is launched fresh each retry; the try/catch/finally around 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. download throws "No readable content found on ${url}" (worker.ts:34) and the SDK retries ctx.run(download, url) against the same step. The retry semantics are identical to any other thrown error inside ctx.run.

The Ollama call fails. summarize throws and the SDK retries ctx.run(summarize, content) (worker.ts:79) without re-running the scrape — content is the cached return value of the earlier ctx.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 the worker group recovers the workflow and waits on the same humanApproval.id. The approval link printed by sendEmail still works against the new worker.

The worker crashes between summarize returning and sendEmail running. On recovery the SDK replays from checkpoints: the ctx.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 /summarize twice. The workflow ID is sha256(url) (gateway.ts:19,29-31). Two beginRpc calls 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 new ctx.promise() for the next approval cycle, calls ctx.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.run checkpoints 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 /summarize for 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.settle call 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#L208

ctx.promise(): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts#L531

resonate.beginRpc(): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts#L380

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