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, nohttp, 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-in | What it's typically used for |
|---|---|
fs | File system access |
path | Path manipulation |
crypto | Hashing, signing, random bytes |
process | Process info, process.env |
http, https | HTTP servers and clients |
net | TCP sockets |
stream | Streaming data |
child_process | Spawning subprocesses |
os | Operating system info |
worker_threads | Worker threads |
cluster | Multi-process clustering |
dgram | UDP sockets |
dns | DNS resolution |
tls | TLS/SSL |
vm | Running code in a VM |
zlib | Compression |
readline | Reading input line by line |
events | Node's EventEmitter |
util | Node's promisify, etc. |
These restrictions have several practical implications for the code you write in your library:
-
Bufferis available and is the standard way to base64-encode request bodies.Despite
bufferbeing a Node built-in in principle, every CRE HTTP example in this guide usesBuffer.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 manualbtoa/String.fromCharCodecalls.TextEncoder/TextDecoderare also available and are the standard way to convert between strings andUint8Array. -
No
process.env.The
processglobal doesn't exist in the WASM sandbox, soprocess.envis unavailable. Accept configuration through your library's function arguments, and read secrets withruntime.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/curves | Pure JS, no native bindings. |
ethers (uses node:crypto internally) | viem | Verified compatible. Use for ABI encoding/decoding, address/unit utilities, and general Ethereum type handling. |
axios, node-fetch, got, undici | HTTPClient from @chainlink/cre-sdk | Not 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 instead | Persistent socket connections are not supported in the WASM sandbox. |
dotenv / process.env | runtime.getSecret() or function parameters | There 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 build | Some uuid package builds pull in node:crypto for randomUUID. Verify with cre workflow simulate. |
lodash, date-fns, dayjs, zod | Generally fine as-is | Pure-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 dependencies | Node'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 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
runtime.runInNodeMode(...)with an explicit consensus aggregation (consensusIdenticalAggregation,consensusMedianAggregation, orConsensusAggregationByFieldsfor 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 ofawait, 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 useasync/awaitfor your own internal pure-JS logic that doesn't touch the SDK, but don't expose anasyncpublic API that wraps SDK calls, since it will mislead consumers into thinking they shouldawaityour 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()ornew Date(). Always useruntime.now()instead, it's available on bothRuntimeandNodeRuntime, so you don't need to branch on whether you're inside arunInNodeModeblock. Math.random()is safe, but only inside the CRE WASM runtime. The CRE runtime overridesMath.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-orderedPromise.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/Setover plain objects when output order matters, and avoidfor...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 isfor...inalso walking inherited enumerable properties. UseObject.keys(obj), orMap/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-sdkas apeerDependency, not a regular dependency. The consuming workflow already depends on@chainlink/cre-sdkdirectly, so a peer dependency avoids bundling two separate copies of the SDK into the same WASM binary and avoids version-mismatch surprises. Add it todevDependenciestoo so your own build and typecheck work. - Ship plain compiled JS and
.d.tsdeclarations.tscoutput 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; targetES2020/ESNextmodule output, not CommonJS. - Don't ship a
postinstallscript, 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 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 import of
fs,path,crypto,process,http,https,net,stream, or any other Node built-in (aside fromBuffer, which is available), directly or transitively. See No Node.js built-ins. - All outbound network calls go through
HTTPClient(orEVMClientfor 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'sruntime. Secrets are resolved withgetSecret(), typically from inside that same block. See No standard HTTP client. - No
Promise.race(),Promise.any(), or order-unpredictablePromise.all()around.result()calls; resolution order is always fixed. See No top-levelasync/awaitaround SDK calls and Determinism. - No plain-object
for...initeration relying on inherited properties; useObject.keys(), orMap/Set, instead. See Determinism. - No
asyncpublic API wrapping SDK capability calls; expose the synchronous.result()-based pattern. See No top-levelasync/awaitaround 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-sdkis apeerDependency, not a bundled dependency. Seepackage.json.- Verified end-to-end with
cre workflow simulatein a real (throwaway) CRE workflow that depends on your published or local package. See No Node.js built-ins.
Learn more
- TypeScript Runtime Environment: QuickJS compatibility and the WASM compilation pipeline
- Avoiding Non-Determinism in Workflows: The complete determinism guide
- SDK Reference: HTTP Client: Full
HTTPClientAPI - SDK Reference: Core:
Runtime,NodeRuntime, and the.result()pattern - Secrets: Storing and retrieving secrets for a deployed workflow