Claude Inference Hooks: Build an AI Security Server (GA)

TL;DR
Claude Inference hooks left beta for Enterprise on Oct 20. How the prompt and tool call frames work, how to verify signatures, and a TypeScript verdict server.
Last updated: October 20, 2026
Claude Inference hooks let a Claude Enterprise organization put its own security server between every user prompt and the model. Anthropic POSTs the conversation transcript to an HTTPS endpoint you run, waits for an allow or deny verdict, and a denied request never reaches the model. Per the Claude Platform release notes, the feature came out of beta for Claude Enterprise organizations on October 20, 2026. The hook runs on Anthropic's servers, so it covers claude.ai, Claude Code, Cowork and the rest of your org's Claude products with nothing to deploy on laptops.
This post is the developer's side of it: what the two request frames look like, how to verify signatures, a TypeScript verdict server we type-checked and ran locally, and the operational rules that decide whether your server helps or breaks everyone's Claude. If your team is weighing whether Enterprise is worth it at all, our Team vs API vs Enterprise comparison covers the plan side.
What shipped#
From the Inference hooks overview:
- Two hook events.
promptfires once per governed inference request, before inference.tool_callfires when Claude's response contains tool calls, before any of them runs, when Validate tool calls is on. That toggle is on by default in a new configuration. - What your server sees: transcript text, tool calls and results, and text extracted from attachments. It never receives raw file or image bytes, system prompts, Anthropic-internal context or Claude's hidden reasoning.
- Who can configure it: Enterprise only, with the
organization:managepermission, which only the Owner and Primary owner roles hold. The Admin role does not have it. - Rollout controls: shadow mode (verdicts recorded, nothing blocked), a 0 to 100 percent rollout, and role exclusions for custom roles.
%% title: One governed Claude request with Inference hooks
sequenceDiagram
participant U as User
participant A as Anthropic
participant S as Your AI security server
participant M as Model
U->>A: Prompt
A->>S: POST prompt frame (signed)
S-->>A: 200 {"action": "allow"}
A->>M: Inference
M-->>A: Response with tool calls
A->>S: POST tool_call frame
S-->>A: 200 {"action": "deny", "deny_reason": "..."}
A-->>U: Blocked by policy: deny_reason + admin messageThe request frames#
Every request carries Content-Type: application/json, User-Agent: anthropic-dlp/1 and Accept-Encoding: identity, plus any custom headers your admin set (up to 16) and the webhook-* signature headers. The integration docs define the prompt frame's fields:
| Field | What it holds |
|---|---|
type | "prompt" or "tool_call". More event types will come; handle unknown ones gracefully |
request_id | Per-frame ID; equals the webhook-id header |
tenant_id | Opaque org identifier, or null |
actor | { type: "user", id, email_address }; id and email_address can be null |
source.application | Open string: claude-ai, claude-code, cowork, and config-test for connection tests |
messages | The transcript as the user sees it, with text, tool_use, tool_result and attachment blocks |
session_id, model | Opaque session ID and public model ID, either can be null |
metadata | Reserved, empty today |
A tool call frame has the same top-level fields, and its tool_use blocks add a tool_info object that says who runs or provides each tool. One verdict covers the whole frame, so you cannot allow some tool calls and deny others.
Two details matter for policy. Treat source.application as advisory routing metadata, not a trust boundary. And transcripts are sent untruncated: bodies are usually under about 10 MB, but the protocol allows up to 64 MiB. That is far above defaults like nginx's 1 MB client_max_body_size or Express's 100 kB express.json(), and a rejected body is a webhook failure.
The verdict#
Always answer HTTP 200 with a JSON body. {"action": "allow"} lets the request through. A deny looks like this:
{
"action": "deny",
"deny_reason": "This prompt appears to contain customer payment card data, which your organization's policy does not allow.",
"reference_id": "scan_01HXPT4R9V"
}
deny_reason is shown to the user (500 characters max, longer is truncated). reference_id is up to 50 characters from [A-Za-z0-9._:/-], is recorded on the inference_hooks_request_denied compliance activity, and is never shown to the user. A non-200 status is not a deny. It is a webhook failure, and your failure handling setting applies instead. Any action other than allow or deny is a failure too.
A verdict server in TypeScript#
This is a minimal policy server: it verifies the signature, denies anything that looks like a 16-digit card number, allows everything else, and allows unknown event types as the docs require. verify.ts is the official TypeScript sample from the signature section, copied unchanged:
// verify.ts - from Anthropic's Inference hooks docs
import { createHmac, timingSafeEqual } from "node:crypto";
import type { IncomingHttpHeaders } from "node:http";
const TOLERANCE_SECONDS = 300;
export function verify(secret: string, headers: IncomingHttpHeaders, body: Buffer): boolean {
const messageId = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (
typeof messageId !== "string" ||
typeof timestamp !== "string" ||
typeof signatures !== "string"
) {
return false; // unsigned request: not from Anthropic
}
const signedAt = Number(timestamp);
if (
!Number.isFinite(signedAt) ||
Math.abs(Date.now() / 1000 - signedAt) > TOLERANCE_SECONDS
) {
return false; // replayed, or the clocks disagree
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const payload = Buffer.concat([Buffer.from(`${messageId}.${timestamp}.`), body]);
const expected = Buffer.from(
"v1," + createHmac("sha256", key).update(payload).digest("base64")
);
return signatures.split(" ").some((candidate) => {
const candidateBytes = Buffer.from(candidate);
return (
candidateBytes.length === expected.length && timingSafeEqual(candidateBytes, expected)
);
});
}
// server.ts
import { createServer } from "node:http";
import { verify } from "./verify.ts";
// Read the secret from the environment; it is shown once at setup.
const SECRET = process.env.INFERENCE_HOOKS_SECRET ?? "";
const MAX_BODY_BYTES = 64 * 1024 * 1024; // the protocol allows up to 64 MiB
type Block =
| { type: "text"; text: string }
| { type: "attachment"; file_name: string | null; text: string | null }
| { type: "tool_result"; content: string; is_error: boolean }
| { type: string; [key: string]: unknown };
interface Frame {
type: string; // "prompt" | "tool_call" today; more later
request_id: string;
source?: { application?: string };
messages?: Array<{ role: string; content: Block[] }>;
}
type Verdict =
| { action: "allow" }
| { action: "deny"; deny_reason: string; reference_id?: string };
// Toy policy: block anything that looks like a 16-digit card number.
const CARD = /\b(?:\d[ -]?){15}\d\b/;
function textOf(block: Block): string {
if (block.type === "text" && typeof block.text === "string") return block.text;
if (block.type === "attachment" && typeof block.text === "string") return block.text;
if (block.type === "tool_result" && typeof block.content === "string") return block.content;
return ""; // unknown block types are skipped, never rejected
}
function decide(frame: Frame): Verdict {
if (frame.type !== "prompt" && frame.type !== "tool_call") return { action: "allow" };
const text = (frame.messages ?? []).flatMap((m) => m.content.map(textOf)).join("\n");
if (CARD.test(text)) {
return {
action: "deny",
deny_reason: "This message looks like it contains a payment card number. Remove it and try again.",
reference_id: `scan_${frame.request_id}`.slice(0, 50)
};
}
return { action: "allow" };
}
createServer((request, response) => {
const chunks: Buffer[] = [];
let size = 0;
request.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size <= MAX_BODY_BYTES) chunks.push(chunk);
});
request.on("end", () => {
const body = Buffer.concat(chunks);
// Unsigned or tampered requests are not from Anthropic.
if (!verify(SECRET, request.headers, body)) {
response.writeHead(401).end();
return;
}
let verdict: Verdict = { action: "allow" };
try {
verdict = decide(JSON.parse(body.toString("utf8")) as Frame);
} catch {
// A signed but unparseable body: allow rather than fail the request.
}
// Always 200: a non-200 status is a webhook failure, not a deny.
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify(verdict));
});
}).listen(8000);
What we verified: both files type-check with tsc --strict against @types/node, and we ran node server.ts on Node 22.22 against requests we signed ourselves using the documented scheme. A clean prompt returned {"action":"allow"}, a prompt with 4111 1111 1111 1111 returned the deny verdict, and an unsigned request got a 401. We could not test against Anthropic's live service, which needs an Enterprise org. The 64 MiB check here only stops buffering; a real deployment should cap body size at the proxy too, and decide whether an oversized body should fail open or closed.
The regex is a placeholder. In production this is where you call your DLP scanner, and the docs' advice is to write deny_reason for the end user, telling them what to remove rather than giving a scanner code.
Hosting rules that will bite you#
From the hosting requirements:
https://on port 443, publicly routable, certificate valid against the public CA store, no redirects.- The host needs an IPv4 address.
localhostand IPv6-literal hosts are refused, as are private, loopback and carrier-grade NAT ranges. - Reverse tunnels such as ngrok are blocked, even for testing. Host it on a domain you control.
- Requests come from
160.79.106.0/24. Allowlist that block, but keep verifying signatures, because the block carries other Anthropic egress too.
Operational semantics#
- Timeout: 1 to 10,000 ms, 5,000 ms by default, covering connection, TLS, request and response. Set the lowest value your server reliably meets, because a slow verdict counts as an unreachable server.
- Retry: exactly once, after 100 ms, and only when the connection attempt fails. It reuses the same
webhook-id, so deduplicate on it. - Failure handling: Block the request (fail closed) or Allow the request (fail open). New configurations default to Allow and 5,000 ms. That default means a body-size rejection or crash lets the prompt through uninspected.
- Circuit breaker: sustained failures stop Anthropic contacting your server and apply failure handling to everything. Starting 10 minutes later, it sends the
config-testrequest about once a minute until a valid verdict resets it. - Latency: your round trip is added to every governed request. With Validate tool calls on, responses with tool calls wait for a second verdict, in shadow mode too.
- Signing secret: shown once at setup, rotation is an immediate cutover, and old-secret requests can keep arriving for about a minute. Accept both secrets during the switch.
Roll out without blocking anyone#
The configuration flow lives in claude.ai under Organization settings > Data and privacy > Inference hooks (setup docs). Allowing the feature always forces Enforce verdicts off, so turning it on never starts inspection by itself. The sane order:
- Deploy the server above with your real policy and set the endpoint. Store the signing secret, then click Test connection. It reports the verdict your server returned and the round-trip time, so it catches a deny-everything bug early.
- Set Mode to Shadow mode and turn on Enforce verdicts. Every request still reaches the model while you watch your server's verdicts on real traffic.
- Raise Requests inspected (%) gradually, and exclude any custom roles that should not be covered.
- Switch Mode to Block the request or Allow the request to start enforcing.
Gotchas and limits#
Denials stick to the conversation. Each request carries the whole transcript, so once something is denied, every later message in that conversation is denied too. In Claude Code the user has to /rewind past the prompt that brought the content in, or /clear. Tell your users before you enforce.
Your 401 is a failure, not a deny. Rejecting an unsigned request with an error status is correct, but it counts toward webhook failures. If your signature check is broken, you will trip the circuit breaker and, with fail-open, inspect nothing.
Unknown is not invalid. New event types, block types, source.application values and actor types will appear. For an unknown top-level type, return allow. Rejecting it is a webhook failure.
Not a substitute for retention controls. The hooks system keeps only configuration and metadata, but the Claude products still store prompts and responses under their own retention rules, blocked ones included. If retention is your concern, read our Fable 5 enterprise data retention post as well.
The closest alternative#
Claude Code already has client-side hooks, such as UserPromptSubmit and PreToolUse, which run on the developer's machine and can block a prompt or tool call. They are flexible and free, but they live in each user's settings and only cover Claude Code. Inference hooks run server-side for every governed request across every Claude product, which is what a security team needs to make a policy claim. Many orgs will want both: client hooks for workflow rules, Inference hooks for DLP.
What people are actually saying#
- The GA note is from today, and we found no Hacker News or Reddit thread on it yet. The feature is aimed at security vendors and enterprise admins, whose discussion mostly happens in vendor docs rather than public forums.
- The design choices worth arguing about are visible in the docs themselves: fail-open is the default, the circuit breaker turns sustained failures into no inspection, and
source.applicationis explicitly not a trust boundary. Expect security teams to switch to fail-closed once their server is proven in shadow mode.
FAQ#
What are Claude Inference hooks?#
An Enterprise feature that sends every governed prompt, and optionally every set of tool calls, to an HTTPS server you run. Your server returns allow or deny before inference or the tool calls run, across claude.ai, Claude Code, Cowork and other Claude products.
What happens if my AI security server is down?#
Timeouts, non-200 responses and unreachable endpoints are webhook failures, never denies. Failure handling decides: Block the request or Allow the request. Sustained failures trip a circuit breaker.
How do I verify that a request came from Anthropic?#
Compute HMAC-SHA256 over webhook-id, webhook-timestamp and the raw body with your base64-decoded secret, compare it in constant time to the v1 values in webhook-signature, and reject timestamps more than five minutes off.
Who can set up Inference hooks?#
Claude Enterprise organizations only, by users with organization:manage, which the Owner and Primary owner roles hold.
Continue Reading#
- Claude Code for Teams: Team Seats vs API vs Enterprise (2026) - whether Enterprise, and with it Inference hooks, is worth it for your team
- Claude Code Hooks Explained - the client-side hooks that complement server-side inspection
- Fable 5 Broke Enterprise ZDR Agreements: What Dev Teams Must Do Now - the retention side of enterprise compliance
Sources#
Get the next deep dive like this in your inbox
One email a week on Anthropic and the rest of the AI dev stack. Free.
Read next on Claude Code
Fable 5 Broke Enterprise ZDR Agreements: What Dev Teams Must Do Now
Claude Fable 5 mandates 30-day data retention on every platform, overriding Zero Data Retention contracts. What compliance teams should audit before deploying.
8 min readClaude Code Hooks Explained
Claude Code hooks run shell commands at exact lifecycle points. Set up auto-format, dangerous-command blocking, tests before stopping and desktop notifications.
12 min readClaude Code for Teams: Team Seats vs API vs Enterprise (2026)
Team seats ($25 or $125 a month), Enterprise ($20 plus API-rate usage) or the Console API? How to pay for Claude Code for a team, with 10-developer math.
11 min readTechnical content at the intersection of AI and development. Building with AI agents, Claude Code, and modern dev tools - then showing you exactly how it works.






