#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
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:
| Kind | Shows |
|---|---|
text | a paragraph |
fields | a label/value table |
tool_call | tool name + arguments |
diff | before / after |
ai_output | model, 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.
// 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:
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.