#Human-in-the-loop
The core of Ratifia. Everything below is a property of the decision request, identical whether it's opened by an agent's request_decision, a workflow's awaitApproval, or a POST /v1/worker/decisions from your own code. Ratifia is a step in your path, never the orchestrator.
#Pause for a decision
Three doors, one request. From an agent, the MCP tools:
request_decision(title: "Manager review", question: "Approve a $1,250.00 expense?", timeout_minutes: 240)await_decision(decision_id)
From your own code, the REST call the other two make under the hood:
curl -X POST "$RATIFIA_API_URL/v1/worker/decisions" \ -H "Authorization: Bearer $RATIFIA_API_KEY" -H "Content-Type: application/json" \ -d '{ "engine": "api", "externalRunId": "expense-4471", "stepRef": "manager-review", "policy": { "key": "expenses" }, "context": { "prompt": "Approve a $1,250.00 expense?" } }'
From a durable workflow, the adapter call inside your own function:
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 Ratifia 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: { key: 'expenses' }, // who approves, how many, the expiry prompt: `Approve a $${amount.toFixed(2)} expense?`, context: { aiReasoning: 'Amount is within the team budget; vendor is known.', data: { amount: { value: amount, src: 'tool', description: 'From the expense report' } }, 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 })) },)
Each resolves once a human (or the policy gate) decides: the adapter returns { approved, decidedBy, note } and the engine resumes; the MCP tool and the API poll return status (APPROVED / REJECTED / EXPIRED), decidedBy, and note. externalRunId + stepRef is your key for the decision: the adapter fills it from the Inngest runId and the step id, the MCP server mints one per tool call, and over the API you pass whatever correlates back to your own run.
#Two ways to ask
Every request is one of two kinds, and says who can answer it in one way only.
| Ad hoc | Policy-driven | |
|---|---|---|
| Chosen by | no policy | policy: { "key": "...", "version": 3 } (version optional) |
| Who answers | approvers (you list them) | the policy |
| How many must approve | approvalsRequired (optional, default 1) | the policy |
| Expiry | timeoutSec (optional) | the policy |
context.data | free-form | must satisfy the policy's context contract |
Sending approvers, approvalsRequired or timeoutSec together with a policy is refused with 422 field_owned_by_policy: the policy decides those, and Ratifia won't quietly merge two answers.
#Ad hoc: a complete request
You set everything. Good for one-offs and for trying Ratifia out.
{ "engine": "api", "externalRunId": "expense-4471", "stepRef": "manager-review", "workflowRef": "expenses", "approvers": ["dana@acme.com", "lee@acme.com"], "approvalsRequired": 1, "timeoutSec": 14400, "required": true, "context": { "title": "Approve a $1,250.00 expense", "prompt": "Dana's team offsite dinner. Approve reimbursing $1,250.00?", "aiReasoning": "Over the $1,000 auto-approve limit; receipt matches the card charge.", "data": { "expenseId": "exp_4471", "employee": { "value": "Sam Ortiz", "src": "tool", "description": "From the HR directory" }, "amount": { "value": 1250.00, "src": "tool", "description": "Card charge from Ramp for this expense", "meta": { "api": "ramp.transactions.get", "transactionId": "txn_9x2" } }, "merchant": { "value": "Nopa", "src": "ai", "description": "Read from the receipt photo", "meta": { "model": "claude-sonnet-5", "ocr_score": 0.82 } } }, "blocks": [ { "kind": "text", "title": "Note from the employee", "text": "Team of 9, client attended." }, { "kind": "links", "title": "Records", "links": [ { "label": "Expense exp_4471", "url": "https://expenses.acme.com/exp_4471" } ] } ] }, "factsHash": "3f7a…c21e"}
approvalsRequired above 1 is M-of-N quorum (Team plan and up), and can't exceed the number of approvers (422 unsatisfiable_quorum).
#Policy-driven: a complete request
The policy decides who approves, how many must agree, and the expiry; you send what's being decided. Recommended for anything that recurs.
{ "engine": "api", "externalRunId": "refund-8812", "stepRef": "refund-review", "workflowRef": "refunds", "policy": { "key": "refunds", "version": 3 }, "required": true, "context": { "title": "Refund $1,250.00 to ACME Corp", "prompt": "Customer reports a double charge on invoice INV-2201. Refund it?", "data": { "refundId": "rf_8812", "customerId": { "value": "cus_ACME", "src": "tool", "description": "Stripe customer on the invoice" }, "amountCents": { "value": 125000, "src": "tool", "description": "Duplicate charge amount from Stripe", "meta": { "api": "stripe.charges.retrieve", "chargeId": "ch_3Pq" } }, "reason": { "value": "duplicate", "src": "ai", "description": "Classified from the support ticket" } }, "blocks": [ { "kind": "fields", "title": "Invoice", "fields": [ { "label": "Invoice", "value": "INV-2201" }, { "label": "Charged twice on", "value": "2026-09-24" } ] } ] }, "factsHash": "9b41…07aa"}
The refunds policy, for reference: approvers risk@acme.com and cfo@acme.com, one approval required, 24-hour expiry, and a context contract requiring refundId (string) and amountCents (integer). If amountCents were missing or sent as "125000", the request would be refused with 422 context_contract_violation and a list of what failed.
policy.version is a check, not a choice. Leave it out to use the current version. Send it to say "I built against version 3": if the policy has changed since, the request is refused with 409 policy_version_mismatch (carrying currentVersion) instead of running under rules you haven't reviewed. Ratifia never runs an old version, because that would keep a removed approver able to vote.
Either way, only the people named can answer; anyone else, including an API key, gets 403 not_an_approver. A request that names nobody, or whose policy lists no approvers, is refused with 422 approver_required.
#context.data: facts, and where they came from
data holds the facts of the decision, keyed by name. Each entry is either a plain value ("refundId": "rf_8812") or an enriched field:
| Key | |
|---|---|
value | The value. Required. Any JSON. |
src | Where it came from: tool (a lookup against a system of record), ai (the model produced it), human (a person entered it), or unknown. Defaults to unknown. |
description | How the value was obtained, in words. Optional. |
meta | Anything else, free-form (ocr_score, an API name, a record id). Optional. |
Record the source when you build the value, not afterwards: it can't be recovered from finished text. The reviewer sees each fact with a badge (Looked up, AI-derived, Entered by a person, Source not stated), so they know which values to double-check. A value without a source is shown as Source not stated, never as verified.
An object counts as an enriched field only if it has value and no keys other than value, src, description and meta; any other object is a plain value. data holds at most 100 entries and 32 KB.
data is what the reviewer sees and what a policy's contract checks. It is not what the approval binds to: that's factsHash, which you compute from exactly the facts your action will act on. See Approvals that can't be skipped.
Fields both kinds share:
| Field | What it's for |
|---|---|
engine, externalRunId, stepRef | Your run and step; together they identify the decision. |
workflowRef | Groups related decisions in the dashboard. |
required | Whether a human is needed. Defaults to true; false creates no decision (approvalNeeded: false). |
context | What the reviewer sees: title, prompt, aiReasoning, data, blocks. |
factsHash | A hash of exactly what the approval covers, checked before the action runs. |
responseSpec | Ask for a choice (select) or a typed answer (text) instead of yes/no. |
proposedOutput | A draft the reviewer can edit before approving. |
docusign | The envelope to sign, for a decision approved by signature. |
Unknown fields are refused with 400. That includes the removed assigneeEmail (use approvers), exclusive, context.payload (use context.data), context.extra, context.sourceUrl (use a links block, or docusign.documentUrl for the envelope to sign), and a bare-string policy (use { "key": "..." }).
#Where people answer
Yes/no decisions can be answered wherever you're notified. Anything more opens the app.
Every connected channel shows every decision: the Ratifia app, email, and Slack and Discord when your org has connected them. A plain yes/no gets Approve and Reject right there. A choice, a typed answer, or a draft to edit needs the whole context, so the email and chat messages link to the app instead, and a vote from them is refused. Where each person is notified is their own setting.
A policy can require a signed document instead: then the signature is the approval, and nothing else can decide it. See Policies.
One person, one vote. Someone who answers in the app and again in Slack is counted once.
#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 |
links | clickable records: the ticket, the customer, the invoice |
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, and no durable engine or agent harness ships a view of the AI action being approved.
A links block takes { label, url, description? }, at most 20, and the URL must be http(s) or the request is refused. It is the only way to put a link in a context: the ticket, the customer, the invoice, each labelled so the reviewer knows what they're opening. When a record holds personal data, link to it instead of copying its values into data: see Sensitive data and PII.
#Evidence: what the decision was made on
context is what the caller supplied when it asked. Evidence is the material the decision was actually judged on: the Slack thread where a customer complained, the email that set the terms, the clause that permits it.
Attach it with POST /v1/decisions/:id/evidence (see the API reference). Three properties are worth knowing, because they're what make it usable as a record:
- Append-only, and sealed at the verdict. Evidence can be attached while a decision is pending, by anyone who can reach it. Once it resolves, the set is closed. So the evidence on a decided decision is exactly what the approver could have seen, with no way to add to it afterwards.
- Stored, not linked. Send the text. A permalink can be edited or deleted after the decision; the stored snapshot and its
sha256cannot. - Attaching twice is free. Identical content is recognised by its hash, so a retry never produces a second copy for the reviewer.
Evidence is context, never a vote. It changes what a reviewer can see, never who may decide.
Kinds: slack_thread, email, document, link, note, and slack_transcript for a captured deliberation channel.
#Policies: who approves, and how
An org-scoped policy supplies the defaults for a decision: who is asked (the approver group and the quorum they must reach), the expiry, and whether a signed document is required. Reference it by its key and the decision inherits all of it, so your calling code never hardcodes an approver. How each approver is notified is their own setting, not the policy's:
await flowplane.awaitApproval(ctx, { id: 'adjuster-review', policy: { key: 'claims' }, required: routeDecision === 'human_review',})
Over the API those are the policy and required fields on the request body; from the MCP server, set RATIFIA_POLICY.
A policy has three identifying fields:
key, what your code passes (claimsabove). A lowercase slug, unique in your org, and permanent: it can't be changed after the policy is created. Use the same key in staging and production and the same code works in both.name, the label people see. Rename it whenever you like; nothing that references the key notices.description, optional, what the policy is for.
To move callers to a different key, create a policy with the new key, deploy the code that uses it, then archive the old one.
Write a policy from either surface. Both are keyed by (org, key), share one schema and one validation, and upsert idempotently:
- In the dashboard, under Policies: set the approver group, quorum, expiry, context contract and signature requirement without deploying anything.
- As code,
POST /v1/worker/policieswith an API key that haspolicies:write, for the same fields from your own tooling. (/v1/policiesis the same operation for a signed-in dashboard session.)
curl -X POST "$RATIFIA_API_URL/v1/worker/policies" \ -H "Authorization: Bearer $RATIFIA_API_KEY" -H "Content-Type: application/json" -d '{ "key": "claims", "name": "Claim payouts", "description": "Two adjusters sign off on a payout", "defaults": { "approvers": ["a@acme.com", "b@acme.com", "c@acme.com"], "approvalsRequired": 2, "timeoutSec": 86400, "contextSchema": { "type": "object", "required": ["claimId", "amountCents"], "properties": { "claimId": { "type": "string" }, "amountCents": { "type": "integer" } } } }}'
A body with only name (policies written before keys existed) is read as key equal to name, as long as that name is a valid key.
defaults.surface is deprecated and no longer stored. A policy doesn't choose where people answer. "surface": "docusign" is still accepted and read as "requiresSignature": true; any other value is ignored.
#Keep policy references from breaking
Your code and your policies deploy separately, so check that the keys your code uses exist before it ships. Run this in CI with the environment's API key:
npx -p @ratifia/sdk ratifia policies check claims refunds-euIt prints one line per key and exits 1 if any key is missing from the org, or exists but is unsatisfiable (its quorum can't be reached, for example a signature policy without enough DocuSign-verified approvers), so the deploy fails instead of the first live approval. From code, new Ratifia({ apiKey }).checkPolicies([...]) returns the same result, and over the API it's POST /v1/worker/policies/check.
Deleting a policy archives it: its key stops resolving, but its versions are kept, so decisions made under it still report the definition they ran under. Upserting the same key restores it. Ratifia also won't quietly archive a policy that's still in use. Archiving one with pending decisions, or that was used in the last 30 days, is refused with 409 policy_in_use, because callers passing its key would start failing. The dashboard shows each policy's usage and asks before archiving anyway; over the API, DELETE /v1/policies/:key?force=true archives it and records the override in the audit log. Decisions already waiting on an archived policy still finish.
Every change to a policy's approvers, quorum or signature requirement creates a new version, and each decision records the one it was created under (policyVersion). Only the approvers in that version can vote on it; anyone else gets 403 not_an_approver. See When a policy changes while a decision is waiting.
An unknown policy key fails closed
If policy is a key that doesn't exist in your org (a typo, or it was deleted), Ratifia refuses the request with 422 { "error": "unknown_policy", "policy": "<name>" } and creates no decision. That holds whether required is true or false: a decision is never sent without the approvers and quorum you meant it to have.
- Inngest adapter. The request step throws
RatifiaPolicyNotFoundError, an InngestRetryAfterError, and retries after 20 minutes (setunknownPolicyRetryAfterMsto change it). Create the policy or deploy the corrected key and the next retry opens the decision; nobody has to touch the run. If the retries run out, the run fails and youronFailurehandler runs. No decision was created, so replaying the failed runs is safe. - Your own code. Treat the 422 as a configuration error: stop and alert. Don't retry it in a tight loop, and don't fall back to acting without the approval.
- MCP server. A bad
RATIFIA_POLICYis reported as a tool error naming the policy, and thegatehook denies the tool call even if you set it to fail open. It fails open only when Ratifia can't be reached, not when it's misconfigured.
Don't wrap awaitApproval in a try/catch that carries on without the approval. Recover in onFailure instead, as in Approvals that can't be skipped.
#When a human is needed
The caller decides, with required. Omit it and it defaults to true, so a human is always asked. Pass required: false (say an AI triage step judged the case routine) and the decision resolves immediately as not required: the API answers 200 { approvalNeeded: false }, no decision is created, and awaitApproval returns without parking.
Because the caller decides, the guarantee that an approval actually happened lives in your code as well. Have the action that matters (the payout, the send, the delete) check the approval itself right before it runs, bound to the facts it covers. See Approvals that can't be skipped.
#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, and the decision resolves (releasing the caller) 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, discord) and methods (how they bind a verdict: inbox, email, slack, discord, docusign), 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, degrading to an email/inbox-verified approver.
This is enforced, not cosmetic. Saving a policy whose quorum could never be met (say a policy that requires a signature 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. Only members of your organization can be added, so invite someone under Members first.
#SLA + reminders
Give a decision an expiry (timeoutSec on an ad-hoc request, or the policy's expiry) and Ratifia will:
- remind the assignee once the decision has used ~75% of its window, and
- auto-expire it on breach (
EXPIRED: a workflow resumes on the rejection branch, while an agent or poller seesstatus: EXPIRED) so an abandoned approval never wedges anything.
The reminder goes to the decision's assignee (its first approver).
The create response carries the decision's expiresAt (ISO time, or null for never), so a workflow can wait exactly as long as the decision lives. The Inngest adapter does this for you: it waits until expiresAt plus ten minutes, so Ratifia expires the decision first and the run resumes on the rejection branch through the normal event. A decision that never expires waits noExpiryWait (default 7d, the longest wait on Inngest's free plan; paid plans allow up to a year):
const flowplane = new FlowplaneInngest({ flowplaneApiKey, flowplaneApiUrl, noExpiryWait: '30d' })