Approvals
A human decision bound to one specific request, with quorum, escalation and expiry — never a standing permission.
When policy returns REQUIRE_APPROVAL, the action stops and an approval request
is created. A human decides. Only then does the action proceed.
An approval authorises one request, not a permission
An approval is bound to the exact request that created it — the agent, the tool, the parameters, the amount. Approving does not grant the agent a standing permission, and an approval cannot be replayed against a different request.
Present an approval id with a different payload and the gateway refuses with
APPROVAL_NOT_GRANTED. This is what stops "approved once" from becoming
"approved forever", which is the failure mode every approval system that keys on
the approver rather than the request eventually has.
#The flow
- Policy returns
REQUIRE_APPROVAL. - Veefore creates the approval, routes it to approvers, and returns
APPROVAL_REQUIREDwith anapprovalIdand apollAfterMs. - An approver sees the request with full decision context and approves or denies.
- The agent retries with the
approvalId. The pipeline runs again, the approval validates against this exact request, and the action proceeds.
Step 4 matters: the pipeline runs again. An approval does not bypass anything. If the agent was revoked between the approval and the retry, the retry is refused.
#Handling it in code
The SDK gives you three levels, from most to least control.
tsimport { VeeforeApprovalRequiredError, VeeforeApprovalDeniedError } from '@veefore/sdk';
// 1. Wait for the decision and then proceed. Good for a background worker.
const decision = await agent.veefore.actWithApproval(
{ tool: 'refund_customer', amount: 250_00, currency: 'INR' },
{ timeoutMs: 15 * 60 * 1000 },
);
// 2. Return control to a caller while the human decides.
try {
await agent.act({ tool: 'refund_customer', amount: 250_00, currency: 'INR' });
} catch (error) {
if (error instanceof VeeforeApprovalRequiredError) {
return { status: 'waiting', approvalId: error.approvalId };
}
if (error instanceof VeeforeApprovalDeniedError) {
return { status: 'denied', reason: error.message };
}
throw error;
}
// 3. Decide as data, no exceptions.
const result = await agent.evaluate({ tool: 'refund_customer', amount: 250_00 });
if (result.approval?.required) {
await persist(result.approval.approvalId);
}
pythonfrom veefore import (
ActionRequest,
VeeforeApprovalDeniedError,
VeeforeApprovalRequiredError,
)
request = ActionRequest(tool="refund_customer", amount=250_00, currency="INR")
# 1. Wait for the decision and then proceed.
decision = agent.veefore.act_with_approval(request, timeout_seconds=900)
# 2. Return control to a caller while the human decides.
try:
agent.act(request)
except VeeforeApprovalRequiredError as error:
return {"status": "waiting", "approval_id": error.approval_id}
except VeeforeApprovalDeniedError as error:
return {"status": "denied", "reason": str(error)}
# 3. Decide as data, no exceptions.
result = agent.evaluate(request)
if result.approval and result.approval.get("required"):
persist(result.approval["approval_id"])
Respect `pollAfterMs`
The response tells you when to poll next. Honour it rather than busy-looping —
polling faster will not make a human decide faster, and it will earn you
RATE_LIMITED. waitForApproval already does this correctly.
#Routing
The policy effect controls who decides and how many of them.
json{
"then": {
"decision": "REQUIRE_APPROVAL",
"reason": "Refunds above ₹1,000 require a human approver.",
"approval": {
"routingMode": "QUORUM",
"requiredApprovals": 2,
"approverRoleKeys": ["APPROVER", "SECURITY_ADMIN"],
"ttlSeconds": 900,
"requireStepUp": true
}
}
}
| Mode | Behaviour |
|---|---|
SINGLE |
One named approver decides |
GROUP |
Anyone in the group may decide |
QUORUM |
requiredApprovals distinct approvers must agree |
SEQUENTIAL |
Approvers decide in order; a denial at any step ends it |
requireStepUp forces the approver to re-authenticate at the moment of
approving. Use it for anything irreversible: it means an unattended, still-valid
browser session is not enough to approve a destructive action.
When several rules both require approval, the effects merge conservatively —
the maximum requiredApprovals, the minimum ttlSeconds, the union of approver
roles, and requireStepUp true if any rule asked for it.
#Expiry
An approval has a TTL. When it passes, the status becomes EXPIRED and the
action is refused with APPROVAL_EXPIRED.
Expiry is not optional. A pending approval is a half-finished action holding an agent's attention, and an approval queue that only grows is one nobody reads. Default TTL is 900 seconds; tune it per rule to match how fast a human can realistically respond to that particular thing.
Statuses: PENDING, APPROVED, DENIED, EXPIRED, CANCELLED.
#What the approver sees
The approval card carries the full decision context, not just a yes/no prompt:
- Which agent, under which delegation chain, acting for whom
- The tool, the capability, the parameters, the amount
- The policy and rule that required approval, with the conditions that matched
- The risk level, score and the signals that fired, each with its explanation
- The agent's recent action history
- The blast radius if it is a destructive tool
An approver without that context is guessing, and an approver who is guessing approves everything. Which is worse than having no approval step, because now there is a record saying a human reviewed it.
#Denying
bashcurl -X POST https://veefore.example.com/v1/approvals/apr_01ABC/deny \
-H "Authorization: Bearer $VEEFORE_API_KEY" \
-H 'content-type: application/json' \
-d '{ "reason": "Order was already refunded on 1 Oct" }'
A reason is required. It reaches the agent as the message on
APPROVAL_DENIED, so the agent can report something meaningful upstream instead
of "the request failed".
#Permissions
| Permission | Who |
|---|---|
approval:read |
Every built-in role |
approval:approve |
APPROVER, ADMIN, OWNER |
approval:deny |
APPROVER, ADMIN, OWNER |
approval:delegate |
APPROVER, ADMIN, OWNER |
approval:configure |
ADMIN, OWNER |
Notably SECURITY_ADMIN does not hold approval:approve by default, and
DEVELOPER does not either. Separation of duties: the person who wrote the rule
that requires approval should not be the person satisfying it. Grant it
explicitly if your organization genuinely wants that overlap.