Workflow SDK (Beta)
Vercel's Workflow SDK backed by Rivet Actors.
This integration is in beta. APIs may change between releases.
@rivet-dev/workflow-world implements the Workflow SDK’s World API with native Rivet Actors.
Quickstart
Create a project
Set up a Workflow SDK project for your framework on Node.js 22 or newer. The getting-started guides cover Next.js, Astro, Express, Fastify, Hono, Nitro, Nuxt, SvelteKit, TanStack Start, and Vite.
Install the World
npm install workflow @rivet-dev/workflow-world
Write a workflow
Create workflows/order.ts:
import { sleep } from "workflow";
export async function processOrder(id: string) {
"use workflow";
const reserved = await reserveInventory(id);
await sleep("1 hour");
return chargeOrder(reserved);
}
async function reserveInventory(id: string) {
"use step";
return { id, reservationId: `reservation-${id}` };
}
async function chargeOrder(order: { id: string; reservationId: string }) {
"use step";
return { ...order, status: "charged" as const };
}
Serve the Workflow SDK and Rivet together
The World starts Rivet lazily in the Workflow SDK process. You do not need a second server or a framework instrumentation hook.
Mark rivetkit as a server external package. RivetKit loads its runtime through
a dynamic import that a bundler cannot resolve statically, so bundling it makes
the flow route fail at request time with Cannot find module:
import { withWorkflow } from "workflow/next";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["rivetkit"],
};
export default withWorkflow(nextConfig);
List rivetkit here, not @rivet-dev/workflow-world. The two need opposite
treatment: withWorkflow resolves the target World through a build alias and
compiles it into the server bundle, while serverExternalPackages means the
package is left to Node at runtime. Next.js rejects a package asked to do both.
Create app/api/orders/[id]/route.ts:
import { start } from "workflow/api";
import { processOrder } from "@/workflows/order";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const run = await start(processOrder, [id]);
return Response.json({ runId: run.runId });
}
Hono has no build system of its own, so use Nitro to compile the workflow and serve its handler in the same process.
Create nitro.config.ts:
import { defineConfig } from "nitro";
export default defineConfig({
modules: ["workflow/nitro"],
routes: {
"/**": "./src/server.ts",
},
});
Create src/server.ts:
import { Hono } from "hono";
import { getRun, start } from "workflow/api";
import { processOrder } from "../workflows/order.ts";
const app = new Hono()
.post("/orders/:id", async (c) => {
const run = await start(processOrder, [c.req.param("id")]);
return c.json({ runId: run.runId });
})
.get("/orders/:runId", async (c) => {
const run = getRun(c.req.param("runId"));
return c.json({ status: await run.status });
});
export default app;
Create .env with the variables under Configuration, then
build and run:
npm run build
npm run dev
Validate the workflow
Start a run:
curl -X POST http://localhost:3000/orders/42
Use the Workflow SDK’s Vitest harness. The first World operation starts the native Rivet registry and control plane in the test process:
npm test
Configuration
Select the World and point it at your own HTTP server:
WORKFLOW_TARGET_WORLD=@rivet-dev/workflow-world
WORKFLOW_RUNTIME_URL=http://127.0.0.1:3000
# The Rivet connection uses the standard RivetKit environment variables. Local
# development needs none of them.
| Variable | Required | Purpose |
|---|---|---|
WORKFLOW_TARGET_WORLD | Yes | Loads @rivet-dev/workflow-world as the World |
WORKFLOW_RUNTIME_URL | Yes | Externally reachable base URL of your Workflow SDK HTTP server |
WORKFLOW_QUEUE_NAMESPACE | No | Shared queue namespace used by the Workflow SDK and crash-safe initial dispatch |
RIVET_WORKFLOW_SECRET | Recommended when public | Shared bearer secret for World-to-runtime delivery |
The World does not read the Rivet connection itself. It hands configuration to RivetKit, so the standard RivetKit environment variables apply unchanged. Local development starts the control plane automatically and needs none of them; set them to run against a remote control plane.
Run the combined server at WORKFLOW_RUNTIME_URL; its World client and native
registry use the same endpoint, namespace, and pool.
HTTP routes
Your framework integration serves the combined workflow handler at
.well-known/workflow/v1/flow. WORKFLOW_RUNTIME_URL must resolve to the
service hosting that route. Do not point it at the control plane. If
RIVET_WORKFLOW_SECRET is set, delivery carries that value as a bearer token and
rejects requests without it.
Deploying
Deploy the app as a Rivet worker like any other. See Self-Host for the per-platform guides. Two constraints come from this World specifically:
- The process must be long-lived. The first World operation opens a persistent worker connection and waits for it. This World does not use RivetKit’s serverless request handler, so a host that only runs per-request functions cannot serve it.
WORKFLOW_RUNTIME_URLmust be reachable from your workers, not just from browsers. The dispatcher calls it to run every queued step. Point it at the app’s load-balanced base URL when running more than one replica, and secure the route as described in HTTP routes once it is publicly reachable.
Durability
The World stores runs, event logs, queues, streams, hook tokens, and recovery alarms in Rivet Actors.
Recovery is local to each run; startup does not scan all actors. Queue an initial
workflow with the default namespace or WORKFLOW_QUEUE_NAMESPACE. The current
Workflow SDK does not include a per-call start({ namespace }) value in the
run_created event, so that per-call override cannot be reconstructed after a
crash and is not supported by this World.
Application code continues to use "use workflow", "use step",
workflow/api, hooks, sleeps, and streams exactly as documented by the Workflow
SDK.
Testing
import { workflow } from "@workflow/vitest";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [workflow()],
test: {
include: ["workflows/**/*.integration.test.ts"],
setupFiles: ["./vitest.setup.ts"],
testTimeout: 60_000,
},
});
import { waitForSleep } from "@workflow/vitest";
import { expect, test } from "vitest";
import { getRun, start } from "workflow/api";
import { processOrder } from "./order.ts";
test("runs the workflow end to end", async () => {
const run = await start(processOrder, ["42"]);
const sleepId = await waitForSleep(run);
await getRun(run.runId).wakeUp({ correlationIds: [sleepId] });
await expect(run.returnValue).resolves.toEqual({
id: "42",
reservationId: "reservation-42",
status: "charged",
});
expect(await run.status).toBe("completed");
});
waitForSleep observes the durable sleep, wakeUp resumes that exact
correlation, and the assertions wait for the final persisted result.
See the Workflow SDK documentation for the SDK itself.