# Workflow SDK (Beta)

> **INFO:** This integration is in beta. APIs may change between releases.

`@rivet-dev/workflow-world` implements the Workflow SDK's [World](https://workflow-sdk.dev/worlds) API with native Rivet Actors.

[View the complete example →](https://github.com/rivet-dev/rivet/tree/main/examples/workflow-sdk)

## Quickstart

### Create a project

Set up a [Workflow SDK](https://workflow-sdk.dev) project for your framework on
Node.js 22 or newer. The
[getting-started guides](https://workflow-sdk.dev/docs/getting-started)
cover Next.js, Astro, Express, Fastify, Hono, Nitro, Nuxt, SvelteKit, TanStack
Start, and Vite.

### Install the World

```sh
npm install workflow @rivet-dev/workflow-world
```

### Write a workflow

Create `workflows/order.ts`:

workflows/order.ts:

```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.

### Next.js

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`:

```ts next.config.ts
import { withWorkflow } from "workflow/next";
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
	serverExternalPackages: ["rivetkit"],
};

export default withWorkflow(nextConfig);
```

> **WARNING:** 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`:

```ts 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

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`:

nitro.config.ts:

```ts
import { defineConfig } from "nitro";

export default defineConfig({
	modules: ["workflow/nitro"],
	routes: {
		"/**": "./src/server.ts",
	},
});
```

Create `src/server.ts`:

src/server.ts:

```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](#configuration), then
build and run:

```sh
npm run build
npm run dev
```

### Validate the workflow

Start a run:

```sh
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:

```sh
npm test
```

## Configuration

Select the World and point it at your own HTTP server:

.env:

```text
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](/actors/docs/general/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](/actors/self-host/workers)
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_URL` must 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](#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

vitest.integration.config.ts:

```ts
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,
	},
});
```

workflows/order.integration.test.ts:

```ts
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](https://workflow-sdk.dev) for the SDK itself.
