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