Skip to content
Agentic Control Plane

Anthropic Agent SDK: Auditable Logging and Governance in TypeScript

David Crowe · 15 min read
anthropic agent-sdk governance reference claude

You’re building a tool-use loop on Anthropic — either with the raw Messages API, the Claude Agent SDK, or your own custom runner. The agent works, the tools fire, and your CISO asks the question every CISO asks: what’s logging this, and on whose authority?

Anthropic’s native SDK doesn’t have an answer to that question. It gives you a model, a tool roster, and a loop. Per-user identity, audit trails, policy enforcement, output redaction — those are your problem.

This is the complete TypeScript reference for solving that problem in production: an auditable logging and evidence framework for AI agents on the Anthropic Agent SDK and Claude Agent SDK. The full shape of @agenticcontrolplane/governance-anthropic — every function, every option, every behavior — and the patterns that turn an Anthropic agent into a governed, audited, identity-attributed system without rewriting your loop.

The integration surface

The package exposes four entry points. You’ll use two or three of them in any production setup.

Symbol What it does When to use
configure({ baseUrl }) Points the SDK at your ACP gateway. Once per process. Always, at startup
governHandlers({ ... }) Wraps a map of tool handlers. Returns a map of the same shape with each handler governed. Default — the one-line install
governed(name, handler) Wraps a single handler. Returns a governed version. When you want per-handler control
withContext({ userToken, ... }, fn) Binds the end-user’s identity to every governance call inside fn. Uses Node’s AsyncLocalStorage. Always, around your request handler

The package re-exports the four primitives from @agenticcontrolplane/governance (the framework-agnostic core) plus its own governHandlers for the map-shape ergonomic. One import, no orchestration overhead.

The minimal governed loop

import Anthropic from "@anthropic-ai/sdk";
import {
  configure,
  governHandlers,
  withContext,
} from "@agenticcontrolplane/governance-anthropic";

configure({ baseUrl: "https://api.agenticcontrolplane.com" });

const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

const handlers = governHandlers({
  lookup_record: async ({ id }: { id: string }) => {
    return await db.records.findOne({ id });
  },
  send_email: async ({ to, subject, body }: { to: string; subject: string; body: string }) => {
    return await mailer.send({ to, subject, body });
  },
});

const tools: Anthropic.Tool[] = [
  { name: "lookup_record", description: "Look up a record by ID.",
    input_schema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } },
  { name: "send_email", description: "Send an email.",
    input_schema: { type: "object",
      properties: { to: { type: "string" }, subject: { type: "string" }, body: { type: "string" } },
      required: ["to", "subject", "body"] } },
];

app.post("/run", async (req, res) => {
  const userToken = req.header("authorization")!.replace(/^Bearer /, "").trim();

  await withContext(
    { userToken, agentName: "research-agent", agentTier: "interactive" },
    async () => {
      const messages: Anthropic.MessageParam[] = [{ role: "user", content: req.body.prompt }];
      for (let i = 0; i < 10; i++) {
        const msg = await anthropic.messages.create({
          model: "claude-sonnet-4-6", max_tokens: 4096, tools, messages,
        });
        messages.push({ role: "assistant", content: msg.content });
        if (msg.stop_reason !== "tool_use") {
          return res.json({ result: msg.content });
        }
        const results: Anthropic.ToolResultBlockParam[] = [];
        for (const block of msg.content) {
          if (block.type !== "tool_use") continue;
          const out = await handlers[block.name](block.input);
          results.push({
            type: "tool_result",
            tool_use_id: block.id,
            content: typeof out === "string" ? out : JSON.stringify(out),
          });
        }
        messages.push({ role: "user", content: results });
      }
    },
  );
});

That’s the complete pattern. Three lines added to whatever loop you already had: configure, governHandlers, withContext.

What governHandlers does

Takes a map of { toolName: AsyncHandler }. Returns a map of the same shape where each handler is replaced with a governed version that:

  1. POSTs to ACP’s /govern/tool-use endpoint with the tool name, input, and the user JWT bound by withContext.
  2. If ACP denies the call, returns the string "tool_error: <reason>" — Claude sees this as a tool result and adapts its next turn.
  3. If ACP allows, runs your handler.
  4. POSTs the output to /govern/tool-output for audit logging and PII scanning.
  5. If ACP redacts the output, returns the redacted version (configurable — see below).
  6. If ACP blocks the output post-hoc (e.g., it contained a leaked secret), returns "tool_error: <reason>".

The map shape is preserved. Your dispatch code (handlers[block.name](block.input)) is unchanged. The wrapping is invisible to the rest of the loop.

governed — per-handler control

When you want a handler-by-handler choice (because some tools shouldn’t be governed, or you want different onRedact behavior per tool):

import { governed } from "@agenticcontrolplane/governance-anthropic";

const handlers = {
  // governed
  send_email: governed("send_email", async ({ to, subject, body }) => sendMail(to, subject, body)),
  // not governed (e.g., a fully internal calculation tool)
  add: async ({ a, b }: { a: number; b: number }) => a + b,
  // governed with redact behavior overridden
  search_logs: governed(
    "search_logs",
    async ({ query }) => searchLogs(query),
    { onRedact: "keep" },  // model sees the unredacted output even if ACP redacts
  ),
};

onRedact defaults to "replace" — when ACP returns a redacted version (e.g., PII removed), the model sees the redacted version. Set to "keep" if you’ve decided that for this specific tool the model needs the raw output. Use sparingly — "replace" is the right default for tool outputs that may contain PII, secrets, or other restricted data.

withContext — binding identity

withContext is built on Node’s AsyncLocalStorage. It runs your function with a context object bound to the async scope; every governed handler (and every direct preToolUse / postToolOutput call) inside that scope uses the bound context.

Context fields:

{
  userToken: string;                        // required — JWT, Firebase ID token, or gsk_ key
  sessionId?: string;                       // optional — auto-generated UUID if omitted
  agentTier?: "interactive" | "subagent" | "background" | "api";  // optional
  agentName?: string;                       // optional — shown in dashboard
}

The userToken is the verified end-user identity. ACP validates the signature against your configured IdP (or the workspace key issuer) on every governed call. If you bind a token that doesn’t validate, every tool call inside the scope is denied.

agentTier matters more than people think:

  • interactive — a real user is at the keyboard and will see what the agent does in real-time. Most permissive default policy.
  • subagent — invoked by another agent, no human in the immediate loop. More restrictive.
  • background — autonomous, no human anywhere in the chain. Most restrictive — destructive verbs typically denied here.
  • api — programmatic call from your own backend, governance applies but tier-specific rules don’t.

The same governed handler can produce different outcomes based on tier — Bash.kubectl denied in background, ask in subagent, allowed in interactive. Set the tier when you bind context, and the policy engine handles the rest.

Fail-open behavior

Network errors, timeouts (5-second default), and non-2xx responses from ACP all fail-open: the tool call proceeds, with the audit row marked reason: "fail-open". This matches Claude Code’s hook behavior and is intentional — governance is never a single point of failure for the agent.

If you want fail-closed (the call is denied when ACP can’t be reached), wrap your governed handlers with your own check on getContext() and fail explicitly when the gateway is unreachable. The package leans toward availability; some deployments lean toward strictness.

Where the audit lands

Every governed call (allowed, denied, or failed-open) lands in the dashboard at cloud.agenticcontrolplane.com/activity with:

  • Actor — the verified user from userToken
  • Tool name — the key from your governHandlers map
  • Decision — allow / deny / redact / block / fail-open + reason
  • Session — groups all tool calls from one withContext scope
  • Findings — PII or secret patterns detected in input or output
  • Tier — the agentTier you bound

Tool calls from this integration sit alongside Claude Code, Cursor, Codex CLI, and any other framework using ACP — one audit log per user across every agent surface. The unified view is the whole reason the package exists; without it you’d have separate audit silos per framework, and your CISO question goes unanswered.

Sub-agents and the wrapping rule

Anthropic’s tool-use loop dispatches by tool name through your handler map. The Claude Agent SDK adds sub-agents (via the Task tool) which can have their own tool roster and their own dispatch.

The rule: only handlers that pass through governHandlers or governed are governed. If a sub-agent has its own roster wired up directly to the SDK, those calls bypass governance.

The fix: wrap the sub-agent’s roster the same way you wrap the parent’s. If you’re using the Claude Agent SDK and defining sub-agents declaratively, ensure the sub-agent’s tools are passed through governHandlers before they reach the runner.

Comparison: ACP’s governHandlers vs Anthropic’s native canUseTool

The Claude Agent SDK exposes canUseTool — a callback that fires before every tool call. It’s Anthropic’s native governance hook, similar in spirit to governHandlers but with different tradeoffs:

Dimension governHandlers (ACP) canUseTool (Anthropic SDK)
Where it lives Wraps your handler map Inside the SDK’s tool dispatch
Coverage Any tool-use loop (Messages API, Agent SDK, custom) Claude Agent SDK only
Policy backend Pluggable — point at any ACP-compatible policy engine You write the callback body
Audit trail Built-in, identity-attributed, sent to ACP dashboard You build it
Cross-framework Same audit log across Claude Code, Cursor, Codex, CrewAI, etc. Anthropic-only
Output redaction Built-in via postToolOutput You build it
Identity propagation withContext binds user JWT through AsyncLocalStorage You manage closure capture

You can use both — canUseTool for pre-LLM-call gating that the model sees, governHandlers for the dispatch-layer governance that produces audit. Many production deployments do.

The ACP package is the right primary choice when:

  • You have more than one framework in your stack (Claude Agent SDK + Codex CLI + an internal LangGraph agent)
  • You need a single audit trail across them
  • You want a pluggable policy backend, not “write the callback yourself”
  • You need identity-attributed compliance evidence (SOC 2, HIPAA)

canUseTool alone is the right choice when:

  • You only use the Claude Agent SDK and never plan to add another framework
  • You’re comfortable building the audit + policy + redaction layer yourself
  • You don’t need a unified cross-framework view

Common pitfalls

Forgetting withContext. Every governed call inside a scope without withContext silently no-ops — the tool runs, but no audit, no policy, no governance. The package design optimizes for the right behavior in tests (no setup → no governance) at the cost of a footgun in production. Always wrap your request handler with withContext.

Dispatching outside the wrapped map. If your tool-use loop has a code path that calls a handler directly without going through governHandlers, that path bypasses governance. Audit your dispatch carefully — every tool call must go through handlers[name](input), not lookup_record(input) directly.

Confusing userToken with workspace key. A gsk_ workspace key works for testing but is not a per-user identity. For real audit attribution, bind the verified end-user JWT (Auth0 sub, Okta user ID, Firebase ID token). The audit row is only as good as the token you bind.

Tier mismatched to actual context. If your background agent runs with agentTier: "interactive", the policy engine treats it as a human-supervised session — and a destructive verb that should have been denied gets through. Match the tier to actual reality, not what’s permissive.

Assuming canUseTool and governHandlers overlap. They don’t. canUseTool runs in the SDK before tool dispatch; governHandlers runs in the dispatch step. Both can fire on the same call. If you’re using both, design the policy split deliberately — typically canUseTool for cheap pre-checks (denylist patterns), governHandlers for the full audit + identity-attributed policy.

Where this fits

governHandlers is the production-ready integration we ship and recommend. The starter at acp-governance-sdks/examples/starters/claude-agent-sdk is the runnable reference — clone it, swap the placeholder tool, ship.

Governing the Anthropic Agent SDK in 3 minutes → · Anthropic Agent SDK governance scorecard → · Integration install guide → · Get started →

Get the next post
Agentic governance, AgentGovBench updates, the occasional incident post-mortem. One email per post. No marketing fluff.
Share: Twitter LinkedIn
Related posts

← back to blog