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 (soon) Slack:

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

This is the disintermediation moat — none of Inngest / Temporal / Hatchet ship a reviewer view of the AI action.

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',})

Register a policy (SDK clients can declare it as code):

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 }}'

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.

Approvers are a list of emails (for now)

An approver is really a person with an approval method and an ordered contact chain. Today Flowplane models the degenerate case — a list of emails, contacted by email. The full identity + verification model (Slack/SMS/DocuSign) is designed but deferred; see the architecture notes.

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.