Resonate Human-in-the-Loop Pattern — Go
Version note. The Go SDK's first tagged release is
0.1.0(go get github.com/resonatehq/resonate-sdk-go@0.1.0— the tag has novprefix, so@latestdoes not resolve to it).0.1.0shipped a top-levelpromisessub-client (r.Promises()), matching TypeScript'sresonate.promisesand Rust'sresonate.promises. Every code block here is verified against the0.1.0tag source andexample-human-in-the-loop-go.
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.
Resolution path. TypeScript has resonate.promises.settle(id, ...). Rust has resonate.promises.resolve(id, ...). Go's 0.1.0 tag has r.Promises().Resolve(id, v) / .Reject(id, v) / .Cancel(id, v) — the same shape, and the preferred way to settle a promise from Go code in another process. Three more mechanisms remain useful for settling from outside Go entirely (an ops runbook, curl, a non-Go service) — listed below in order of preference.
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 <- promiseID // example: buffered channel to a local resolver
return struct{}{}, nil
}, struct{}{})
if err != nil {
return "", fmt.Errorf("publish promise ID: %w", err)
}
// Await parks the workflow until the promise settles. The decision value
// encoded by the settler is decoded here.
var decision string
if err := f.Await(&decision); err != nil {
return "", fmt.Errorf("await approval: %w", err)
}
return fmt.Sprintf("item %q approved: %s", req.Item, decision), nil
}
// promiseIDs is a buffered channel for single-process demos. Replace with a
// DB write or notification in production.
var promiseIDs = make(chan string, 1)
Key points:
ctx.Promise()with no args is valid; opts are optional.f.ID()is available immediately after the call — the promise record is created synchronously.- Publish the ID inside a
ctx.Runso the publication is itself durable. A bare side-effect abovef.Awaitre-runs on every replay pass. - On replay after a crash,
ctx.Promisere-issues with the same SDK-generated ID (idempotent).f.Awaitshort-circuits when the promise has already settled. f.Await(nil)is valid when you need to know the promise settled but don't need the payload.
Resolving from outside
1. r.Promises() sub-client (preferred — resolving from Go code)
Use this when resolving from Go code in another process (an HTTP webhook handler, an admin CLI). Resolve/Reject/Cancel route the value through the instance's Codec — the same encode path the workflow machinery uses — so you never hand-roll the base64 layer.
import (
"context"
"log"
resonate "github.com/resonatehq/resonate-sdk-go"
)
func resolveApproval(r *resonate.Resonate, promiseID string, decision string) error {
rec, err := r.Promises().Resolve(context.Background(), promiseID, decision)
// or: r.Promises().Reject(ctx, promiseID, err) / r.Promises().Cancel(ctx, promiseID, nil)
if err != nil {
return err
}
log.Printf("settled — state=%s", rec.State)
return nil
}
Resolve/Reject/Cancel return *resonate.ServerError{Code: 404} when the promise does not exist. Pass a Go error to Reject/Cancel and it encodes as the standard error payload, so Future.Await on the workflow side deserializes it into an *ApplicationError — the same round-trip DeserializeError does elsewhere in the SDK.
2. CLI (ops gates, manual approval)
# approve
resonate promise resolve <promiseID> --data '"approved"'
# reject
resonate promise reject <promiseID> --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.
3. 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) that isn't going through an SDK's codec:
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 4 for why.
4. Low-level Go SDK — Sender().PromiseSettle
This is the transport-level primitive r.Promises().Resolve is itself built on. Reach for it directly only when you need to bypass the codec (e.g. a value that's already wire-encoded) — otherwise prefer mechanism 1.
import (
"context"
"encoding/base64"
"encoding/json"
"log"
resonate "github.com/resonatehq/resonate-sdk-go"
)
func resolveApprovalLowLevel(r *resonate.Resonate, promiseID string, decision string) error {
// The Codec stores values as: JSON bytes → base64 → JSON-quoted string
// stored in Value.Data. Replicate it manually when going around the Codec.
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 on this low-level path only: resonate.NewValue(decision) stores raw JSON bytes in Value.Data WITHOUT the base64 wrapper the Codec expects, so Future.Await on the workflow side fails with a decode error and no warning at the settle call site. r.Promises().Resolve (mechanism 1) does not have this trap — it goes through the Codec automatically. The manual JSON → base64 → quoted encoding above is only needed when you deliberately bypass the sub-client.
Cross-process example
The resonatehq-examples/example-node-drain-orchestrator-go shows a net/http gateway process resolving promises owned by a separate worker process — a realistic two-process boundary. Prefer r.Promises().Resolve(...) (mechanism 1) for new code; the example may predate the 0.1.0 sub-client and use Sender().PromiseSettle directly (mechanism 4) — check which path it takes before copying it verbatim. 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,
})
r.Promises().Resolve and Sender().PromiseSettle both work against localnet exactly as they do 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.
ctx.Promisegenerates the ID internally — you cannot pass a deterministic application-layer ID. This is shared behavior, not a Go shortfall: TypeScript'sctx.promise()only accepts{ timeout, data, tags }and generates the ID the same way. Fetch the ID viaf.ID()after creation (TypeScript:promise.id) and publish it explicitly. - 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 callsr.Promises().Resolveon 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)passed straight toSender().PromiseSettle. It omits the base64 layer the codec expects, causing a silent decode failure on the workflow side. User.Promises().Resolve(...)(mechanism 1) instead — it encodes correctly — or the manualJSON → base64 → quotedencoding shown in mechanism 4 if you must stay on the low-level path.- 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-ephemeral-world-usage-go—r.Promises()/r.Schedules()sub-client referenceresonate-basic-durable-world-usage-go—ctx.Promisebuilder, opts, andFuture.Awaitdetailsresonate-cli—resonate promise resolve / reject / cancelflag reference (mechanism 2)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::<T>()builder andresonate.promises.resolvereference