On this page
How the two hooks workAdd the payment hookAdd the content check hookRegister both hooksHeld paymentsWhat it does not stopGuides / Claude Agent SDK
Stop a payment in a Claude Agent SDK program
An agent built on the Claude Agent SDK runs hooks in your own process, before and after each tool. A hook on your payment tools asks the guard first and refuses the tool call when the guard does not allow it.
- Lane
- Advisory: your code acts
- You need
- An API key and a TypeScript agent on the Claude Agent SDK
- Calls
- The HTTP API
The hooks call the guard from your code, so your code acts on the answer. They cover only the tools their matchers name. For a check on every x402 payment, use the proxy URL.
How the two hooks work
The sample below is one file of two hooks, plus the code that registers them.
checkPaymentruns before a payment tool. It asks the guard withPOST /api/v1/decisionsand refuses the call unless the guard allows it.sendWhatItReadruns after a fetch, browse or payment tool. It sends the result for checking withPOST /api/v1/context, so a page that tells the agent to pay counts against the payment.
The payment hook never approves a tool call. An allowed payment still goes through your own permission rules.
| The guard answers | The hook |
|---|---|
allowedreview_approved | Makes no permission decision, so your own permission rules and canUseTool prompt still run. |
review_pending | Refuses the tool call with the decision id, and keeps that id for the same payment. |
denied | Refuses the tool call with the guard's reasons. |
review_deniedreview_expired | Refuses the tool call and says not to pay. |
| No answer, or an error | Refuses the tool call. The reason says the guard did not answer or decide. |
Add the payment hook
Save this as guard.ts. It reads VULSIGHT_API_KEY and VULSIGHT_BASE_URL from the environment.
import type {
HookCallback,
HookJSONOutput,
PostToolUseHookInput,
PreToolUseHookInput,
} from "@anthropic-ai/claude-agent-sdk";
const base =
process.env.VULSIGHT_BASE_URL ?? "https://vulsight-guard.vercel.app";
const headers = {
authorization: `Bearer ${process.env.VULSIGHT_API_KEY}`,
"content-type": "application/json",
"x-vulsight-channel": "api",
};
const sessionId = crypto.randomUUID();
// The hash of the last page sent for checking from each origin. A payment
// sends its seller's, so it inherits that page's result.
const lastSent = new Map<string, string>();
type PendingRead = { origin: string; text: string; sending?: Promise<void> };
const pending = new Set<PendingRead>();
let reads = 0;
// A payment sent to a person keeps its decision id, so the retry after the
// approval reads that decision instead of opening a second hold. Every new
// read clears held (see sendWhatItRead), so the key needs no page hash.
const held = new Map<string, string>();
type Decision = {
id: string;
status: string;
rules: { result: string; sentence?: string }[];
};
// The x402 method fields a seller's 402 may name, at its top level or in its
// extra object.
type Method = { assetTransferMethod?: string; paymentFlow?: string };
type Quote = Method & {
extra?: Method;
x402Version?: number;
observed402PayTo?: string;
};
// No permission decision, so your own permission rules and prompts still run.
const noDecision: HookJSONOutput = {};
const deny = (permissionDecisionReason: string): HookJSONOutput => ({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason,
},
});
export const checkPayment: HookCallback = async (input) => {
const signal = AbortSignal.timeout(110_000);
const before = reads;
const { tool_input } = input as PreToolUseHookInput;
const { payTo, amountAtomic, asset, network, scheme, resourceUrl } =
tool_input as Record<string, string>;
// The 402's method fields, when your payment tool takes them: at the top
// level, or inside the 402's extra object passed as it is. The guard
// checks both. Left out, they read as the defaults.
const {
assetTransferMethod,
paymentFlow,
extra,
x402Version,
observed402PayTo,
} = tool_input as Quote;
if (x402Version !== 2 || !observed402PayTo) {
return deny(
"The payment tool must pass the seller's x402 v2 version and " +
"original 402 payee. Do not pay without them; send the payment " +
"through the proxy URL instead.",
);
}
// The seller's origin: the payment cites the last page sent from it.
const seller = URL.canParse(resourceUrl)
? new URL(resourceUrl).origin
: undefined;
const payment = JSON.stringify([
network, payTo, amountAtomic, asset, scheme,
assetTransferMethod, paymentFlow,
extra?.assetTransferMethod, extra?.paymentFlow,
x402Version, observed402PayTo, resourceUrl,
]);
// Taken out before any await, so two attempts at once cannot both read
// one approval.
const heldId = held.get(payment);
held.delete(payment);
let decision: Decision;
try {
await Promise.all(
[...pending].map(async (read) => {
await read.sending;
if (pending.has(read)) await sendRead(read, signal);
}),
);
signal.throwIfAborted();
if (pending.size) {
return deny(
"VulSight Guard could not check a page the agent read. Retry this " +
"payment. If it still fails, check the API key and service " +
"connection. Do not pay.",
);
}
if (reads !== before) {
return deny(
"A new page was read during this check. Retry the payment to " +
"include it. Do not pay.",
);
}
const answer = heldId
? await fetch(`${base}/api/v1/decisions/${heldId}`, {
headers,
signal,
})
: await fetch(`${base}/api/v1/decisions`, {
method: "POST",
headers,
body: JSON.stringify({
kind: "x402_payment",
payTo, amountAtomic, asset, network, scheme,
assetTransferMethod, paymentFlow, extra,
x402Version, observed402PayTo, resourceUrl, sessionId,
contextSha256: seller && lastSent.get(seller),
}),
signal,
});
const body = await answer.json();
if (!answer.ok) {
// A guard that is busy or down keeps the hold for the next attempt; a
// refused read drops it.
if (
heldId &&
reads === before &&
(answer.status >= 500 || answer.status === 429)
) {
held.set(payment, heldId);
}
return deny(
`VulSight Guard did not decide. ${body.error.message} Do not pay.`,
);
}
if (
!body ||
typeof body.id !== "string" ||
!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(body.id) ||
![
"allowed", "denied", "review_pending",
"review_approved", "review_denied", "review_expired",
].includes(body.status) ||
!Array.isArray(body.rules) ||
!body.rules.every(
(rule: unknown) =>
typeof rule === "object" && rule !== null &&
"result" in rule && typeof rule.result === "string" &&
["pass", "review", "deny", "info"].includes(rule.result) &&
(!("sentence" in rule) || typeof rule.sentence === "string"),
)
) {
if (heldId && reads === before) held.set(payment, heldId);
return deny(
"VulSight Guard returned an invalid decision. Retry the payment. " +
"Do not pay.",
);
}
decision = body;
} catch {
if (heldId && reads === before) held.set(payment, heldId);
return deny("VulSight Guard did not answer. Do not pay.");
}
if (reads !== before) {
return deny(
"A new page was read during this check. Retry the payment to " +
"include it. Do not pay.",
);
}
if (decision.status === "review_pending") {
held.set(payment, decision.id);
return deny(
`Held for a person. Approve or deny it on Review, decision ` +
`${decision.id}, then try the same payment again.`,
);
}
if (
decision.status === "allowed" ||
decision.status === "review_approved"
) {
return noDecision;
}
if (decision.status === "review_denied") {
return deny("A reviewer denied this payment. Do not pay.");
}
if (decision.status === "review_expired") {
return deny("The review expired before anyone answered. Do not pay.");
}
const denied = decision.rules.filter((r) => r.result === "deny");
return deny(
denied.map((r) => r.sentence).join(" ").trim() ||
"VulSight Guard denied this payment. Check its decision before " +
"trying again. Do not pay.",
);
};The hook needs the seller's x402Version and the payee its own 402 named, as observed402PayTo. It refuses a payment without them, so make your payment tool pass both. That is how the guard catches a redirected payee or an x402 v1 quote.
It also sends assetTransferMethod and paymentFlow, at the top level or inside extra. The guard refuses a method it does not support, or two that disagree. A tool that passes neither is judged on the defaults, so check it pays only supported payments.
Add the content check hook
Add this to the same file. It sends a page fetched by URL under its origin, and any other result under the tool's name. A pay tool that takes resourceUrl rather than url sends under its name.
// guard.ts, continued: shared state comes from the block above.
function sendRead(read: PendingRead, signal: AbortSignal): Promise<void> {
if (read.sending) return read.sending;
read.sending = (async () => {
try {
const answer = await fetch(`${base}/api/v1/context`, {
method: "POST",
headers,
// The guard takes a label of at most 200 characters, and a hostname
// can run to 253.
body: JSON.stringify({
sessionId,
origin: read.origin.slice(0, 200),
text: read.text,
}),
signal,
});
if (!answer.ok) throw new Error(`HTTP ${answer.status}`);
const receipt = await answer.json();
if (
typeof receipt.sha256 !== "string" ||
!/^[a-f0-9]{64}$/.test(receipt.sha256) ||
!["pass", "restrict", "unavailable"].includes(receipt.route)
) {
throw new Error("Invalid check receipt");
}
lastSent.set(read.origin, receipt.sha256);
pending.delete(read);
} catch {
console.error(
`VulSight Guard could not check ${read.origin}. Payments stay ` +
"blocked until a retry succeeds, so retry the payment.",
);
} finally {
read.sending = undefined;
}
})();
return read.sending;
}
export const sendWhatItRead: HookCallback = async (input) => {
const { tool_name, tool_input, tool_response } =
input as PostToolUseHookInput;
const page =
typeof tool_response === "string"
? tool_response
: JSON.stringify(tool_response);
if (!page) return {};
// The first 64 KiB, whole characters, each NUL as the replacement
// character (the guard refuses a NUL), as the shipped clients send it.
const bytes = new TextEncoder()
.encode(page.replaceAll("\u0000", "\uFFFD"))
.subarray(0, 65_536);
const text = new TextDecoder("utf-8", { ignoreBOM: true })
.decode(bytes, { stream: true });
// A page fetched by URL is sent under its origin, anything else under the
// tool's name, as the Claude Code hook sends them.
const url = (tool_input as { url?: unknown } | undefined)?.url;
const origin =
typeof url === "string" &&
URL.canParse(url) &&
/^https?:$/.test(new URL(url).protocol)
? new URL(url).origin
: tool_name;
const read: PendingRead = { origin, text };
pending.add(read);
reads += 1;
held.clear();
await sendRead(read, AbortSignal.timeout(110_000));
return {};
};A flagged page under its origin holds payments to that seller for an hour, in any session, and any contract call in this session. A flagged result under a tool's name holds every payment in the session.
The payment hook sends the hash of the seller's last page as contextSha256, so the decision includes that page's result. Before it decides, it waits for pending sends and retries each failed one once.
Register both hooks
Register them where the agent starts, and name your payment tools in both matchers.
import { createInterface } from "node:readline/promises";
import { query } from "@anthropic-ai/claude-agent-sdk";
import { checkPayment, sendWhatItRead } from "./guard";
// One prompt at a time: tool calls can run in parallel, and two prompts
// open on one terminal would both take the same typed answer.
let asking = Promise.resolve();
async function ask(toolName: string, input: Record<string, unknown>) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
return await rl.question(
`Allow ${toolName} ${JSON.stringify(input)}? (y/n) `,
);
} finally {
rl.close();
}
}
// Name the tools your agent pays with in both matchers.
for await (const message of query({
prompt:
"Buy the dataset at https://vulsight-guard.vercel.app/merchant/dataset.",
options: {
// Asks a person before each tool call the guard hook has not refused.
// To approve the pay tools with no prompt instead, replace this with
// allowedTools: ["pay_x402", "mcp__wallet__pay"].
canUseTool: async (toolName, input) => {
const turn = asking.then(() => ask(toolName, input));
asking = turn.then(
() => {},
() => {},
);
const answer = await turn;
return answer.trim() === "y"
? { behavior: "allow", updatedInput: input }
: { behavior: "deny", message: "The user declined this tool call." };
},
hooks: {
PreToolUse: [
{
matcher: "pay_x402|mcp__wallet__pay",
hooks: [checkPayment],
timeout: 120,
},
],
PostToolUse: [
{
matcher: "pay_x402|mcp__wallet__pay|WebFetch|mcp__browser__.*",
hooks: [sendWhatItRead],
timeout: 120,
},
],
},
},
})) {
console.log(message);
}The matcher timeout sits above the guard's own wait, so a slow answer is a refusal, never a tool call. The hook only refuses, so the pay tools still need your own approval, or the SDK refuses them.
The sample asks a person in canUseTool. An allowedTools entry instead skips your own prompt for those tools, so a payment the hook does not refuse goes ahead with no one asked.
Held payments
A held payment waits on the Review page. The hook refuses the tool call with the decision id, and the run goes on so the agent can tell the person where to answer. Return continue: false as well to end the run there.
Once a person approves, the agent's next try at the same payment reads that decision once. A denied or expired review is refused in words the agent can quote. A new page read in between clears the kept decision, so the payment is checked again. The Reviews page has the buttons and the timeout.
What it does not stop
- A payment tool the matchers do not name.
- An agent that pays outside the Claude Agent SDK, or runs without the hooks.
- A page the agent read with a tool the content check hook does not match.
In observe mode the guard allows most payments and records what enforce mode would have done. It still denies a wrong network or asset, a payee other than the 402's, a payment method it cannot judge, and an unsupported contract call.
The proxy URL checks every x402 payment sent through it, whatever tool makes it.