Veefore docs

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.

ts
const 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 },
);
python
decision = 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.

ts
import { 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';
}
python
from 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.

ts
const 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.

ts
purpose: '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:

  1. The agent requests and gets APPROVAL_REQUIRED.
  2. The approval appears in the console queue with full context.
  3. Someone approves it.
  4. 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.

Identity, authority, policy and enforcement for autonomous AI agents.