Writing a workflow
A workflow is data: an ordered list of steps with transitions. Foliot never runs your code. It calls your handlers over HTTP.
import { defineWorkflow, task, sleep, wait, choice, when, END, FAIL } from "@foliot/sdk";
export const orderFulfilment = defineWorkflow<Order>()({
name: "order-fulfilment",
version: 3,
runTimeout: "14d",
retry: { maxAttempts: 5, backoff: "exponential", initial: "10s", max: "1h" },
steps: [
task("reserve-stock", { timeout: "30s" }),
task("charge-card", { timeout: "60s", retry: { maxAttempts: 3 }, onFailure: "release-stock" }),
task("create-shipment"),
wait("shipped", { timeout: "5d", onTimeout: "escalate-warehouse" }),
sleep("settle", { for: "3d" }),
choice("needs-review?", {
when: when.gt("state.charge-card.amount", 500),
then: "send-review-email",
else: END,
}),
task("send-review-email", { next: END }),
task("release-stock", { next: FAIL }),
task("escalate-warehouse", { next: "shipped" }),
],
});
Handlers
One handler per task step. A missing one is a compile error.
export const handlers = orderFulfilment.handlers({
"reserve-stock": async (ctx) => inventory.reserve(ctx.input.items, { key: ctx.idempotencyKey }),
"charge-card": async (ctx) => payments.charge(ctx.input.card, ctx.input.amount),
"create-shipment": async (ctx) => shipping.create(ctx.output("reserve-stock")),
"send-review-email": async (ctx) => mail.send(ctx.input.customerEmail),
"release-stock": async (ctx) => inventory.release(ctx.output("reserve-stock")),
"escalate-warehouse": async (ctx) => warehouse.escalate(ctx.input.orderId),
});
Handlers return the step’s output. Then:
- Throw
NonRetryableErrorto go straight toonFailure. - Throw anything else to let the retry policy decide.
- Return
defer()to answer 202 and finish later withclient.completeTask(token, output). - Return
jumpTo("step", output)to choose the next step.
Mount the handler and register the definition
// Express
const taskHandler = createTaskHandler({ workflows: [handlers], signingSecret: process.env.FOLIOT_SIGNING_SECRET! });
app.use("/foliot", foliotExpress(taskHandler)); // before express.json()
// NestJS (create the app with { rawBody: true })
FoliotModule.forRoot({ handler: taskHandler })
Foliot signs each push over the raw request body, so the handler must see the bytes before any JSON parser does. That is why the Express mount goes before express.json() and Nest needs rawBody: true.
Register the definition from CI or on deploy:
foliot push dist/workflows.js --url https://api.foliot.app --token $FOLIOT_TOKEN --app orders
Definitions are versioned and a run stays on the version it started on, so registering a new version never disturbs work in flight. See Concepts.
Start runs and send signals
From anywhere in the app:
const foliot = new FoliotClient({ baseUrl, token, app: "orders" });
await foliot.start(orderFulfilment, {
input: order,
businessKey: order.id,
idempotencyKey: order.id,
});
await foliot.signalByKey(orderFulfilment, order.id, {
name: "shipped",
payload: { tracking },
key: eventId,
});
Both calls are safe to retry: idempotencyKey makes start return the existing run, and a signal’s key is deduplicated for the life of the run.
The wire contract
If you are not using the SDK, the handler contract (the payload, the foliot-signature header and the response codes) is documented under Webhooks and in the receiveTask reference.