AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified Apache-2.0 Self-run

Resonate Human In The Loop Pattern Typescript

skill-resonatehq-resonate-skills-resonate-human-in-the-loop-pattern-typescript · by resonatehq

Implement human-in-the-loop workflows where durable functions pause for human decisions, approvals, or reviews. Use this pattern for approval gates, manual review workflows, and human-assisted processes.

— No reviews yet
0 installs
32 views
0.0% view→install

Install

$ agentstack add skill-resonatehq-resonate-skills-resonate-human-in-the-loop-pattern-typescript

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • ✓ Prompt-injection patterns
  • ✓ Secret / credential exfiltration
  • ✓ Dangerous shell & filesystem operations
  • ✓ Untrusted network calls
  • ✓ Known-malicious package signatures

What it can access

  • ✓ Network access No
  • ✓ Filesystem access No
  • ✓ Shell / process execution No
  • ✓ Environment & secrets No
  • ✓ Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-resonatehq-resonate-skills-resonate-human-in-the-loop-pattern-typescript)

Reliability & compatibility

✓ Security review passed
0 installs to date
— no reviews yet
● 2mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of Resonate Human In The Loop Pattern Typescript? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

Resonate Human-in-the-Loop Pattern (TypeScript)

> SDK version: This skill reflects @resonatehq/sdk v0.10.0 (current on npm).

Overview

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.

Mental Model

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 workflow

Core Pattern

Step 1: Create Durable Promise with Explicit ID

function* approvalWorkflow(ctx: Context, orderId: string) {
  // Create promise with EXPLICIT ID so external resolver can find it
  const approvalPromise = yield* ctx.promise({
    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.

Step 2: Communicate Promise ID

function* approvalWorkflow(ctx: Context, orderId: string) {
  const approvalPromise = yield* ctx.promise({
    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: "manager@example.com",
    subject: `Approval needed for order ${orderId}`,
    body: `Accept: ${acceptLink}\nReject: ${rejectLink}`
  });
}

Alternative: Store promise ID in database for UI-based workflows.

Step 3: Await Promise

function* approvalWorkflow(ctx: Context, orderId: string) {
  const approvalPromise = yield* ctx.promise({
    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 };
  }
}

Step 4: External Resolution (Ephemeral World)

// 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.

Complete Example: Order Approval

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({
    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: "approver@example.com",
    subject: `Order approval needed: ${order.id}`,
    html: `
      Order ${order.id} requires approval.
      Amount: $${order.total}
      Approve | Reject
    `
  });
}

// 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: "manager@example.com",
    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);

Pattern Variants

Multiple Approvers (Sequential)

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"] };
}

Multiple Approvers (Parallel - Any Approve)

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" };
}

Approval with Retry Loop

function* approvalWithRetry(ctx: Context, orderId: string, maxAttempts: number = 3) {
  for (let attempt = 1; attempt  !r.accept);
  yield* ctx.rpc("dbUpdateApprovalStatus", approval.approval_id, "resolved");

  return rejected
    ? { status: "REJECTED", results }
    : { status: "ACCEPTED", results };
}

Promise ID Strategy

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 ID

Timeout Handling

Always 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)
  })
});

Database Integration

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;
}

Common Pitfalls

1. Forgetting Base64 Encoding

// ❌ 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 });

2. Non-Deterministic Promise IDs

// ❌ WRONG
const promise = yield* ctx.promise({
  id: `approval-${Date.now()}`
});

// ✅ CORRECT
const promise = yield* ctx.promise({
  id: `approval-${orderId}`
});

3. Missing Timeout

// ❌ 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
});

Decision Tree

When to use HITL pattern:

  • Human approval/review required
  • Manual intervention needed
  • External webhook callback expected
  • UI-driven decision workflows
  • Compliance/audit trails needed

When NOT to use:

  • Fully automated decisions
  • Time-based triggers (use ctx.sleep())
  • Polling external APIs (use regular RPC)

Summary

The Human-in-the-Loop pattern enables workflows to:

  • Pause execution indefinitely without consuming resources
  • Resume exactly where they left off when humans respond
  • Handle approvals, reviews, and manual interventions naturally
  • Scale to thousands of concurrent pending decisions
  • Maintain full durability across crashes and restarts

Core recipe: Create promise with explicit ID → Communicate ID → Await promise → Process decision

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.