Skill Guide | Go SDK
APIs that stay simple, even when your use cases aren't
The Go SDK has no semver-tagged release yet and is in active development. APIs may change before the first tag is cut. Use @latest to track the most recent main, or pin a specific commit for stability, and check the open issues for known gaps. The internal protocol is stable; the Go API surface is still settling.
Whether you are a human or an AI agent, this skill guide will help you develop applications using the Resonate Go SDK.
This skill sheet assumes that you already know Resonate is a good fit for your use case. If you are unsure, please refer to Why Resonate.
It's important to think about the potential architecture of your application as it ties to your use case.
This can determine which APIs and options you will use for function activations.
If you have a use case in mind, but haven't built with Resonate before, we recommend reviewing some of the common patterns in our example applications — the Go examples (example-hello-world-go, example-fan-out-fan-in-go, example-human-in-the-loop-go, and more) live in the resonatehq-examples org.
Installation#
How to install the Resonate Go SDK into your project.
The Go SDK is a standard Go module. Add it with go get:
go get github.com/resonatehq/resonate-sdk-go@latestNo semver tag is published yet — @latest resolves to a Go module pseudo-version (a generated string like v0.0.0-<timestamp>-<commit>) pinned to the latest main commit. Pin to a specific commit if you need stability before the first tag is cut:
go get github.com/resonatehq/resonate-sdk-go@<commit-sha>Once added, import the package (the canonical import alias is resonate):
import resonate "github.com/resonatehq/resonate-sdk-go"The in-process transport used for local development lives in a leaf subpackage:
import "github.com/resonatehq/resonate-sdk-go/localnet"Initialization#
How to initialize a Resonate instance.
Initializing a Resonate instance gives you the APIs needed to register, activate, and await durable functions.
import resonate "github.com/resonatehq/resonate-sdk-go"
r, err := resonate.New(resonate.Config{URL: "http://localhost:8001"})
if err != nil {
log.Fatalf("resonate.New: %v", err)
}
defer func() { _ = r.Stop() }()- A Resonate instance can connect to either an in-process local network (best for development) or a remote Resonate Server (best for production).
- A Resonate instance is used in the Ephemeral World to register and activate functions — it cannot be used inside Durable Functions. Use the Context APIs for function activations inside Durable Functions.
resonate.New selects its network using the following precedence:
Config.URL— when set, the SDK builds a default HTTP transport pointing at this address.Config.Network— an explicitly constructed transport (used whenURLis empty).RESONATE_URLenvironment variable — consulted when bothURLandNetworkare empty.
If none of the three is set, New returns resonate.ErrNetworkRequired.
The full set of Config fields:
| Field | Type | Default | Purpose |
|---|---|---|---|
URL | string | unset | Builds a default HTTP transport at this address. Takes precedence over Network and RESONATE_URL. |
Network | Network | nil | An explicitly constructed transport (e.g. localnet.NewLocal(...) or httpnet.NewHTTP(...)). Used when URL is empty. |
Heartbeat | Heartbeat | AsyncHeartbeat at TTL/2 | Keeps an acquired task's lease alive. Pass resonate.NoopHeartbeat{} for local mode. |
Encryptor | Encryptor | NoopEncryptor | Controls codec encryption at the durability boundary. |
TTL | time.Duration | 60s | Per-task lease duration. |
Prefix | string | empty | Prepended (with :) to every promise/task ID created by Run, RPC, or Get. |
Token | string | unset | Sent as Bearer auth on every protocol request. |
There is no Group field on Config. To run under a named routing group, construct the transport explicitly and pass it as Config.Network:
import "github.com/resonatehq/resonate-sdk-go/httpnet"
r, err := resonate.New(resonate.Config{
Network: httpnet.NewHTTP("http://localhost:8001", httpnet.HTTPOptions{
Group: "worker-group-a",
}),
})Config.URL is shorthand for an httpnet.NewHTTP(url, httpnet.HTTPOptions{}) transport, which uses the default group ("default").
Zero-dependency development#
Unlike other Durable Execution offerings, apart from installing the SDK itself, Resonate enables you to get started without any additional dependencies.
This is because Resonate can run in a local development mode (localnet) that runs the server state machine in-process, using in-memory storage for promises and tasks.
import (
resonate "github.com/resonatehq/resonate-sdk-go"
"github.com/resonatehq/resonate-sdk-go/localnet"
)
pid := "dev-worker"
r, err := resonate.New(resonate.Config{
Network: localnet.NewLocal("default", &pid),
Heartbeat: resonate.NoopHeartbeat{}, // localnet has no lease endpoint to heartbeat against
})The default AsyncHeartbeat issues HTTP keep-alive requests to keep a task lease alive. localnet has no such endpoint, so you must set Heartbeat: resonate.NoopHeartbeat{} when using it — otherwise the heartbeat loop errors against a transport that can't serve it.
Local development mode can be suitable for a while. However, when you want to run multiple worker processes, persist state across restarts, or share work between machines, you will need to connect to a Resonate Server. See the Quickstart guide or How to run a Resonate Server for guidance on getting a server up and running.
Authentication#
The Go SDK supports token-based authentication (JWT) when connecting to a secured Resonate Server. Set Config.Token:
r, err := resonate.New(resonate.Config{
URL: "https://localhost:8001",
Token: os.Getenv("RESONATE_TOKEN"),
})Environment variables#
The Go SDK's constructor inspects a single environment variable:
RESONATE_URL#
-
Provides the full base URL (scheme, host, and port) for connecting to a remote Resonate server. Used only when neither
Config.URLnorConfig.Networkis set. -
Default is unset (no network —
NewreturnsErrNetworkRequired). -
Example:
bashexport RESONATE_URL="http://localhost:8001"gor, err := resonate.New(resonate.Config{}) // picks up RESONATE_URL
Unlike some sibling SDKs, the Go SDK does not consult RESONATE_HOST / RESONATE_PORT / RESONATE_SCHEME / RESONATE_TOKEN / RESONATE_PREFIX. Token, prefix, and group are set explicitly on Config or the transport. The single env hook is RESONATE_URL.
Defining durable functions#
How to write a function that Resonate can run durably.
A durable function in Go is an ordinary Go function — there is no attribute or wrapper macro. Every durable function must return exactly two values, (T, error), where T is any JSON-serializable type. There are two ways a function enters the system, and they have different signature rules.
Registered functions — anything you pass to resonate.Register (so it can be invoked by name via RPC, the CLI, or as a top-level Run entrypoint) must have the canonical signature:
func(*resonate.Context, A) (R, error)Register is generic over A (the args type) and R (the result type), so both parameters are required. Use struct{} for A when the function takes no input, and for R when it produces no meaningful result.
import resonate "github.com/resonatehq/resonate-sdk-go"
type GreetArgs struct {
Name string `json:"name"`
}
// Registered workflow — orchestrates sub-tasks via the Context.
func greetWorkflow(ctx *resonate.Context, args GreetArgs) (string, error) {
var msg string
f, err := ctx.Run(formatGreeting, args.Name)
if err != nil {
return "", err
}
if err := f.Await(&msg); err != nil {
return "", err
}
return msg, nil
}
// A registered workflow with no input and no result.
func cleanup(ctx *resonate.Context, _ struct{}) (struct{}, error) {
// ...
return struct{}{}, nil
}Leaf functions invoked via ctx.Run — a function you only ever hand to ctx.Run (never register by name) may use a leaner shape. ctx.Run resolves the signature by reflection and accepts any of:
| Signature | Kind |
|---|---|
func(*resonate.Context, A) (R, error) | Takes the Context and args |
func(*resonate.Context) (R, error) | Takes the Context, no args |
func(A) (R, error) | Stateless leaf with args |
func() (R, error) | Stateless leaf, no args |
// Leaf — stateless computation, no Context needed. Valid as a ctx.Run target.
func formatGreeting(name string) (string, error) {
return fmt.Sprintf("hello, %s!", name), nil
}The args value is encoded into the durable promise's payload so the workflow can resume on any worker that has the function registered. Use exported struct fields with json tags for anything you want carried across a crash or a remote dispatch.
Client APIs#
The following Client APIs are used in the Ephemeral World — they cannot be used inside Durable Functions.
resonate.Register#
Register a durable function to expose it to the Resonate system. A registered function can then be activated by the Resonate Client, the Resonate CLI, or from inside another Durable Function via the Context APIs.
Register is a package-level generic function (Go has no method-level generics): it takes the Resonate instance, a name, and the function, and returns a typed handle whose Run method carries the function's result type.
greetFn, err := resonate.Register(r, "greet", greetWorkflow)
if err != nil {
log.Fatalf("Register: %v", err)
}RegisteredFunc.Run#
Run invokes a registered function in the same process and returns a typed handle.
You can think of it as a "run right here" invocation.
After invocation, the function is durable and will recover in another process if required.
// greetFn is the *RegisteredFunc returned by resonate.Register
h, err := greetFn.Run(ctx, "greet-1", GreetArgs{Name: "world"})
if err != nil {
log.Fatalf("Run: %v", err)
}
result, err := h.Result(ctx) // typed: returns (string, error)Run accepts an optional resonate.RunOptions:
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},
})RunOptions field | Purpose |
|---|---|
Timeout | Caps the root promise deadline. Zero uses DefaultTopLevelTimeout (24 h). |
Target | Logical routing address (the resonate:target tag). Empty falls back to the configured group. |
Tags | Merged into the root promise's tag set. |
RetryPolicy | Re-execution policy when the function returns an error. Nil applies DefaultRetryPolicy. 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 always uses DefaultRetryPolicy. See Retries. |
Version | Reserved for future use — declared but not yet consumed (issue #5). |
Resonate.RPC#
RPC (Remote Procedure Call) invokes a function in a remote process by registered name and returns an untyped handle.
You can think of it as a "run somewhere else" invocation.
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).
Resonate.Get#
Get a handle to an existing execution by its promise ID. Returns a *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 a handle's result#
A handle blocks until its underlying promise settles (or the passed context.Context is cancelled), then decodes the value.
- Typed:
RegisteredFunc.Runreturns a*TypedHandle[R]whoseResult(ctx)returns(R, error)directly. - Untyped:
RPCandGetreturn a*HandlewhoseResult(ctx, out any)decodes into the pointer you pass.
// Untyped handle into a typed value, via the generic helper:
result, err := resonate.ResultOf[string](ctx, h)A rejected promise returns the application error embedded in the rejection payload (as a *resonate.ApplicationError, or the deserialized concrete error where available).
Resonate.Stop#
Gracefully shuts down a Resonate instance. Closes the network connection, stops the heartbeat loop, and cancels the background subscription-refresh goroutine. Idempotent.
defer func() { _ = r.Stop() }()When to call it. Call Stop from any process that should exit after its work finishes — demo binaries, one-shot jobs, examples, CI tasks. Without it, the background goroutines keep the process alive after main would otherwise return.
Calling Stop on a worker (an RPC service, a queue consumer, an MCP server, or any other long-lived process) tears down the channels the 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 keeps running but silently stops processing work. Workers should stay up; let process termination (SIGINT / SIGTERM) end the lifecycle.
The Go SDK does not (yet) expose a Schedule API or a top-level promises sub-client. To create or settle a durable promise from outside a workflow — for human-in-the-loop and external-coordination patterns — create the promise from inside a workflow with ctx.Promise and settle it via the Resonate CLI (resonate promise resolve <id>) or the server's HTTP API. These surfaces will land as the API settles toward the first tag.
Context APIs#
How to use the Resonate Context inside Durable Functions.
Resonate's Context enables you to invoke sub-tasks from inside a workflow. This is how you extend the Call Graph and create a world of Durable Functions.
Each Context method returns a *resonate.Future immediately (the durable promise is created synchronously); you call Future.Await(&out) to read the result. If the awaited promise is still pending, Await suspends the workflow and re-enters it when the promise settles. Because of this, fan-out is "dispatch all, then await all": call ctx.Run / ctx.RPC for every branch first to create the promises, then Await each future.
ctx.Run#
Invoke a function in the same process.
By default, calling Await immediately after executes sequentially — the workflow blocks until the invoked function returns.
func myWorkflow(ctx *resonate.Context, input string) (string, error) {
f, err := ctx.Run(myLeaf, input)
if err != nil {
return "", err
}
var result string
if err := f.Await(&result); err != nil {
return "", err
}
return result, nil
}To run in parallel, dispatch every child first, then await each future:
func myWorkflow(ctx *resonate.Context, inputs []string) ([]string, error) {
fa, err := ctx.Run(process, inputs[0])
if err != nil {
return nil, err
}
fb, err := ctx.Run(process, inputs[1])
if err != nil {
return nil, err
}
var a, b string
if err := fa.Await(&a); err != nil {
return nil, err
}
if err := fb.Await(&b); err != nil {
return nil, err
}
return []string{a, b}, nil
}The workflow runtime joins every ctx.Run-spawned goroutine before it can suspend or fulfill the parent task. A function that blocks indefinitely holds the task lease open until it expires. Long-running or external-blocking work belongs in ctx.RPC (remote dispatch) or ctx.Promise (latent durable promise), not ctx.Run.
ctx.RPC#
Invoke a function in a remote process by registered name. Await on the returned future suspends the workflow until the remote function returns.
func myWorkflow(ctx *resonate.Context, input string) (string, error) {
f, err := ctx.RPC("remote-func", input)
if err != nil {
return "", err
}
var result string
if err := f.Await(&result); err != nil {
return "", err
}
return result, nil
}ctx.RPC accepts an optional resonate.RPCOpts{Timeout, Target}. The fan-out-then-await pattern shown for ctx.Run applies equally to ctx.RPC — see example-fan-out-fan-in-go.
ctx.Sleep#
Durable sleep. There is no limit to how long the function can sleep — hours, days, or weeks. The sleep survives process restarts.
func reminder(ctx *resonate.Context, userID string) (struct{}, error) {
f, err := ctx.Sleep(1 * time.Hour)
if err != nil {
return struct{}{}, err
}
if err := f.Await(nil); err != nil { // no value to decode
return struct{}{}, err
}
// ... send the reminder
return struct{}{}, nil
}Whenever a workflow suspends and resumes (after a Sleep, a remote RPC, or a pending Promise), the entire workflow body runs again from the top. Durable child promises short-circuit work that already settled, but any side effect that runs before reaching that boundary executes again. Wrap observable side effects (DB writes, payments, emails) in their own ctx.Run / ctx.RPC so the durable promise records the result.
ctx.Promise#
Create a latent durable promise resolved by an external system (a webhook, a human, another process). Hand the promise's ID to the external resolver; Await suspends until it settles. This is the building block for human-in-the-loop and webhook workflows.
func approval(ctx *resonate.Context, _ struct{}) (string, error) {
f, err := ctx.Promise(resonate.PromiseOpts{Timeout: 24 * time.Hour})
if err != nil {
return "", err
}
// f.ID() is the promise ID — hand it to whoever resolves it
// (e.g. resonate promise resolve <id> --data '"approved"')
var decision string
if err := f.Await(&decision); err != nil {
return "", err
}
return decision, nil
}ctx.Detached#
Dispatch a remote function fire-and-forget. Unlike ctx.RPC, the parent workflow does not suspend on it — Detached returns only the new promise's ID, and the detached execution's lifecycle is independent of the parent.
func placeOrder(ctx *resonate.Context, orderID string) (struct{}, error) {
// Fire-and-forget: send the receipt without blocking the order workflow.
id, err := ctx.Detached("send-receipt", ReceiptArgs{Order: orderID})
if err != nil {
return struct{}{}, err
}
_ = id // references the detached execution; the parent does not await it
return struct{}{}, nil
}The Go SDK derives Detached IDs with FNV-1a; the Rust SDK uses a different hash. Do not rely on running the same workflow body on two different SDKs producing the same Detached IDs.
Context accessors#
Inside a workflow, the Context provides read-only metadata:
func myWorkflow(ctx *resonate.Context, _ struct{}) (struct{}, error) {
_ = ctx.ID() // this execution's promise ID
_ = ctx.ParentID() // parent promise ID (empty for root)
_ = ctx.OriginID() // root workflow ID — stable across the whole tree
_ = ctx.FuncName() // registered function name
_ = ctx.TimeoutAt() // promise deadline, epoch milliseconds
return struct{}{}, nil
}Builder options#
Context entrypoints take options as a trailing struct argument (Go uses option structs rather than chained builders):
| Method | Options struct | Fields |
|---|---|---|
ctx.Run | resonate.RunOpts | Timeout, RetryPolicy |
ctx.RPC | resonate.RPCOpts | Timeout, Target |
ctx.Promise | resonate.PromiseOpts | Timeout, Data |
ctx.Detached | resonate.DetachedOpts | Timeout, Target |
f, err := ctx.Run(myLeaf, "input", resonate.RunOpts{
Timeout: 30 * time.Second,
RetryPolicy: resonate.LinearRetry{MaxAttempts: 5, Base: time.Second},
})Retries#
When a function passed to ctx.Run (or a top-level Run) returns an error, Resonate re-executes it according to a retry policy. Nil applies DefaultRetryPolicy (exponential backoff, 3 attempts). The SDK ships three policies plus a no-retry sentinel, all implementing the resonate.RetryPolicy interface:
| Policy | Behavior |
|---|---|
resonate.ConstantRetry{MaxAttempts, Delay} | Fixed delay between attempts. |
resonate.LinearRetry{MaxAttempts, Base} | Attempt N waits Base * N. |
resonate.ExponentialRetry{MaxAttempts, Base, Max, Jitter} | Doubling delay (Base, 2·Base, …) capped at Max, with optional jitter. |
resonate.NoRetry | A single attempt (no retries). |
f, err := ctx.Run(chargeCard, args, resonate.RunOpts{
RetryPolicy: resonate.ExponentialRetry{
MaxAttempts: 5,
Base: 100 * time.Millisecond,
Max: 30 * time.Second,
Jitter: true,
},
})Non-retryable errors#
To mark a specific error as terminal so the retry loop stops immediately — regardless of the policy — wrap it with resonate.NewNonRetryable. The wrapped error remains reachable through errors.Is / errors.As / errors.Unwrap.
func validate(input string) (string, error) {
if input == "" {
// No amount of retrying fixes bad input — fail fast.
return "", resonate.NewNonRetryable(errors.New("input is required"))
}
return input, nil
}Defaults#
The Go SDK ships with these key defaults:
resonate.New(resonate.Config{})—TTL = 60s;AsyncHeartbeatatTTL/2;NoopEncryptor; no prefix. Network is resolved fromURL→Network→RESONATE_URL, elseErrNetworkRequired.- Top-level
Run/RPCtimeout —DefaultTopLevelTimeout = 24hwhenOptions.Timeoutis zero. - Child
ctx.Run/ctx.RPC/ctx.Promise/ctx.Sleeptimeout —DefaultChildTimeout = 24hwhen the call'sTimeoutis zero (capped at the parent's remaining deadline). DefaultRetryPolicy—ExponentialRetry{MaxAttempts: 3, Base: 100ms, Max: 30s, Jitter: true}, applied whenRetryPolicyis nil.
The values above are the Go defaults. For server defaults and the TypeScript/Python/Rust comparison with source citations, see the Defaults reference.