Docs

#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:

text
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:

bash
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",        "context": { "prompt": "Approve a $1,250.00 expense?" } }'

From a durable workflow, the adapter call inside your own function:

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

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.

#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, and no durable engine or agent harness ships a view of the AI action being approved.

#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) and how they record a binding verdict (surface). 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:

ts
await flowplane.awaitApproval(ctx, {  id: 'adjuster-review',  policy: '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 (claims above). 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 delete 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, and surface without deploying anything.
  • As code, POST /v1/policies (Clerk) or /v1/worker/policies (API key), for the same fields from your own tooling.
bash
curl -X POST $API/v1/policies -H "Authorization: Bearer $KEY" -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,    "surface": "slack"  }}'

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.

#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:

bash
npx -p @ratifia/sdk ratifia policies check claims refunds-eu

It 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 with the approvers verified for its surface), 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.

Ratifia also won't quietly delete a policy that's still in use. Deleting 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 deleting anyway; over the API, DELETE /v1/policies/:key?force=true deletes it and records the override in the audit log.

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, quorum and surface you meant it to have.

  • Inngest adapter. The request step throws RatifiaPolicyNotFoundError, an Inngest RetryAfterError, and retries after 20 minutes (set unknownPolicyRetryAfterMs to 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 your onFailure handler 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_POLICY is reported as a tool error naming the policy, and the gate hook 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, 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, degrading 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 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 sees status: EXPIRED) so an abandoned approval never wedges anything.

Tiered escalation (falling back to another approver on breach) is on the roadmap.