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 npm package that other developers can install and import directly into their own CRE TypeScript 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 package to npm that other people's CRE workflows will depend on.

Your library runs where the workflow runs

A CRE TypeScript workflow is not run by Node.js. The workflow is transpiled and compiled into a single WebAssembly binary that executes inside QuickJS, a minimal JavaScript engine embedded via Javy. When a workflow author adds your package as a dependency, your code gets bundled into that same WASM binary, so it runs under the exact same restrictions as the workflow's own code. See TypeScript Runtime Environment for the full compilation pipeline.

Running inside that WASM sandbox has two direct consequences for your library:

  • There is no Node.js runtime underneath your library at execution time: no fs, no http, no native modules.
  • There is no network stack for your code to call into directly. All non-deterministic work (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.

Design your library around these constraints from the start. Don't write a generic Node or browser package and assume it will run unmodified. Instead, build it directly against the CRE SDK's runtime primitives.

Pitfalls

Because your library runs inside the same WASM sandbox as the workflow, the code you write can't rely on the Node.js or browser runtimes you're used to. The sections below cover the pitfalls that trip up most library authors: missing Node.js built-ins, no standard HTTP client, no top-level async/await around SDK calls, 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 Node.js built-ins

QuickJS provides standard ECMAScript (Map, Set, Promise, most ES2020+ syntax) but not the Node.js API surface. The following Node.js built-in modules are unavailable and will fail at compile or runtime if your library, or one of its transitive dependencies, imports them:

Node.js built-inWhat it's typically used for
fsFile system access
pathPath manipulation
cryptoHashing, signing, random bytes
processProcess info, process.env
http, httpsHTTP servers and clients
netTCP sockets
streamStreaming data
child_processSpawning subprocesses
osOperating system info
worker_threadsWorker threads
clusterMulti-process clustering
dgramUDP sockets
dnsDNS resolution
tlsTLS/SSL
vmRunning code in a VM
zlibCompression
readlineReading input line by line
eventsNode's EventEmitter
utilNode's promisify, etc.

These restrictions have several practical implications for the code you write in your library:

  • Buffer is available and is the standard way to base64-encode request bodies.

    Despite buffer being a Node built-in in principle, every CRE HTTP example in this guide uses Buffer.from(bytes).toString("base64") to encode request bodies, and it works the same in simulation and production. Use it the way the reference examples do instead of working around it with manual btoa/String.fromCharCode calls. TextEncoder/TextDecoder are also available and are the standard way to convert between strings and Uint8Array.

  • No process.env.

    The process global doesn't exist in the WASM sandbox, so process.env is unavailable. Accept configuration through your library's function arguments, and read secrets with runtime.getSecret() (see Secrets).

  • No Node crypto.

    If you need hashing or signing, use a pure-JS library with no native bindings. Noble is a popular JavaScript cryptography library with minimal dependencies that works well with QuickJS. Always verify any third-party library in simulation before publishing.

  • No setTimeout/setInterval.

    The WASM execution model is synchronous; there is no event loop to schedule against.

Before depending on any third-party package inside your library, check its package.json and source for the built-ins above. When in doubt, check the QuickJS Node.js compatibility reference, and confirm with cre workflow simulate in a throwaway test workflow, since simulation runs your code in the same WASM environment as production, so incompatibilities surface immediately.

Alternatives to common Node built-ins

Instead of...Use...Notes
node:crypto@noble/hashes, @noble/curvesPure JS, no native bindings.
ethers (uses node:crypto internally)viemVerified compatible. Use for ABI encoding/decoding, address/unit utilities, and general Ethereum type handling.
axios, node-fetch, got, undiciHTTPClient from @chainlink/cre-sdkNot optional since there is no working substitute; every HTTP call must go through the SDK's node-mode and consensus path.
ws (WebSockets)Poll via HTTPClient on a cron or HTTP trigger insteadPersistent socket connections are not supported in the WASM sandbox.
dotenv / process.envruntime.getSecret() or function parametersThere is no process, see above.
import { Buffer } from "node:buffer"The global Buffer (already available, no import needed)Buffer is available as a global, so importing it from node:buffer is unnecessary.
uuid / crypto.randomUUID()Math.random()-based generation inside the CRE runtime, or a uuid package's pure-JS buildSome uuid package builds pull in node:crypto for randomUUID. Verify with cre workflow simulate.
lodash, date-fns, dayjs, zodGenerally fine as-isPure-JS utility/validation libraries with no Node built-ins typically work unmodified. zod is a documented SDK dependency. Still confirm with simulation.
Node's events (EventEmitter)Plain callbacks/arrays, or a pure-JS emitter with zero dependenciesNode's own EventEmitter isn't available; most third-party emitters that don't import node:events work fine.

No standard HTTP client

Do not depend on axios, node-fetch, ws, or any HTTP or socket library, since they all sit on top of Node's http/net/stream modules and will not work. Additionally, there is no bare global fetch you can rely on directly from a library the way you would in a browser or in Node 18+.

Instead, all outbound requests must go through HTTPClient from @chainlink/cre-sdk. Beyond being a technical requirement, this is 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 shells out to axios 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 (or, in some cases, a NodeRuntime) as a parameter, and use it to construct requests via HTTPClient, exactly as a workflow author would inline. Keep your library a thin, well-tested wrapper around that Runtime-based request pattern.

// src/index.ts, inside your library package
import { HTTPClient, consensusIdenticalAggregation, ok, type Runtime, type NodeRuntime } from "@chainlink/cre-sdk"

export interface PagerDutyOptions {
  routingKeySecret: string
}

export interface PagerDutyAlert {
  summary: string
  severity: "critical" | "error" | "warning" | "info"
  source: string
}

export class PagerDuty {
  constructor(private options: PagerDutyOptions) {}

  trigger(runtime: Runtime<unknown>, alert: PagerDutyAlert): { statusCode: number } {
    const sendAlert = (nodeRuntime: NodeRuntime<unknown>): { statusCode: number } => {
      const routingKey = nodeRuntime.getSecret({ id: this.options.routingKeySecret }).result().value

      const payload = {
        routing_key: routingKey,
        event_action: "trigger",
        payload: alert,
      }

      const bodyBytes = new TextEncoder().encode(JSON.stringify(payload))
      const body = Buffer.from(bodyBytes).toString("base64")

      const httpClient = new HTTPClient()
      const response = httpClient
        .sendRequest(nodeRuntime, {
          url: "https://events.pagerduty.com/v2/enqueue",
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body,
          // Prevents every node from firing a duplicate alert
          cacheSettings: { readFromCache: true, maxAgeMs: 60_000 },
        })
        .result()

      if (!ok(response)) {
        throw new Error(`PagerDuty request failed with status ${response.statusCode}`)
      }

      return { statusCode: response.statusCode }
    }

    return runtime.runInNodeMode(sendAlert, consensusIdenticalAggregation<{ statusCode: number }>())().result()
  }
}

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 runtime.runInNodeMode(...) with an explicit consensus aggregation (consensusIdenticalAggregation, consensusMedianAggregation, or ConsensusAggregationByFields for multi-field objects) so the DON agrees on one result before your function returns control to the workflow.
  • All SDK capability calls use the .result() pattern instead of await, see the next section.

No top-level async/await around SDK calls

Promise and async/await exist in QuickJS, but CRE SDK capabilities (HTTPClient, EVMClient, secrets, and so on) do not use them, they use a synchronous .result() handshake between the WASM guest and the CRE host instead, because the guest/host boundary can't await across WASM calls. This means:

  • Never use Promise.race() / Promise.any() around capability calls: result order between nodes is not deterministic and will break consensus.
  • Write your library's functions as plain synchronous functions that call .result() inline, matching how workflow code itself is written. You can still use async/await for your own internal pure-JS logic that doesn't touch the SDK, but don't expose an async public API that wraps SDK calls, since it will mislead consumers into thinking they should await your function when they shouldn't (and can't, at the top level of a handler).

Determinism

Every trigger callback in a CRE workflow runs independently on every DON node and must produce identical results (DON mode), except inside runtime.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 Date.now() or new Date(). Always use runtime.now() instead, it's available on both Runtime and NodeRuntime, so you don't need to branch on whether you're inside a runInNodeMode block.
  • Math.random() is safe, but only inside the CRE WASM runtime. The CRE runtime overrides Math.random() with a seeded generator so every node produces the same sequence, and it isn't cryptographically secure, so don't use it for anything security-sensitive such as key generation or signing nonces. Randomness computed outside that runtime, such as a pre-computed value passed in or a dependency with its own PRNG, won't go through CRE's override and will diverge across nodes.
  • Never use Promise.race(), Promise.any(), or unpredictably-ordered Promise.all() around .result() calls. Call .result() on each operation in a fixed order instead, for example always try API 1 then fall back to API 2. You can still initiate multiple requests before resolving any of them, as long as the resolution order never varies.
  • Prefer Map/Set over plain objects when output order matters, and avoid for...in. Since ES2015, plain-object key enumeration order is defined by spec (integer-like keys ascending, then string keys in insertion order) and is consistent across DON nodes running the same engine, so it isn't a source of non-determinism on its own. The real risk is for...in also walking inherited enumerable properties. Use Object.keys(obj), or Map/Set, which guarantee insertion order without that gotcha.

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 bigint (the 123n suffix), never number. JavaScript number silently loses precision above 2^53, which corrupts large integers without throwing an error. For scaling between human-readable decimals and fixed-point onchain representations, use viem's parseUnits()/formatUnits() (string-based, no floating-point math) rather than hand-rolled * 10**18 arithmetic.

Packaging your library

Structure it as an ordinary TypeScript npm package, there's no special CRE project layout for a library, only for a workflow.

Project structure

A library is a minimal npm package with a single source file, its build config, and a README.

my-cre-library/
├── src/
│   └── index.ts
├── package.json
├── tsconfig.json
└── README.md

package.json

The manifest declares the SDK as a peer dependency and points consumers at your compiled output.

{
  "name": "my-cre-library",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "build": "tsc"
  },
  "peerDependencies": {
    "@chainlink/cre-sdk": ">=1.0.0"
  },
  "devDependencies": {
    "@chainlink/cre-sdk": "latest",
    "typescript": "^5.9.0"
  }
}

Key points:

  • Declare @chainlink/cre-sdk as a peerDependency, not a regular dependency. The consuming workflow already depends on @chainlink/cre-sdk directly, so a peer dependency avoids bundling two separate copies of the SDK into the same WASM binary and avoids version-mismatch surprises. Add it to devDependencies too so your own build and typecheck work.
  • Ship plain compiled JS and .d.ts declarations. tsc output is enough, no bundler is required, since the workflow's own build step is what ultimately compiles everything down to WASM via Bun and Javy. Keep your compiled output free of Node-specific syntax; target ES2020/ESNext module output, not CommonJS.
  • Don't ship a postinstall script, that's a workflow-project concern (bunx cre-setup), not a library concern.
  • Keep your own dependency tree small. Everything you import gets bundled 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 can push an otherwise-fine workflow over its quota.

tsconfig.json

The compiler settings mirror the workflow's own so your output stays compatible with the WASM build.

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*.ts"]
}

Publishing

Publish it like any other npm package:

npm run build
npm publish --access public

Nothing about npm publish itself is CRE-specific. The workflow author's bun install / cre-setup pipeline is what turns your published JS into something that runs inside the WASM sandbox.

Using your library from a CRE workflow

The workflow author adds it like any other dependency:

cd my-workflow
bun add my-cre-library

Then imports and calls it from their workflow handler, passing in the runtime they already have:

import { CronCapability, handler, Runner, type Runtime } from "@chainlink/cre-sdk"
import { PagerDuty } from "my-cre-library"

type Config = { schedule: string }

const pagerDuty = new PagerDuty({ routingKeySecret: "pager-duty-routing-key" })

const onCronTrigger = (runtime: Runtime<Config>): string => {
  pagerDuty.trigger(runtime, {
    summary: "ETH/USD price breached threshold",
    severity: "critical",
    source: "cre-workflow",
  })
  return "alert sent"
}

const initWorkflow = (config: Config) => {
  const cron = new CronCapability()
  return [handler(cron.trigger({ schedule: config.schedule }), onCronTrigger)]
}

export async function main() {
  const runner = await Runner.newRunner<Config>()
  await runner.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 import of fs, path, crypto, process, http, https, net, stream, or any other Node built-in (aside from Buffer, which is available), directly or transitively. See No Node.js built-ins.
  • All outbound network calls go through HTTPClient (or EVMClient for chain reads/writes), never a third-party HTTP or socket library. See No standard HTTP client.
  • HTTP calls, time, and randomness are wrapped in runtime.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 Promise.race(), Promise.any(), or order-unpredictable Promise.all() around .result() calls; resolution order is always fixed. See No top-level async/await around SDK calls and Determinism.
  • No plain-object for...in iteration relying on inherited properties; use Object.keys(), or Map/Set, instead. See Determinism.
  • No async public API wrapping SDK capability calls; expose the synchronous .result()-based pattern. See No top-level async/await around SDK calls.
  • Secrets are accepted as an ID/name, never as a literal value baked into your library. See No standard HTTP client.
  • @chainlink/cre-sdk is a peerDependency, not a bundled dependency. See package.json.
  • Verified end-to-end with cre workflow simulate in a real (throwaway) CRE workflow that depends on your published or local package. See No Node.js built-ins.

Learn more

Get the latest Chainlink content straight to your inbox.