Building a Reusable Library
This guide shows you how to package reusable integration logic (for example, a Slack notifier, a PagerDuty client, or a price-feed parser) as a standard Go module that other developers can go get and import directly into their own CRE workflows.
This guide is not a tutorial on writing a workflow itself: if you're new to CRE, start with the getting-started guide first.
Who this is for
Any developer who wants to publish a reusable Go module that other people's CRE workflows will depend on.
Your library runs where the workflow runs
A CRE Go workflow isn't run as a normal Go binary. It's cross-compiled with GOOS=wasip1 GOARCH=wasm into a WebAssembly module that runs inside the CRE host's WASI sandbox. When a workflow author adds your module as a dependency, your code is compiled into that same binary, so it runs under the exact same restrictions as the workflow's own code.
Unlike the TypeScript SDK, which runs inside a stripped-down QuickJS engine, Go workflows compile with the full Go standard library available at compile time. This means far more of the ecosystem "just works" without compatibility checking, but the WASI sandbox still enforces real limits at runtime:
- No filesystem access. There's no real disk backing the sandbox, so file I/O fails or is meaningless even though the code compiles.
- No arbitrary outbound network access. There's no raw socket layer to dial into, so everything non-deterministic (HTTP calls, secrets, blockchain reads/writes) must go through CRE SDK capability APIs, which the DON executes and brings to consensus on your library's behalf.
- Single-threaded, deterministic execution. All DON nodes must execute your code identically and produce the same output.
Design your library around these constraints from the start. Don't write a generic Go module and assume it will run unmodified under WASI. Instead, build it directly against the CRE SDK's runtime primitives.
Pitfalls
Because your library runs inside the same WASI sandbox as the workflow, the code you write can't rely on the host filesystem, network, or concurrency primitives you're used to. The sections below cover the pitfalls that trip up most library authors: no filesystem or arbitrary network access, no standard HTTP client, Promise/Await concurrency instead of goroutines, non-determinism, and numeric precision for onchain values. Read through them before you start, and keep them in mind as you design your library's API.
No filesystem or arbitrary network access
Your library (or one of its transitive dependencies) must not depend on:
- Filesystem access:
os.Open,os.ReadFile,os.WriteFile, config-file loaders, embedded file caches written to disk, and so on. There is no writable (or meaningfully readable) filesystem in the WASI sandbox the workflow runs in. - Direct network access:
net.Dial,net/http'shttp.Client/http.Get, gRPC dialing, database drivers, or any other package that opens its own socket. These either fail at runtime or, worse, silently do nothing useful, because there's no outbound network stack available to your code directly, only to the CRE host, through capability calls.
Anything your library needs from disk (default config, lookup tables, certificates) should be compiled in as Go constants, embedded with //go:embed (a compile-time read, not a runtime filesystem access, so this is safe), or passed in as arguments by the caller. Anything it needs from the network must go through cre-sdk-go's http.Client/http.SendRequest, never a raw dependency that dials sockets itself.
No standard HTTP client
Do not depend on net/http, a REST client wrapper built on it, or any package that dials its own connections. Instead, all outbound requests must go through http.Client from github.com/smartcontractkit/cre-sdk-go/capabilities/networking/http. This isn't just a technical requirement, it's what gives the request DON-wide consensus. An HTTP call in CRE runs independently on every node in the DON (node mode), and the SDK aggregates the individual results into one trusted value before the workflow continues. A library that dials out with net/http would bypass this consensus mechanism entirely, and even if it appeared to run, it would fail in production since the DON nodes would never agree on a single result.
Consequence for library design: your exported functions should take a cre.Runtime as a parameter, and use it (via http.SendRequest or cre.RunInNodeMode) to construct requests, exactly as a workflow author would inline. Keep your library a thin, well-tested wrapper around that Runtime-based request pattern.
// pagerduty.go, inside your library module
package pagerduty
import (
"encoding/json"
"fmt"
"github.com/smartcontractkit/cre-sdk-go/capabilities/networking/http"
"github.com/smartcontractkit/cre-sdk-go/cre"
)
type Options struct {
RoutingKeySecret string
}
type Alert struct {
Summary string `json:"summary"`
Severity string `json:"severity"`
Source string `json:"source"`
}
type Client struct {
options Options
}
func New(options Options) *Client {
return &Client{options: options}
}
func (c *Client) Trigger(runtime cre.Runtime, alert Alert) (int, error) {
sendAlert := func(config Options, nodeRuntime cre.NodeRuntime) (int, error) {
secret, err := nodeRuntime.GetSecret(&cre.SecretRequest{Id: config.RoutingKeySecret}).Await()
if err != nil {
return 0, fmt.Errorf("failed to get routing key: %w", err)
}
payload := map[string]any{
"routing_key": secret.Value,
"event_action": "trigger",
"payload": alert,
}
body, err := json.Marshal(payload)
if err != nil {
return 0, fmt.Errorf("failed to marshal payload: %w", err)
}
client := &http.Client{}
resp, err := client.SendRequest(nodeRuntime, &http.Request{
Url: "https://events.pagerduty.com/v2/enqueue",
Method: "POST",
MultiHeaders: map[string]*http.HeaderValues{
"Content-Type": {Values: []string{"application/json"}},
},
Body: body,
// Prevents every node from firing a duplicate alert
CacheSettings: &http.CacheSettings{Store: true},
}).Await()
if err != nil {
return 0, fmt.Errorf("PagerDuty request failed: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return 0, fmt.Errorf("PagerDuty request failed with status %d", resp.StatusCode)
}
return int(resp.StatusCode), nil
}
promise := cre.RunInNodeMode(c.options, runtime, sendAlert, cre.ConsensusIdenticalAggregation[int]())
return promise.Await()
}
Notes on this pattern:
- Secrets are only ever resolved with
runtime.GetSecret()/nodeRuntime.GetSecret()(backed by the Vault DON), never read from environment variables or hardcoded. Your library should accept a secret ID/name, not a raw credential, and let the consuming workflow own the actual value in CRE secrets. GetSecret()itself doesn't require node mode, but you'll typically call it from inside aRunInNodeModeblock anyway, since that's where you're already building the request that needs the secret value.- Non-idempotent calls (POST/PUT/PATCH/DELETE) should set
CacheSettingsso a single DON-wide action isn't repeated once per node. - Wrap HTTP calls in
cre.RunInNodeMode(...)with an explicit consensus aggregation (cre.ConsensusIdenticalAggregation,cre.ConsensusMedianAggregation, orcre.ConsensusAggregationFromTagsfor a tagged result struct) so the DON agrees on one result before your function returns control to the workflow. Bodyonhttp.Requestis a plain[]byte; unlike the TypeScript SDK, there's no base64-encoding step to worry about.- All SDK capability calls return a
Promise[T]that you resolve with.Await(), see the next section.
Promise/Await concurrency, not goroutines
The Go SDK doesn't use async/await keywords, it uses a Promise[T] type with .Await(), cre.Then(), and cre.ThenPromise() for chaining (see Core SDK Reference). This exists because the underlying operation only actually runs when you call .Await(), building a promise chain without awaiting it does nothing.
The pitfall specific to Go: don't reach for goroutines, channels, or sync primitives (WaitGroup, Mutex, and so on) to run multiple SDK calls "concurrently" inside your library. CRE workflows execute in a single-threaded WASM environment, so using Go's concurrency primitives to fan out capability calls doesn't get you real parallelism, and a select across multiple channels picks a ready channel non-deterministically, which will cause consensus failures if the result depends on which one "won."
- Never use
selectwith multiple ready channels to decide which result to use or which branch to take, see Avoiding Non-Determinism in Workflows. - If you need to make several capability calls, initiate them in a fixed order and resolve them (
.Await()) in that same fixed order. You can build up severalPromisevalues before awaiting any of them, but the order you await them in must never vary. cre.Then()/cre.ThenPromise()are the idiomatic way to chain dependent async steps without nested.Await()calls, prefer them over manually orchestrating goroutines.
Determinism
Every trigger callback in a CRE workflow runs independently on every DON node and must produce identical results (DON mode), except inside cre.RunInNodeMode() blocks, which are explicitly for non-deterministic, per-node work that then gets aggregated. If nodes diverge, they generate different request IDs for capability calls and consensus fails outright.
- Never call
time.Now()or any othertimepackage clock function. Always useruntime.Now()instead, so you don't need to branch on whether you're inside aRunInNodeModeblock. - Never use
math/rand(orcrypto/rand) directly. Useruntime.Rand()from the CRE SDK, which provides a consensus-safe generator so every node produces the same sequence. - Never iterate a Go map directly when the order affects your output. Go maps intentionally randomize iteration order. Use
cre.OrderedEntries(orcre.OrderedEntriesFuncfor non-cmp.Orderedkeys) instead offor k, v := range someMap. - Use
encoding/jsonv1, not v2. The v2 library uses randomized hashing for field ordering, which serializes the same struct differently across nodes. If you serialize Protocol Buffers, useproto.MarshalOptions{Deterministic: true}.Marshal()too, since the defaultproto.Marshaldoesn't guarantee field order. - Never use
selectwith multiple ready channels to make a decision, seePromise/Awaitconcurrency, not goroutines.
See Avoiding Non-Determinism in Workflows for the complete guide, including LLM output handling.
Numeric precision for onchain values
If your library produces or accepts values that will end up in a smart contract call, such as a price, an amount, or a uint256, use *big.Int, never a plain int/int64/float64. For monetary or price values that aren't already onchain integers, use a fixed-point decimal type such as github.com/shopspring/decimal rather than float64, which loses precision under repeated arithmetic. This matches the convention used throughout the CRE Go SDK's own examples.
Packaging your library
Structure it as an ordinary Go module, there's no special CRE project layout for a library, only for a workflow (which is itself a package inside the consuming project's single Go module).
Project structure
A library is a minimal Go module with its source files, module definition, and a README.
my-cre-library/
├── go.mod
├── go.sum
├── pagerduty.go
└── README.md
go.mod
The module definition declares the SDK as a normal dependency; Go's module resolution handles version unification automatically.
module github.com/you/my-cre-library
go 1.25.3
require github.com/smartcontractkit/cre-sdk-go v1.6.0
Key points:
- Use a normal
requireforgithub.com/smartcontractkit/cre-sdk-go, there's nopeerDependencyconcept in Go modules, and you don't need one. Go's module resolution (minimal version selection) automatically unifies the SDK version across your library and the consuming workflow'sgo.modto a single compatible version, so there's no risk of bundling two copies the way there is with npm. - Don't add a
//go:build wasip1build tag to your library's own files unless you have a specific reason to (for example, wrapping a WASI-only import).cre.Runtime,cre.NodeRuntime, and the capability client types are plain, portable Go, they compile under anyGOOS. Only the workflow's own entry point (the file callingwasm.NewRunner) needs thewasip1tag. Keeping your library tag-free means your library's owngo testsuite, and any consumer's tests that exercise your library, run under the host's nativeGOOSinstead of requiring a full WASM build. - Take advantage of the SDK's mock packages where they exist (for example,
github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/evm/mockfor EVM calls) to unit test logic that depends oncre.Runtimewithout runningcre workflow simulate. There is currently no equivalent mock package for the HTTP capability, so HTTP-calling code still needs an end-to-endcre workflow simulatepass to verify. - Keep your own dependency tree small. Everything you import gets compiled into the consuming workflow's single WASM binary, which is subject to production size and memory quotas (inspect them with
cre workflow limits export, see Testing Production Limits). A heavy library, or one with a dependency that unexpectedly requires cgo, can push an otherwise-fine workflow over its quota or break its WASM build entirely (cgo is not supported underwasip1).
Publishing
Go modules don't have a central publish step or registry like npm, you publish by pushing your code to a Git repository (typically GitHub) and tagging a semver release:
git tag v1.0.0
git push origin v1.0.0
Your module path (in go.mod) should match the repository's import path, for example github.com/you/my-cre-library. Nothing about tagging a release is CRE-specific. The workflow author's own go build/cre workflow simulate pipeline is what turns your published Go code into something that runs inside the WASM sandbox.
Using your library from a CRE workflow
Recall that a Go CRE project is a single Go module, so the workflow author adds your library with go get from the project root (not from inside the workflow subdirectory):
go get github.com/you/my-cre-library@v1.0.0
Then imports and calls it from their workflow handler, passing in the runtime they already have:
//go:build wasip1
package main
import (
"log/slog"
"github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron"
"github.com/smartcontractkit/cre-sdk-go/cre"
"github.com/smartcontractkit/cre-sdk-go/cre/wasm"
"github.com/you/my-cre-library"
)
type Config struct {
Schedule string `json:"schedule"`
}
var pagerDutyClient = pagerduty.New(pagerduty.Options{RoutingKeySecret: "pager-duty-routing-key"})
func onCronTrigger(config *Config, runtime cre.Runtime, trigger *cron.Payload) (string, error) {
_, err := pagerDutyClient.Trigger(runtime, pagerduty.Alert{
Summary: "ETH/USD price breached threshold",
Severity: "critical",
Source: "cre-workflow",
})
if err != nil {
return "", err
}
return "alert sent", nil
}
func InitWorkflow(config *Config, logger *slog.Logger, secretsProvider cre.SecretsProvider) (cre.Workflow[*Config], error) {
return cre.Workflow[*Config]{
cre.Handler(cron.Trigger(&cron.Config{Schedule: config.Schedule}), onCronTrigger),
}, nil
}
func main() {
wasm.NewRunner(cre.ParseJSON[*Config]).Run(InitWorkflow)
}
The workflow author still owns:
- Storing the actual secret value (
pager-duty-routing-key) in their CRE secrets; your library only ever refers to it by name. - Running
cre workflow simulateto verify your library behaves correctly compiled into their WASM binary before deploying.
Pre-publish checklist
Before you publish, run through this checklist to confirm your library follows every constraint covered in this guide. Each item maps to a pitfall or packaging requirement above.
- No filesystem access (
os.Open,os.ReadFile,os.WriteFile, and similar) anywhere in your library or its dependencies. See No filesystem or arbitrary network access. - No direct network access (
net.Dial,net/http, database drivers, gRPC dialing); all outbound calls go throughhttp.Client/http.SendRequest(orevm.Clientfor chain reads/writes). See No standard HTTP client. - HTTP calls, time, and randomness are wrapped in
cre.RunInNodeMode()with an explicit consensus aggregation, or delegated back to the caller'sruntime. Secrets are resolved withGetSecret(), typically from inside that same block. See No standard HTTP client. - No goroutines, channels, or
selectacross multiple channels used to make a decision that affects your function's output. SeePromise/Awaitconcurrency, not goroutines. - No direct map iteration where order affects the output, use
cre.OrderedEntries/cre.OrderedEntriesFunc. See Determinism. encoding/jsonv1 (not v2) and, if applicable,proto.MarshalOptions{Deterministic: true}for serialization. See Determinism.- No
time.Now(),math/rand, orcrypto/rand, useruntime.Now()andruntime.Rand(). See Determinism. *big.Intfor onchain integer values; a decimal type (notfloat64) for prices and other precision-sensitive values. See Numeric precision for onchain values.- Secrets are accepted as an ID/name, never as a literal value baked into your library. See No standard HTTP client.
- No
//go:build wasip1tag on library files unless truly required, keep your library testable with plaingo test. Seego.mod. - Verified end-to-end with
cre workflow simulatein a real (throwaway) CRE workflow that depends on your published or local module. See No filesystem or arbitrary network access.
Learn more
- Avoiding Non-Determinism in Workflows: The complete determinism guide
- SDK Reference: HTTP Client: Full
http.ClientAPI - SDK Reference: Core:
Runtime,NodeRuntime,Promise, andcre.OrderedEntries - SDK Reference: Consensus & Aggregation: Aggregation functions for
cre.RunInNodeMode - Secrets: Storing and retrieving secrets for a deployed workflow