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's http.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 a RunInNodeMode block 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 CacheSettings so 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, or cre.ConsensusAggregationFromTags for a tagged result struct) so the DON agrees on one result before your function returns control to the workflow.
  • Body on http.Request is 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 select with 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 several Promise values 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 other time package clock function. Always use runtime.Now() instead, so you don't need to branch on whether you're inside a RunInNodeMode block.
  • Never use math/rand (or crypto/rand) directly. Use runtime.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 (or cre.OrderedEntriesFunc for non-cmp.Ordered keys) instead of for k, v := range someMap.
  • Use encoding/json v1, not v2. The v2 library uses randomized hashing for field ordering, which serializes the same struct differently across nodes. If you serialize Protocol Buffers, use proto.MarshalOptions{Deterministic: true}.Marshal() too, since the default proto.Marshal doesn't guarantee field order.
  • Never use select with multiple ready channels to make a decision, see Promise/Await concurrency, 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 require for github.com/smartcontractkit/cre-sdk-go, there's no peerDependency concept 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's go.mod to 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 wasip1 build 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 any GOOS. Only the workflow's own entry point (the file calling wasm.NewRunner) needs the wasip1 tag. Keeping your library tag-free means your library's own go test suite, and any consumer's tests that exercise your library, run under the host's native GOOS instead 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/mock for EVM calls) to unit test logic that depends on cre.Runtime without running cre workflow simulate. There is currently no equivalent mock package for the HTTP capability, so HTTP-calling code still needs an end-to-end cre workflow simulate pass 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 under wasip1).

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 simulate to 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 through http.Client/http.SendRequest (or evm.Client for 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's runtime. Secrets are resolved with GetSecret(), typically from inside that same block. See No standard HTTP client.
  • No goroutines, channels, or select across multiple channels used to make a decision that affects your function's output. See Promise/Await concurrency, not goroutines.
  • No direct map iteration where order affects the output, use cre.OrderedEntries/cre.OrderedEntriesFunc. See Determinism.
  • encoding/json v1 (not v2) and, if applicable, proto.MarshalOptions{Deterministic: true} for serialization. See Determinism.
  • No time.Now(), math/rand, or crypto/rand, use runtime.Now() and runtime.Rand(). See Determinism.
  • *big.Int for onchain integer values; a decimal type (not float64) 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 wasip1 tag on library files unless truly required, keep your library testable with plain go test. See go.mod.
  • Verified end-to-end with cre workflow simulate in a real (throwaway) CRE workflow that depends on your published or local module. See No filesystem or arbitrary network access.

Learn more

Get the latest Chainlink content straight to your inbox.