Go SDK
Write durable agents in Go: the duraton.dev/sdk-go package authors workflows, runs a runner over connect, calls step.ai, and talks to the REST API.
duraton.dev/sdk-go is the Go runner SDK. It speaks the same connect protocol as the TypeScript
SDK: a runner dials Duraton over a WebSocket, registers its workflows, and the engine drives each run
one replay pass at a time. Durable steps memoize across passes, so a handler resumes exactly where it
left off after a crash, deploy, sleep, or human approval.
go get duraton.dev/sdk-goThe core module depends only on the standard library and one WebSocket package. The default Anthropic
provider for step.ai is a separate module so a runner that never calls a
model stays lean.
import "duraton.dev/sdk-go/duraton"Define a workflow
A workflow is a name, its triggers, and a Handler that receives a *duraton.Context. Author it with
DefineWorkflow; decode the trigger payload with EventData.
type Ticket struct {
ID string `json:"id"`
Total int `json:"total"`
}
wf := duraton.DefineWorkflow(duraton.WorkflowDefinition{
Name: "ticket.created",
Triggers: []duraton.Trigger{{Event: "ticket.created"}},
Handler: func(c *duraton.Context) (any, error) {
ticket, err := duraton.EventData[Ticket](c)
if err != nil {
return nil, err
}
triage, err := duraton.Run(c, "triage", func() (Triage, error) {
return triageTicket(c.Ctx, ticket.Subject)
})
if err != nil {
return nil, err
}
return triage, nil
},
})The handler re-runs top to bottom on every replay pass, so it MUST be deterministic: wrap all
non-determinism (I/O, clocks, randomness, unique ids) inside a Run step. It runs in a single
goroutine - do not call step operations from a goroutine you spawn.
Prop
Type
Steps
Every step is a package function taking the context first and a stable id. The result is recorded
under that id, and on replay a completed step returns its saved value instead of running again. The
value-returning steps are generic over their result type.
// Run: the unit of durable work. RunInput also records an explicit input.
triage, err := duraton.Run(c, "triage", func() (Triage, error) { return classify(subject) })
reply, err := duraton.RunInput(c, "reply", ticket, func(t Ticket) (Reply, error) { return draft(t) })
// Suspend without holding a worker; survives a restart.
err = duraton.Sleep(c, "cool-off", 30*time.Second)
err = duraton.SleepUntil(c, "follow-up", time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC))
// Suspend until an event arrives, or the zero value of T when the timeout elapses first.
paid, err := duraton.WaitForEvent[Payment](c, "await-payment", duraton.WaitForEventOptions{
Event: "payment.received", Timeout: 24 * time.Hour,
})
// Invoke another workflow as a linked child run and await its result.
score, err := duraton.RunWorkflow[RiskScore](c, "risk", duraton.RunWorkflowOptions{
Name: "fraud.score", App: "risk", Data: ticket,
})
// Emit an event, or enqueue a durable outbound webhook.
err = duraton.Emit(c, "shipped", duraton.EmitOptions{Name: "ticket.shipped", App: "notifications", Data: ticket})
err = duraton.Webhook(c, "ping", duraton.WebhookOptions{URL: "https://example.com/hook", Data: ticket})Prop
Type
Approvals
Approval parks the run in needs_attention - checkpoint kept, no worker held - until a human
approves or denies it.
decision, err := duraton.Approval[Refund](c, "refund-gate", duraton.ApprovalRequest{
Tool: "issue-refund",
Args: Refund{Amount: 4200},
Risk: duraton.RiskHigh,
Summary: "Refund ticket A1 in full",
})
if err == nil && decision.Status == duraton.Approved {
_, err = duraton.Run(c, "refund", func() (any, error) { return stripeRefund(decision.Args.Amount) })
}Running a runner
Connect starts the transport and returns immediately with a *Runner; the connection is served on
background goroutines that reconnect with backoff and re-register on every (re)connect. Wait blocks
until shutdown; Close stops reconnecting and blocks until every goroutine has exited.
runner, err := duraton.Connect(duraton.ConnectOptions{
App: "support-app",
Workflows: []duraton.WorkflowDefinition{wf},
// URL / App / APIKey fall back to DURATON_URL / DURATON_APP / DURATON_API_KEY.
})
if err != nil {
log.Fatal(err)
}
defer runner.Close()
runner.Wait()Prop
Type
The ping/pong liveness and reconnect knobs (PingInterval, PongTimeout, ReconnectInitial,
ReconnectMax) are also fields here; see connect - Liveness for what they
do and their defaults.
AI steps
The step.ai surface is a set of package functions taking the context first. Each makes a model call a
durable step: it takes a stable id, records its result, and returns the saved result on replay
instead of calling the model again. Duraton stores only the AI metadata (model, token counts, latency)
as an opaque journal block - never your prompt, the response text, or your API key.
res, err := duraton.Generate(c, "draft-reply", duraton.GenerateOptions{
Model: "claude-opus-4-8",
Prompt: fmt.Sprintf("Write a one-line apology for ticket %s.", ticketID),
})
res.Text // the response
res.Usage.InputTokens, res.Usage.OutputTokensProp
Type
Structured output
GenerateStruct[T] parses the completion into T. Set Output (a JSON Schema) to durably re-ask on a
parse or validation failure - each re-ask its own memoized step. Add Validate for rules the schema
can't express.
type Triage struct {
Category string `json:"category"`
Priority string `json:"priority"`
}
res, err := duraton.GenerateStruct[Triage](c, "triage", duraton.GenerateOptions{
Model: "claude-opus-4-8",
Prompt: "Triage: " + subject,
Output: json.RawMessage(`{"type":"object","required":["category","priority"]}`),
})
res.Output // the parsed Triage, re-asked once on a parse failureProp
Type
Wrap, Embed, Loop
Wrap makes a call you already write yourself durable, unchanged:
completion, err := duraton.Wrap(c, "classify", func() (*openai.ChatCompletion, error) {
return openai.Chat.Completions.New(c.Ctx, params)
})Embed batches your embedding call, checkpointing each batch (Anthropic has no embeddings API, so you
supply the call via EmbedFn):
res, err := duraton.Embed(c, "embed-kb", duraton.EmbedOptions{
Model: "voyage-3",
Inputs: []string{"duplicate charge policy", "annual plan refunds", "refund SLA"},
Embed: func(batch []string) ([][]float64, error) { return voyage.Embed(batch) },
BatchSize: 2,
})
res.Vectors // one vector per input, in input ticketLoop[T] is a durable agent loop: every turn and every tool call is its own durable step, so an agent
that crashes mid-run resumes at the last committed turn. Bring your own model call in Turn; the loop
runs the tools it requests and feeds results into the next turn.
agent, err := duraton.Loop[Resolution](c, "agent", duraton.LoopOptions{
Prompt: "Resolve the ticket about: " + subject,
MaxIterations: 6,
Tools: map[string]duraton.LoopTool{
"search-kb": {Handler: func(in any) (any, error) { return searchKB(in) }},
"lookup-ticket": {Workflow: "orders.lookup", App: "orders"},
},
Turn: func(lc duraton.LoopContext, i int) (duraton.LoopTurn, error) {
return callModel(lc.Prompt, lc.History, i)
},
})
agent.Final, agent.HasFinal, agent.Iterations, agent.StopReasonTurn returns a LoopTurn - either ToolCalls to run, or a Final answer. Keep Turn and Stop
deterministic: they run again on replay while the memoized model results do not.
Providers
The core module ships no built-in provider registry, so Generate needs a ResolveProvider. The
default Anthropic adapter is a separate module - install it only if you use it:
go get duraton.dev/sdk-go/anthropicimport (
"duraton.dev/sdk-go/duraton"
"duraton.dev/sdk-go/anthropic"
)
runner, err := duraton.Connect(duraton.ConnectOptions{
App: "support-app",
Workflows: []duraton.WorkflowDefinition{wf},
ResolveProvider: func(name duraton.ProviderName) duraton.AIProvider {
return anthropic.Provider()
},
})anthropic.Provider() implements duraton.AIProvider plus the optional streaming and
error-classification capabilities. The API key rides each call and is never stored, journaled, or sent
to Duraton; omit it to fall back to ANTHROPIC_API_KEY. To supply your own model backend, implement
duraton.AIProvider and return it from ResolveProvider - the call sites are unchanged.
REST client
The duraton.dev/sdk-go/client package is a typed facade over the engine's control-plane REST API for
code outside a runner: trigger events, read and control runs, and read the numbers behind the console's
charts. It is safe for concurrent use.
import "duraton.dev/sdk-go/client"
dx := client.New(client.Options{})
res, err := dx.Events.Send(ctx, client.SendEventInput{
Name: "ticket.created", App: "support-app", Data: json.RawMessage(`{"ticketId":"T-421"}`),
})
res.RunID
page, err := dx.Runs.List(ctx, client.ListRunsOptions{Status: []client.RunStatus{"failed"}, Limit: 20})
page.Runs, page.NextCursor
run, err := dx.Runs.Get(ctx, "01HXYZ...")URL and APIKey fall back to DURATON_URL / DURATON_API_KEY. The client exposes the
Runs, Events, and Approvals resources, plus Workflows, Apps, Runners, and Health methods,
mirroring the REST client surface. A non-2xx response returns an *APIError
carrying the status and body. Write methods need a secret key; the read methods accept a public key.