Skill Guide | TypeScript SDK
APIs that stay simple, even when your use cases aren’t
This page reflects @resonatehq/sdk v0.11.4 (current on npm). APIs are subject to change in future releases.
Since v0.11.0, the SDK ships two interchangeable engines: the generator engine (function* with yield* ctx.run(fn), documented throughout this page) and an async/await engine where workflows are ordinary async functions and durable steps are await ctx.run(myFn, ...args). The async engine is imported from @resonatehq/sdk/async. See Async/await engine at the end of this page for a full walkthrough and migration guide.
Requires Node.js 22 or later and npm 10 or later.
Whether you are a human or an AI agent, this skill guide will help you develop applications using the Resonate TypeScript SDK. Though this is a skill guide, and not a detailed API reference and you will still likely need to refer to the detailed Resonate TypeScript SDK API reference for type definitions, defaults, and other technical details.
This skill sheet assumes that you already know Resonate is a good fit for your use case. If you are unsure, please refer to Why Resonate.
It's important to think about the potential architecture of your application as it ties to your use case. This can determine which APIs and options you will use for function activations. If you have a use case in mind, but haven't built with Resonate before, we recommend reviewing some of the common patterns in our example applications.
Installation#
How to install the Resonate Typescript SDK into your project.
To install the Resonate Typescript SDK, you can use any of your favorite package managers.
bun add @resonatehq/sdknpm install @resonatehq/sdkyarn add @resonatehq/sdkOnce installed, you can import the Resonate SDK into your TypeScript files, and start using the Resonate Client.
Initialization#
How to initialize a Resonate Client.
Initializing a Resonate Client gives you the APIs needed to register, activate, and await on functions.
import { Resonate } from "@resonatehq/sdk";
const resonate = new Resonate();- A Resonate Client can connect to either an in-memory local store and message source (best for development), or a remote store and message sources (best for moving out of development).
- You can only have one Resonate Client per process.
- A Resonate Client can only be used in the Ephemeral World to register and activate functions, it cannot be used inside Durable Functions. Use the Context APIs for function activations inside Durable Functions.
Upon initialization, the Resonate Client will look for environment variables and/or use the options passed to it. For example, to specify a Resonate Server and process group, pass the url and group in the options.
import { Resonate } from "@resonatehq/sdk";
const resonate = new Resonate({
// ...
url: "https://localhost:8001", // Resonate Server URL
group: "worker-group-a", // Process group name
// ...
});For a comprehensive list of Resonate Client defaults, see https://resonatehq.github.io/resonate-sdk-ts/classes/Resonate.html#constructor.
Zero-dependency development#
Unlike other Durable Execution offerings, apart from installing the SDK itself, Resonate enables you to get started without any additional dependencies. This is because Resonate can run in a local development mode that uses in-memory storage for promises and tasks.
This is ideal for getting started quickly or for integrating Resonate into an existing application without relying on dependencies.
Resonate will automatically operate in "local development mode" when no URL is provided via constructor options or environment variables.
import { Resonate } from "@resonatehq/sdk";
const resonate = new Resonate();Local development mode can be suitable for awhile. However, when you want to run multiple worker processes, persist state across restarts, or share work between machines, you will need to connect to a Resonate Server. See the Quickstart guide or How to run a Resonate Server for guidance on getting a server up and running.
Postgres-backed durable execution#
Alongside the in-memory local network and a remote Resonate Server, the TypeScript SDK can run durable execution directly on Postgres — with no separate Resonate Server process.
The Resonate protocol runs as stored procedures in a resonate schema in your database (see resonate-pg), and your workers talk to Postgres directly.
The Postgres network ships as a separate package export and connects through pg, which is a peer dependency — install it alongside the SDK:
npm install @resonatehq/sdk pgApply the resonate.sql schema (from resonate-pg) to a Postgres 16+ database, then wire a PostgresNetwork into the Resonate Client:
import { Resonate } from "@resonatehq/sdk";
import { PostgresNetwork } from "@resonatehq/sdk/postgres";
// Pass your Postgres connection string (for example, from the DATABASE_URL environment variable).
const network = new PostgresNetwork({
connectionString: process.env.DATABASE_URL ?? "postgres://localhost:5432/mydb",
group: "workers", // a task targets the group (anycast); defaults to "default"
});
const resonate = new Resonate({ network });PostgresNetwork also accepts pid (this process's id, which callbacks and listeners target directly; defaults to a random id), tickMs (the fallback poll interval, in milliseconds), and logger.
Both execution engines work over the Postgres network — pass the same network option to the Resonate class from @resonatehq/sdk/async.
Workers receive dispatched work over Postgres LISTEN/NOTIFY (with a periodic fallback poll, so a missed notification still gets picked up), and due timers advance whenever a worker is running.
Because there is no standalone server process, durable sleeps and timeouts advance only while at least one worker is connected — with every worker down, they are suspended until a worker reconnects (a standalone Resonate Server advances timers on its own).
This keeps the durable-execution stack on infrastructure you already run, with Postgres handling promise state and routing — a good fit for serverless and Postgres-centric deployments.
Like the SDK itself, the Postgres network is pre-1.0 and in active development; its API may change between releases.
Authentication#
The TypeScript SDK authenticates to a secured Resonate Server with a token.
Token-based authentication#
Use JWT bearer tokens for production deployments:
import { Resonate } from "@resonatehq/sdk";
const resonate = new Resonate({
url: "https://localhost:8001",
token: process.env.RESONATE_TOKEN, // Load from environment
});Earlier releases accepted auth: { username, password } and read RESONATE_USERNAME / RESONATE_PASSWORD, sending an Authorization: Basic header.
That was removed in v0.10.0 along with the rest of the networking rewrite, and the constructor now ignores unknown options rather than rejecting them.
An auth block carried forward from v0.9.x therefore raises no error — the client simply sends unauthenticated requests. Replace it with token.
Concurrency#
A single TypeScript worker handles multiple tasks concurrently on the Node.js event loop.
When the server dispatches tasks to a worker, each one starts executing immediately — tasks do not queue behind each other.
There is no concurrency limit configuration; all tasks run concurrently via async/await.
This is efficient for I/O-bound work but means CPU-intensive tasks will block the event loop and starve other in-flight tasks. If your worker holds in-process state that cannot be shared across tasks, consider running multiple single-purpose worker processes instead.
See Scaling — Worker concurrency for concurrency strategies and patterns for stateful workers.
Message sources#
A Resonate Client receives messages by making HTTP Long Polling requests to a Resonate Server. The messages tell the client/sdk what to work on next.
You can customize the transport by which the client receives messages using a transport plugin. See Message transports for the transports the current server ships.
Consuming Kafka with Resonate#
Example: Kafka worker. Kafka is the most common message-broker integration ask, so we ship a complete reference implementation. The pattern below works for any message broker — substitute your client library, keep the resonate.beginRun(messageId, "workflow", ...) shape.
There is no native Kafka transport in the current server. The supported pattern is a normal Kafka consumer that dispatches a Resonate workflow per message — a stable per-message identifier becomes the durable promise ID, so duplicate deliveries reconnect to the in-flight execution rather than starting over.
import { Kafka } from "kafkajs";
import { resonate } from "./workflow";
const kafka = new Kafka({ brokers: ["localhost:9092"] });
const consumer = kafka.consumer({ groupId: "workers" });
await consumer.connect();
await consumer.subscribe({ topic: "records_to_process", fromBeginning: true });
await consumer.run({
eachMessage: async ({ topic, partition, message }) => {
// topic-partition-offset uniquely identifies the message even when no key is set
const messageId = `${topic}-${partition}-${message.offset}`;
// messageId is the durable promise id — duplicate invocations dedupe.
await resonate.beginRun(messageId, "workflow", messageId, message.offset);
},
});If your producer sets a stable message key (or your payload carries a domain ID), that's an equally valid promise ID — pick whichever uniquely identifies the logical message.
The full example, with the workflow definition and a runnable Redpanda compose file, lives at resonatehq-examples/example-kafka-worker-ts — see also the Kafka worker example page for a side-by-side TypeScript / Python / Rust walkthrough.
→ Full guide: Kafka integration
Native kafka:// transport is specified in the Message Passing Protocol and on the roadmap. This page will gain a transport-plugin section once it ships.
Environment Variables#
The Resonate client constructor automatically inspects a handful of environment variables when instantiating the SDK.
Resolution order
When a Resonate Client is instantiated, explicit constructor arguments always win. If an argument is not provided, the client looks for related environment variables before falling back to built-in defaults.
The following order is used when determining the remote endpoint:
urlargument supplied to the constructor.RESONATE_URLenvironment variable.RESONATE_SCHEME,RESONATE_HOST, andRESONATE_PORTenvironment variables.- Built-in local development mode (no remote network).
If neither an argument nor environment variable produces a URL, the client falls back to a local in-memory network. This is convenient for unit tests or quick experiments that do not require a Resonate server.
RESONATE_URL#
-
Provides the full base URL (scheme, host, and port) for connecting to a remote Resonate server. Takes precedence over the individual scheme/host/port variables.
-
Default is unset
-
Example, compose from a single URL:
bashexport RESONATE_URL="https://resonate.example.com"typescriptimport { Resonate } from "@resonatehq/sdk"; const resonate = new Resonate(); // picks up RESONATE_URL automatically -
Example, compose a URL from components:
bashexport RESONATE_SCHEME=https export RESONATE_HOST=resonate.example export RESONATE_PORT=8443typescriptconst resonate = new Resonate(); // resolves to https://resonate.example:8443
RESONATE_SCHEME#
- Scheme to use when
RESONATE_URLis unset and a host is supplied. Combine withRESONATE_HOST(required) andRESONATE_PORT(optional) to build the full URL. - Default is http.
RESONATE_HOST#
- Hostname or IP address of the remote Resonate server. Must be present to use the scheme/host/port fallback path.
- Default is unset.
RESONATE_PORT#
- Port number to use when building the URL from scheme/host/port.
Ignored if
RESONATE_URLis provided. - Default is
8001.
Troubleshooting tips
- Prefer passing constructor arguments when bootstrapping in application code that owns configuration loading. Environment variables are ideal for CLI usage, tests, or infrastructure-managed settings.
- Always set
RESONATE_HOSTif you intend to rely on the scheme/host/port combination. Without a host the client will treat the configuration as missing and default to local mode.
Client APIs#
The following client APIs can only be used in the Ephemeral World, they can not be used inside Durable Functions.
.register()#
Register a function to expose it to the Resonate system. A registered function can then be activated using by the Resonate Client, Resonate CLI, or from inside another Durable Function using the Context APIs.
function foo(ctx: Context, ...args: any[]) {
// ...
return result;
}
resonate.register(foo);
// or alternatively
resonate.register("foo", (ctx: Context, ...args: any[]) => {
// ...
return result;
});You can specify a name for the function that is different from the function name by passing it as the first argument.
resonate.register("custom-foo", (ctx: Context, ...args: any[]) => {
// ...
return result;
});Registration does provide a stub that can be used to activate the function locally, ranther than using the run or rpc methods on the Resonate Client.
const fooStub = resonate.register("foo", (ctx: Context, ...args: any[]) => {
// ...
return result;
});
fooStub.run(promiseId, args);.setDependency()#
Resonate's .setDependency() method enables you to set a dependency for other functions in the local call graph to use.
resonate.setDependency("dependency-name", dependency);- Setting a dependency can only be done in the Ephemeral World.
- It is useful for sharing resources like database connections across functions.
- You can then access the dependency inside a Durable Function using Context's
.getDependency()method.
.run()#
Resonate's .run() method invokes a function in the same process and returns the result.
You can think of it as a "run right here" invocation.
After invocation, the function is considered durable and will recover in another process if required.
const result = await resonate.run("invocation-id", foo, ...args);.beginRun()#
Similar to .run() but instead of returning the result, returns a handle so the result can be awaited later.
const handle = await resonate.beginRun("invocation-id", foo, ...args);
const result = await handle.result();.rpc()#
Resonate's .rpc() method (Remote Procedure Call) invokes a function in a remote process and returns the result.
You can think of it as a "run somewhere else" invocation (Asynchronous Remote Procedure Call).
After invocation, the function is considered durable and will recover in another process if required.
// worker.ts
resonate.register("foo", (ctx: Context, ...args: any[]) => {
// ...
return result;
});// client.ts
const result = await resonate.rpc(
"invocation-id",
"foo",
...args,
resonate.options({
target: "poll://any@workers",
})
);.beginRpc()#
Similar to .rpc() but instead of returning the result, returns a handle so the result can be awaited later.
const handle = await resonate.beginRpc(
"invocation-id",
"foo",
...args,
resonate.options({
target: "poll://any@workers",
})
);
const result = await handle.result();.schedule()#
Resonate's .schedule() method allows you to schedule a function to be invoked on a specified cron schedule.
The scheduled function will be invoked until the schedule is deleted.
const schedule = await resonate.schedule(
"scheduled-foo",
"0 * * * *", // every hour, on the hour, UTC
foo,
...args
);Cron expressions are evaluated in UTC, and the day-of-week field uses Quartz numbering (1 = Sunday) — prefer day names like MON-FRI. See the Schedules & cron reference for the expression format, promise-ID templates, and the tags the server injects on fired promises.
Target routing. Each promise the schedule fires carries a resonate:target tag that routes it to a worker group — without it, fired promises are never dispatched to any worker (and the server release after v0.9.8 rejects the call outright; see Schedules & cron reference). .schedule() injects the tag for you from the target option (as the final argument, same as .run()/.rpc()), defaulting to the default group:
await resonate.schedule(
"nightly-report",
"0 2 * * *",
generateReport,
resonate.options({ target: "poll://any@report-workers" })
);Delete and inspect. The returned handle deletes the schedule; the resonate.schedules sub-client gives you direct get / create / delete access:
await schedule.delete(); // stop future ticks (already-fired promises are unaffected)
const record = await resonate.schedules.get("nightly-report");
console.log(record.nextRunAt);.options()#
Options can be used with .run(), .beginRun(), .rpc(), .beginRpc(), and .schedule() as the final argument.
await resonate.run(
"invocation-id",
foo,
...args,
resonate.options({
timeout: 60_000, // 1 minute in ms
target: "poll://any@workers",
tags: { key: "value" },
})
);Local calls (.run() / .beginRun()) also accept nonRetryableErrors, an array of error class constructors that should not be retried by Resonate's retry policy.
When the invoked function throws an instance of any listed class (or a subclass), Resonate skips the retry policy and rejects the call with the thrown error.
class ValidationError extends Error {}
await resonate.run(
"invocation-id",
foo,
...args,
resonate.options({
nonRetryableErrors: [ValidationError],
})
);.get()#
Resonate's .get() method allows you to subscribe to a function invocation.
If the function invocation does not exist, an error will be thrown.
const handle = await resonate.get("invocation-id");
const result = await handle.result();.promises.get()#
Resonate's .promises.get() method allows you to get a promise by ID.
const p = await resonate.promises.get("promise-id");.promises.create()#
Resonate's .promises.create() method allows you to create a promise.
await resonate.promises.create(
"promise-id",
Date.now() + 30000 // 30 seconds in the future
);.promises.resolve()#
Resonate's .promises.resolve() method settles a promise by ID into the resolved terminal state.
This is useful for HITL use cases where you want to wait for a human to approve a function execution.
It works well in conjunction with the .promise() method.
await resonate.promises.resolve(
id: string,
options?: { data?: string; headers?: Record<string, string> }
);Examples:
// Resolve a promise (optionally with data)
await resonate.promises.resolve("promise-id");
await resonate.promises.resolve("promise-id", {
data: JSON.stringify({ approved: true }),
});.promises.reject()#
Resonate's .promises.reject() method settles a promise by ID into the rejected terminal state.
await resonate.promises.reject(
id: string,
options?: { data?: string; headers?: Record<string, string> }
);Examples:
// Reject a promise (optionally with data describing the failure)
await resonate.promises.reject("promise-id");
await resonate.promises.reject("promise-id", {
data: JSON.stringify({ reason: "denied" }),
});.promises.cancel()#
Resonate's .promises.cancel() method settles a promise by ID into the rejected_canceled terminal state.
await resonate.promises.cancel(
id: string,
options?: { data?: string; headers?: Record<string, string> }
);Example:
// Cancel a promise
await resonate.promises.cancel("promise-id");.stop()#
Gracefully shuts down a Resonate Client. Closes the long-polling connection to the Resonate Server, stops the heartbeat loop, and clears the subscription refresh interval.
await resonate.stop();.stop() returns a Promise<void> and must be awaited.
When to call it. Call .stop() from any process that should exit after its work finishes — demo scripts, one-shot jobs, examples, CI tasks. Without it, the client keeps the Node.js event loop alive via the long-poll connection and the subscription refresh interval, and the process hangs after main() returns.
import { Resonate } from "@resonatehq/sdk";
async function main() {
const resonate = new Resonate();
resonate.register("greet", (ctx, name) => `Hello, ${name}!`);
const result = await resonate.run("greet-1", "greet", "world");
console.log(result);
// Required for the process to exit.
await resonate.stop();
}
main();Calling .stop() on a worker (an RPC service, a Kafka consumer, an MCP server, or any other long-lived process) tears down the very channels the worker uses to receive and hold work:
- The long-polling connection to the Resonate Server closes — the worker stops receiving dispatched tasks.
- The heartbeat loop stops — the server-side TTL on in-flight tasks expires, and the server reassigns them.
- The subscription refresh interval stops — listeners on awaited promises are no longer re-registered.
The worker keeps running but silently stops processing work. Workers should stay up; let process termination (SIGINT / SIGTERM) end the lifecycle.
Common pitfalls.
- Don't reuse the client after
.stop(). The transport is closed and the refresh interval is gone. Subsequent calls do not throw, but they will not behave correctly. Construct a newResonateinstance instead. - Always
awaitthe call. The unawaited form (resonate.stop();) starts the shutdown and discards the returned promise. Node drains the microtasks before exit, so a script that has nothing after the stop call still exits, but any code that follows — anotherawait, a secondresonate.run(), aprocess.exit(0)— runs against an in-flight teardown. Tests in the SDK uniformly useawait resonate.stop(); match that. - Between debug runs, stop the previous client. If you keep starting fresh workers in the same process group without stopping the previous one, the old workers still hold task leases and will pick up new work, producing surprising routing.
Context APIs#
How to use the Resonate Context object in the TypeScript SDK.
Resonate's Context object enables you to invoke functions from inside a Durable Function.
This is how you extend the Call Graph and create a world of Durable Functions.
Inside a Durable Function you use the yield* keyword to interact with the Context object.
.getDependency()#
Context's .getDependency() method allows you to get a dependency that was set in the ephemeral world using the .setDependency() method and use it the Durable World.
resonate.register("foo", function* (ctx: Context, ...args: any[]) {
// ...
const dependency = ctx.getDependency("dependency-name");
// do something with the dependency
// ...
});.run()#
Context's .run() method invokes a function in the same process in a synchronous manner.
That is — the calling function blocks until the invoked function returns.
resonate.register("foo", function* (ctx: Context, ...args: any[]) {
// ...
const result = yield* ctx.run(bar, ...args);
// do more stuff
// ...
});
function bar(ctx: Context, ...args: any[]) {
// ...
return;
}.beginRun()#
Context's .beginRun() method invokes a function in the same process in an asynchronous manner.
That is — the invocation returns a promise which can be awaited later.
resonate.register("foo", function* (ctx: Context, ...args: any[]) {
// ...
const promise = yield* ctx.beginRun(bar, ...args);
// do more stuff
const result = yield* promise;
// ...
});
function bar(ctx: Context, ...args: any[]) {
// ...
return;
}.rpc()#
Context's .rpc() method invokes a function in a remote process in a synchronous manner.
That is — the calling function blocks until the invoked function returns.
// process a
resonate.register("foo", function* (ctx: Context, ...args: any[]) {
// ...
const result = yield* ctx.rpc(
"bar",
...args,
ctx.options({ target: "poll://any@workers" })
);
// do more stuff
// ...
});
// process b
resonate.register("bar", function (ctx: Context, ...args: any[]) {
// ...
return;
});.beginRpc()#
Context's .beginRpc() method invokes a function in a remote process in an asynchronous manner.
That is — the invocation returns a promise which can be awaited on later.
// process a
resonate.register("foo", function* (ctx: Context, ...args: any[]) {
// ...
const promise = yield* ctx.beginRpc(
"bar",
...args,
ctx.options({ target: "poll://any@workers" })
);
// do more stuff
const result = yield* promise;
// ...
});
// process b
resonate.register("bar", function (ctx: Context, ...args: any[]) {
// ...
return;
});.detached()#
Context's .detached() method invokes a function in a remote process in an asynchronous manner
but unlike .beginRpc(), the promise is not implictly awaited.
Use .detached() when you want to fire-and-forget a function invocation.
resonate.register("foo", function* (ctx: Context, ...args: any[]) {
// ...
yield* ctx.detached("bar", ...args);
// do more stuff
});.options()#
Options can be used with .run(), .beginRun(), .rpc(), .beginRpc(), and .detached() as the final argument.
resonate.register("foo", function* (ctx: Context, ...args: any[]) {
// ...
yield* ctx.run(
bar,
...args,
ctx.options({
timeout: 60_000, // 1 minute in ms
target: "poll://any@workers",
tags: { key: "value" },
})
);
});Local calls (.run() / .beginRun()) also accept nonRetryableErrors, an array of error class constructors that should not be retried by Resonate's retry policy.
When the invoked function throws an instance of any listed class (or a subclass), Resonate skips the retry policy and rejects the call with the thrown error.
This pairs well with retryPolicy: errors that match nonRetryableErrors short-circuit retries, while all other errors still follow the configured policy.
class ValidationError extends Error {}
resonate.register("foo", function* (ctx: Context, input: unknown) {
yield* ctx.run(
validate,
input,
ctx.options({
nonRetryableErrors: [ValidationError],
})
);
});
function validate(ctx: Context, input: unknown) {
if (!isValid(input)) {
throw new ValidationError("input failed validation");
}
}.promise()#
Context's .promise() method allows you to get or create a promise that can be awaited on.
A new promise is created and its ID is generated automatically.
This is very useful for HITL (Human-In-The-Loop) use cases where you want to block progress until a human has taken an action or provided data.
It works well in conjunction with the .promises.resolve(), .promises.reject(), and .promises.cancel() methods.
resonate.register("foo", function* (ctx: Context, ...args: any[]) {
// ...
const promise = yield* ctx.promise();
// do more stuff
const result = yield* promise;
// ...
});You can also pass custom data into the promise.
resonate.register("foo", function* (ctx: Context, ...args: any[]) {
// ...
const promise = yield* ctx.promise({ data: { key: "value" } });
// do more stuff
const result = yield* promise;
// ...
});.sleep()#
There is no limit to how long the function can sleep.
This API accepts either a millisecond duration or an options object.
The options object can specify a duration with the for property or an absolute wake-up time with the until property.
yield* ctx.sleep(5_000); // wait 5 seconds
yield* ctx.sleep({ for: 5_000 }); // identical to passing the number
yield* ctx.sleep({ until: new Date() });Passing a number or for value always measures the delay in milliseconds from "now".
until expects a JavaScript Date instance representing the exact time when the workflow should resume.
The options object is useful when you want to build the wake-up time conditionally before calling sleep.
Sleep for a fixed duration.
import { Context, Resonate } from "@resonatehq/sdk";
const resonate = new Resonate();
resonate.register("send-reminder", function* (ctx: Context, userId: string) {
// Pause for five seconds without blocking the worker
yield* ctx.sleep(5_000);
yield* ctx.rpc("notify-user", userId);
});Sleep until a calendar time.
import { Context, Resonate } from "@resonatehq/sdk";
const resonate = new Resonate();
resonate.register("schedule-digest", function* (ctx: Context, userId: string) {
const nextEightAm = new Date();
nextEightAm.setHours(8, 0, 0, 0);
// If it is already past 8am today, schedule for tomorrow
if (nextEightAm.getTime() <= Date.now()) {
nextEightAm.setDate(nextEightAm.getDate() + 1);
}
// Resume exactly at 8am
yield* ctx.sleep({ until: nextEightAm });
yield* ctx.rpc("send-digest", userId);
});.date.now()#
Context's .date.now() method allows you to deterministically get the time in milliseconds since the epoch.
If your function execution is recovered after the time has been retrieved, the same time will be returned.
This is helpful for ensuring the same code path is taken in the event of a recovery.
resonate.register("foo", function* (ctx: Context, ...args: any[]) {
// ...
const time = yield* ctx.date.now();
// do something with time
// ...
});.math.random()#
Context's .math.random() method allows you to generate a deterministic random number.
If your function execution is recovered after the random number has been generated, the same number will be returned.
This is helpful for ensuring the same code path is taken in the event of a recovery.
resonate.register("foo", function* (ctx: Context, ...args: any[]) {
// ...
const rand = yield* ctx.math.random();
// do something with rand
// ...
});.panic() and .assert()#
Context's .panic() and .assert() aborts the top level durable execution based on a condition.
.panic() will abort if the condition is true. .assert() will abort if the condition is false.
These methods are intended to be used for invariant violations, for normal operation errors it is recommended
to throw an Exception.
resonate.register("foo", function* (ctx: Context) {
// ...
yield* ctx.panic(true, "Invariant violation");
// Code after this call will not be executed
});resonate.register("foo", function* (ctx: Context) {
// ...
yield* ctx.panic(false, "Invariant violation");
// Code after this call will be executed as normal
});resonate.register("foo", function* (ctx: Context) {
// ...
yield* ctx.assert(false, "Invariant violation");
// Code after this call will not be executed
});resonate.register("foo", function* (ctx: Context) {
// ...
yield* ctx.assert(true, "Invariant violation");
// Code after this call will be executed as normal
});Defaults#
The TypeScript SDK ships with these key defaults. All values are in milliseconds unless noted.
new Resonate()—group = "default",ttl = 60_000 ms,logLevel = "warn". URL falls back toRESONATE_URL; when both are unset, a local in-memory network is used.HttpNetwork— requesttimeout = 10_000 ms(overridable viaRESONATE_TIMEOUT), URL fallback"http://localhost:8001".ctx.run/Options—timeout = 86_400_000 ms (24 h),target = "default",version = 0,tags = {},nonRetryableErrors = [].retryPolicy— generator engine —Exponential()for regularasyncfunctions;Never()for generator functions (function*).retryPolicy— async engine —Never()for all function types; opt in per call withctx.options({ retryPolicy: new Exponential() }).Exponential()defaults —delay = 1_000 ms,factor = 2,maxDelay = 30_000 ms,maxRetries = Number.MAX_SAFE_INTEGER.Constant()/Linear()defaults —delay = 1_000 ms,maxRetries = Number.MAX_SAFE_INTEGER.
For the full table, per-SDK comparison, and source citations, see the Defaults reference.
Async/await engine#
Since v0.11.0, the TypeScript SDK ships a second execution engine alongside the generator engine. Both engines speak the same protocol to the same server and use the same durable-promise model — you can run them in the same application side by side and migrate one function at a time.
When to use which engine:
- Generator engine (
import { Resonate } from "@resonatehq/sdk",function*+yield*) — the original engine; sequential control flow made explicit at each step; good when you want durable checkpoints to be visible as you read the code. - Async/await engine (
import { Resonate } from "@resonatehq/sdk/async",async function+await) — operations are eager, fan-out is ordinaryPromise.all, workflows look like regular Node.js async code; closest to what Restate or DBOS users will already be writing; retries default toNeverfor all function types — opt in explicitly per call.
Import#
The async engine lives at a separate package export — both Resonate classes accept the same constructor options:
import { Resonate, type Context, Exponential, Linear, Constant, Never } from "@resonatehq/sdk/async";Register and run#
Workflows are ordinary async functions. Register and invoke them the same way as in the generator engine:
import { Resonate, type Context } from "@resonatehq/sdk/async";
async function greet(ctx: Context, name: string): Promise<string> {
return `Hello, ${name}!`;
}
const resonate = new Resonate({ url: "http://localhost:8001" });
resonate.register(greet);
// resonate.run() returns a handle, not the result directly
const handle = await resonate.run("greet-1", greet, "world");
const result = await handle.result(); // Hello, world!
await resonate.stop();Key difference from the generator engine: resonate.run() returns Promise<Handle>, not the result directly. Call .result() on the handle to get the value. There are no beginRun or beginRpc variants in the async engine — every run and rpc on the client already returns a handle, so beginRun would be redundant.
Context APIs — eager operations and fan-out#
Inside an async workflow, ctx.run(fn) starts the durable operation immediately and returns an awaitable DurablePromise. Because operations are eager, fan-out is plain Promise.all:
import { type Context } from "@resonatehq/sdk/async";
async function processItems(ctx: Context, items: string[]) {
// All durable steps start immediately and run concurrently
const handles = items.map(item => ctx.run(processItem, item));
const results = await Promise.all(handles);
return results;
}
async function processItem(ctx: Context, item: string): Promise<string> {
return `processed: ${item}`;
}In the generator engine, yield* ctx.run() blocks until the step completes, and fan-out requires yield* ctx.beginRun(). In the async engine, ctx.run() always starts eagerly — await ctx.run(fn) is call-and-wait, while ctx.run(fn) without an immediate await is fire-and-collect-later.
A plain await on a timer, network I/O, or any non-durable promise is invisible to the engine. The workflow may resume after its execution pass has ended, making the plain-await continuation a zombie. Wrap all side effects in ctx.run, ctx.rpc, or ctx.sleep — the same rule as yield* ctx.run(fn) in the generator engine.
Retries — opt in explicitly#
The async engine defaults to Never for all retry policies. This differs from the generator engine, where a plain async function registered with resonate.register gets Exponential retries by default. The reason: an async workflow and a plain async helper are indistinguishable at the type level, so a blanket retry default would silently retry code you never intended to retry.
Opt in per call via ctx.options({ retryPolicy }):
import { type Context, Exponential } from "@resonatehq/sdk/async";
async function checkout(ctx: Context) {
// No retry by default; opt in explicitly per call
const chargeResult = await ctx.run(
chargeCard,
ctx.options({ retryPolicy: new Exponential() })
);
return chargeResult;
}
async function chargeCard(ctx: Context) {
// ... charge logic ...
}Exponential, Linear, Constant, and Never are all exported from @resonatehq/sdk/async.
Migrating from the generator engine#
Both engines live in the same package and speak the same protocol, so you can migrate function by function. The mechanical changes:
| Generator engine | Async engine |
|---|---|
import { Resonate } from "@resonatehq/sdk" | import { Resonate } from "@resonatehq/sdk/async" |
function* (ctx: Context, ...) | async function (ctx: Context, ...) |
yield* ctx.run(fn, ...args) | await ctx.run(fn, ...args) |
yield* ctx.beginRun(fn), later yield* future | const p = ctx.run(fn), later await p — every op is eager; no begin* variants |
resonate.run(id, fn, ...args) → returns result | resonate.run(id, fn, ...args) → returns handle; await handle.result() to get value |
resonate.beginRun(id, fn, ...args) → returns handle | beginRun does not exist — resonate.run already returns a handle, making it equivalent |
ctx.run retries: Exponential() for async fns by default | ctx.run retries: Never() by default — pass ctx.options({ retryPolicy: new Exponential() }) |
For the full walkthrough, see the SDK README migration guide.