AI agent tools
auditedTool() wraps an AI SDK tool so every agent call enters the audit record — with risk labels, required reasons, and an authorization gate.
auditedTool() from audkit/ai wraps an AI SDK-compatible tool. It runs your handler,
and around it writes exactly one audit event per call describing what the agent tried,
whether it was allowed, and how it ended.
import { auditedTool } from "audkit/ai";
import { z } from "zod";
export const refundPayment = auditedTool({
name: "refund_payment",
description: "Refund a captured payment to the original method.",
inputSchema: z.object({
paymentId: z.string(),
amountCents: z.number().int().positive(),
reason: z.string().min(10),
}),
risk: "high",
requireReason: true,
authorize: ({ input }) => input.amountCents <= 50_00,
handler: ({ paymentId, amountCents }) => payments.refund(paymentId, amountCents),
});Lifecycle to status
One tool call produces one event. Which status it carries depends on where the call stopped:
| What happened | Status | Also |
|---|---|---|
requireReason is set and no reason was supplied | failed | AuditedToolMissingReasonError is thrown after the event is written |
authorize returned false | denied | AuditedToolAuthorizationError is thrown after the event is written |
handler resolved | success | the output is returned to the model |
handler threw | failed | the original error is rethrown |
The event is always written before the error propagates, so a refusal is evidence
rather than a silent no. authorize returning true, undefined, or nothing at all
means allowed — only an explicit false denies.
No `pending` event is written
pending is the status your resolvers and authorize see while the call is being
decided — it is the context status, not a second stored event. Exactly one event per
tool call reaches the log.
Errors are recorded structurally, not stringified: metadata.error is
{ name, message } for an Error, or { message } for anything else.
Configuration
Identity
| Option | Type | Notes |
|---|---|---|
name | string | Required. Also becomes the event's toolName. |
title | string | Passed through to the AI SDK tool. |
description | string | Passed through to the AI SDK tool. |
handler | (input, options) => MaybePromise<TOutput> | Required. Your actual tool implementation. |
Schema (AI SDK v4 and v5)
| Option | Type | Notes |
|---|---|---|
inputSchema | Standard Schema | AI SDK v5 shape. Also drives input type inference. |
parameters | schema | AI SDK v4 shape. |
outputSchema | schema | Passed through. |
inputExamples, strict, needsApproval, providerOptions, toModelOutput | — | Passed through untouched. |
onInputStart, onInputDelta, onInputAvailable | — | Passed through untouched. |
toolMetadata | any | Emitted as the tool's metadata field (renamed so it can't collide with the audit metadata resolver). |
The returned object carries both inputSchema and parameters, so it is structurally
compatible with either AI SDK major version — audkit/ai never imports from ai, which
stays an optional peer.
Pass inputSchema and the handler's input is typed from it. Everything else falls back
to the second overload, where you can supply the input type yourself:
auditedTool<RefundInput, Receipt>({ ... }).
Governance
| Option | Type | Default | Notes |
|---|---|---|---|
risk | "low" | "medium" | "high" | "critical" or resolver | — | Recorded on the event; drives dashboard filters and alert rules. |
requireReason | boolean | false | Reject and log failed when no reason is present. |
reasonField | string | "reason" | Which input field to read the reason from. |
reason | resolver | — | Override or derive the reason. Falls back to the input field. |
authorize | (ctx) => MaybePromise<boolean | void> | — | Return false to deny. Sees status: "pending" and no output. |
includeInput | boolean | false | Copy the full tool input into metadata.input. |
includeOutput | boolean | false | Copy the handler output into metadata.output, when defined. |
Reasons need schema support
A reason is picked up automatically when the tool input has a string field named
reason (or whatever reasonField names). For reasons you can rely on, make it
required in the schema and set requireReason: true — the schema makes the model
produce one, requireReason makes its absence an audited failure.
includeInput / includeOutput copy raw tool payloads into the event. They are encrypted
at rest and blinded in the commitment, but they are still content — and content is subject
to your project's retention window. Prefer resolved metadata for anything sensitive.
Event fields
Every field below is a literal value or a (possibly async) function of the audit context:
| Option | Resolves to | Default |
|---|---|---|
action | string | `ai.tool.${name}.called` |
actor | Entity | from execute options, else { type: "ai_tool", id: name } |
target | Entity | — |
risk | EventRisk | — |
metadata | record | merged over the defaults below |
context | record | — |
requestId, sessionId, agentId, model, approvalId, policyVersion | string | — |
The audit context handed to every resolver is:
type AiAuditContext = {
input; // the tool input
output?; // present only on success
options; // the AI SDK execute options
actor: Entity;
toolName: string;
toolCallId?: string;
reason?: string;
error?: unknown;
status: EventStatus; // "pending" | "success" | "denied" | "failed"
};metadata is assembled by merging, in order: the built-in defaults (reason, error,
and input / output when enabled), your metadata resolver, then receipt.metadata.
Later keys win.
Receipts
receipt is a second, higher-priority layer for the fields an auditor cares about most —
useful when a shared base config supplies defaults and one tool needs to override the
target it touched:
receipt: {
target: ({ output }) => ({ type: "refund", id: output.refundId }),
metadata: ({ output }) => ({ settledAt: output.settledAt }),
context: () => ({ policy: "refunds/v3" }),
}receipt.target wins over target; receipt.metadata and receipt.context merge last.
Where the actor comes from
auditedTool never guesses a human. It reads, in order:
options.audit.actoroptions.context.actoroptions.experimental_context.actor
and falls back to { type: "ai_tool", id: <tool name> }. A value counts only if it has
string type and id (display is optional). Your actor resolver overrides all of it.
toolCallId is read from options.toolCallId when the SDK supplies one.
Transport
auditedTool accepts the shared runtime options — see the
SDK reference:
| Option | Notes |
|---|---|
client | Reuse an existing Audkit instance. |
apiKey, baseUrl, signingKeyId, signingPrivateKey, … | Build one implicitly; falls back to AUDKIT_* env vars. |
failClosed | Throw audit failures instead of letting the tool call succeed silently. |
onAuditError | (error, input?) => void — report the failure yourself. |
By default, an audit write that fails does not fail the tool call.
Worked example
import { generateText } from "ai";
import { auditedTool } from "audkit/ai";
import { z } from "zod";
const refundPayment = auditedTool({
name: "refund_payment",
description: "Refund a captured payment to the original method.",
inputSchema: z.object({
paymentId: z.string(),
amountCents: z.number().int().positive(),
reason: z.string().min(10).describe("Why this refund is justified."),
}),
// Governance
risk: "high",
requireReason: true,
authorize: async ({ input, actor }) => {
if (input.amountCents > 50_00) return false;
return policies.canRefund(actor.id);
},
// What the record says
action: "payment.refunded",
target: ({ input }) => ({ type: "payment", id: input.paymentId }),
metadata: ({ input }) => ({ amountCents: input.amountCents }),
model: ({ options }) => options.audit?.model,
policyVersion: "refunds/v3",
receipt: {
metadata: ({ output }) => (output ? { refundId: output.refundId } : undefined),
},
// Fail loudly rather than lose the record
failClosed: true,
onAuditError: (error) => reportToSentry(error),
handler: ({ paymentId, amountCents }) =>
payments.refund(paymentId, amountCents),
});
const result = await generateText({
model,
tools: { refund_payment: refundPayment },
experimental_context: {
actor: { type: "user", id: user.id, display: user.email },
},
prompt,
});Three calls, three records:
// the agent refunded $12.00 with a reason
{ "action": "payment.refunded", "status": "success", "risk": "high",
"actor": { "type": "user", "id": "usr_82…" },
"target": { "type": "payment", "id": "pay_9f…" },
"toolName": "refund_payment", "toolCallId": "call_a1…",
"metadata": { "reason": "Duplicate charge confirmed by processor", "amountCents": 1200, "refundId": "ref_31…" } }
// the agent tried $980.00 — over the gate
{ "action": "payment.refunded", "status": "denied", "risk": "high",
"metadata": { "reason": "Customer escalated", "error": { "name": "AuditedToolAuthorizationError", "message": "Audkit: tool `refund_payment` was not authorized" } } }
// the processor rejected the refund
{ "action": "payment.refunded", "status": "failed", "risk": "high",
"metadata": { "error": { "name": "ProcessorError", "message": "payment already refunded" } } }Related
- SDK reference — the client the wrapper writes through.
- Verification — proving the record above was never edited.
- Next.js — the same resolver pattern for route handlers and Server Actions.