4 min readResonate HQ

Coming from Temporal: Human-in-the-Loop in Go

Temporal's await-signals needs a signal channel, a listener goroutine, a flag, and workflow.Await. Resonate collapses all four into one ctx.Promise().

Resonate brand card on a dark background with an ember spectrum wave at the lower left, the headline 'Human in the Loop' in white Sansation, and the subtitle 'Coming from Temporal · Go SDK'.

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-111

On 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

TemporalResonate
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 queueresonate.Register(r, "approvalWorkflow", approvalWorkflow)

Porting it

  1. Remove the listener goroutine. Delete the AwaitSignals struct, the Listen method, the workflow.Go(ctx, a.Listen) call, and all Signal*Received flag variables.

  2. Replace each workflow.Await(cond) with ctx.Promise() + f.Await(&value). Where you had GetSignalChannel + a handler that sets a flag + workflow.Await on that flag, you now have one ctx.Promise() call. The settlement value is decoded directly into the variable passed to f.Await.

  3. Expose the promise ID. Call f.ID() immediately after ctx.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.

  4. Replace client.SignalWorkflow with 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.

  5. Multiple sequential gates. await-signals chains three signals sequentially. In Resonate, call ctx.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.

  6. Drop the Temporal worker setup. Replace RegisterWorkflow + task queue wiring with resonate.Register(r, "approvalWorkflow", approvalWorkflow). No task queue to declare.

  7. Wire the runner. Replace temporalClient.ExecuteWorkflow with approvalFn.Run(ctx, id, req) and h.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-211

Do 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