SDKs
Go SDK
Go is a launch language as of 2026-07-25, and it gets there through the upstream Go toolchain rather than TinyGo. Two Go 1.24 features made it viable, and Go 1.24 is a hard floor the build script checks for with a message that says why:
//go:wasmexport(and//go:wasmimportfrom 1.21), so a Go program can expose therun/write_result/result_scratch_ptrfunctions a workflow module has to export.-buildmode=c-sharedforGOOS=wasip1, which produces a reactor module:_initializeplus exported functions, withmainnever run. A default build produces a command that runs to completion and exits, which is not a shape a resumable workflow can have.
Starting
hop init ./greetings --lang go
This writes a project in the shape this SDK uses, already pointing at the
guest SDK, and prints the exact hop build and hop run lines for what
it wrote.
From there, point the build at the directory and every workflow under
it is discovered (a workflow here being a package main .go calling
hopskip.Register(), each under a task type derived from its own name:
hop build --manifest-path ./my-workflows
hop dev --watch ./my-workflows
hop dev --watch rebuilds and redeploys what changed on every save and
prints the hop run line for it.
A derived task type never contains the implementation language or the source layout. A task type is the contract a client calls, so porting a workflow to another language (or moving its directory) must not rename it. Pin one explicitly when you want a name that outlives the thing it was derived from:
// @hopskip task-type: hopskip:orders.process@2
// @hopskip skip excludes a file the rule would otherwise match, and
hop workflows <dir> lists what discovery finds without building
anything.
A workflow
package main
import (
hopskip "github.com/iand675/aion/sdk/go/hopskip-workflow"
)
func orderProcessing() *hopskip.Workflow {
var reserved int64
return hopskip.InvokeActivity("reserve_inventory", []byte("sku:ABC")).
Then(func(result []byte) *hopskip.Workflow {
reserved = hopskip.DecodeInt64LE(result)
return hopskip.InvokeActivity("charge_card", []byte("amount:20"))
}).
Then(func(result []byte) *hopskip.Workflow {
charged := hopskip.DecodeInt64LE(result)
return hopskip.Pure(hopskip.EncodeInt64LE(reserved + charged))
})
}
func init() { hopskip.Register(orderProcessing) }
func main() {}
Register accepts the entry in several shapes - no-argument, taking the
start payload as []byte, or codec-typed via Encoded (below) - and
the SDK owns the wasm exports; main is required by -buildmode=c-shared
but never runs.
Typed payloads
The SDK ships no serialization format, by design - but it carries the
one you choose. Implement Codec[T] for your type: Encoded decodes
the workflow’s input before your function sees it, and EncodeResult
encodes the result and declares its content-type header where the
value is produced (a Go workflow’s result comes out of a Then chain,
not a return statement):
func init() {
hopskip.Register(hopskip.Encoded(orderCodec, process))
}
func process(order Order) *hopskip.Workflow {
return hopskip.InvokeActivity("reserve", order.SKU).
Then(func(result []byte) *hopskip.Workflow {
return hopskip.EncodeResult(receiptCodec, receiptFor(order, result))
})
}
Codec[T] is generic, so a mismatch between a codec and the workflow it
wraps is a compile error. Decoding is permissive about the declared
content-type; ContentTypeMatches(codec) is there for a workflow that
wants to check and decide for itself. The same codec contract exists in
all five SDKs.
Generated activity names
The activity name is not checked. Misspell it and everything still compiles: the dispatch is accepted, durably recorded under a task type no worker is registered for, and never picked up. The run blocks forever, with every component behaving exactly as designed.
hop typegen go removes that by generating the names from what is
deployed:
hop typegen go --out internal/hopskipdeployed/deployed.go
import deployed "example.com/orders/internal/hopskipdeployed"
return hopskip.InvokeActivity(deployed.ReserveInventory, []byte("sku:ABC")).
Then(func(held []byte) hopskip.Future {
return hopskip.InvokeActivity(deployed.ChargeCard, []byte("amount:20"))
})
Go’s export rule is the capital, so the constants are ReserveInventory
and ChargeCard: importable, and const, so nothing can reassign what
you dispatch.
A name that is not deployed is not in the generated file, so calling one stops compiling. Each constant carries its whole task type, including the version, so importing one is how you choose which version to call. See Which version you call.
hop typegen go --check fails a build whose committed copy no longer
matches the plan it was generated from. Put it in CI, and regenerate
after a deploy that changes the set.
Why Then chains instead of goroutines
This is the load-bearing decision, and it refuses the idiomatic Go answer.
Suspension in Hopskip means the export returns to the host with the native stack empty. A goroutine blocked on a channel receive is pending execution state that is not heap-resident, which the quiescence invariant forbids: the export cannot return while that goroutine is mid-computation, so the host would be handed an instance whose linear memory is no longer the whole story.
Mechanically it does not even get that far. A //go:wasmexport function
that blocks cannot return, and Go’s runtime (single-threaded under
GOOS=wasip1, with nothing else to schedule) reaches for poll_oneoff,
which this build pipeline turns into a trap. The failure is loud by
construction rather than silently non-deterministic.
So Workflow is a value carrying a step function that either finishes or
reports “I suspended on pending id N, and here is the rest of the
computation” as an ordinary Go closure. Haskell reached the same answer,
for the same reason.
Snapshots and the tracing collector
Go contributes a problem Haskell’s route did not: the continuation
closure lives in a tracing-collected heap. Without help, a snapshot taken
after a burst of allocation captures every superseded continuation the
chain left behind. The sample therefore exports
__hopskip_prepare_snapshot and implements it as runtime.GC() plus
debug.FreeOSMemory(), so what gets captured is the live state and not
the garbage in front of it.
WASI elimination happens to the compiled module
The host registers no WASI imports for any guest, not even stubbed.
A module that still imports wasi_snapshot_preview1 fails to
instantiate, and that link error is the enforcement mechanism.
Rust avoids WASI by not needing it. Haskell strips it at C link time.
Neither works for Go: the Go runtime declares its
wasi_snapshot_preview1 calls inside the standard library, where no
guest-controlled link step can substitute a stub. The same is true of
every prebuilt-runtime language targeting wasm32-wasi.
So the substitution moves one level down, onto the compiled artifact.
hop build runs hopskip_ir_rewrite::strip_wasi_imports, which removes
each wasi_snapshot_preview1 function import and defines a deterministic
replacement inside the module. Go is the first guest whose compiled
module is rewritten before it reaches the host, and the mechanism is not
Go-specific. It is the route any prebuilt-runtime language will take.
Dataset lineage
Declare what a run reads and writes and it joins your OpenLineage graph. See the lineage guide:
hopskip.DatasetInput("orders_source", "postgres://db:5432", "shop.public.orders")
hopskip.DatasetOutput("rollup", "s3://warehouse", "orders/2026-07-30.parquet")
Both ride the emit_searchable import this SDK already has, so nothing
about the ABI changes.
Status
Go is at the same maturity Haskell shipped at: the sample’s ABI surface, not full feature parity with Rust and TypeScript. What is proven today:
- The Go-authored order-processing sample runs to completion against the
unmodified host, with
logandemit_searchableround-trips asserted by content rather than by “did not trap.” runis idempotent after completion, and__hopskip_prepare_snapshotis callable mid-flight without disturbing the workflow.- The compiled module imports nothing but
hopskip:workflow@0.1. - Go and Rust guests record interchangeable histories: identical command content hashes, and a Rust recording replays through the Go guest reproducing both the commands and the final output.
- The cross-SDK conformance suite runs Go as a fifth participant: a 5-recorder × 4-replayer matrix, twenty ordered directions.
Not yet available from a Go guest: @query/@signal/@update contract
handlers (the WIT contract route has no Go codegen yet), and the
client-side dispatch calls the Rust, Python and TypeScript clients
carry. A Go workflow that needs an externally callable surface today is
better authored in one of those languages.
As with every SDK, no guest-facing change ships until it passes that suite in all five languages. See Compatibility & versioning for what is stable across versions.