If you know how Temporal's await-signals Go sample works and want to see what changes in Resonate, this is the concrete side-by-side. Both systems solve the same problem: a workflow pauses mid-execution and waits for something outside the process — a human approval, an external event, a third-party callback — before continuing. The mechanisms differ.
Temporal (samples-go/await-signals)
Temporal models the pause as a named signal channel. For each external trigger, you define a name, add a receive handler to a Selector, run the listener in a separate goroutine, and maintain a received flag that workflow.Await polls:
type AwaitSignals struct {
Signal1Received bool
// ...
}
func (a *AwaitSignals) Listen(ctx workflow.Context) {
for {
selector := workflow.NewSelector(ctx)
selector.AddReceive(workflow.GetSignalChannel(ctx, "Signal1"), func(c workflow.ReceiveChannel, more bool) {
c.Receive(ctx, nil)
a.Signal1Received = true
})
// Signal2, Signal3 follow the same shape
selector.Select(ctx)
}
}
func AwaitSignalsWorkflow(ctx workflow.Context) error {
var a AwaitSignals
workflow.Go(ctx, a.Listen) // listener goroutine
err = workflow.Await(ctx, func() bool { return a.Signal1Received })
// ... then AwaitWithTimeout for Signal2, Signal3
return nil
}The external actor sends each signal via the client:
err = temporalClient.SignalWorkflow(ctx, workflowID, runID, "Signal1", nil)Resonate (example-human-in-the-loop-go)
Resonate models the pause as a single latent durable promise — a promise with no registered function behind it that settles only when an external caller resolves it by ID. No signal names, no listener goroutine, no flag:
func approvalWorkflow(ctx *resonate.Context, req ReviewRequest) (string, error) {
// Create a latent durable promise. No function is registered behind it;
// the promise only settles when promise.settle is called externally.
f, err := ctx.Promise()
if err != nil {
return "", fmt.Errorf("ctx.Promise: %w", err)
}
promiseID := f.ID()
// Surface this ID however makes sense: log it, write it to a DB,
// embed it in a notification email, expose it via HTTP.
// f.Await parks the workflow goroutine. When the promise is still pending,
// the SDK suspends and re-enters the workflow once the promise settles.
// The settled value is decoded directly into `decision`.
var decision string
if err := f.Await(&decision); err != nil {
return "", fmt.Errorf("await approval: %w", err)
}
return fmt.Sprintf("workflow for %q completed with decision: %s", req.Item, decision), nil
}
// from example-human-in-the-loop-go/main.go:73-111On replay, ctx.Promise() sees the promise is already settled and f.Await short-circuits without parking — no blocking, no double-settle.
The external actor settles via r.Sender().PromiseSettle (see Notes for encoding details) or via the CLI:
resonate promise resolve <promiseID> --data '"approved"'What maps to what
| Temporal | Resonate |
|---|---|
workflow.GetSignalChannel(ctx, "Signal1") | ctx.Promise() |
selector.AddReceive(ch, handler) | — (not needed) |
a.Signal1Received = true in handler | — (promise record carries the value) |
workflow.Go(ctx, a.Listen) | — (no background goroutine) |
workflow.Await(ctx, func() bool { return a.Signal1Received }) | f.Await(&decision) |
workflow.AwaitWithTimeout(ctx, timeout, cond) | f.Await(&decision) + ctx.Promise(resonate.PromiseOpts{Timeout: d}) |
client.SignalWorkflow(ctx, wfID, runID, "Signal1", payload) | r.Sender().PromiseSettle(ctx, req) or resonate promise resolve <id> |
RegisterWorkflow + task queue | resonate.Register(r, "approvalWorkflow", approvalWorkflow) |
Porting it
-
Remove the listener goroutine. Delete the
AwaitSignalsstruct, theListenmethod, theworkflow.Go(ctx, a.Listen)call, and allSignal*Receivedflag variables. -
Replace each
workflow.Await(cond)withctx.Promise()+f.Await(&value). Where you hadGetSignalChannel+ a handler that sets a flag +workflow.Awaiton that flag, you now have onectx.Promise()call. The settlement value is decoded directly into the variable passed tof.Await. -
Expose the promise ID. Call
f.ID()immediately afterctx.Promise(). This is what the external actor needs. Surface it however fits the use case: log it, write it to a database, embed it in a notification, expose it via an HTTP endpoint. -
Replace
client.SignalWorkflowwith a promise settle. The external actor (HTTP handler, CLI, Slack bot) resolves the promise by ID rather than sending a named signal. See Notes for the encoding path. -
Multiple sequential gates.
await-signalschains three signals sequentially. In Resonate, callctx.Promise()once per gate in the workflow body. Each call produces a distinct promise ID. You can surface all IDs upfront or reveal them one step at a time. -
Drop the Temporal worker setup. Replace
RegisterWorkflow+ task queue wiring withresonate.Register(r, "approvalWorkflow", approvalWorkflow). No task queue to declare. -
Wire the runner. Replace
temporalClient.ExecuteWorkflowwithapprovalFn.Run(ctx, id, req)andh.Result(ctx)to read the final value.
What's actually different
One concept instead of four. In await-signals, handling an external event requires: a named signal channel, a listener goroutine, a flag variable, and a workflow.Await condition. The listener-goroutine/flag pattern is the idiomatic Temporal approach — not accidental complexity. Resonate collapses all four into ctx.Promise() because the durable promise record in the server IS the pending state; there is no in-process flag to maintain.
Value-carrying resolution. In await-signals, signals carry no data — c.Receive(ctx, nil) uses nil because the signal is a pure trigger. Temporal's ReceiveChannel.Receive can accept typed payloads; nil is a sample choice, not a platform constraint. Resonate's settlement value travels with the resolve call and decodes directly into the f.Await target variable.
Timeout model. await-signals enforces timeouts using workflow.AwaitWithTimeout with computed durations. Resonate has no AwaitWithTimeout; instead you set a deadline on the latent promise at creation via ctx.Promise(resonate.PromiseOpts{Timeout: d}). If unset, the SDK applies min(now + 24h, parent.timeoutAt). If the promise expires before settlement, f.Await returns an error.
Replay semantics. Both systems replay the workflow function from the top on resume. In Temporal, the listener goroutine re-registers on the Selector, but already-received signals replay from history without blocking. In Resonate, ctx.Promise() on replay sees the promise is already settled and f.Await short-circuits immediately. The example uses a non-blocking send to promiseIDs specifically for this: on replay the ID is already consumed, so the send is a no-op.
Notes & coverage
The resolve path is lower-level than you might expect. There is no high-level r.Promises().Resolve(id, value) method in the Go SDK yet (resonate-sdk-go#28). To settle a promise programmatically you use r.Sender().PromiseSettle(ctx, req) and encode the value manually. The SDK's codec represents values as: JSON-encode the Go value → base64-encode the JSON bytes → JSON-encode the base64 string → store as Value.Data:
rawJSON, _ := json.Marshal(decision) // e.g. `"approved"`
b64 := base64.StdEncoding.EncodeToString(rawJSON) // base64 of that JSON
quotedB64, _ := json.Marshal(b64) // quoted base64 string
val := resonate.Value{Data: json.RawMessage(quotedB64)}
settleReq := resonate.PromiseSettleReq{
ID: promiseID,
State: resonate.SettleStateResolved,
Value: val,
}
rec, err := r.Sender().PromiseSettle(ctx, settleReq)
// from example-human-in-the-loop-go/main.go:182-211Do not use resonate.NewValue for external settlement. NewValue stores raw JSON in Value.Data without the base64 wrapper, which causes a decode error inside f.Await. There is no compile-time warning; the failure surfaces as a DecodingError at runtime.
CLI path skips the encoding. If the external actor is a human or a script rather than Go code, the CLI handles encoding automatically:
resonate promise resolve <promiseID> --data '"approved"'Pass a JSON-encoded value to --data. The CLI wraps it correctly before sending to the server.
In-process vs. external settlement. This example's simulator goroutine calls PromiseSettle from within the same process as the worker, using r.Sender() directly. In production the settlement call typically lives in a separate binary — an HTTP gateway, a CLI tool, a Slack bot — that creates its own resonate.New(cfg) instance pointing at the same server URL. The workflow binary and the gateway binary share nothing except the promise ID.
Pre-release SDK. resonate-sdk-go has no semver tag yet. Expect API changes before v0.1.0, including the higher-level Promises sub-client tracked in resonate-sdk-go#28.
Sources
- Example repo: github.com/resonatehq-examples/example-human-in-the-loop-go
- Temporal sample: github.com/temporalio/samples-go/await-signals
- Concept-level guide, all SDKs: docs.resonatehq.io/evaluate/coming-from/temporal
