Install
$ agentstack add skill-resonatehq-resonate-skills-resonate-human-in-the-loop-pattern-go ✓ 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 Used
- ✓ 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.
About
Resonate Human-in-the-Loop Pattern — Go
> Pre-release caveat. The Go SDK has no semver-tagged release yet, and — unlike the TypeScript/Rust SDKs — exposes no top-level promises sub-client: external resolution goes through the CLI, the server HTTP API, or the low-level Sender().PromiseSettle (sdk-go issue #28). Every code block here is verified against develop/go.mdx and example-human-in-the-loop-go at SDK commit 22076134651f.
Overview
For the language-agnostic mental model, start with resonate-human-in-the-loop-pattern-typescript. The idea is identical: create a latent durable promise, hand its ID to the external actor who will settle it, and await — the workflow goroutine parks until settlement arrives, surviving any number of crashes or restarts.
The Go-specific difference is in the resolution path. TypeScript has resonate.promises.settle(id, ...). Rust has resonate.promises.resolve(id, ...). Go has neither. The Go SDK has no top-level promises sub-client. To settle a promise from outside the workflow you use one of three mechanisms — listed below in order of preference — and they are not interchangeable with the sibling SDK APIs.
When to use
- Approval gates (budget, deploy, content moderation)
- Third-party webhook callbacks (Stripe, DocuSign, Twilio)
- Operator unblock steps in runbooks
- Any step where the decision or data originates outside the Resonate worker set
Basic shape
Workflow side — ctx.Promise → f.ID() → publish → Await
import (
"context"
"fmt"
"time"
resonate "github.com/resonatehq/resonate-sdk-go"
)
type ReviewRequest struct {
Item string `json:"item"`
Requester string `json:"requester"`
}
// approvalWorkflow parks until an external actor settles the latent promise.
// promiseIDs is a buffered channel (capacity 1) that hands the promise ID to
// whoever resolves it — swap this for a DB write, a notification queue, etc.
func approvalWorkflow(ctx *resonate.Context, req ReviewRequest) (string, error) {
// Create a latent durable promise. No registered function is behind it;
// it only settles when an external caller issues a promise-settle.
f, err := ctx.Promise(resonate.PromiseOpts{Timeout: 24 * time.Hour})
if err != nil {
return "", fmt.Errorf("ctx.Promise: %w", err)
}
promiseID := f.ID() // hand this to the external resolver
// Publish the promise ID inside a ctx.Run so the write is checkpointed.
// On replay, ctx.Run re-issues with the same child promise ID and
// short-circuits — the side-effect does not run twice.
// ctx.Run takes a single args value: ctx.Run(fn, args, opts...). Capture the
// values the leaf needs via the closure and pass struct{}{} as the (unused) arg.
_, err = ctx.Run(func(_ struct{}) (struct{}, error) {
// In production: write to DB, push to a notification queue, etc.
fmt.Printf(" [workflow] awaiting approval for %q — promise ID: %s\n", req.Item, promiseID)
promiseIDs --data '"approved"'
# reject
resonate promise reject --data '"rejected: budget exceeded"'
The --data value is the JSON payload that will be decoded into the type you passed to f.Await. For a string awaiter, a quoted JSON string like '"approved"' is correct. See the resonate-cli skill for full flag reference.
2. Server HTTP API
POST directly to the Resonate Server's promise-settle endpoint with the promise ID and a JSON body. Use this from any language or tool that can make HTTP calls (curl, an admin script, a serverless function):
curl -s -X POST "http://localhost:8001/promises/${PROMISE_ID}/resolve" \
-H "Content-Type: application/json" \
-d '{"value":{"data":"ImFwcHJvdmVkIg=="}}'
The data field must be the base64 encoding of the JSON-serialized payload — see the encoding note in mechanism 3 for why.
3. Low-level Go SDK — Sender().PromiseSettle
Use this when resolving from Go code in another process (an HTTP webhook handler, an admin CLI). There is no r.Promises().Resolve helper (issue #28), so you call the internal sender and encode the value by hand.
import (
"context"
"encoding/base64"
"encoding/json"
"log"
resonate "github.com/resonatehq/resonate-sdk-go"
)
func resolveApproval(r *resonate.Resonate, promiseID string, decision string) error {
// The SDK codec stores values as: JSON bytes → base64 → JSON-quoted string
// stored in Value.Data. Replicate it manually until issue #28 ships a
// high-level Promises().Resolve(id, v) helper.
rawJSON, err := json.Marshal(decision) // "approved" → `"approved"`
if err != nil {
return err
}
b64 := base64.StdEncoding.EncodeToString(rawJSON) // → base64 string
quoted, err := json.Marshal(b64) // → JSON-quoted string
if err != nil {
return err
}
val := resonate.Value{Data: json.RawMessage(quoted)}
rec, err := r.Sender().PromiseSettle(context.Background(), resonate.PromiseSettleReq{
ID: promiseID,
State: resonate.SettleStateResolved, // or SettleStateRejected
Value: val,
})
if err != nil {
return err
}
log.Printf("settled — state=%s", rec.State)
return nil
}
Known friction (issue #28 — call this out in code reviews): resonate.NewValue(decision) stores raw JSON bytes in Value.Data WITHOUT the base64 wrapper. The workflow side's Future.Await calls the codec's Decode, which expects base64 — it fails with a decode error and emits no warning. The manual JSON → base64 → quoted encoding above is required until a high-level Promises().Resolve API lands. This is the single most common mistake when porting HITL code from TypeScript or Rust to Go.
Cross-process example
The resonatehq-examples/example-node-drain-orchestrator-go shows a net/http gateway process calling Sender().PromiseSettle to resolve promises owned by a separate worker process — a realistic two-process boundary. Refer to it for the full plumbing (shared server URL, token config, error handling on 409 Already Settled). Do not reproduce the drain-orchestrator example inline; it is a large multi-file app.
Localnet setup (development only)
For single-process demos and tests, use localnet. No external server is needed.
import (
resonate "github.com/resonatehq/resonate-sdk-go"
"github.com/resonatehq/resonate-sdk-go/localnet"
)
pid := "hitl-worker"
r, err := resonate.New(resonate.Config{
Network: localnet.NewLocal("default", &pid),
Heartbeat: resonate.NoopHeartbeat{}, // required — localnet has no heartbeat endpoint
TTL: 5 * time.Minute,
})
Sender().PromiseSettle works against localnet exactly as it does against a real server. The -url flag in example-human-in-the-loop-go switches between the two modes at runtime.
Known gaps
- No hand-chosen promise IDs. Go's
ctx.Promisegenerates the ID internally. Unlike TypeScript'sctx.promise({ id: "approval/order-42" }), you cannot pass a deterministic application-layer ID. Fetch the ID viaf.ID()after creation and publish it explicitly. This gap will close as the Go API surface settles toward the first tag. - No first-responder race helper. There is no
select-style mechanism to await whichever of several latent promises settles first. Workaround: create one promise per participant, await each in sequence, and have a coordinator cancel the others once a winner is known; or use a helper workflow that callsSender().PromiseSettleon a shared promise from the first responder's branch.
Distinct Go idioms
- Publish inside
ctx.Run. The Go SDK does not have generator-based checkpoint semantics (noyield*). Wrap every observable side effect — DB writes, notifications, the channel send that hands off the promise ID — insidectx.Runorctx.RPCso the result is checkpointed and the action is not repeated on replay. - Options struct, not method chain. All context calls take an optional trailing struct:
ctx.Promise(resonate.PromiseOpts{Timeout: 24*time.Hour}). The struct is optional;ctx.Promise()is valid when defaults are acceptable. f.Await(nil)for fire-and-observe. When you only need to know a promise settled (not its payload), passniltoAwait. Saves a decode round-trip.defer r.Stop(). Required in demo binaries and one-shot jobs; keeps background goroutines from blocking process exit. Do not callStopon a long-lived worker process — it tears down the task-receive channel and the heartbeat loop silently.
Avoid
- Polling via
ctx.Sleep+ a status check. Defeats the park-and-resume semantics; burns checkpoints and wall-clock time. Usectx.Promise+f.Awaitinstead. resonate.NewValue(v)for the settlement payload. It omits the base64 layer the codec expects, causing a silent decode failure on the workflow side. Use the manualJSON → base64 → quotedencoding shown in mechanism 3 until issue #28 ships.- Settling before publishing the ID. A narrow race: if the external resolver runs before the
ctx.Runthat publishes the ID has been checkpointed, a crash between those two lines can lose the ID. Always checkpoint the publication first (viactx.Run), then await. - Using
ctx.Runfor long-blocking work.ctx.Rungoroutines must return promptly; a blocking call holds the task lease open until TTL expires. Long waits belong inctx.Promise(latent, externally settled) orctx.RPC(remote dispatch).
Related skills
resonate-basic-durable-world-usage-go—ctx.Promisebuilder, opts, andFuture.Awaitdetailsresonate-cli—resonate promise resolve / reject / cancelflag reference (mechanism 1)durable-execution— foundational replay semantics; whyf.Awaitsurvives crashesresonate-human-in-the-loop-pattern-typescript— mental model andresonate.promises.settlereferenceresonate-human-in-the-loop-pattern-rust—ctx.promise::()builder andresonate.promises.resolvereference
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.