resonate-human-in-the-loop-pattern-typescript — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited resonate-human-in-the-loop-pattern-typescript (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
SDK version: This skill reflects @resonatehq/sdk v0.10.0 (current on npm).The Human-in-the-Loop (HITL) pattern enables workflows to pause execution and wait for human input—decisions, approvals, reviews, or interventions. The workflow suspends (not blocks resources) and resumes exactly where it left off when the human responds, whether that's seconds, hours, or days later.
Core mechanism: Create a durable promise with an explicit ID, communicate that ID to a human (via email, UI, webhook), and yield* await the promise until it's externally resolved.
Workflow Human
│ │
├─ Create promise (ID: "approval-123")
├─ Send email with links │
│ (accept_link, reject_link) │
│ │
├─ yield* promise │
│ [SUSPENDED - not consuming │
│ resources, durable state] │
│ │
│ ├─ Click "Approve"
│ ├─ HTTP POST resolves promise
│ [RESUMES from checkpoint] │
│ │
├─ Process approval decision
└─ Complete workflowfunction* approvalWorkflow(ctx: Context, orderId: string) {
// Create promise with EXPLICIT ID so external resolver can find it
const approvalPromise = yield* ctx.promise<Decision>({
id: `approval/${orderId}`,
timeout: 24 * 60 * 60 * 1000 // 24 hours
});
// Continue...
}Why explicit ID? The human (or webhook) needs to know which promise to resolve. Auto-generated IDs are deterministic but not communicable.
function* approvalWorkflow(ctx: Context, orderId: string) {
const approvalPromise = yield* ctx.promise<Decision>({
id: `approval/${orderId}`
});
// Send email with accept/reject links containing promise ID
yield* ctx.run(sendApprovalEmail, orderId, approvalPromise.id);
// Continue...
}
async function sendApprovalEmail(_ctx: Context, orderId: string, promiseId: string) {
const acceptLink = `https://example.com/approve/${promiseId}?action=accept`;
const rejectLink = `https://example.com/approve/${promiseId}?action=reject`;
await emailService.send({
to: "[email protected]",
subject: `Approval needed for order ${orderId}`,
body: `Accept: ${acceptLink}\nReject: ${rejectLink}`
});
}Alternative: Store promise ID in database for UI-based workflows.
function* approvalWorkflow(ctx: Context, orderId: string) {
const approvalPromise = yield* ctx.promise<Decision>({
id: `approval/${orderId}`,
timeout: 24 * 60 * 60 * 1000
});
yield* ctx.run(sendApprovalEmail, orderId, approvalPromise.id);
// SUSPEND HERE - workflow pauses until promise resolves
const decision = yield* approvalPromise;
// RESUMES HERE when human responds
if (decision.approved) {
yield* ctx.run(processOrder, orderId);
return { status: "approved", orderId };
} else {
yield* ctx.run(cancelOrder, orderId);
return { status: "rejected", orderId, reason: decision.reason };
}
}// In Express route handler or webhook
app.post("/approve/:promiseId", async (req, res) => {
const { promiseId } = req.params;
const { action } = req.query;
const decision = {
approved: action === "accept",
timestamp: Date.now(),
approver: req.user?.email
};
// CRITICAL: Base64 encode data for Resonate server
const encodedData = Buffer.from(JSON.stringify(decision)).toString('base64');
await resonate.promises.settle(promiseId, "resolved", {
data: encodedData,
});
res.json({ status: "recorded" });
});Note: The Resonate server expects base64-encoded data. The SDK automatically decodes it when the workflow receives it.
import { Resonate, type Context } from "@resonatehq/sdk";
import express from "express";
const resonate = new Resonate({
url: "http://localhost:8001",
group: "workflows"
});
// Workflow: Create order and await approval
function* createOrderWithApproval(ctx: Context, orderData: any) {
// 1. Create order record
const order = yield* ctx.run(createOrderRecord, orderData);
// 2. Create approval promise
const approvalPromise = yield* ctx.promise<ApprovalDecision>({
id: `approval/${order.id}`,
timeout: 48 * 60 * 60 * 1000 // 48 hours
});
// 3. Send approval request
yield* ctx.run(sendApprovalRequest, order, approvalPromise.id);
// 4. SUSPEND and wait for human decision
try {
const decision = yield* approvalPromise;
// 5. Process based on decision
if (decision.approved) {
yield* ctx.run(chargePayment, order);
yield* ctx.run(createShipment, order);
yield* ctx.run(sendConfirmation, order, decision.approver);
return { status: "approved", order };
} else {
yield* ctx.run(cancelOrder, order);
yield* ctx.run(sendRejectionNotice, order, decision.reason);
return { status: "rejected", order, reason: decision.reason };
}
} catch (error) {
// Promise timed out or was rejected
yield* ctx.run(expireOrder, order);
return { status: "expired", order };
}
}
// Helper functions
async function createOrderRecord(_ctx: Context, data: any) {
// Create DB record
return { id: `order-${Date.now()}`, ...data, status: "pending" };
}
async function sendApprovalRequest(_ctx: Context, order: any, promiseId: string) {
const acceptLink = `http://localhost:3000/approve/${promiseId}?action=accept`;
const rejectLink = `http://localhost:3000/approve/${promiseId}?action=reject`;
await emailService.send({
to: "[email protected]",
subject: `Order approval needed: ${order.id}`,
html: `
<p>Order ${order.id} requires approval.</p>
<p>Amount: $${order.total}</p>
<p><a href="${acceptLink}">Approve</a> | <a href="${rejectLink}">Reject</a></p>
`
});
}
// Express routes for human interaction
const app = express();
app.post("/orders", async (req, res) => {
const orderId = `order-${Date.now()}`;
await resonate.beginRun(
orderId,
createOrderWithApproval,
req.body
);
res.status(202).json({ orderId });
});
app.get("/approve/:promiseId", async (req, res) => {
const { promiseId } = req.params;
const { action } = req.query;
const decision = {
approved: action === "accept",
approver: "[email protected]",
timestamp: Date.now(),
reason: action === "reject" ? "Budget exceeded" : null
};
const encoded = Buffer.from(JSON.stringify(decision)).toString('base64');
await resonate.promises.settle(promiseId, "resolved", { data: encoded });
res.send(`Decision recorded: ${action}`);
});
resonate.register(createOrderWithApproval);
app.listen(3000);function* multiStageApproval(ctx: Context, orderId: string) {
// Stage 1: Manager approval
const managerPromise = yield* ctx.promise({
id: `approval/${orderId}/manager`
});
yield* ctx.run(sendManagerApproval, orderId, managerPromise.id);
const managerDecision = yield* managerPromise;
if (!managerDecision.approved) {
return { status: "rejected", stage: "manager" };
}
// Stage 2: Finance approval
const financePromise = yield* ctx.promise({
id: `approval/${orderId}/finance`
});
yield* ctx.run(sendFinanceApproval, orderId, financePromise.id);
const financeDecision = yield* financePromise;
if (!financeDecision.approved) {
return { status: "rejected", stage: "finance" };
}
return { status: "approved", stages: ["manager", "finance"] };
}function* parallelApproval(ctx: Context, orderId: string) {
// Create promises for multiple approvers
const alice = yield* ctx.promise({ id: `approval/${orderId}/alice` });
const bob = yield* ctx.promise({ id: `approval/${orderId}/bob` });
const carol = yield* ctx.promise({ id: `approval/${orderId}/carol` });
// Send requests to all
yield* ctx.run(sendApprovalRequests, orderId, [
alice.id,
bob.id,
carol.id
]);
// Race: first to respond wins
// Note: Resonate doesn't have built-in race() yet, so implement via timeout polling
const aliceFuture = alice;
const bobFuture = bob;
const carolFuture = carol;
// For now, await first (or implement custom race logic)
const decision = yield* aliceFuture;
return { status: decision.approved ? "approved" : "rejected", approver: "alice" };
}function* approvalWithRetry(ctx: Context, orderId: string, maxAttempts: number = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const promise = yield* ctx.promise({
id: `approval/${orderId}/attempt-${attempt}`,
timeout: 24 * 60 * 60 * 1000
});
yield* ctx.run(sendApprovalRequest, orderId, promise.id, attempt);
try {
const decision = yield* promise;
if (decision.approved) {
return { status: "approved", attempt };
}
} catch (error) {
// Timeout or rejection
if (attempt === maxAttempts) {
return { status: "failed", attempts: maxAttempts };
}
// Continue to next attempt
}
}
}A complete workflow that creates a record, emails each participant an accept/reject link, suspends until every participant responds (or times out), and returns the collective decision:
function* approvalWorkflow(ctx: Context, input: ApprovalInput) {
// Create approval record in database
const approval = yield* ctx.rpc("dbCreateApproval", input, ctx.options({
target: "poll://any@database-service"
}));
// Create promises for each participant
const promises = [];
for (const [index, promiseId] of input.promise_ids.entries()) {
const email = input.participant_emails[index];
const handle = yield* ctx.promise({
id: promiseId,
timeout: input.participant_timeout_ms,
tags: { approval_id: input.approval_id, participant_email: email }
});
promises.push({ promiseId, handle });
// Send email with accept/reject links
yield* ctx.rpc("sendApprovalEmail", {
participant_email: email,
promise_id: promiseId,
approval_name: input.approval_name
}, ctx.options({ target: "poll://any@emailer-service" }));
}
// SUSPEND: Workflow pauses here for hours/days
// Participants click links, resolve promises externally
const results = [];
for (const { handle } of promises) {
const decision = yield* handle;
results.push({ accept: decision?.data?.accept === true });
}
// All promises resolved, continue workflow
const rejected = results.find(r => !r.accept);
yield* ctx.rpc("dbUpdateApprovalStatus", approval.approval_id, "resolved");
return rejected
? { status: "REJECTED", results }
: { status: "ACCEPTED", results };
}Determinism is critical. Promise IDs must be reproducible on replay:
// ❌ BAD - Non-deterministic
const promise = yield* ctx.promise({
id: `approval/${Date.now()}` // Different on replay!
});
// ❌ BAD - Non-deterministic
const promise = yield* ctx.promise({
id: `approval/${Math.random()}` // Different on replay!
});
// ✅ GOOD - Deterministic
const promise = yield* ctx.promise({
id: `approval/${orderId}` // Same on replay
});
// ✅ GOOD - Deterministic with counter
let attempt = 1;
const promise = yield* ctx.promise({
id: `approval/${orderId}/attempt-${attempt}`
});
// ✅ BEST - Auto-generated (if no external resolution needed)
const promise = yield* ctx.promise(); // Resonate generates deterministic IDAlways set timeouts for HITL promises to prevent indefinite suspension.
IMPORTANT: Timeout values differ between SDK and HTTP API:
| Context | Timeout Format | Example |
|---|---|---|
SDK (ctx.promise()) | Duration in milliseconds | 48 * 60 * 60 * 1000 (48 hours) |
HTTP API (POST /promises) | Absolute epoch milliseconds | Date.now() + (48 * 60 * 60 * 1000) |
// SDK usage - duration from now
function* approvalWithTimeout(ctx: Context, orderId: string) {
const promise = yield* ctx.promise({
id: `approval/${orderId}`,
timeout: 48 * 60 * 60 * 1000 // 48 hours (duration)
});
yield* ctx.run(sendApprovalRequest, orderId, promise.id);
try {
const decision = yield* promise;
return { status: "approved" };
} catch (error) {
// Timeout occurred
yield* ctx.run(handleTimeout, orderId);
return { status: "timeout" };
}
}
// HTTP API usage - absolute timestamp
const response = await fetch(`${RESONATE_URL}/promises`, {
method: "POST",
body: JSON.stringify({
id: `approval/${orderId}`,
timeout: Date.now() + (48 * 60 * 60 * 1000) // 48 hours from NOW (absolute)
})
});For UI-based workflows, store promise IDs in database:
function* uiApprovalWorkflow(ctx: Context, orderId: string) {
const promise = yield* ctx.promise({
id: `approval/${orderId}`
});
// Store in database for UI to query
yield* ctx.run(async () => {
await db.from("pending_approvals").insert({
order_id: orderId,
promise_id: promise.id,
status: "pending",
created_at: new Date().toISOString()
});
});
const decision = yield* promise;
// Update database
yield* ctx.run(async () => {
await db.from("pending_approvals")
.update({ status: "resolved", resolved_at: new Date().toISOString() })
.eq("promise_id", promise.id);
});
return decision;
}// ❌ WRONG - Resonate server expects base64
await resonate.promises.settle(promiseId, "resolved", { data: { approved: true } as any });
// ✅ CORRECT
const data = Buffer.from(JSON.stringify({ approved: true })).toString('base64');
await resonate.promises.settle(promiseId, "resolved", { data });// ❌ WRONG
const promise = yield* ctx.promise({
id: `approval-${Date.now()}`
});
// ✅ CORRECT
const promise = yield* ctx.promise({
id: `approval-${orderId}`
});// ❌ WRONG - Can hang forever
const promise = yield* ctx.promise({
id: `approval/${orderId}`
});
// ✅ CORRECT
const promise = yield* ctx.promise({
id: `approval/${orderId}`,
timeout: 24 * 60 * 60 * 1000
});When to use HITL pattern:
When NOT to use:
ctx.sleep())The Human-in-the-Loop pattern enables workflows to:
Core recipe: Create promise with explicit ID → Communicate ID → Await promise → Process decision
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.