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-26The 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-52The 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 intosendEmailso 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 theworkersgroup 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 theworkersgroup, keyed byworkflowId. Resonate deduplicates by ID: a second call with the sameworkflowIdreconnects 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 theworkersgroup, which resumes atyield* blockingPromiseon the same promise ID (worker.ts:21). - The gateway receives the same
start-workflowrequest twice. The RPC is keyed onworkflow_idfrom 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* blockingPromiseand completed. On the gateway side, the secondresonate.promises.settleis a forbidden state transition on the Resonate server; the SDK throwsSERVER_ERRORon the non-success response (promises.ts:118-122inresonate-sdk-tsv0.10.0), the gateway'scatchblock fires (gateway.ts:47-51), and the HTTP response is500 { 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
sendEmailstep 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 callsendEmailagain, 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/sdkv0.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 — passctx.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-workflowrequests 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#L531ctx.run(): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts#L276resonate.rpc(): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts#L373resonate.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
