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:wasmimport from 1.21), so a Go program can expose the run / write_result / result_scratch_ptr functions a workflow module has to export.
  • -buildmode=c-shared for GOOS=wasip1, which produces a reactor module: _initialize plus exported functions, with main never 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 log and emit_searchable round-trips asserted by content rather than by “did not trap.”
  • run is idempotent after completion, and __hopskip_prepare_snapshot is 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.