foliot docs

Guides / Writing a workflow

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 NonRetryableError to go straight to onFailure.
  • Throw anything else to let the retry policy decide.
  • Return defer() to answer 202 and finish later with client.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 })
▶ Note.

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.