Tutorial: an approval workflow

Build a human-in-the-loop approval from scratch, then watch a run park on a form and resume when it's submitted.

This tutorial builds a real workflow: a request comes in, a human approves or rejects it via a form, and the run branches on their decision. Along the way you’ll see flowwork’s defining behaviour — a run parks when it needs a human and resumes when the form is submitted, all durably in Postgres.

The shape of the workflow

submit_request ──▶ approval_form (parks: WAITING_FORM)
                        │  human submits {approved: …}

                  process_decision ──▶ approved_step ──▶ end
                        └──────────────▶ rejected_step ──▶ end

Step 1 — handlers

Each step is a plain function that receives ctx and returns the variables to merge. The form handler receives a second argument, data — the submitted payload.

function submit_request(ctx) {
  return { status: "awaiting_approval" };
}

// Form handlers receive (ctx, data): data is the submitted form payload.
function on_approval(ctx, data) {
  return {
    approved: data.approved === true,
    approver_comments: data.approver_comments || "",
  };
}

function approved_step(ctx) { return { status: "approved" }; }
function rejected_step(ctx) { return { status: "rejected" }; }

// A read-only query — call it any time to inspect a run.
function approval_status(ctx) {
  return { status: ctx.vars.status, approved: ctx.vars.approved };
}

Step 2 — assemble the workflow

workflow({
  name: "SimpleApproval",
  version: "1",
  description: "Basic form-based human approval workflow.",
  initial: { request_id: "REQ-001", amount: 100, status: "pending", approved: null },
  triggers: [apiTrigger("submit")],
  steps: [
    step("submit_request", { start: true, next: "approval_form", label: "Submit Request" }, submit_request),

    form("approval_form", {
      title: "Approval Required",
      schema: {
        type: "object",
        properties: {
          approved: { type: "boolean", description: "Approve this request?" },
          approver_comments: { type: "string", description: "Optional comments" },
        },
        required: ["approved"],
      },
      next: "process_decision",
    }, on_approval),

    step("process_decision", {
      branches: [
        branch("approved == true", "approved_step"),
        branch("true", "rejected_step"),
      ],
    }, process_decision),

    step("approved_step", { next: "end", label: "Approved" }, approved_step),
    step("rejected_step", { next: "end", label: "Rejected" }, rejected_step),

    query("approval_status", {}, approval_status),
  ],
});

A few things worth calling out:

  • form(...) declares a step that pauses the run until a human submits it. Its schema is standard JSON Schema and is validated on submit.
  • branches route to the first branch whose condition is true. Conditions are expressions over the current variables (==, !=, >, AND, OR, single-quoted strings). List a branch("true", …) last as the catch-all.
  • process_decision does no work itself — it’s a pure router — but it still needs a handler function (here a no-op returning {}).
Every step needs a handler

Even a pure routing step must be given a handler function. Omitting it fails the run. A no-op function decide(ctx) { return {}; } is fine.

Step 3 — run it and drive the form

flowctl def create --file simple-approval.js
flowctl run start --workflow SimpleApproval --input '{"amount":250}' -q   # prints <exec>

The run advances to approval_form and parks in WAITING_FORM — it consumes no CPU and is invisible to the engine’s poller until the form arrives. Inspect and submit it:

flowctl form get <exec> approval_form                       # see the form schema
flowctl form submit <exec> approval_form --data '{"approved":true}'
flowctl run watch <exec>                                    # follow to completion
flowctl query run <exec> approval_status                    # { status: "approved", approved: true }

Submitting the form resumes the run: on_approval runs, merges approved: true into the variables, process_decision routes to approved_step, and the run completes.

Human-in-the-loop, without polling

Pair a run with a Claude Code channel (flowctl run start … —channel <id>) and the engine pushes the form straight into your session — the agent gathers the input and submits it for you. See the Claude Code channels guide.

What you learned

  • A workflow is workflow(...) with triggers and steps; handlers return variable updates.
  • form() (and signal() / webhook()) park a run; an inbound event resumes it.
  • branches + branch(condition, target) express conditional routing.
  • flowctl run / form / query drive a run from the shell.

Next, skim the DSL cheatsheet for the full set of builders and options.