Audkit
Guides

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.

tools/refund.ts
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 happenedStatusAlso
requireReason is set and no reason was suppliedfailedAuditedToolMissingReasonError is thrown after the event is written
authorize returned falsedeniedAuditedToolAuthorizationError is thrown after the event is written
handler resolvedsuccessthe output is returned to the model
handler threwfailedthe 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

OptionTypeNotes
namestringRequired. Also becomes the event's toolName.
titlestringPassed through to the AI SDK tool.
descriptionstringPassed through to the AI SDK tool.
handler(input, options) => MaybePromise<TOutput>Required. Your actual tool implementation.

Schema (AI SDK v4 and v5)

OptionTypeNotes
inputSchemaStandard SchemaAI SDK v5 shape. Also drives input type inference.
parametersschemaAI SDK v4 shape.
outputSchemaschemaPassed through.
inputExamples, strict, needsApproval, providerOptions, toModelOutputPassed through untouched.
onInputStart, onInputDelta, onInputAvailablePassed through untouched.
toolMetadataanyEmitted 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

OptionTypeDefaultNotes
risk"low" | "medium" | "high" | "critical" or resolverRecorded on the event; drives dashboard filters and alert rules.
requireReasonbooleanfalseReject and log failed when no reason is present.
reasonFieldstring"reason"Which input field to read the reason from.
reasonresolverOverride 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.
includeInputbooleanfalseCopy the full tool input into metadata.input.
includeOutputbooleanfalseCopy 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:

OptionResolves toDefault
actionstring`ai.tool.${name}.called`
actorEntityfrom execute options, else { type: "ai_tool", id: name }
targetEntity
riskEventRisk
metadatarecordmerged over the defaults below
contextrecord
requestId, sessionId, agentId, model, approvalId, policyVersionstring

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:

  1. options.audit.actor
  2. options.context.actor
  3. options.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:

OptionNotes
clientReuse an existing Audkit instance.
apiKey, baseUrl, signingKeyId, signingPrivateKey, …Build one implicitly; falls back to AUDKIT_* env vars.
failClosedThrow 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

agent.ts
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" } } }
  • 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.

On this page