Human-in-the-Loop
Build workflows that pause for human input in Node.js
Side-Effect Steps
Return null from a step and use ctx.sendEvent() for manual routing. This lets you insert asynchronous human review, webhook callbacks, or queue polling before the workflow continues:
wf.addStep("review", ["ReadyForReview"], async (event, ctx) => {
await ctx.set("needs_approval", true);
// In production: wait for webhook, poll queue, etc.
const approved = simulateHumanReview(event);
await ctx.set("approved", approved);
await ctx.sendEvent({ type: "ReviewComplete" });
return null;
});
Important: ctx.sendEvent(), ctx.set(), and ctx.get() are all async — always use await.
Pause and Resume
For long-running human tasks, serialize the workflow state and resume it later:
const handler = await wf.runWithHandler(input);
const snapshot = handler.pause(); // Serialize workflow state
// Later...
const handler2 = await wf.resume(snapshot);
const result = await handler2.result();
This lets you persist the snapshot to a database or message queue and pick the workflow back up in a separate process or after a deployment.
Responding to Input Requests
When a step emits an InputRequestEvent, the workflow auto-pauses and surfaces the event on the handler’s stream. Subscribe to it and call handler.respondToInput(requestId, response) to inject the answer; the workflow resumes with that value as the step result:
const handler = await wf.runWithHandler(input);
await handler.streamEvents((event) => {
if (event.type === "blazen::InputRequestEvent") {
const answer = await prompt(event.prompt);
handler.respondToInput(event.request_id, answer);
}
});
const result = await handler.result();
The streamed event uses serde’s snake_case fields, so read event.request_id, event.prompt, and event.metadata directly off the object. The requestId you pass to respondToInput is the camelCase napi argument name, but the value itself is whatever string the step assigned to request_id.
If you already constructed an InputResponseEvent object elsewhere, prefer respondToInputTyped(event) — it accepts the typed { requestId, response } shape and skips the per-field positional call:
handler.respondToInputTyped({
requestId: event.request_id,
response: { approved: true, note: "looks good" },
});
Both methods borrow the handler (they do not consume it), so you can answer multiple input requests over the lifetime of one workflow run before finally calling handler.result().
The same respondToInput(requestId, response) API is also exposed on WasmWorkflowHandler in the blazen-wasm-sdk package, so browser HITL flows use an identical pattern — see the WASM workflows guide for the in-browser variant.
:::caution[ctx.session and pause/resume]
Values stored via ctx.session.set(...) are deliberately excluded from snapshots. Use ctx.state.set(...) for anything that must survive pause() / resume(), and ctx.session.set(...) for ephemeral values (request IDs, rate-limit counters, caches).
The workflow’s session_pause_policy governs what happens to session entries at pause time:
pickle_or_error(default) — attempt to pickle each session entry into the snapshot; raise a clear error if any entry can’t be serialised.warn_drop— drop session entries from the snapshot and emit a warning. For ephemeral runs.hard_error— refuse to pause if any session entries are in flight.
Also note: on Node, JS object identity through ctx.session is not preserved — session values are routed through serde_json::Value because napi-rs’s Reference<T> is !Send (its Drop must run on the v8 main thread). await ctx.session.get("k") returns a plain object equal to the one you passed in, not the same object. For true identity preservation of live JS objects across steps, use the Python or WASM bindings.
:::