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

Resonate Human In The Loop Pattern Python

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

Implement human-in-the-loop workflows in Python — durable functions that suspend on ctx.promise() until an external actor (webhook, UI, CLI, operator) resolves or rejects. Use when a workflow must wait on an action that doesn't originate from another worker, such as approval gates, manual review, or out-of-band data.

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

Install

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

✓ 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 Used
  • ✓ 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-python)

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 Python? 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 — Python

Overview

A human-in-the-loop workflow suspends on a durable promise that is resolved (or rejected) by something outside the Resonate worker set — a person clicking an Approve button, a webhook from a third-party system, an operator running a CLI command. The worker doesn't poll or sleep; it opens a ctx.promise() and awaits it, and Resonate wakes it when the promise settles.

When to use

  • Approval gates in business workflows (expense approval, content moderation, deploy gate)
  • Waiting on third-party callbacks (Stripe webhooks, DocuSign signature events)
  • Operator-driven unblock steps (break-glass in incident runbooks)
  • Any workflow step where the data or decision comes from outside the Resonate worker

Basic shape

from __future__ import annotations
import asyncio, os, time
from typing import TYPE_CHECKING
from resonate.resonate import Resonate
from resonate.types import Value

if TYPE_CHECKING:
    from resonate.context import Context

r = Resonate(url=os.environ.get("RESONATE_URL", "http://localhost:8001"))

async def notify_reviewer(ctx: Context, order_id: str, amount: int, approval_id: str) -> str:
    # Side effects live in leaves — this prints/notifies exactly once
    print(f"  order {order_id} (${amount}) needs review; promise id: {approval_id!r}")
    return approval_id

async def ship_order(ctx: Context, order_id: str) -> str:
    return f"shipped-{order_id}"

async def cancel_order(ctx: Context, order_id: str) -> str:
    return f"canceled-{order_id}"

async def fulfill_order(ctx: Context, order_id: str, amount: int) -> str:
    # Open the approval promise; its id is deterministic (derived from the workflow id)
    approval = ctx.promise()
    approval_id = await approval.id()

    # Notify the reviewer via a leaf (side effects belong in leaves)
    await ctx.run(notify_reviewer, order_id=order_id, amount=amount, approval_id=approval_id)

    # Suspend here — the worker holds no state while waiting
    # This can be seconds, hours, or days; no process needs to stay alive
    decision_raw = await approval

    if decision_raw.get("approve"):
        return await ctx.run(ship_order, order_id=order_id)
    return await ctx.run(cancel_order, order_id=order_id)

From outside the worker (a webhook, a UI route, a CLI):

from resonate.types import Value

# Resolve the promise — wakes the suspended workflow
await r.promises.resolve(approval_id, Value(data={"approve": True, "note": "looks good"}))

# Or reject it
await r.promises.reject(approval_id, Value(data={"reason": "policy violation"}))

r.promises.resolve/reject are async — always await them.

The canonical SDK example

The SDK's examples/human-in-the-loop shows the full pattern with a ReviewerInbox dependency (an asyncio.Future) so the simulated reviewer learns the promise id the instant the leaf creates it — without polling:

import asyncio
from resonate.resonate import Resonate
from resonate.types import Value

class ReviewerInbox:
    def __init__(self) -> None:
        self.approval_id: asyncio.Future[str] = asyncio.get_running_loop().create_future()

    def publish(self, approval_id: str) -> None:
        if not self.approval_id.done():
            self.approval_id.set_result(approval_id)

async def notify_reviewer(ctx, order_id: str, amount: int, approval_id: str) -> str:
    print(f"  order {order_id} (${amount}) pending; id: {approval_id!r}")
    ctx.get_dependency(ReviewerInbox).publish(approval_id)
    return approval_id

async def fulfill_order(ctx, order_id: str, amount: int) -> str:
    approval = ctx.promise()
    approval_id = await approval.id()
    await ctx.run(notify_reviewer, order_id=order_id, amount=amount, approval_id=approval_id)
    decision_raw = await approval
    if decision_raw.get("approve"):
        return await ctx.run(ship_order, order_id=order_id, note=decision_raw.get("note", ""))
    return await ctx.run(cancel_order, order_id=order_id, note=decision_raw.get("note", ""))

async def main() -> None:
    r = Resonate(url="http://localhost:8001")
    inbox = ReviewerInbox()
    r.with_dependency(inbox)
    r.register(fulfill_order)
    try:
        wid = f"fulfill-{time.time_ns()}"
        handle = r.run(wid, fulfill_order, "order-42", 199)

        # Simulated reviewer: wait for the inbox, then resolve
        async def simulate_reviewer() -> None:
            approval_id = await inbox.approval_id
            await r.promises.resolve(approval_id, Value(data={"approve": True, "note": "ok"}))

        reviewer = asyncio.create_task(simulate_reviewer())
        out = await handle.result()
        await reviewer
        print(f"OK: {out}")
    finally:
        await r.stop()

asyncio.run(main())

Promise IDs are deterministic

The orchestrator and the external resolver need to agree on the promise ID. When you call ctx.promise() without an explicit id, the SDK assigns a deterministic id derived from the workflow's own promise id (e.g., {workflow_id}.1 for the first promise). Your leaf captures and publishes this id so the external resolver knows where to settle.

If you need a portable, human-readable id, pass a keyword argument (not yet in the v0.7.0 public API for ctx.promise; derive it from workflow inputs and embed it in the leaf instead). The safe pattern is always: open the promise, await its id, pass the id to a leaf for notification.

Passing context to the resolver

Pass data to the resolver via a leaf that publishes the promise id alongside any routing metadata:

async def notify_for_travel(
    ctx: Context, traveler_id: str, destination: str, approval_id: str
) -> str:
    # In a real system: Slack message, email, dashboard row
    print(f"  travel approval needed: {traveler_id} → {destination} [{approval_id}]")
    return approval_id

The reviewer sees the id and calls r.promises.resolve(approval_id, Value(data=decision)).

Webhook-driven resolution

A typical FastAPI webhook route that resolves a promise:

from fastapi import FastAPI, Request
from resonate.resonate import Resonate
from resonate.types import Value

app = FastAPI()
r = Resonate(url="http://localhost:8001")

@app.post("/webhooks/docusign")
async def docusign_callback(req: Request) -> dict:
    body = await req.json()
    envelope_id = body["envelopeId"]
    status = body["status"]

    if status == "completed":
        await r.promises.resolve(
            f"docusign:{envelope_id}",
            Value(data={"signed": True, "signed_at": body["completedAt"]}),
        )
    elif status in {"declined", "voided"}:
        await r.promises.reject(
            f"docusign:{envelope_id}",
            Value(data={"reason": status}),
        )

    return {"ok": True}

Note: the worker that is awaiting ctx.promise() is woken as soon as the webhook resolves.

Multiple human approvers (parallel)

Fan out to independent approvers; wait for all to respond:

async def multi_approver(ctx: Context, request_id: str, count: int) -> dict:
    # Open N approval promises in sequence (each gets a deterministic id)
    approvals = [ctx.promise() for _ in range(count)]
    ids = [await a.id() for a in approvals]

    # Notify each approver via a leaf
    for i, approval_id in enumerate(ids):
        await ctx.run(notify_approver, request_id, i, approval_id)

    # Await all decisions
    decisions = [await a for a in approvals]
    approved_count = sum(1 for d in decisions if d.get("approve"))

    if approved_count == count:
        return {"status": "approved"}
    return {"status": "rejected", "approved": approved_count, "of": count}

Distinct Python idioms

  • Value(data=...) — r.promises.resolve/reject wrap the payload in a Value object. Import from resonate.types.
  • r.promises.* are all async — always await them.
  • FastAPI async def routes work naturally with await r.promises.resolve(...).
  • ctx.get_dependency(ReviewerInbox) — dependencies are fetched by type, not string key.
  • try/except on await approval — handles timeout/rejection naturally with Python's exception model.

Avoid: polling

Bad:

async def bad_approval(ctx: Context, order_id: str) -> dict:
    # Polling defeats the point
    while True:
        status = await ctx.run(check_approval_status, order_id)
        if status:
            break
        await ctx.sleep(timedelta(seconds=30))
    return {"status": "approved"}

ctx.promise() gives you wake-on-event; polling wastes checkpoints and loses the ability to pass decision data into the workflow.

Related skills

  • resonate-basic-ephemeral-world-usage-python — r.promises.resolve/reject/get from the ephemeral world (webhook handler side)
  • resonate-basic-durable-world-usage-python — ctx.promise, ctx.get_dependency
  • resonate-saga-pattern-python — sagas with human-gated steps
  • durable-execution — foundational replay semantics; promise-wait survives crashes

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.