# Anthropic Agent SDK: Auditable Logging and Governance in TypeScript

The complete TypeScript reference for adding auditable logging, per-user identity, and policy enforcement to Anthropic's Agent SDK and Claude Agent SDK loops.

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

```typescript
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):

```typescript
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:

```typescript
{
  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](https://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.

## Frequently asked questions



## Where this fits

`governHandlers` is the production-ready integration we ship and recommend. The starter at [acp-governance-sdks/examples/starters/claude-agent-sdk](https://github.com/agentic-control-plane/acp-governance-sdks/tree/main/examples/starters/claude-agent-sdk) is the runnable reference — clone it, swap the placeholder tool, ship.

[Governing the Anthropic Agent SDK in 3 minutes →](/blog/governing-the-anthropic-agent-sdk) · [Anthropic Agent SDK governance scorecard →](/blog/anthropic-agent-sdk-governance-scorecard) · [Integration install guide →](/integrations/anthropic-agent-sdk) · [Get started →](/getting-started)

