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

Resonate Human In The Loop Pattern Rust

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

Implement human-in-the-loop workflows in Rust with Resonate — durable functions that block on ctx.promise::<T>() until an external actor (webhook, UI, CLI, operator) resolves via resonate.promises.resolve/reject. Use when a Rust workflow step must wait on a decision or data that doesn't come from another worker. v0.1.0 caveat: ctx.promise is in sdk-rs source but not yet in rust.mdx.

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

Install

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

✓ 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-rust)

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

> v0.1.0 caveat. ctx.promise::() is a real pub fn in the Rust SDK source (resonate-sdk-rs:resonate/src/context.rs:352) with a full PromiseTask builder, but it is not yet covered in docs/develop/rust.mdx as of April 2026. The API is safe to use; cite source paths when reviewers ask. APIs may shift between v0.x releases.

Overview

A human-in-the-loop workflow blocks a durable function on a promise that an external actor settles — a reviewer clicking "approve," a webhook firing from a third-party system, an operator running a CLI command. The worker doesn't poll; it awaits the PromiseTask future and Resonate parks the execution until the promise settles.

For the language-agnostic mental model, see resonate-human-in-the-loop-pattern-typescript. The Rust shape differs in (a) type-parameterized promises ctx.promise::(), (b) lazy builder with .timeout / .data / .id / .create / .await, (c) SDK-generated IDs you fetch via .id().await?.

When to use

  • Approval gates (expense, deploy, content moderation)
  • Third-party callbacks (Stripe, DocuSign, Twilio)
  • Operator unblock steps (break-glass in runbooks)
  • Any step where the data or decision comes from outside the Resonate worker set

Basic shape

use resonate::prelude::*;
use serde::{Serialize, Deserialize};
use std::time::Duration;

#[derive(Serialize, Deserialize)]
struct Decision {
    approved: bool,
    reviewer: String,
}

#[resonate::function]
async fn expense_approval(ctx: &Context, expense_id: String) -> Result {
    // build a promise with a 24-hour SLA and the expense ID attached as data
    let task = ctx
        .promise::()
        .timeout(Duration::from_secs(24 * 60 * 60))
        .data(&serde_json::json!({ "expense_id": expense_id }))?;

    // fetch the SDK-generated promise ID so an external actor can target it
    let promise_id = task.id().await?;

    // stash the ID where the reviewer UI / webhook will see it
    ctx.run(save_approval_id, (expense_id.clone(), promise_id.clone())).await?;

    // block until someone resolves/rejects/cancels the promise
    let decision: Decision = task.await?;

    if decision.approved {
        ctx.run(process_reimbursement, expense_id.clone()).await?;
        Ok(format!("approved by {}", decision.reviewer))
    } else {
        Ok(format!("rejected"))
    }
}

async fn save_approval_id((expense_id, promise_id): (String, String)) -> Result {
    // write (expense_id, promise_id) to a DB or queue a notification
    Ok(())
}

async fn process_reimbursement(expense_id: String) -> Result {
    Ok(())
}

From outside the worker — a webhook, CLI, or admin UI — resolve it via the ephemeral-world Resonate client using the fetched promise_id:

use serde_json::json;

// in an Axum/Actix handler, or a separate admin CLI
let resonate = Resonate::new(ResonateConfig::default());
resonate
    .promises
    .resolve(&promise_id, json!({ "approved": true, "reviewer": "alice@acme.io" }))
    .await?;

// or reject:
resonate
    .promises
    .reject(&promise_id, json!({ "reviewer": "bob@acme.io" }))
    .await?;

// or cancel (settles as rejected_canceled):
resonate.promises.cancel(&promise_id, json!(null)).await?;

The resolved JSON is deserialized into the T you type-parameterized the promise with — Decision in this example.

Eagerly create, hand off the future

If the call graph wants to create the promise now but await it later (e.g., after kicking off downstream work), use .create() to get a RemoteFuture handle:

#[resonate::function]
async fn approve_and_also_work(ctx: &Context, job_id: String) -> Result {
    // eagerly create the promise record so downstream code can see its ID
    let approval: RemoteFuture = ctx
        .promise::()
        .timeout(Duration::from_secs(3600))
        .create()
        .await?;

    // do other durable work in parallel
    ctx.run(send_reviewer_notification, job_id.clone()).await?;

    // later, block on the human
    let decision = approval.await?;
    Ok(decision)
}

This matches the TS ctx.promise() → yield promise split into two calls, giving the parent function latitude to do other work between creation and await.

Webhook-driven resolution (Axum)

A typical third-party-callback route:

use axum::{extract::Path, Json, routing::post, Router};
use serde_json::Value;

async fn docusign_webhook(
    Path(envelope_id): Path,
    Json(body): Json,
) -> axum::http::StatusCode {
    let resonate = Resonate::new(ResonateConfig::default());
    let status = body.get("status").and_then(|v| v.as_str()).unwrap_or("");

    let promise_id = format!("docusign:{}", envelope_id);

    match status {
        "completed" => {
            let _ = resonate
                .promises
                .resolve(&promise_id, serde_json::json!({ "signed": true }))
                .await;
        }
        "declined" | "voided" => {
            let _ = resonate
                .promises
                .reject(&promise_id, serde_json::json!({ "reason": status }))
                .await;
        }
        _ => {}
    }

    axum::http::StatusCode::OK
}

pub fn app() -> Router {
    Router::new().route("/webhooks/docusign/:envelope_id", post(docusign_webhook))
}

The durable function that awaits the DocuSign promise would have created it with ctx.promise().timeout(...).create().await? earlier — once the webhook hits, the worker's .await? unblocks and the workflow continues.

Multi-approver fan-out (all must approve)

#[resonate::function]
async fn multi_approver(ctx: &Context, request_id: String) -> Result {
    let approvers = vec!["alice", "bob", "carol"];

    // create one promise per approver, fetch IDs in parallel
    let mut tasks = Vec::with_capacity(approvers.len());
    for approver in &approvers {
        let task = ctx
            .promise::()
            .timeout(Duration::from_secs(48 * 60 * 60))
            .data(&serde_json::json!({ "approver": approver, "request": request_id }))?;
        let id = task.id().await?;
        ctx.run(notify_approver, (approver.to_string(), id.clone())).await?;
        tasks.push(task);
    }

    // block on every promise in order; any rejection propagates
    let mut approvals = Vec::with_capacity(tasks.len());
    for task in tasks {
        approvals.push(task.await?);
    }

    if approvals.iter().all(|d| d.approved) {
        Ok("approved".into())
    } else {
        Ok("rejected".into())
    }
}

async fn notify_approver((approver, promise_id): (String, String)) -> Result {
    // send email, Slack, or queue a UI task
    Ok(())
}

"First-to-respond wins" races aren't directly supported — there's no select! equivalent on PromiseTask yet. Workaround: spawn a helper workflow per approver and have the first to resolve mark a shared promise via resonate.promises.resolve from outside the Resonate execution layer.

Distinct Rust idioms

  • Typed promises PromiseTask — ctx.promise::() makes the return type explicit at creation; no turbofish needed when T is inferable from downstream use, otherwise .await requires T: DeserializeOwned
  • .id().await? before .await? — the SDK generates IDs; fetch via .id() if you need to stash it before blocking. If you don't need the ID, skip this step and go straight to .await
  • serde_json::json! + &impl Serialize — .data() takes any serializable; json! macro is the fastest path for ad-hoc payloads. Returns Result because serialization can fail — handle the ?
  • RemoteFuture — the handle returned by .create(). Implements Future>; await when ready
  • Axum/Actix webhook handlers — fire-and-return-200 is the right shape; the SDK's .resolve() is async but webhook handlers shouldn't block the incoming request on SDK internals beyond network latency

Avoid

  • Polling via ctx.sleep + a status check — defeats the durable-await semantics; costs checkpoints + wall-clock time
  • Using the promise ID outside serde-compatible payloads — if an external actor constructs a custom JSON payload that doesn't deserialize to your typed T, .await? returns a deserialization error. Match the schema on both sides or use serde_json::Value for flexibility
  • Assuming promise IDs are stable across invocations — the SDK generates them per-invocation within the call graph. A replay reuses the same ID for the same logical promise (idempotency), but you cannot hand-craft IDs like you could in TS/Python's ctx.promise(id=...) at v0.1.0

Related skills

  • resonate-basic-ephemeral-world-usage-rust — resonate.promises.create/resolve/reject/cancel from the webhook side
  • resonate-basic-durable-world-usage-rust — ctx.promise::() builder details
  • resonate-saga-pattern-rust — sagas with human-gated forward steps
  • resonate-human-in-the-loop-pattern-typescript / -python — sibling SDKs for comparison
  • durable-execution — foundational replay semantics; promise-await 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.