FlowplaneDocs

#Human-in-the-loop

The core of Flowplane. Everything below composes through a single call — flowplane.awaitApproval(ctx, config) — placed inside a function you own and run. Flowplane is a step, never the orchestrator.

#Pause for a decision

ts
export const expenseApproval = inngest.createFunction(  { id: 'expense-approval' },  { event: 'acme/expense.submitted' },  async (ctx) => {    const { event, step } = ctx    const amount = (event.data as { amount: number }).amount
    // Pause this run for a human decision. Inngest pauses here (via    // step.waitForEvent) until Flowplane records the decision and resumes the    // run. Your function keeps full control — this is one step, not a wrapper.    const decision = await flowplane.awaitApproval(ctx, {      id: 'manager-review',      required: amount > 1000,      policy: 'expenses',      assigneeEmail: 'manager@acme.com',      prompt: `Approve a $${amount.toFixed(2)} expense?`,      context: {        aiReasoning: 'Amount is within the team budget; vendor is known.',        blocks: [          {            kind: 'fields',            title: 'Expense',            fields: [{ label: 'Amount', value: `$${amount.toFixed(2)}` }],          },        ],      },    })
    if (!decision.approved) {      return { status: 'rejected', by: decision.decidedBy }    }
    return await step.run('disburse', () => ({ status: 'paid', amount }))  },)

The call parks the run and returns an { approved, decidedBy, note } decision once a human (or the policy gate) resolves it. It needs ctx (not just step) because the decision is keyed by the Inngest runId for dashboard linkage.

#Context — what the reviewer sees

A reviewer approving an AI action must see the action, not a yes/no prompt. Pass context.blocks — a typed list rendered by kind in the Inbox and Slack:

KindShows
texta paragraph
fieldsa label/value table
tool_calltool name + arguments
diffbefore / after
ai_outputmodel, prompt, output, cost, tokens

context is free-form: attach whatever the decision needs. This is what the reviewer actually sees — none of Inngest / Temporal / Hatchet ship a view of the AI action being approved.

#The gate — AI proposes, policy bounds

A named, org-scoped policy decides whether a human is needed at all. The caller (or an AI triage step) proposes required; the policy's mandatory floor can force approval but never skip it.

ts
// A $4,000 claim the AI would auto-approve still parks for review,// because the policy floor mandates review at/above $3,000.await flowplane.awaitApproval(ctx, {  id: 'adjuster-review',  policy: 'claims',  required: routeDecision === 'human_review',})

Policies are managed in two equivalent ways — pick whichever fits your team. Both share the same validation, and both are keyed by (org, name):

As code, from an SDK client holding an API key:

bash
curl -X POST $API/v1/worker/policies -H "Authorization: Bearer $KEY" -d '{  "name": "claims",  "floor": [{ "field": "amountEstimatedCents", "op": "gte", "value": 300000 }],  "defaults": { "approvers": ["a@acme.com","b@acme.com","c@acme.com"], "approvalsRequired": 2 }}'

In the dashboard, under Policies — create, edit, and delete named policies without deploying anything. The same routes are available to your own tooling at /v1/policies (see the API reference).

If neither the caller nor the floor requires approval, the gate resolves "not required" and the step proceeds without parking.

#Quorum — M-of-N

A decision can require M approvals (approvalsRequired) to approve and N rejections (rejectionsRequired, default 1) to reject. Reject-wins is the pessimistic default: one rejection kills it. Each reviewer votes once; the decision resolves — and the engine resumes — only when a threshold is met.

An approver is a person, not just an email

An approver carries channels (how we reach them — email, slack, phone, sms, webhook) and methods (how they bind a verdict — inbox, slack, docusign, email, api), each with its own verification state. email and inbox are zero-config and verify on registration; the rest stay pending until proven. A bare email that was never registered still works — it degrades to an email/inbox-verified approver.

This is enforced, not cosmetic. Saving a policy whose quorum could never be met — say surface: docusign with approvalsRequired: 2 but only one DocuSign-verified approver — is rejected with 422 unsatisfiable_policy, naming the approvers that need verifying. Without that check, every matching decision would park forever.

Manage them in the dashboard under Approvers, or via /v1/approvers.

#SLA + reminders

Pass timeoutSec and Flowplane will:

  • remind the assignee once the decision has used ~75% of its window, and
  • auto-expire it on breach (EXPIRED, resuming the engine on the rejection branch) so an abandoned approval never wedges a workflow.

Tiered escalation (re-route to a fallback approver on breach) is on the roadmap.