Human-in-the-loop with durable promises

Pause a Resonate workflow until a human responds — and resume it from any process — using durable promises.

Human in the Loop banner

A workflow blocks on a durable promise, prints a callback URL, and resumes the moment a human resolves it — from any process, any machine, any time.

SDK versions

TypeScript: @resonatehq/sdk v0.10.1 (current). Python: resonate-sdk v0.6.x against the legacy Resonate Server. Rust: 0.4.0, in active development — pulled as a git dependency from resonate-sdk-rs until it ships on crates.io.

The problem#

Real processes wait on humans — refunds, reviews, address confirmations — for seconds to weeks, with the answer arriving over email, webhook, UI, or CLI. Without a runtime that understands suspension, you hand-roll the coordination layer: a queue to park the work, a job to wake it up, a row to track which step you're on, a polling loop somewhere — every shop reinvents it, every implementation has its own bugs.

Resonate's solution#

Yield a Resonate Promise from inside a workflow and execution suspends until that promise resolves. The promise has a stable ID; anything that knows the ID — another process, a CLI, an HTTP handler, a button click — can resolve it. No scheduler, no queue, no external state machine.

Code walkthrough#

Two processes: a worker runs the workflow, a gateway drives HTTP.

The blocking workflow#

The workflow creates a promise, makes its ID visible (here via a sendEmail step that prints a callback URL), then yields the promise. Execution pauses until something resolves it.

worker.ts
function* fooWorkflow(ctx: Context, workflowId: string) {
  // Latent durable promise — resolved externally.
  const blockingPromise = yield* ctx.promise({});

  // Make the promise ID reachable from outside — email, webhook, log, etc.
  yield* ctx.run(sendEmail, blockingPromise.id);
  console.log("workflow blocked, waiting on human interaction");

  // Suspend until the promise resolves. Survives crashes.
  const data = yield* blockingPromise;
  console.log(`workflow unblocked, promise resolved with ${data}`);
  return `foo workflow ${workflowId} complete`;
}

resonate.register("foo-workflow", fooWorkflow);

Resolving the promise#

The gateway exposes two endpoints — one to start a workflow, one to resolve the blocking promise. Resolving the promise unblocks the workflow waiting on that ID, from any process.

gateway.ts
app.post("/start-workflow", async (req, res) => {
  const workflowId = req.body?.workflow_id;
  // Same workflow_id reconnects to a PENDING execution rather than starting a new one.
  const result = await resonate.rpc(
    workflowId,
    "foo-workflow",
    workflowId,
    resonate.options({ target: "poll://any@workers" }),
  );
  res.status(200).json({ message: result });
});

app.get("/unblock-workflow", async (req, res) => {
  const promiseId = req.query.promise_id as string;
  const data = Buffer.from(JSON.stringify("human_approval"), "utf8").toString("base64");
  await resonate.promises.resolve(promiseId, { data });
  res.status(200).json({ message: "workflow unblocked" });
});

Run it locally#

You need the Resonate Server, the worker, and the gateway running.

Clone the repo and install dependencies (uses Bun):

code
git clone https://github.com/resonatehq-examples/example-human-in-the-loop-ts
cd example-human-in-the-loop-ts
bun install

In separate terminals, start the Resonate Server, the worker, and the gateway:

Terminal 1 — Resonate Server
brew install resonatehq/tap/resonate
resonate dev
Terminal 2 — gateway
bun run gateway.ts
Terminal 3 — worker
bun run worker.ts

Start a workflow:

code
curl -X POST http://localhost:5001/start-workflow \
  -H "Content-Type: application/json" \
  -d '{"workflow_id": "hitl-001"}'

The worker prints a callback URL — visit it to unblock the workflow.

Try the recovery story#

Same setup, load balancing and crash recovery included. Run multiple workers in parallel, start several workflows with different IDs, and watch them spread across workers. Kill one mid-block — Resonate reassigns it and the workflow resumes on a survivor.