Handle approvals
Three integration patterns for waiting on a human, and how to resume work that was suspended for twenty minutes.
When policy returns REQUIRE_APPROVAL, the action stops. Your code has to do
something sensible with a request that may be answered in ten seconds or twenty
minutes, or not at all.
Three patterns. The right one depends on whether you have a process that can afford to wait.
#Pattern 1 — block and wait
For a background worker or a queue consumer, where blocking costs nothing.
tsconst decision = await agent.veefore.actWithApproval(
{
tool: 'refund_customer',
capability: 'refund.create',
amount: 250_00,
currency: 'INR',
parameters: { orderId: 'ord_8812' },
purpose: 'Customer reported damage; agent verified photo evidence.',
},
{ timeoutMs: 15 * 60 * 1000 },
);
pythondecision = agent.veefore.act_with_approval(
ActionRequest(
tool="refund_customer",
capability="refund.create",
amount=250_00,
currency="INR",
parameters={"orderId": "ord_8812"},
purpose="Customer reported damage; agent verified photo evidence.",
),
timeout_seconds=900,
)
It evaluates, waits on the approval honouring pollAfterMs, then resubmits the
identical request with the approvalId. On denial it raises
VeeforeApprovalDeniedError; on timeout, a connection error.
#Pattern 2 — return control, resume later
For a request-response path, where holding a connection for twenty minutes is not acceptable.
tsimport { VeeforeApprovalRequiredError } from '@veefore/sdk';
async function startRefund(orderId: string) {
try {
const decision = await agent.act({
tool: 'refund_customer',
capability: 'refund.create',
amount: 250_00,
currency: 'INR',
parameters: { orderId },
// Stable across the first attempt and the resume, so the approved retry
// is recognised as the same operation rather than a second refund.
idempotencyKey: `refund-${orderId}`,
});
return { status: 'done', result: decision.result };
} catch (error) {
if (error instanceof VeeforeApprovalRequiredError) {
await jobs.enqueue('resume-refund', {
orderId,
approvalId: error.approvalId,
});
return { status: 'awaiting_approval', approvalId: error.approvalId };
}
throw error;
}
}
async function resumeRefund(job: { orderId: string; approvalId: string }) {
const status = await agent.veefore.approvalStatus(job.approvalId);
if (status.status === 'PENDING') return 'retry_later';
if (status.status !== 'APPROVED') return `abandoned:${status.status}`;
const decision = await agent.act({
tool: 'refund_customer',
capability: 'refund.create',
amount: 250_00,
currency: 'INR',
parameters: { orderId: job.orderId },
idempotencyKey: `refund-${job.orderId}`,
approvalId: job.approvalId,
});
return decision.allowed ? 'done' : 'refused';
}
pythonfrom veefore import ActionRequest, VeeforeApprovalRequiredError
def start_refund(order_id: str):
request = ActionRequest(
tool="refund_customer",
capability="refund.create",
amount=250_00,
currency="INR",
parameters={"orderId": order_id},
idempotency_key=f"refund-{order_id}",
)
try:
decision = agent.act(request)
return {"status": "done", "result": decision.result}
except VeeforeApprovalRequiredError as error:
jobs.enqueue("resume-refund", order_id=order_id, approval_id=error.approval_id)
return {"status": "awaiting_approval", "approval_id": error.approval_id}
def resume_refund(order_id: str, approval_id: str) -> str:
status = agent.veefore.approval_status(approval_id)
if status.status == "PENDING":
return "retry_later"
if status.status != "APPROVED":
return f"abandoned:{status.status}"
decision = agent.act(
ActionRequest(
tool="refund_customer",
capability="refund.create",
amount=250_00,
currency="INR",
parameters={"orderId": order_id},
idempotency_key=f"refund-{order_id}",
approval_id=approval_id,
)
)
return "done" if decision.allowed else "refused"
Resubmit the identical request
The approval is bound to the exact request that created it — the agent, the
tool, the parameters, the amount. Change any of it and the gateway refuses with
APPROVAL_NOT_GRANTED.
That is the point: it stops "approved once" from becoming "approved for whatever
I send next". Persist the request alongside the approvalId rather than
rebuilding it from scratch and hoping it comes out the same.
#Pattern 3 — decide as data
When you want the decision without exception control flow.
tsconst decision = await agent.evaluate(request);
if (decision.allowed) return execute(decision);
if (decision.approval?.required) {
return {
status: 'awaiting_approval',
approvalId: decision.approval.approvalId,
expiresAt: decision.approval.expiresAt,
pollAfterMs: decision.approval.pollAfterMs,
};
}
return { status: 'refused', reason: decision.reason };
evaluate() throws only on transport and authentication failures, so a BLOCK
and a REQUIRE_APPROVAL both come back as values.
#Respect pollAfterMs
The response tells you when to poll next. Honour it.
Polling faster will not make a human decide faster, and it will earn you
RATE_LIMITED. waitForApproval already uses it as its interval.
#Handle expiry
An approval has a TTL — default 900 seconds, set per rule. When it passes the
status becomes EXPIRED and the action is refused with APPROVAL_EXPIRED.
Your resume path must handle all four terminal states:
| Status | Meaning | Do |
|---|---|---|
APPROVED |
Proceed | Resubmit with approvalId |
DENIED |
A human said no | Report the reason. Do not retry |
EXPIRED |
Nobody answered in time | Escalate to a person, or abandon |
CANCELLED |
Withdrawn | Abandon |
EXPIRED is the one that gets missed, and it is the most likely in practice.
#Make the approval card useful
The approver sees the request, the policy and rule that required approval, the
risk signals, the delegation chain, the agent's recent history — and purpose.
tspurpose: 'Customer reported damage on arrival; agent verified photo evidence and the order is within the 30-day window.'
An approver without context approves everything, which is worse than having no approval step — because now there is a record saying a human reviewed it. Spend a sentence here.
#Test it before you need it
Walk the whole loop at least once in SIMULATE, with a real person:
- The agent requests and gets
APPROVAL_REQUIRED. - The approval appears in the console queue with full context.
- Someone approves it.
- The agent resumes and proceeds.
Then deny one, and check the agent reports the denial reason usefully. Then let one expire, and check the resume path does not loop forever.
Confirm someone owns the queue
ENFORCE with approval rules and an unwatched queue is just a slower kind of
blocked. Decide who owns it, what the response-time expectation is, and what
happens out of hours — before you promote.