Resonate Basic Ephemeral World Usage — Go
Version note. The Go SDK's first tagged release is
0.1.0. The tag is0.1.0, notv0.1.0, sogo get …@latestdoes not resolve to it (it walksmaininstead and returns a newer pseudo-version). Pin the tag explicitly — see Install below. APIs may still change before a1.0. Every code block here is verified against the0.1.0tag source and theresonatehq-examples/*-gorepos.
Overview
The ephemeral world is wherever your Go program starts: main(), an HTTP handler, a CLI entry point, a background goroutine. You use a *resonate.Resonate instance to register durable functions and invoke them top-level. Once an invocation starts, it crosses into the durable world and Resonate guarantees its completion — retries on failure, resumes after crashes, continues across process restarts.
This skill covers the Client API surface only. The Durable World (Context APIs inside workflow functions) is covered in resonate-basic-durable-world-usage-go.
Install
go get github.com/resonatehq/resonate-sdk-go@0.1.0
The tag on GitHub is 0.1.0 (no v prefix), so this is not the same as go get …@latest — @latest walks main past the tag and resolves to a newer pseudo-version instead. Requesting the tag by name resolves correctly: the module proxy mints the pseudo-version pinned to that exact commit (verify with curl -s https://proxy.golang.org/github.com/resonatehq/resonate-sdk-go/@v/0.1.0.info). Pin a later commit the same way (@<short-sha>) for reproducible builds ahead of the next tag.
Core imports:
import (
resonate "github.com/resonatehq/resonate-sdk-go"
"github.com/resonatehq/resonate-sdk-go/localnet" // zero-dependency dev
"github.com/resonatehq/resonate-sdk-go/httpnet" // named worker groups
)
Basic Shape
A complete one-file program: register, run, read, stop.
package main
import (
"context"
"fmt"
"log"
"time"
resonate "github.com/resonatehq/resonate-sdk-go"
)
type GreetArgs struct {
Name string `json:"name"`
}
func greet(_ *resonate.Context, args GreetArgs) (string, error) {
return fmt.Sprintf("hello, %s!", args.Name), nil
}
func main() {
r, err := resonate.New(resonate.Config{URL: "http://localhost:8001"})
if err != nil {
log.Fatalf("resonate.New: %v", err)
}
defer func() { _ = r.Stop() }()
greetFn, err := resonate.Register(r, "greet", greet)
if err != nil {
log.Fatalf("Register: %v", err)
}
ctx := context.Background()
id := fmt.Sprintf("hello-%d", time.Now().UnixNano())
h, err := greetFn.Run(ctx, id, GreetArgs{Name: "world"})
if err != nil {
log.Fatalf("Run: %v", err)
}
out, err := h.Result(ctx)
if err != nil {
log.Fatalf("Result: %v", err)
}
fmt.Println(out) // hello, world!
}
Initialization
Remote server
r, err := resonate.New(resonate.Config{URL: "http://localhost:8001"})
Config fields:
| Field | Type | Default | Purpose |
|---|---|---|---|
URL |
string |
unset | Shorthand HTTP transport at this address (default group "default"). |
Network |
Network |
nil | Explicit transport — use when URL is empty. |
Heartbeat |
Heartbeat |
AsyncHeartbeat at TTL/2 |
Keeps acquired task leases alive. |
Encryptor |
Encryptor |
NoopEncryptor |
Codec encryption at the durability boundary. |
TTL |
time.Duration |
60s |
Per-task lease duration. |
Prefix |
string |
empty | Prepended (with :) to every promise/task ID. |
Token |
string |
unset | Sent as Bearer auth on every protocol request. |
Network precedence: Config.URL → Config.Network → RESONATE_URL env var → resonate.ErrNetworkRequired. The only env var the constructor reads is RESONATE_URL.
Zero-dependency development (localnet)
pid := "dev-worker"
r, err := resonate.New(resonate.Config{
Network: localnet.NewLocal("default", &pid),
Heartbeat: resonate.NoopHeartbeat{}, // required — localnet has no lease endpoint
})
Named worker group
There is no Group field on Config. Pass the transport explicitly:
r, err := resonate.New(resonate.Config{
Network: httpnet.NewHTTP("http://localhost:8001", httpnet.HTTPOptions{
Group: "worker-group-a",
}),
})
Authentication
r, err := resonate.New(resonate.Config{
URL: "https://resonate.example.com",
Token: os.Getenv("RESONATE_TOKEN"),
})
resonate.Register
Register is a package-level generic function (Go has no method-level generics). It takes the instance, a name, and the function; returns a typed *RegisteredFunc[A, R] and an error.
greetFn, err := resonate.Register(r, "greet", greet)
if err != nil {
log.Fatalf("Register: %v", err)
}
Registered functions must have the canonical signature func(*resonate.Context, A) (R, error). Use struct{} for A or R when the function takes no input or produces no meaningful result.
RegisteredFunc.Run
Invokes a registered function in the same process and returns a typed *TypedHandle[R]. The returned handle is durable — the function will complete even if the process crashes and another worker picks it up.
h, err := greetFn.Run(ctx, "greet-1", GreetArgs{Name: "world"})
if err != nil {
log.Fatalf("Run: %v", err)
}
result, err := h.Result(ctx) // returns (string, error) — typed
if err != nil {
log.Fatalf("Result: %v", err)
}
RunOptions
Pass as a trailing argument to Run:
h, err := greetFn.Run(ctx, "greet-1", GreetArgs{Name: "world"}, resonate.RunOptions{
Timeout: 60 * time.Second,
Target: "poll://any@workers",
Tags: map[string]string{"team": "checkout"},
RetryPolicy: resonate.ConstantRetry{MaxAttempts: 3, Delay: time.Second},
})
| Field | Purpose |
|---|---|
Timeout |
Caps the root promise deadline. Zero uses DefaultTopLevelTimeout (24h). |
Target |
Logical routing address (resonate:target tag). Empty falls back to the configured group. |
Tags |
Merged into the root promise's tag set. |
RetryPolicy |
Re-execution policy on error. Nil applies DefaultRetryPolicy (exponential, 3 attempts). Only takes effect when this worker wins the create-and-acquire race and runs the function locally; a task picked up by another worker via server push uses DefaultRetryPolicy. |
Version |
Reserved — declared but not yet consumed (issue #5). |
Resonate.RPC
Invokes a registered function in a remote process by name. Returns an untyped *Handle; the target function does not need to be registered locally.
h, err := r.RPC(ctx, "greet-1", "greet", GreetArgs{Name: "world"})
if err != nil {
log.Fatalf("RPC: %v", err)
}
var result string
if err := h.Result(ctx, &result); err != nil {
log.Fatalf("Result: %v", err)
}
RPC accepts an optional resonate.RPCOptions{Timeout, Target, Tags, Version} as a trailing argument.
Resonate.Get
Gets a handle to an existing execution by promise ID. Returns *resonate.ServerError with Code: 404 when the promise does not exist.
h, err := r.Get(ctx, "greet-1")
if err != nil {
log.Fatalf("Get: %v", err)
}
var result string
if err := h.Result(ctx, &result); err != nil {
log.Fatalf("Result: %v", err)
}
Reading Handle Results
- Typed handle —
RegisteredFunc.Runreturns*TypedHandle[R]; callh.Result(ctx)to get(R, error)directly. - Untyped handle —
RPCandGetreturn*Handle; callh.Result(ctx, &out)and pass a pointer to the target variable. - Generic helper for untyped handles:
result, err := resonate.ResultOf[string](ctx, h)
A rejected promise surfaces as *resonate.ApplicationError (or the deserialized concrete error where available).
Direct promise & schedule API
0.1.0 shipped two sub-clients on *Resonate for working with durable promises and cron schedules outside the workflow machinery — no registered function, no dispatch tags, just a promise or a schedule you manage directly. Get them via r.Promises() / r.Schedules() (methods, not fields).
r.Promises() — create, get, resolve/reject/cancel
ctx := context.Background()
// Create a promise settled by some external party (a webhook, an operator).
rec, err := r.Promises().Create(ctx, "order-1", 24*time.Hour, resonate.PromiseCreateOptions{
Param: Order{Item: "book"},
Tags: map[string]string{"kind": "order"},
})
if err != nil {
log.Fatalf("Create: %v", err)
}
// Settle it from anywhere holding the ID.
rec, err = r.Promises().Resolve(ctx, "order-1", Receipt{Total: 42})
// ...or r.Promises().Reject(ctx, "order-1", err) / r.Promises().Cancel(ctx, "order-1", nil)
// Read it back; Value decodes directly into a Go value.
rec, err = r.Promises().Get(ctx, "order-1")
var receipt Receipt
err = rec.Value.Decode(&receipt)
Resolve/Reject/Cancel handle the JSON → codec encoding for you (the same Codec the workflow machinery uses) — this is the fix for the old "manual base64 encoding" trap on the low-level Sender().PromiseSettle path (see resonate-human-in-the-loop-pattern-go and resonate-basic-debugging-go for that path, still useful for cross-process/non-Go settlement). Create's timeout is relative to now; <= 0 defaults to resonate.DefaultTopLevelTimeout (24h). Get returns *resonate.ServerError{Code: 404} when the promise does not exist.
r.Schedules() — create, get, delete
// A cron schedule creates a fresh promise on every firing, from a promise ID
// template (may include placeholders like {{.timestamp}}).
s, err := r.Schedules().Create(ctx, "nightly", "0 0 * * *", "report-{{.timestamp}}", time.Hour,
resonate.ScheduleCreateOptions{PromiseParam: ReportArgs{Region: "us"}})
if err != nil {
log.Fatalf("Create: %v", err)
}
s, err = r.Schedules().Get(ctx, "nightly")
err = r.Schedules().Delete(ctx, "nightly")
This creates the cron-fired promise directly — it is the Go equivalent of Python's resonate.schedules.create(...) sub-client, not the higher-level resonate.schedule(id, cron, fn, args) convenience wrapper that Python, TypeScript, and Rust also expose (which dispatches a registered function on the cron and wires the resonate:target/resonate:origin tags for you). Go's 0.1.0 tag does not have that convenience wrapper yet — to fire a registered function on a schedule, set PromiseTags: map[string]string{"resonate:target": <group>} and shape PromiseParam as map[string]any{"func": "your-fn-name", "args": args} by hand, matching what RegisteredFunc.Run and Resonate.RPC build internally.
Stop Semantics
defer func() { _ = r.Stop() }()
Stop closes the network connection, stops the heartbeat loop, and cancels the background subscription-refresh goroutine. It is idempotent.
Call Stop from processes that should exit after their work finishes: demo binaries, one-shot jobs, CI tasks, examples. Without it, background goroutines keep the process alive after main would otherwise return.
Do not call Stop on a long-running worker. Calling it tears down the channels a worker uses to receive and hold work:
- The connection to the Resonate Server closes — the worker stops receiving dispatched tasks.
- The heartbeat loop stops — the server-side TTL on in-flight tasks expires, and the server reassigns them.
- The subscription-refresh goroutine is cancelled — listeners on awaited promises are no longer re-registered.
The worker process keeps running but silently stops processing. Let SIGINT / SIGTERM end a worker's lifecycle instead.
Distinct Go Idioms
if err != nileverywhere — bothresonate.New,resonate.Register,Run,RPC,Get, andResultreturn errors. Use explicit checks; no method chaining.- Option structs, not builders —
RunOptions,RPCOptions,RunOpts,RPCOptsare plain structs with exported fields. Pass them as trailing arguments; zero values mean "use the default." - Package-level generic
resonate.Register— Go has no method-level generics, so registration is a free function, not a method on the instance. This is whygreetFncarries the type parameters, notr. - Exported struct fields with
jsontags for args — args are JSON-serialized into the durable promise so any worker that resumes the function can decode them. Unexported fields are silently dropped. context.Contextisctxpassed to Run/RPC/Get/Result — this is the standardcontext.Context(cancellation, deadlines), not the*resonate.Contextinside a workflow.defer func() { _ = r.Stop() }()— the closure-wrapped defer is idiomatic Go for deferred calls where you want to discard the error without a naked_assignment at the call site.time.Durationfor timeouts —60 * time.Second, not raw integers. Durations are nativetime.Durationthroughout the SDK.
Avoid
- Calling
resonate.Newwith neitherURLnorNetworkand withoutRESONATE_URLset — returnsErrNetworkRequiredat startup. - Using
localnetwithoutHeartbeat: resonate.NoopHeartbeat{}— the defaultAsyncHeartbeatissues HTTP keep-alive requests that localnet cannot serve; the heartbeat loop errors. - Passing a
GrouponConfig— there is noGroupfield. Usehttpnet.NewHTTP(..., httpnet.HTTPOptions{Group: "..."})viaConfig.Network. - Using Client APIs (
r.RPC,greetFn.Run) inside a durable function — those belong to the Ephemeral World. Usectx.RPCandctx.Runinside workflows. - Calling
Stopon a long-running worker (see Stop Semantics above). - Relying on
Config.VersioninRunOptions/RPCOptions— the field is declared but not yet consumed.
Related Skills
resonate-basic-durable-world-usage-go— Context APIs:ctx.Run,ctx.RPC,ctx.Sleep,ctx.Promise,ctx.Detached, fan-out patterns, retry policies, context accessorsresonate-human-in-the-loop-pattern-go—Promises().Resolve/Reject/Cancelin the human-in-the-loop context, plus the CLI and low-level fallback pathsresonate-durable-sleep-scheduled-work-go—Schedules()in the recurring-work context, and the workaround for dispatching a registered function on a crondurable-execution— foundational concepts; read this first if new to Resonateresonate-defaults— SDK-wide defaults reference (TTL, retry policy, top-level timeout, child timeout)resonate-basic-ephemeral-world-usage-typescript— TypeScript sibling for comparisonresonate-basic-ephemeral-world-usage-rust— Rust sibling for comparison