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. Itsschemais standard JSON Schema and is validated on submit.branchesroute to the first branch whose condition is true. Conditions are expressions over the current variables (==,!=,>,AND,OR, single-quoted strings). List abranch("true", …)last as the catch-all.process_decisiondoes no work itself — it’s a pure router — but it still needs a handler function (here a no-op returning{}).
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.
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()(andsignal()/webhook()) park a run; an inbound event resumes it.branches+branch(condition, target)express conditional routing.flowctl run / form / querydrive a run from the shell.
Next, skim the DSL cheatsheet for the full set of builders and options.