On this page
Run it against the demo sellerRules that keep it workingWhat the SDK sendsThe guard objectOptionsModesErrors in codeSee a reviewSend other text for checkingReport the settlementSolanaLimitsPurchase tasksNext stepsLanes / SDK
Add Cerberus to a TypeScript agent
The SDK hooks Cerberus into the x402 client your agent already pays with. It asks Cerberus before each payment is signed, so a denied payment is never signed.
It checks the payments made through that x402 client. Another client, another process, or a signature made by hand is not checked.
- Lane
- Enforced on routed payments
- You need
- An API key, an x402 client, and a wallet with test USDC
- Takes
- About 10 minutes
Set up with AI
Paste a prompt into Claude Code, Cursor, or Codex, and your assistant wires Cerberus into your agent. The prompts name the environment variables and never hold your key. MCP is advisory, since the agent chooses whether to ask.
Set up Cerberus in this project so it checks the x402 payments my agent makes.
1. First read https://cerberus.vulsight.com/llms.txt and the skill at https://cerberus.vulsight.com/skills/vulsight-guard/SKILL.md.
2. Find the function that makes paid x402 requests.
3. Install @vulsight/guard and wrap the agent's x402 client with const vulsight = guard(client, { apiKey: process.env.VULSIGHT_API_KEY ?? "", baseUrl: "https://cerberus.vulsight.com" }). Read pages with vulsight.fetch(fetch), and pay with wrapFetchWithPayment(vulsight.fetch(fetch), client), as https://cerberus.vulsight.com/docs/sdk shows, so each payment is checked before it is signed and the pages the agent read are checked with it.
4. Add an empty VULSIGHT_API_KEY= line to .env.example. Never write a real key, proxy token, or proxy URL into a committed file. Make sure .env is in .gitignore, and add it if it is not. I put the value in my own .env.
5. When I say the value is in place, make one test payment to https://cerberus.vulsight.com/merchant/dataset (0.05 test USDC on Base Sepolia). Then tell me to confirm the decision shows on Home at https://cerberus.vulsight.com/dashboard.bun add @vulsight/guard @x402/fetch @x402/evm viemThe SDK runs on Node 20 or later, or Bun. It takes any 2.x release of @x402/core from 2.24 on, and the install line adds one copy of it.
Run it against the demo seller
These steps pay the demo seller 0.05 test USDC on Base Sepolia, then show a denied payment. Run them in bash or zsh, in one shell.
Export your API key as
VULSIGHT_API_KEY. If you have none, get an API key first.export VULSIGHT_API_KEY="<your key>"You see: no output. Keep this shell open.
Make a throwaway wallet. Never use one that holds real funds.
export EVM_PRIVATE_KEY="$( node --input-type=module -e ' import { generatePrivateKey } from "viem/accounts"; console.log(generatePrivateKey()); ' )" node --input-type=module -e ' import { privateKeyToAccount } from "viem/accounts"; const key = process.env.EVM_PRIVATE_KEY; console.log(privateKeyToAccount(key).address); 'You see: the wallet's address, 0x and 40 hex digits. The key is exported and never printed.
Paste the address into the Circle faucet and request USDC on Base Sepolia.
You see: test USDC at that address a few moments later.
Save this program as agent.ts.
import { guard } from "@vulsight/guard"; import { ExactEvmScheme } from "@x402/evm/exact/client"; import { wrapFetchWithPayment, x402Client } from "@x402/fetch"; import { privateKeyToAccount } from "viem/accounts"; const need = (name: string) => { const value = process.env[name]; if (!value) throw new Error(`Set ${name} before starting the agent.`); return value; }; const key = need("EVM_PRIVATE_KEY") as `0x${string}`; const account = privateKeyToAccount(key); const client = new x402Client().register( "eip155:*", new ExactEvmScheme(account), ); const vulsight = guard(client, { apiKey: need("VULSIGHT_API_KEY"), baseUrl: "https://cerberus.vulsight.com", }); // files the pages the agent reads const read = vulsight.fetch(fetch); // checks every payment before it is signed const pay = wrapFetchWithPayment(read, client); const url = "https://cerberus.vulsight.com/merchant/dataset"; await read(url, { headers: { accept: "text/html" } }); // 0.05 test USDC on Base Sepolia const paid = await pay(url); console.log(paid.status, await vulsight.reportSettlement(paid));Run
bun agent.ts, ornode agent.tson Node 22.18 or newer with"type": "module"in yourpackage.json.If Node says
Unknown file extension ".ts", your Node build leaves out TypeScript support (some Linux packages do). With"type": "module"in yourpackage.json, runnpx tsx agent.ts, or use Bun.200 { decisionId: "91373fee…3907", network: "eip155:84532", txHash: "0xa216…64be", payer: "0x6C5F…0fa4", payee: "0x1111…1111", amountAtomic: "50000", asset: "0x036C…CF7e", direction: "sent", }You see: 200 and the settlement the SDK reported. The payment is on Decisions.
In agent.ts, replace the lines after
const paywith these, then run it again. They pay a payee off your allowlist.// x402's own one dollar cap would refuse 2.50 before Cerberus sees it. client.setSpendControls({ maxAmountPerPayment: false }); try { // The priority product costs 2.50 USDC. await pay("https://cerberus.vulsight.com/merchant/priority"); } catch (error) { console.log(String(error)); }Error: Failed to create payment payload: Payment creation aborted: The amount 2.50 USDC is over the per-transaction limit of 0.10 USDC. This payment of 2.50 USDC would bring today's total across all networks to 2.55 USDC, over the daily cap of 1.00 USDC. 0x894d…4807 is not on your allowlist. Auto-allow is off in your policy.You see: why Cerberus denied it, the rules that denied it first. Nothing is signed. The daily total counts every payment today, so yours may differ.
Rules that keep it working
- Wrap in this order,
wrapFetchWithPayment(vulsight.fetch(fetch), client). The guard's fetch sits inside, so it sees the paid request and never lets it follow a redirect. - Give each x402 client one guard. A second
guard()on the same client throws. - Call
guard()before you register anonPaymentResponsehook of your own. x402 stops at the first response hook that asks for a retry, and the SDK needs to see the receipt. - Keep x402's one dollar cap per payment, or lift it with
client.setSpendControls({ maxAmountPerPayment: false }). The cap refuses a larger payment before Cerberus sees it, so it never reaches Decisions. - Point
guard()at the seller's own URL, never at a proxy URL. Theguard()hook and the proxy URL are alternatives, not layers. To take the proxy URL from TypeScript instead, givewrapFetchWithPaymentthe fetchviaProxy(0.8.1 or newer) returns and leaveguard()out. - Read streams and long polls with the plain fetch. A body still arriving 5 s after its headers blocks the next payment to that site until you read it again.
- Leave the payment terms and the resource as they are once x402 picks them. The SDK blocks a payment whose terms changed after the check.
What the SDK sends
Before a payment is signed, the hook sends Cerberus the seller's terms. They are the payee, amount, asset, network and scheme, plus the transfer method and payment flow when the 402 declares them. It adds the URL the agent asked for and the session id.
It also cites the last page the agent read from that seller, as its SHA-256 hash and an excerpt of up to 2,000 characters. The page itself reached Cerberus when the agent read it.
Each payment attempt carries its own Idempotency-Key. When the SDK retries the call once after an outage, Cerberus decides it once. A payment you run again is a new attempt and a new decision.
Your wallet key never leaves your process. The API key is the only secret the SDK sends.
The guard object
guard(client, options) registers the SDK's hooks on the x402 client and returns this object. file and reportSettlement return promises.
| Member | Returns | What it does |
|---|---|---|
fetch(fetch) | The fetch you passed, wrapped | Wraps the fetch the agent reads with. It sends each page and each seller's 402 for checking, then returns the seller's response. |
file(text, origin?) | pass, restrict or unavailable | Sends text the agent read another way for checking. It never throws. |
reportSettlement(response) | SettlementReport | Reports the seller's receipt on the paid response and returns the report it posted. An optional second argument takes an AbortSignal. |
api | GuardClient | A typed client for the HTTP API, with decide, decision, policy, recentDecisions, reportContext and reportSettlement, and selectTask. For a paid response, call reportSettlement(response) above instead, which reads the receipt for you. |
startTask(id, options?) | Promise<void> | Selects an existing owner-created task. options.maxPurchases can lower its lifetime purchase limit. |
session | string | The session id every check from this guard carries. |
reportSettlement returns the report it posted, with the fields the run above prints. The stored settlement on the HTTP API also has an id and the time it was reported.
vulsight.api.decide asks for a decision yourself, such as for a contract call before you sign it. That call is advisory, so your code acts on the answer.
Options
guard(client, options) takes these options.
| Option | Default | What it does |
|---|---|---|
apiKey | Required | Your API key. guard() throws when it is missing or blank. |
baseUrl | Required | Where Cerberus answers, https://cerberus.vulsight.com. It must use https, and plain http works only on a loopback address such as localhost. The SDK follows no redirect, so a URL that redirects blocks the payment and names the address to set. |
task | None, so no task limit | An owner-created task id copied from Policy. |
maxPurchasesPerTask | Task chosen limit | Lowers the task lifetime purchase limit. A client cannot raise it. Set it with task, since a limit alone is refused. |
session | One id per process | Groups one run's pages and payments. It must be 1 to 200 characters with no NUL character, or guard() throws. |
waitForReviewSeconds | 120 | How long a held payment waits for a person, 0 or more seconds. 0 never waits. The wait also ends when the review expires, so raise the Review timeout on Policy with it. |
mode | "enforce" | What happens when Cerberus does not answer in time. See Modes. |
fetch | The global fetch | The fetch the SDK uses for its own calls to Cerberus. |
Modes
mode changes one case only, when Cerberus does not answer in time. Everything else blocks in both modes.
| What happened | enforce | observe |
|---|---|---|
| Cerberus allowed it, or a reviewer approved it | Pays | Pays |
| Cerberus denied it, or it was still held when the wait ended | Blocks | Blocks |
| Cerberus did not answer in time: unreachable, a 408, a 5xx, or past its time budget | Blocks | Pays and logs a warning |
| Cerberus refused the call: a wrong API key, a rate limit, a page it refused | Blocks | Blocks |
| The SDK could not check it: an unreadable 402, an unsupported payment, a failed page read | Blocks | Blocks |
A 503 payment_screening_unavailable is an answer. Cerberus judged the payment and holds it, so it blocks in both modes.
observe pays without a check when Cerberus does not answer, on a mainnet too. Keep enforce for real money.
The agent's mode on the dashboard is separate. When the agent is in observe mode and enforce would have denied or held a payment, the payment goes ahead. The SDK logs one console.warn with that decision and its reasons.
Four denials still block in both modes. Two are a network or an asset the policy does not allow. The others are a payee the seller's 402 did not name and a payment Cerberus cannot judge. Reason codes shows which codes block in observe mode.
Errors in code
A blocked payment and an error from Cerberus reach your code in different ways.
- When the SDK blocks a payment,
wrapFetchWithPaymentthrows a plainErrorwith no code. Its message starts withFailed to create payment payload:and gives the reason. vulsight.apicalls andreportSettlementthrow aGuardApiErrorwhen Cerberus answers with an error. It carriesstatus,codeandmessage, andoutageis true on a 408, a 429 or a 5xx. Every code is on Errors.reportSettlementthrows a plainErrorwhen the response holds no receipt it can report. The message says why and what to check.guard()throws at startup on a missing API key, a bad base URL, a bad session, or a second guard on one client. The base URL must be absolute and use https, with no credentials, query or fragment. Plain http works only on a loopback address such as localhost.
import { GuardApiError } from "@vulsight/guard";
let paid: Response;
try {
paid = await pay(url);
} catch (error) {
// "Failed to create payment payload:" means the SDK blocked it and
// sent no payment, unless an onPaymentRequired hook on your client
// already sent one of its own. Any other error can come after a
// signed request left, so check Decisions before you pay again.
console.log(String(error));
process.exit(1);
}
try {
console.log(paid.status, await vulsight.reportSettlement(paid));
} catch (error) {
// An outage keeps the payment on record, so report the same
// response again later.
if (error instanceof GuardApiError && error.outage) {
console.log("Report it again later.");
} else {
console.log(String(error));
}
}Each message is written for a person, and its sentences can change between releases. Do not branch on its text. Find the decision on Decisions, or read the newest with vulsight.api.recentDecisions(1). When other agents share the account, the newest can be theirs, so confirm it on Decisions.
See a review
Hold one payment on purpose to test how your agent handles a review.
On Policy, set Hold over (USDC) to 0.01 and press Save policy.
You see: "Saved. The next decision runs under these rules."
Put back the lines from step 4, then run agent.ts again.
Cerberus is holding the payment for https://cerberus.vulsight.com/merchant/dataset for review (decision 91373fee-f5a2-4215-8133-81d8a5bf3907). Approve or deny it on the Review page at https://cerberus.vulsight.com/review. The SDK waits up to 120 s for the answer, or until the review expires if that comes first.You see: the SDK waits, and the payment is on Review.
Press Approve on Review while the SDK waits.
You see: 200 and the settlement, as in step 4 above.
Set Hold over (USDC) back to 0 and save.
You see: the next payment goes through without a hold.
If nobody answers in time, the call throws and nothing is signed. Reviews says how to run it again.
Send other text for checking
vulsight.fetch sends each page the agent reads for checking. A file, a search result or a tool's output reaches Cerberus only through vulsight.file(text, origin).
Pass the page's URL as origin when you know it, so a flag on that text denies only payments to that seller. A tool's name, or the default tool, ties the text to no seller.
await generateText({
model, tools, prompt,
onStepFinish: async ({ toolResults }) => {
for (const r of toolResults) {
const text = JSON.stringify(r.output) ?? "";
await vulsight.file(text, r.toolName);
}
},
});passmeans the check flagged nothing.restrictmeans the check flagged the text.unavailablemeans Cerberus did not finish a check of it, or was not reached.
undefined or null, such as JSON.stringify of a tool that returned nothing, sends nothing and answers pass. Any other value that is not a string answers unavailable with a warning.
A flagged text denies later payments for an hour. Under a page's URL it denies payments to that seller, in any session and through the proxy URL too. Under a tool's name it denies every payment in the session.
What each result does to a payment is under injection_suspected. Send tool output only, never the user's own turns. The check reads untrusted content, and the person gives the orders.
Cerberus reads the first 64 KiB of each text. A longer one is sent cut at 64 KiB and holds the payments it concerns for an hour, so send long output in parts.
Sending faster than the rate limit, or past the account's 16 MiB a UTC day, gets a 429. Then file() answers unavailable, and the next payment that text concerns sends it again. If Cerberus still answers 429, that payment blocks in both modes. Past the day's limit, that lasts until midnight UTC.
Report the settlement
Call vulsight.reportSettlement(paid) right after each paid request. It reads the seller's receipt and posts its transaction to the decision that allowed the payment. Then Cerberus records it as reported and does not check it on chain.
A second call with the same response returns the recorded report and posts nothing. After an outage, call it again with the same response. Report at once, since a later receipt that names the same transaction blocks the report.
A paid request you send without wrapFetchWithPayment needs processPaymentResult on new x402HTTPClient(client) first.
A 3xx other than a 304 from the paid request means the payment may have settled. The SDK does not follow it and logs a warning. Before you pay again, report the response or check the decision on Decisions.
When the seller answers the paid request with another 402 or a failed receipt, reportSettlement throws. It names the seller's code only when it is a known x402 refusal code.
| Outcome | Codes | What it means | Next step |
|---|---|---|---|
| The wallet is short | insufficient_ invalid_ invalid_ | The seller still holds the signed payment until it expires. On EVM that is when the offer's time limit passes, and on Solana when its blockhash is no longer valid. | Wait until the signed payment expires, then fund the wallet. Check its transfers to the payee on chain before you pay again. |
| It may have settled | invalid_ invalid_ invalid_ settlement_ | A seller can send these codes for a payment that settled, or that may still settle. | Wait until the transfer confirms or the signed payment expires. Then check the wallet's transfers to the payee on chain before you pay again. |
| Any other known code | invalid_ invalid_ invalid_ invalid_ invalid_ invalid_ | The seller says the payment did not settle. That is only its claim while it holds the signed payment. | Wait until the transfer confirms or the signed payment expires. Then check the wallet's transfers to the payee on chain before you pay again. |
| No known code | None | Cerberus cannot tell whether the payment settled. | Wait until the transfer confirms or the signed payment expires. Then check the wallet's transfers to the payee on chain before you pay again. |
In each case, an amount Cerberus allowed or a reviewer approved still counts toward the daily cap. It counts for the UTC day Cerberus checked it.
Solana
The SDK checks Solana Mainnet and Solana Devnet payments too. Register the @x402/svm client scheme with your own Solana signer, then call guard(client, options). Allow the same network on Policy.
Solana addresses are case-sensitive, so never lowercase them. A held payment needs a fresh blockhash after approval, so let the x402 client build a new transaction. The daily cap is one budget across every network.
Limits
- The SDK pays only the supported payments. When a seller offers several, it keeps the supported ones before the x402 client picks.
- In both modes, it refuses before signing when no offer is supported. It does the same for an unknown network, a 402 that is not x402 version 2, or a payment it cannot read.
- It refuses a 402 that arrives through a redirect. Ask for the final URL.
- Code you register on the x402 client is trusted, and Cerberus does not check what it sends:
- Your schemes and extensions run after the check.
- A hook you register after
guard()can change a checked payment. - An
onPaymentCreationFailurehook can pay past a block. - An
onPaymentRequiredhook can answer a 402 with its own payment. - Code that edits the 402's offers changes what Cerberus judges.
Upgrading from 0.6
Update with bun add @vulsight/guard@latest, then check these changes. The changelog lists every one.
- Call
guard()before your ownonPaymentResponsehooks, and passreportSettlementthe responsewrapFetchWithPaymentreturned. - A paid request sent another way needs
processPaymentResultbeforereportSettlement. - A second
guard()on one x402 client throws, and so does a missing API key or a badsession. observepays only when Cerberus did not answer in time.- The held payment messages changed, so code that matched their text must too.
- A client you wrap yourself must expose
registerPolicy. Anx402Clienthas it. vulsight.api.deciderefuses fields the API does not take and a scheme that is not lowercase, and a base URL that redirects now throws.RuleIdincludespayment_method_supported, so handle it in exhaustive maps and switches.
Purchase tasks
Create a task on Policy and copy its id. Only the signed-in owner can start new work. A task counts x402 purchases for its lifetime, across sessions, process restarts, key rotation, and payment channels. A payment without a task has no task limit.
const vulsight = guard(client, {
apiKey, task: serverIssuedId, maxPurchasesPerTask: 3,
});
await vulsight.startTask(anotherOwnerIssuedId, { maxPurchases: 3 });The default ceiling is 10 purchases. A client can lower the chosen limit but cannot raise it back to the policy ceiling. A purchase over the limit waits for review. Approve once permits that purchase without raising the task limit.
Purchases to the same payee and network within 300 seconds also wait for review, even when the payee is allowlisted. A five-minute repeat exception waives that reason only. Other review reasons and hard denials still apply.
Next steps
- Policy says how to raise a limit or allow a payee.
- Reason codes explains each sentence in a decision.
- Reviews covers approving, denying and rerunning a held payment.
- Errors lists every code Cerberus returns.
- Proxy URL needs no code change and works with any x402 client.
