Run your TanStack workflows on Restate.
npm install @restatedev/tanstack-workflows @restatedev/restate-sdk @tanstack/workflow-coreOr start from the ready-to-run template (server + client):
npx degit restatedev/tanstack-workflows/template my-workflowsA plain TanStack workflow:
// checkout.ts
import { createWorkflow } from "@tanstack/workflow-core";
import { z } from "zod";
export const checkout = createWorkflow({
id: "checkout",
input: z.object({ userId: z.string(), amount: z.number() }),
output: z.object({ status: z.enum(["approved", "rejected"]) }),
}).handler(async (ctx) => {
const charge = await ctx.step("charge-card", (step) =>
stripe.charges.create(
{ customer: ctx.input.userId, amount: ctx.input.amount },
{ idempotencyKey: step.id }
)
);
if (ctx.input.amount > 10_000) {
const decision = await ctx.approve({
id: "manager",
title: "Approve large charge?",
});
if (!decision.approved) return { status: "rejected" as const };
}
await ctx.step("send-receipt", () => sendReceipt(charge.id));
return { status: "approved" as const };
});// server.ts
import * as restate from "@restatedev/restate-sdk";
import { restateWorkflows } from "@restatedev/tanstack-workflows";
import { checkout } from "./checkout.js";
await restate.serve({ services: restateWorkflows(checkout), port: 9080 });Start Restate:
docker run --name restate --network=host ghcr.io/restatedev/restate:mainAnd register your service:
npx @restatedev/restate deployments register http://localhost:9080npm install @restatedev/tanstack-workflows-client @restatedev/restate-sdk-clientsimport { createRestateWorkflowRuntime } from "@restatedev/tanstack-workflows-client";
const runtime = createRestateWorkflowRuntime({ url: "http://localhost:8080" });
// Start a run. `runId` is the idempotency key: starting it twice is a no-op.
await runtime.startRun({
workflowId: "checkout",
runId: "order-42",
input: { userId: "cus_123", amount: 20_000 },
});
// Later, from anywhere (a webhook, an admin UI, ...):
await runtime.deliverApproval({
workflowId: "checkout",
runId: "order-42",
approval: { approvalId: "manager", approved: true },
});
// Wait for the result...
const output = await runtime.attachRun("checkout", "order-42");
// ...or just check on it.
const run = await runtime.getRun("checkout", "order-42");
// { runId, workflowId, status: "queued" | "running" | "finished" | "errored", output?, error? }Signals work the same way, resolving ctx.waitForEvent(name):
await runtime.deliverSignal({
workflowId: "checkout",
runId: "order-42",
signalId: "evt-1",
name: "payment-confirmed",
payload: { paymentId: "pay_1" },
});TanStack ctx |
On Restate |
|---|---|
step(id, fn, { retry }) |
ctx.run: journaled, retried, never re-executed on replay |
sleep / sleepUntil |
Durable Restate timer |
waitForEvent(name) |
Restate signal; early deliveries are buffered until the run waits |
approve({ id, ... }) |
Restate signal, resolved by deliverApproval with that id |
now() / uuid() |
Deterministic ctx.date.now() / ctx.rand.uuidv4() |
runId |
Idempotency key of the run |
You get Restate's UI, tracing and journal introspection for free.
Each workflow becomes one Restate service named after its id, with three handlers:
| Handler | What |
|---|---|
run |
The entry point: input = the workflow's input, output = its output. Needs an idempotency key: the runId. |
signal |
{ invocationId, name, payload? }: resolves a waitForEvent(name) of the run (deliverSignal). |
approve |
{ invocationId, approvalId, approved, feedback? }: resolves an approve({ id }) of the run (deliverApproval). |
invocationId is the run invocation's id, from the ingress lookup API; the client does that for you.
Each workflow service carries this service metadata, so registries and UIs can recognize it:
| Key | Value |
|---|---|
restate.dev/integration |
tanstack-workflow |
restate.dev/integration-version |
the @restatedev/tanstack-workflows version |
And run's input and output carry the workflow's input / output schemas as JSON Schema
in the discovery manifest, when the schema library can produce one
(Standard JSON Schema, e.g. zod 4). Otherwise they're left out.
Validation is unchanged: the workflow validates its input and output itself.
To deliver from another Restate handler without going through signal / approve, resolve
the run's signals directly: event:<name> with the event's payload, approval:<id> with
{ approved, feedback? }.
ctx.invocation(invocationId).signal("approval:manager").resolve({ approved: true });See DEVELOPMENT.md.