Approval Handlers
Build the userland system that resolves tool approvals.
Approval handlers are userland code. Core creates an approval request when a tool approval policy or tool.requestApproval(...) hook action requires a decision. Your handler can call a database, wait on a queue, show a UI, notify reviewers, apply permission checks, or enforce timeout policy before it returns a decision.
Studio gives you a local approval UI. Build your own handler when approval needs to integrate with product permissions, Slack, ticketing systems, audit logs, or production queues.
Runtime Shape
approvalRuntime is a name for your own application object. Do not import it from Anvia; create it next to your database, queue, notification, or admin UI code.
const approvalRuntime = {
async waitForDecision(request: {
runId: string;
toolName: string;
args: unknown;
reason?: string;
}): Promise<{ approved: boolean; reason?: string }> {
const approval = await db.approval.create({
data: {
runId: request.runId,
toolName: request.toolName,
args: request.args,
reason: request.reason,
status: "pending",
},
});
await notifyReviewers({ approvalId: approval.id });
const decision = await waitUntilResolved(approval.id);
return {
approved: decision.status === "approved",
reason: decision.reason,
};
},
};Anvia does not define this object. It is your app boundary for approval state, notifications, optional timeouts, and audit records.
Use It from .approvals(...)
const agent = new AgentBuilder("support", model)
.tool(refundOrder)
.approvals({
async handler(request) {
const decision = await approvalRuntime.waitForDecision({
runId: request.run.runId,
toolName: request.toolName,
args: request.args,
reason: request.reason,
});
return decision.approved
? { approved: true, reason: decision.reason }
: {
approved: false,
reason: decision.reason,
rejectMessage: request.rejectMessage ?? "Tool call was not approved.",
};
},
})
.build();Configure approvals per request when the handler needs request-local product state.
const response = await agent
.prompt(message)
.approvals({
handler: (request) =>
approvalRuntime.waitForDecision({
runId: request.run.runId,
toolName: request.toolName,
args: request.args,
reason: request.reason,
}),
})
.send();Store Review State
Persist enough data to explain what happened later.
type ApprovalRecord = {
id: string;
runId: string;
agentId: string;
toolName: string;
args: unknown;
reason?: string;
status: "pending" | "approved" | "rejected" | "timed_out";
requestedAt: string;
resolvedAt?: string;
reviewerId?: string;
decisionReason?: string;
};The model prompt is not an audit log. Store tool arguments, reviewer identity, decision reason, and timestamps in your application database.
Notify Reviewers
Your runtime can notify one or more review surfaces.
async function waitForDecision(request: ApprovalRequest): Promise<ApprovalDecision> {
const approval = await approvalStore.create(request);
await Promise.all([
slack.sendApprovalMessage(approval),
adminEvents.publish("approval.created", approval),
]);
const decision = await approvalStore.waitUntilResolved(approval.id);
return {
approved: decision.status === "approved",
reason: decision.reason,
};
}approvalStore is also your code. It can be a Prisma model wrapper, SQL repository, queue-backed service, or any persistence layer your application already uses.
The approval handler does not care whether the decision came from Studio, your app, Slack, email, or a queue worker. It only awaits a boolean or a richer decision object.
Optional Timeouts
Timeouts are not required by Anvia. If the approval promise never resolves, the agent run keeps waiting. Use a timeout when the caller needs bounded latency, especially in production HTTP requests, queues, or UIs.
const decision = await Promise.race([
approvalRuntime.waitForDecision({
runId: request.run.runId,
toolName: request.toolName,
args: request.args,
reason: "Refunds require staff approval.",
}),
sleep(60_000).then(() => ({
approved: false,
reason: "No reviewer approved this action in time.",
})),
]);
return decision.approved
? { approved: true }
: {
approved: false,
reason: decision.reason,
rejectMessage: "No reviewer approved this action in time.",
};Use clear rejection messages because the model sees them as the skipped tool result.
Rich Decisions
Return more than a boolean when the final tool result should include reviewer context.
const decision = await approvalRuntime.waitForDecision({
runId: request.run.runId,
toolName: request.toolName,
args: request.args,
reason: "Refunds require staff approval.",
});
if (decision.approved) {
return { approved: true, reason: decision.reason };
}
return {
approved: false,
reason: decision.reason,
rejectMessage: decision.reason ?? "The reviewer rejected this action.",
};Keep the rejection message concise. It becomes the tool result the model uses to produce the final response.
