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.
Where Consent Requests Come From
| Source | When |
|---|---|
POST /executions | An execution needs a person's approval. See Execution Approvals. |
POST /consents | An 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 /consentsraised.
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.
- The verified sign-in email of the approver's Mandaitor account, when that account belongs to the tenant.
- Otherwise, the email on the approver's active role assignment in the tenant.
- 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_SENTwith areason: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.
Creating Consent Policies
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:
| Filter | Holds when |
|---|---|
trusted_agent_ids | The request's agent is in the list. |
trusted_tool_patterns | The tool name matches one of the patterns. * matches any run of characters and ? any one character. |
max_drift_score | The request's drift score is not above it. |
max_auto_amount, max_auto_currency | The request carries an amount at or below it, in that currency. A request without a readable amount does not match. |
conditions | Every 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.
Managing Consent Policies
Consent policies can be listed, inspected, updated, and deleted through the tenant API. Reading them needs tenant:read; changing them needs tenant:write.
| Operation | Endpoint | Purpose |
|---|---|---|
| List policies | GET /consent-policies | Returns the tenant's policies as { items, count }. |
| Get policy | GET /consent-policies/{id} | Retrieves one policy. |
| Update policy | PUT /consent-policies/{id} | Changes the fields it is sent. The others keep their values. |
| Delete policy | DELETE /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.
Resolving a Consent Request
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_atleaves the pending list. Deciding it later answers409 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 answers409 NOT_AWAITING_APPROVAL. - Sending
{ "decision": "REJECT" }rejects it; no digest is needed. - No consent policy approves an execution automatically.
Execution approvals are in Preview, like the execution API itself.
Viewing Consent History
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.
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.