Resonate HTTP Service Design
SDK version: This skill reflects
@resonatehq/sdkv0.11.4 (current on npm).
Overview
Use this skill to design HTTP services where route handlers start or await durable workflows. The HTTP server is the entrypoint; durable functions do the work and coordinate via Resonate. Downstream services (like a database service) expose their own durable functions and are invoked via RPC.
Architecture Model
- HTTP service: Express/Fastify server handling routes and mapping requests to durable workflows.
- Worker service: Resonate worker group that runs durable functions for business logic.
- DB service: Separate worker group exposing durable DB functions via Resonate RPC.
- Resonate Server: Durable promise store and coordination hub.
client -> HTTP routes -> Resonate Client (beginRpc/run)
-> worker group (durable workflow)
-> ctx.rpc -> db worker group (durable db functions)
Rules
- Route handlers are ephemeral: use Resonate Client APIs, not Context APIs.
- Durable functions are generator functions:
function*withyield*. - All external effects occur in durable steps and are awaited or explicitly detached.
- Use stable promise IDs for idempotency and replay safety.
Route Design Patterns
1) Submit and poll (async HTTP)
POST /jobsstarts a workflow and returnsjobId.GET /jobs/:idreturns status or result.
2) Submit and callback
POST /jobsstarts a workflow and returnsjobId.- External system resolves a promise when done.
3) Webhook gate
POST /webhooks/serviceresolves a durable promise tied to a workflow.
Code Examples
HTTP server entrypoints (Express)
import express from "express";
import { Resonate } from "@resonatehq/sdk";
import crypto from "node:crypto";
const app = express();
app.use(express.json());
const resonate = new Resonate({
url: "http://localhost:8001",
group: "api",
});
// Start workflow
app.post("/jobs", async (req, res) => {
const jobId = `job/${crypto.randomUUID()}`;
await resonate.beginRpc(
jobId,
"process-job",
req.body,
resonate.options({ target: "poll://any@workers" })
);
res.status(202).json({ id: jobId });
});
// Poll status
app.get("/jobs/:id", async (req, res) => {
const handle = await resonate.get(req.params.id);
const result = await handle.result();
res.json({ id: req.params.id, result });
});
// Look up a pending approval promise for a job.
// The worker records the generated promise id against the job (see below);
// this route reads it back through the db service. There is no promise-search
// method on the SDK client, so the mapping has to live in your own storage.
app.get("/jobs/:id/approval", async (req, res) => {
const promiseId = await resonate.rpc(
`approval-lookup/${req.params.id}`,
"db.getApprovalPromiseId",
req.params.id,
resonate.options({ target: "poll://any@db" })
);
if (!promiseId) {
res.status(404).json({ error: "no pending approval" });
return;
}
res.json({ promiseId });
});
// Webhook: external system resolves promise
app.post("/webhooks/approval", async (req, res) => {
const { promiseId, approved } = req.body;
const data = Buffer.from(JSON.stringify({ approved })).toString("base64");
await resonate.promises.resolve(promiseId, { data });
res.status(204).end();
});
Worker service (durable workflow)
import { Resonate, type Context } from "@resonatehq/sdk";
const resonate = new Resonate({ url: "http://localhost:8001", group: "workers" });
function* processJob(ctx: Context, payload: { accountId: string }) {
const account = yield* ctx.rpc(
"db.getAccount",
payload.accountId,
ctx.options({ target: "poll://any@db" })
);
const result = yield* ctx.run(processAccount, account);
// The id is generated by the SDK, so create the promise first, then record
// the id where the HTTP layer can find it.
const approval = yield* ctx.promise<boolean>({ tags: { job: ctx.id } });
yield* ctx.rpc(
"db.saveApprovalPromiseId",
{ jobId: ctx.id, promiseId: approval.id },
ctx.options({ target: "poll://any@db" })
);
const ok = yield* approval;
if (!ok) {
throw new Error("rejected");
}
yield* ctx.rpc(
"db.saveResult",
{ id: ctx.id, result },
ctx.options({ target: "poll://any@db" })
);
return { status: "done", result };
}
resonate.register("process-job", processJob);
DB service (durable database functions)
import { Resonate, type Context } from "@resonatehq/sdk";
const resonate = new Resonate({ url: "http://localhost:8001", group: "db" });
function* getAccount(_: Context, id: string) {
return await db.loadAccount(id);
}
function* saveResult(_: Context, record: { id: string; result: unknown }) {
await db.save(record);
return { ok: true };
}
function* saveApprovalPromiseId(
_: Context,
record: { jobId: string; promiseId: string }
) {
await db.saveApprovalPromiseId(record.jobId, record.promiseId);
return { ok: true };
}
function* getApprovalPromiseId(_: Context, jobId: string) {
return await db.loadApprovalPromiseId(jobId);
}
resonate.register("db.getAccount", getAccount);
resonate.register("db.saveResult", saveResult);
resonate.register("db.saveApprovalPromiseId", saveApprovalPromiseId);
resonate.register("db.getApprovalPromiseId", getApprovalPromiseId);
Determinism Notes
- Use
ctx.date.now()andctx.math.random()inside durable functions. - Wrap side effects in durable steps (
ctx.run,ctx.rpc). - Ensure returned objects are serializable.
Structured Concurrency Example
function* aggregate(ctx: Context, ids: string[]) {
const futures = ids.map((id) =>
ctx.beginRpc("db.getAccount", id, ctx.options({ target: "poll://any@db" }))
);
const results = [];
for (const f of futures) {
results.push(yield* f);
}
return results;
}
Error Handling
- Throw errors for normal failures; Resonate retries by default.
- Use
ctx.options({ timeout: ... })to bound retries. - Treat 40900 (promise exists) as idempotency, not failure.
Promise ID Strategy
- Use request-derived IDs for idempotency, or generate UUIDs for fire-and-forget.
- Keep IDs readable:
job/<uuid>,db/op/<jobId>. ctx.promise()does not accept a caller-supplied id — readpromise.idoff the returned RFI and propagate it (e.g. as a tag) if something outside the workflow needs to resolve it.
Route Checklist
- Inputs validated and serialized before RPC.
- Durable workflow entrypoint registered and reachable.
- Target group matches worker group name.
- Promise ID uniqueness guaranteed.
- Routes return 202 + ID for async workflows.