Long-lived human-in-the-loop step in TypeScript on Resonate

A workflow that needs a human decision — approval, sign-off, click-a-link — has to pause for an arbitrary amount of time without losing its place if the process running it dies. With Resonate, that pause is a yield* on a latent durable promise: a promise that no function backs, that lives on the Resonate server, and that anything reachable on the network can settle by ID. The example-human-in-the-loop-ts repo demonstrates this in two files — a worker that runs the generator workflow and an Express gateway that both starts workflows and settles their blocking promises — pinned to @resonatehq/sdk ^0.10.0.

The shape of the solution

The worker registers foo-workflow on a Resonate client created with group: "workers" (worker.ts:4-7); that group name is what RPC callers target and what the server uses to reassign a suspended workflow if its worker dies.

function* fooWorkflow(ctx: Context, workflowId: string) {
const blockingPromise = yield* ctx.promise({});
yield* ctx.run(sendEmail, blockingPromise.id);
console.log("workflow blocked, waiting on human interaction");
// Wait for the promise to be resolved
const data = yield* blockingPromise;
console.log("workflow unblocked, promise resolved with " + data);
return "foo workflow " + workflowId + " complete";
}

resonate.register("foo-workflow", fooWorkflow);
// from example-human-in-the-loop-ts/worker.ts:16-26

The gateway side is two Express handlers — one starts the workflow by RPC, one settles the latent promise:

app.get("/unblock-workflow", async (req: Request, res: Response) => {
try {
const promiseId = req.query.promise_id;
if (!promiseId || typeof promiseId !== "string") {
return res.status(400).json({ error: "promise_id is required" });
}
const raw = "human_approval";
const data = Buffer.from(JSON.stringify(raw), "utf8").toString("base64");
const result = await resonate.promises.settle(promiseId, "resolved", { data: data });
console.log(result);
return res.status(200).json({ message: "workflow unblocked" });
} catch (e: any) {
return res.status(500).json({
error: "failed_to_unblock_workflow: " + String(e),
});
}
});
// from example-human-in-the-loop-ts/gateway.ts:35-52

The durable primitives in play

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. worker.ts:17.

blockingPromise.id — the server-side promise ID, surfaced so external systems can target it. Passed into sendEmail so the callback link carries it. worker.ts:18.

ctx.run(sendEmail, blockingPromise.id) — durable local function call; checkpointed so the notification side-effect is not re-issued on replay. worker.ts:18.

yield* blockingPromise — suspends the generator workflow until the durable promise settles. If the worker process dies while suspended, the promise stays PENDING on the server; another worker in the workers group recovers the workflow and waits on the same promise. worker.ts:21.

resonate.rpc(workflowId, "foo-workflow", workflowId, resonate.options({ target: "poll://any@workers" })) — RPC into the workers group, keyed by workflowId. Resonate deduplicates by ID: a second call with the same workflowId reconnects to the PENDING execution instead of starting a new one. gateway.ts:21.

resonate.promises.settle(promiseId, "resolved", { data }) — settles the latent promise from outside any workflow context with a base64-encoded JSON payload; this is what unblocks the workflow. gateway.ts:44.

What the SDK handles vs. what you write

You write: the fooWorkflow generator (one ctx.promise, one ctx.run, one yield* to suspend), a leaf sendEmail that prints the callback URL, and two Express handlers (/start-workflow, /unblock-workflow). The full repo is worker.ts (28 lines) and gateway.ts (60 lines). You also hand-encode the settle payload in the gateway — Buffer.from(JSON.stringify(raw), "utf8").toString("base64") (gateway.ts:42-43) — because resonate.promises.settle takes an already-encoded data: string and passes it through to the server unchanged.

The SDK and the Resonate server handle: creating the latent promise record, persisting the promise ID, suspending the worker's generator while keeping the promise PENDING on the server, recovering the workflow onto another worker in the workers group if the suspended one dies, deduplicating RPC by ID so re-invoking with the same workflowId reconnects to the PENDING execution rather than starting a new one, persisting the ctx.run checkpoint so sendEmail is not repeated on replay, decoding the settled value (base64 + JSON via the SDK's codec) before it is bound by yield* blockingPromise, and unblocking the suspended yield* when the promise settles on the server. None of that bookkeeping appears in user code.

Failure modes covered

Worker crashes while suspended on human input. The latent promise lives on the Resonate server (worker.ts:17), not in the worker's memory. After the worker dies, the promise is still PENDING; the server reassigns the workflow to another worker in the workers group, which resumes at yield* blockingPromise on the same promise ID (worker.ts:21).

The gateway receives the same start-workflow request twice. The RPC is keyed on workflow_id from the POST body (gateway.ts:17, gateway.ts:21). The second call reconnects to the PENDING execution started by the first call and awaits the same result rather than starting a parallel workflow.

The human clicks the callback URL after the workflow has already settled. The workflow itself is unaffected — it has already moved past yield* blockingPromise and completed. On the gateway side, the second resonate.promises.settle is a forbidden state transition on the Resonate server; the SDK throws SERVER_ERROR on the non-success response (promises.ts:118-122 in resonate-sdk-ts v0.10.0), the gateway's catch block fires (gateway.ts:47-51), and the HTTP response is 500 { error: "failed_to_unblock_workflow: ..." } rather than the 200 success path. The workflow's completion is durable regardless of how the duplicate click is reported back to the human.

The sendEmail step runs and then the worker crashes before suspending. ctx.run(sendEmail, ...) is a durable checkpoint (worker.ts:18); on recovery the SDK replays from the checkpoint and does not call sendEmail again, so the user does not receive two emails for one workflow.

Long wait, but not unbounded. The example passes an empty options object to ctx.promise({}) (worker.ts:17), so it does not specify a per-call timeout. In @resonatehq/sdk v0.10.0, that resolves to a default 24-hour latent-promise timeout, capped by the parent workflow's own timeout (context.ts:649-663: timeoutAt = Math.min(now + (timeout ?? 24 * util.HOUR), this.info.timeout)); the parent workflow's default is also 24 hours (options.ts:19, 49). To wait longer — for a multi-day approval, say — pass ctx.promise({ timeout }) with the desired window and start the workflow with a matching parent timeout. The pattern is "human-shaped wait", not literally unbounded.

When to reach for this pattern

If a workflow needs to block on a human action (approval, signature, click-to-confirm) whose response time is bounded only by the human, not by a process lifetime.

If you have a long-running approval flow currently implemented as a state machine in a database, polled by a cron job, and you want it to read as straight-line generator code instead.

If a webhook from a third party is the resume signal, and the workflow needs to remain reconnectable across deploys and restarts while it waits.

If multiple workers should be able to recover any suspended workflow, not just the one that started it.

If callers should be able to safely retry start-workflow requests without spawning duplicate executions.

Sources

Example repo: https://github.com/resonatehq-examples/example-human-in-the-loop-ts

Resonate TypeScript SDK: https://github.com/resonatehq/resonate-sdk-ts

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

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

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

resonate.promises.settle(): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/promises.ts#L98

Human-in-the-loop pattern docs: https://docs.resonatehq.io/get-started/examples/human-in-the-loop