Skip to main content

Consent UX (Human-in-the-Loop)

The Consent UX system provides human-in-the-loop approval workflows for AI agent actions. When an action a mandate covers still needs a person's approval, Mandaitor raises a consent request, and the action waits until the approver the mandate names approves or rejects it.

SourceWhen
POST /executionsAn execution needs a person's approval. See Execution Approvals.
POST /consentsAn application raises one itself, for a mandate and a tool call. It needs mandates:write.

Only an execution a mandate already permits waits for an approval. One that no mandate covers is denied, and an approval cannot grant what a mandate does not.

A request raised at POST /consents goes to the mandate's principal, named by approver_subject_id on the request, and expires at its expires_at: an hour after it is raised, unless it set another ttl_seconds. An execution's approval has its own approver and expiry (Execution Approvals).

Until October 2026, POST /mcp/authorize also raised a request when a tool call it decided escalated, and the agent sent the call again once it was approved. That path has been removed; an agent's tool calls are executions now.

Approver Notifications​

When a request starts waiting for a person, Mandaitor emails its approver once. This applies to:

  • an execution's approval, and the approval that replaces it when the record changes;
  • a request that POST /consents raised.

What the email says.

  • That a request in the tenant waits for the approver, why in general terms, and when it expires.
  • The request's reference.
  • A link to it in the dashboard: the execution's page, or the Consent inbox with the request in focus. If the approver is not signed in, the link takes them there after they sign in.

It carries no amount, record or argument, and nothing the agent wrote. Nothing in it approves or rejects: the approver signs in to the dashboard to decide. Mandaitor never asks anyone to approve or reject by email.

Where it goes.

  1. The verified sign-in email of the approver's Mandaitor account, when that account belongs to the tenant.
  2. Otherwise, the email on the approver's active role assignment in the tenant.
  3. Otherwise, no email is sent, and the request waits in the inbox.

A mandate that names an account of another tenant as its approver does not get anyone emailed on its word. Only the tenant's own role assignments bring an address in.

What the request records. notification says how the email went:

"notification": { "status": "SENT", "channel": "email", "at": "2026-10-14T09:00:02.000Z" }
  • SENT: the email was handed to the mail service.
  • NOT_SENT with a reason:
    • NO_ADDRESS: there was no address to send to;
    • RATE_LIMITED: the approver had already been emailed 20 times that hour in the tenant;
    • DECIDED_BY_POLICY: a consent policy decided the request as it was made, so it never waited.

notification is absent until Mandaitor reaches the request, usually within seconds, and on a request that expired or was decided first. The address itself is never stored on the request. Emails come from noreply@mandaitor.io, and there are no reminders.

A consent policy settles some requests without a person, by approving or rejecting them automatically. Policies apply to requests that POST /consents raises, never to an execution's approval.

await client.post("/consent-policies", {
name: "Small supplier payments",
description: "Approve payments up to EUR 500 from the payments agent",
action: "AUTO_APPROVE",
priority: 10,
trusted_agent_ids: ["agt_payments"],
trusted_tool_patterns: ["payments.*"],
max_auto_amount: 500,
max_auto_currency: "EUR",
});

name and action are required. action is AUTO_APPROVE, AUTO_REJECT or REQUIRE_APPROVAL. enabled defaults to true, and priority to 100.

When a request is raised, Mandaitor takes the enabled policies in order of priority, lowest first, and applies the first one that matches. A policy matches when every filter it sets holds:

FilterHolds when
trusted_agent_idsThe request's agent is in the list.
trusted_tool_patternsThe tool name matches one of the patterns. * matches any run of characters and ? any one character.
max_drift_scoreThe request's drift score is not above it.
max_auto_amount, max_auto_currencyThe request carries an amount at or below it, in that currency. A request without a readable amount does not match.
conditionsEvery condition holds. Each is { field, operator, value }, with field one of tool_name, agent_id, drift_score, amount and urgency, and operator one of eq, neq, lt, lte, gt, gte, matches and in.

When no policy matches, the request waits for its approver.

Consent policies can be listed, inspected, updated, and deleted through the tenant API. Reading them needs tenant:read; changing them needs tenant:write.

OperationEndpointPurpose
List policiesGET /consent-policiesReturns the tenant's policies as { items, count }.
Get policyGET /consent-policies/{id}Retrieves one policy.
Update policyPUT /consent-policies/{id}Changes the fields it is sent. The others keep their values.
Delete policyDELETE /consent-policies/{id}Removes the policy.

A change applies to requests raised after it. A request already settled keeps its decision. An automatic decision is recorded as made by system:policy-engine, with a comment naming the policy that matched.

await client.put(`/consent-policies/${policyId}`, {
max_auto_amount: 250,
enabled: true,
});

Listing Pending Consents​

Approvers can view their pending consent requests:

GET /consents/pending

This returns the consent requests of the caller's own tenant that await the caller's decision, newest first, as { items, count }. limit caps the number returned (default 50). A request another tenant raised with the caller named as approver is not listed, and neither is an execution approval past its expiry.

Each request carries what it is for in tool_call (tool_name and arguments), why it was raised in reason, the stakes in amount and currency when there are any, its urgency, and its status: PENDING, APPROVED, REJECTED, EXPIRED, AUTO_APPROVED or SUPERSEDED.

The approver resolves a consent request with POST /consents/{id}/resolve:

await client.post(`/consents/${consentId}/resolve`, {
decision: "APPROVED",
comment: "Within the project budget",
});

decision is APPROVED or REJECTED, and comment is optional. The decision is recorded on the audit trail.

  • Only the approver the request names can resolve it. Anyone else gets 403.
  • A request that is no longer pending answers 409.
  • An execution's approval is not resolved here: it answers 409 EXECUTION_APPROVAL (see below).

Applications can retrieve the current request state through GET /consents/{id} before rendering an approval screen, which prevents stale or already-resolved requests from being approved twice.

Execution Approvals​

When an agent asks Mandaitor to perform an operation itself (POST /executions) and a person must approve it, the approval appears in the approver's pending consents too. Its execution field shows what is being approved: the record at the version read and the facts read from it, the resource, why approval is needed, whether MFA is required, and the approval_digest. The Executions guide describes the whole approval.

An execution approval is decided at POST /executions/{execution_id}/approval, not through POST /consents/{id}/resolve, which refuses it with 409 EXECUTION_APPROVAL:

await client.post(`/executions/${consent.execution.execution_id}/approval`, {
decision: "APPROVE",
approval_digest: consent.execution.approval_digest,
consent_id: consent.consent_id,
});
  • The approver is the person the mandate names in escalation_rules.escalate_to, or else its principal. Only they can decide it, from their own signed-in session. An API key cannot approve an execution.
  • Once approved, the answer is the execution in AUTHORIZED_NOT_EXECUTED. Mandaitor writes it after the answer, usually within seconds, and the execution's page shows the outcome.
  • If the record changed after the approval was requested, nothing is approved: a new approval with a new digest replaces it, and the answer is 409 DIGEST_MISMATCH.
  • An approval not decided by its expires_at leaves the pending list. Deciding it later answers 409 APPROVAL_EXPIRED, and nothing is approved.
  • The tenant's kill switch ends a waiting approval: its status becomes SUPERSEDED, it leaves the pending list, and deciding it answers 409 NOT_AWAITING_APPROVAL.
  • Sending { "decision": "REJECT" } rejects it; no digest is needed.
  • No consent policy approves an execution automatically.
note

Execution approvals are in Preview, like the execution API itself.

All consent requests for a specific mandate can be retrieved, newest first:

GET /mandates/{id}/consents

The answer is { items, count }. status keeps the requests in one status, and limit caps the number returned (default 50). With each request's decision, it shows who decided what and when, for every human-in-the-loop decision made under the mandate.

info

The Consent UX endpoints are Stable. An approver is emailed once when a request starts waiting for them, and is not reminded: the request waits in their pending list until it is decided or expires.