Examples
Guarded Side Effects
A pattern for approvals, idempotency, audit records, and restricted writes.
Side-effect tools change the world: refunds, cancellations, emails, database updates, deployments, and account changes. The model may request the action, but application code decides whether it is allowed, approved, idempotent, persisted, and auditable.
Scenario
A backoffice agent can refund an order. The operation requires operator permission, an idempotency key, human approval for large refunds, and an audit record written by the tool.
Flow
| Guard | Where it lives |
|---|---|
| permission | authz service inside the tool |
| business validation | product service |
| approval | ToolApprovalPolicy.when(...) plus request approval handler |
| idempotency | product write service |
| audit | tool execution path |
| safe output | tool output schema |
Approval Policy
import type { ToolApprovalPolicy } from "@anvia/core";
import { z } from "zod";
type RefundArgs = {
orderId: string;
amountCents: number;
reason: string;
};
const refundApproval = {
when(ctx) {
return ctx.args.amountCents > 10_000;
},
reason(ctx) {
return `Refund ${ctx.args.amountCents} cents for order ${ctx.args.orderId}.`;
},
rejectMessage: "The refund was not approved.",
} satisfies ToolApprovalPolicy<RefundArgs>;
Guarded Tool
import { createTool } from "@anvia/core";
export function createIssueRefundTool(scope: BackofficeToolScope) {
return createTool({
name: "issue_refund",
description: "Issue a refund for an eligible order after policy checks.",
input: z.object({
orderId: z.string(),
amountCents: z.number().int().positive(),
reason: z.string().min(1),
}),
output: z.object({
refundId: z.string().optional(),
status: z.enum(["issued", "not_allowed"]),
message: z.string(),
}),
approval: refundApproval,
async execute(input) {
await scope.authz.require(scope.operator, "orders:refund");
const eligibility = await scope.services.refunds.checkEligibility({
orderId: input.orderId,
tenantId: scope.operator.tenantId,
});
if (!eligibility.ok) {
return {
status: "not_allowed",
message: eligibility.publicReason,
};
}
const refund = await scope.services.refunds.issue({
...input,
tenantId: scope.operator.tenantId,
requestedBy: scope.operator.id,
idempotencyKey: scope.idempotencyKey,
});
await scope.auditLog.record({
actorId: scope.operator.id,
action: "refund.issue",
targetId: input.orderId,
idempotencyKey: scope.idempotencyKey,
metadata: { refundId: refund.id, amountCents: input.amountCents },
});
return {
refundId: refund.id,
status: "issued",
message: "Refund issued.",
};
},
});
}
Approval Handler
import type { ToolApprovalsOptions } from "@anvia/core";
export const approvals = {
async handler(request) {
const decision = await reviewerQueue.requestDecision({
toolName: request.toolName,
args: request.args,
reason: request.reason,
runId: request.run.runId,
operatorId: request.run.metadata?.operatorId,
});
return decision.approved
? { approved: true, reason: decision.reason }
: {
approved: false,
reason: decision.reason,
rejectMessage: "A reviewer rejected this action.",
};
},
} satisfies ToolApprovalsOptions;
Attach approvals at the request boundary:
const response = await agent
.prompt(input.message)
.withTrace({
name: "backoffice-agent",
userId: operator.id,
metadata: { operatorId: operator.id, tenantId: operator.tenantId },
})
.approvals(approvals)
.send();
Failure Modes
- A retry creates duplicate refunds because no idempotency key reaches the service.
- Approval is requested in prompt text instead of enforced by the tool/runtime.
- Audit records are written only after the model’s final answer.
- Tool returns sensitive payment data the model does not need.
- Expected denials throw raw provider or database errors.
