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
✓ PassedNo 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.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
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 →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 whenTis inferable from downstream use, otherwise.awaitrequiresT: 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.awaitserde_json::json!+&impl Serialize—.data()takes any serializable;json!macro is the fastest path for ad-hoc payloads. ReturnsResultbecause serialization can fail — handle the?RemoteFuture— the handle returned by.create(). ImplementsFuture>; 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 useserde_json::Valuefor 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/cancelfrom the webhook sideresonate-basic-durable-world-usage-rust—ctx.promise::()builder detailsresonate-saga-pattern-rust— sagas with human-gated forward stepsresonate-human-in-the-loop-pattern-typescript/-python— sibling SDKs for comparisondurable-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.
- Author: resonatehq
- Source: resonatehq/resonate-skills
- License: Apache-2.0
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.