SDK and CLI for building ERC-8257 compliant AI agent tools. Provides manifest validation, onchain registration, gating middleware, framework adapters, and project scaffolding.
Pairs with the onchain reference implementation at ProjectOpenSea/tool-registry — the ToolRegistry contract and example access predicates this SDK reads from and writes to.
# 1. Scaffold a new tool project
npx @opensea/tool-sdk init my-tool
# 2. Implement your tool logic
cd my-tool && npm install
# Edit src/handler.ts
# NOTE: If your project sits adjacent to a pnpm workspace, use
# pnpm install --ignore-workspace to prevent pnpm from walking
# up to the parent workspace.
# 3. Deploy
npx vercel # or wrangler deploy, etc.
# 4. Register onchain
npx @opensea/tool-sdk register \
--metadata https://my-tool.vercel.app/.well-known/ai-tool/my-tool.json \
--network baseScaffold a new ERC-8257 tool project with interactive prompts.
npx @opensea/tool-sdk init my-tool
npx @opensea/tool-sdk init my-tool --no-interactive # CI modeSupports Vercel, Cloudflare Workers, and Express templates.
Validate a tool manifest JSON file against the ERC-8257 schema.
npx @opensea/tool-sdk validate ./manifest.jsonCompute the JCS keccak256 hash of a tool manifest (RFC 8785 canonicalization).
npx @opensea/tool-sdk hash ./manifest.jsonLoad a TypeScript manifest and output it as JSON. Validates the manifest before printing.
npx @opensea/tool-sdk export ./src/manifest.tsVerify a deployed well-known tool endpoint. Checks URL format, HTTP 200, schema validation, and origin binding.
npx @opensea/tool-sdk verify https://my-tool.vercel.app/.well-known/ai-tool/my-tool.jsonRegister a tool onchain via the ToolRegistry contract.
PRIVATE_KEY=0x... RPC_URL=https://... npx @opensea/tool-sdk register \
--metadata <url> \
--network base \
--nft-gate 0xCOLLECTION # optional: gate via ERC721OwnerPredicate| Flag | Description |
|---|---|
--metadata <url> |
Metadata URI (required) |
--network <network> |
base or mainnet (default: base) |
--nft-gate <address> |
ERC-721 collection address; gates the tool via the canonical ERC721OwnerPredicate (version auto-detected from registry) |
--access-predicate <address> |
Access predicate address (mutually exclusive with --nft-gate) |
--predicate-config <json> |
JSON config for the access predicate (e.g. '{"collections":["0x..."]}'). Bundles predicate setup with registration |
--wallet-provider <provider> |
Wallet provider to use for signing |
--rpc-url <url> |
RPC endpoint for gas estimation and tx broadcast |
--dry-run |
Print summary without transacting |
-y, --yes |
Skip confirmation prompt |
Update a tool's metadata URI and manifest hash onchain.
npx @opensea/tool-sdk update-metadata \
--tool-id 1 \
--metadata https://my-tool.vercel.app/.well-known/ai-tool/my-tool.json \
--network base| Flag | Description |
|---|---|
--tool-id <id> |
Numeric tool ID (required) |
--metadata <url> |
New metadata URI (required) |
--network <network> |
base or mainnet (default: base) |
--wallet-provider <provider> |
Wallet provider to use for signing |
--rpc-url <url> |
RPC endpoint for gas estimation and tx broadcast |
--dry-run |
Print summary without transacting |
-y, --yes |
Skip confirmation prompt |
Read onchain tool state and cross-check against the live manifest.
npx @opensea/tool-sdk inspect --tool-id 1 --network base
npx @opensea/tool-sdk inspect --tool-id 1 --check-access 0xYourAddress| Flag | Description |
|---|---|
--tool-id <id> |
Numeric tool ID (required) |
--network <network> |
base or mainnet (default: base) |
--check-access <address> |
Check whether an address has access to the tool |
Deploy a tool-sdk project to a hosting platform.
npx @opensea/tool-sdk deploy --host vercel
npx @opensea/tool-sdk deploy --host vercel --non-interactive -y| Flag | Description |
|---|---|
--host <host> |
Hosting platform (required; currently vercel) |
--non-interactive |
Read env var values from environment (for CI) |
-y, --yes |
Auto-confirm prompts (e.g., Vercel link) |
Make a paid call to a tool endpoint via x402. Probes the endpoint for payment requirements, signs an EIP-3009 transferWithAuthorization, and replays the request with the X-Payment header. Optionally includes SIWE authentication for predicate-gated endpoints.
npx @opensea/tool-sdk pay https://my-tool.vercel.app/api/tool \
--body '{"query":"hello"}'
# Combined payment + SIWE auth (for predicate-gated paid tools):
PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org \
npx @opensea/tool-sdk pay https://my-tool.vercel.app/api/tool \
--auth siwe --body '{"query":"hello"}'| Flag | Description |
|---|---|
--body <json> |
JSON body (inline string or @path/to/file.json) |
--auth <type> |
Authentication type (siwe). Auto-enabled when manifest declares an access block |
--manifest <path> |
Path to tool manifest (JSON or TS). If it declares an access block, SIWE auth is auto-enabled |
--chain <name> |
Chain for SIWE message (default: base) |
--wallet-provider <provider> |
Wallet provider to use for signing |
Make an authenticated call to a predicate-gated tool endpoint via SIWE.
PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org npx @opensea/tool-sdk auth https://my-tool.vercel.app/api/tool \
--body '{"query":"hello"}'| Flag | Description |
|---|---|
--body <json> |
JSON body (inline string or @path/to/file.json) |
--wallet-provider <provider> |
Wallet provider to use for signing |
Invoke a tool handler locally with no X-Payment header and assert a valid 402 response (x402 gate test).
npx @opensea/tool-sdk dry-run-gate \
--manifest ./src/manifest.ts \
--input '{"query":"test"}'| Flag | Description |
|---|---|
--manifest <path> |
Path to manifest .ts or .json file (required) |
--input <json> |
JSON input body (inline or @path) |
Invoke a tool handler locally with no SIWE auth header and assert a valid 401 response (predicate gate test).
npx @opensea/tool-sdk dry-run-predicate-gate \
--manifest ./src/manifest.ts \
--tool-id 1| Flag | Description |
|---|---|
--manifest <path> |
Path to manifest .ts or .json file (required) |
--tool-id <id> |
Onchain tool ID to configure in the gate |
--input <json> |
JSON input body (inline or @path) |
Smoke-test a live tool endpoint: SIWE-sign, send an authenticated request, and assert the HTTP status. Classifies 402 as "auth passed, payment required" for paywalled tools.
PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org \
npx @opensea/tool-sdk smoke \
--endpoint https://my-tool.vercel.app/api/tool \
--tool-id 4 \
--input '{"query":"hello"}'| Flag | Description |
|---|---|
--endpoint <url> |
Production endpoint URL (required) |
--tool-id <id> |
Onchain tool ID (included in log output) |
--input <json> |
JSON body (inline or @path; default: {}) |
--expect <status> |
Expected HTTP status code |
--chain <name> |
Chain for wallet client and SIWE message (default: base) |
--paid |
Handle x402 payment challenge after SIWE authentication |
--wallet-provider <provider> |
Wallet provider to use for signing |
--max-amount <amount> |
Maximum payment amount in base units (default: 1000000 = 1 USDC) |
Set the ERC-721 collection gate list for an already-registered tool.
PRIVATE_KEY=0x... npx @opensea/tool-sdk set-collections 4 \
0x07152bfde079b5319e5308c43fb1dbc9c76cb4f9 \
--network base| Flag | Description |
|---|---|
--network <network> |
base or mainnet (default: base) |
--wallet-provider <provider> |
Wallet provider to use for signing |
--rpc-url <url> |
RPC endpoint |
--dry-run |
Print encoded calldata without transacting |
Read the ERC-721 collection gate list for a registered tool (read-only).
npx @opensea/tool-sdk get-collections 4 --network base| Flag | Description |
|---|---|
--network <network> |
base or mainnet (default: base) |
--rpc-url <url> |
RPC endpoint |
Set the ERC-1155 collection + token ID gate for an already-registered tool.
PRIVATE_KEY=0x... npx @opensea/tool-sdk set-collection-tokens 4 \
0xCOLLECTION_ADDRESS 1 2 3 \
--network base| Flag | Description |
|---|---|
--network <network> |
base or mainnet (default: base) |
--wallet-provider <provider> |
Wallet provider to use for signing |
--rpc-url <url> |
RPC endpoint |
--dry-run |
Print encoded calldata without transacting |
All commands that sign transactions (register, update-metadata, pay, auth, smoke, set-collections, set-collection-tokens) need a wallet. You can configure one in two ways:
- Environment variables — set the env vars for your provider and the CLI auto-detects it (priority: Privy > Fireblocks > Turnkey > Bankr > PrivateKey).
--wallet-providerflag — explicitly select a provider by name.
| Provider | --wallet-provider value |
Required env vars |
|---|---|---|
| Privy | privy |
PRIVY_APP_ID, PRIVY_APP_SECRET, PRIVY_WALLET_ID |
| Fireblocks | fireblocks |
FIREBLOCKS_API_KEY, FIREBLOCKS_API_SECRET, FIREBLOCKS_VAULT_ID |
| Turnkey | turnkey |
TURNKEY_API_PUBLIC_KEY, TURNKEY_API_PRIVATE_KEY, TURNKEY_ORGANIZATION_ID, TURNKEY_WALLET_ADDRESS, TURNKEY_RPC_URL |
| Bankr | bankr |
BANKR_API_KEY |
| Private Key | private-key |
PRIVATE_KEY, RPC_URL |
See .env.example for a full annotated template.
# Auto-detect from env vars (simplest)
PRIVATE_KEY=0x... RPC_URL=https://mainnet.base.org npx @opensea/tool-sdk register \
--metadata <url> --network base
# Explicit provider selection
BANKR_API_KEY=... npx @opensea/tool-sdk register \
--metadata <url> --network base --wallet-provider bankr
# Privy server wallet
PRIVY_APP_ID=... PRIVY_APP_SECRET=... PRIVY_WALLET_ID=... npx @opensea/tool-sdk auth \
https://my-tool.vercel.app/api/tool --body '{"query":"hello"}'Type-narrowing identity function for manifest definitions.
import { defineManifest } from "@opensea/tool-sdk"
export const manifest = defineManifest({
type: "https://ercs.ethereum.org/ERCS/erc-8257#tool-manifest-v1",
name: "my-tool",
description: "A useful tool",
endpoint: "https://my-tool.vercel.app",
inputs: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
outputs: {
type: "object",
properties: { result: { type: "string" } },
},
creatorAddress: "0x1234567890abcdef1234567890abcdef12345678",
})Validates unknown data against the ERC-8257 manifest schema.
import { validateManifest } from "@opensea/tool-sdk"
const result = validateManifest(jsonData)
if (result.success) {
console.log(result.data.name)
} else {
console.error(result.error.issues)
}Creates a Web Request/Response handler for your tool.
import { z } from "zod/v4"
import { createToolHandler } from "@opensea/tool-sdk"
import { manifest } from "./manifest.js"
const handler = createToolHandler({
manifest,
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({ result: z.string() }),
gates: [], // optional: predicateGate, x402Gate
handler: async (input, ctx) => {
return { result: `Hello: ${input.query}` }
},
})Creates a handler for the /.well-known/ai-tool/<slug>.json endpoint.
import { createWellKnownHandler } from "@opensea/tool-sdk"
const wellKnown = createWellKnownHandler(manifest)
// Responds at /.well-known/ai-tool/<derived-slug>.jsonComputes the JCS keccak256 hash of a manifest (RFC 8785 canonicalization + keccak256).
import { computeManifestHash } from "@opensea/tool-sdk"
const hash = computeManifestHash(manifest)
// => "0x85f160012d9fd30c7e82bc9d3959c90ec9df3c7d..."Client for interacting with the onchain ToolRegistry contract.
import { ToolRegistryClient } from "@opensea/tool-sdk"
import { base } from "viem/chains"
const client = new ToolRegistryClient({
chain: base,
walletClient, // viem WalletClient with account
})
const { toolId, txHash } = await client.registerTool({
metadataURI: "https://example.com/.well-known/ai-tool/my-tool.json",
manifest,
})Delegates the access decision to the onchain ToolRegistry. The middleware
verifies SIWE auth, recovers the caller's address, and staticcalls
IToolRegistry.tryHasAccess(toolId, caller, data). Whatever predicate the
tool's creator registered (single-collection ERC-721, multi-collection,
ERC-1155, subscription, composite, anything future) is the policy enforced.
import { predicateGate } from "@opensea/tool-sdk"
const gate = predicateGate({
toolId: 42n, // from the ToolRegistered event
rpcUrl: "https://mainnet.base.org", // optional
})
const handler = createToolHandler({
manifest,
inputSchema,
outputSchema,
gates: [gate],
handler: async (input, ctx) => {
// ctx.callerAddress is set on success
// ctx.gates.predicate.granted === true
return { result: "access granted" }
},
})Status code mapping:
| Outcome | Status | Body |
|---|---|---|
| Missing or malformed SIWE | 401 |
{ error, hint } |
tryHasAccess returned (true, true) |
(passes) | n/a |
tryHasAccess returned (true, false) |
403 |
{ error, toolId, predicate } |
tryHasAccess returned (false, *) |
502 |
{ error: "Predicate misbehaved..." } |
The predicate field in the 403 body is the registered access predicate's
address, fetched lazily from getToolConfig on first denial and cached
in-process. Callers can read the predicate's onchain config to learn what
they need to satisfy.
Authorization header format: SIWE <base64url(siwe-message)>.<hex-signature>
Note: Stateless SIWE: does not track nonces. Callers should include a short-lived
expirationTimein their SIWE messages to limit replay window. Tool operators requiring stronger replay protection should implement server-side nonce tracking.
An AI agent can call a predicate-gated tool on behalf of an NFT holder without the holder's private key. The holder delegates to the agent at delegate.xyz (onchain, one TX, revocable anytime), and the agent includes the holder's address in the request:
import { authenticatedFetch } from "@opensea/tool-sdk"
const response = await authenticatedFetch(toolUrl, {
method: "POST",
headers: {
"X-Delegate-For": holderAddress, // holder who delegated
},
account: agentAccount,
body: JSON.stringify({ query: "hello" }),
})When X-Delegate-For is present, the middleware:
- Verifies the agent's SIWE signature normally
- Calls
checkDelegateForAll(agent, holder)on the delegate.xyz DelegateRegistry - If valid, runs the access predicate against the holder (not the agent)
- Sets
ctx.callerAddress = holderAddressandctx.agentAddress = agentAddress
| Outcome | Status | Body |
|---|---|---|
Invalid X-Delegate-For format |
400 |
{ error } |
| Delegation not found onchain | 403 |
{ error, hint } |
| Delegate registry call failed | 502 |
{ error } |
See docs/predicate-gating-guide.md for the full delegation walkthrough.
Off-chain helper for clients that want to gate UI before invocation. Same
staticcall as predicateGate, no SIWE required.
import { checkToolAccess } from "@opensea/tool-sdk"
const { ok, granted } = await checkToolAccess({
toolId: 42n,
account: "0xabc...",
rpcUrl: "https://mainnet.base.org", // optional
})
if (ok && granted) {
// enable "Use Tool" affordance
}ok === false means the predicate misbehaved upstream and the result is
indeterminate; treat it as a transient failure, not a denial.
The SDK ships two hosted-facilitator gates with the same shape:
payaiX402Gate (PayAI hosted facilitator — free, no auth required) and
cdpX402Gate (Coinbase Developer Platform facilitator — requires a CDP API
key and JWT auth). Pick one based on the trade-offs:
| Gate | Facilitator | Auth | Best for |
|---|---|---|---|
payaiX402Gate |
PayAI (https://facilitator.payai.network) |
None | Prototyping, dogfooding, anything you want to deploy today |
cdpX402Gate |
Coinbase Developer Platform (https://api.cdp.coinbase.com/platform/v2/x402) |
CDP JWT (you supply via createAuthHeaders) |
Production, when you have CDP credentials |
Both emit an x402-protocol-compliant 402 response with
accepts: [PaymentRequirements] when X-Payment is missing, and verify the
payload against the facilitator's /verify endpoint when present. The
manifest-side helper x402UsdcPricing is shared — the advertised price is
identical regardless of which facilitator enforces it.
Trade-offs:
- PayAI is community-operated. It is free and requires no credentials, which is exactly the right fit for a first deploy. It comes with no uptime SLA and its operational maturity is whatever the community has built. For real money flowing at volume, evaluate CDP.
- CDP is operated by Coinbase. It requires JWT auth signed with your
CDP_API_KEY_SECRET. The SDK does not bundle a JWT signer; pass acreateAuthHeaderscallback that mints headers per request. A built-in helper that wraps@coinbase/cdp-sdkis a planned follow-up.
import {
createToolHandler,
defineManifest,
payaiX402Gate,
x402UsdcPricing,
} from "@opensea/tool-sdk"
const gate = payaiX402Gate({
recipient: "0xYourPayoutAddress",
amountUsdc: "0.01", // decimal string; "10000" (base units) also accepted
})
export const manifest = defineManifest({
// ...
pricing: x402UsdcPricing({
recipient: "0xYourPayoutAddress",
amountUsdc: "0.01",
}),
})
const handler = createToolHandler({
manifest,
inputSchema,
outputSchema,
gates: [gate],
handler: async (input, ctx) => {
// ctx.gates.x402.paid === true
return { /* ... */ }
},
})import { cdpX402Gate, x402UsdcPricing } from "@opensea/tool-sdk"
import { generateCdpJwt } from "./your-cdp-auth.js" // your code, today
const gate = cdpX402Gate({
recipient: "0xYourPayoutAddress",
amountUsdc: "0.01",
createAuthHeaders: async () => ({
Authorization: `Bearer ${await generateCdpJwt({
apiKeyId: process.env.CDP_API_KEY_ID!,
apiKeySecret: process.env.CDP_API_KEY_SECRET!,
method: "POST",
path: "/platform/v2/x402/verify",
})}`,
}),
})If you omit createAuthHeaders on cdpX402Gate, every verify call returns
401/403 from CDP and the gate surfaces 502. PayAI is the unauthenticated
fallback for development.
Common defaults: USDC on Base mainnet, maxTimeoutSeconds: 60,
description "Tool invocation". network: "base-sepolia" is supported for
testing. Override any default via the config; facilitatorUrl is also
overridable if you want to pin to a specific facilitator instance.
Settlement. Both gates settle on chain automatically: the gate verifies
the payment before your handler runs, then calls the facilitator's /settle
endpoint after your handler succeeds and the output validates. USDC moves
from payer to recipient once /settle confirms. The settled tx hash is
stashed on ctx.gates.x402.settlementTxHash for downstream observability.
Latency. Settlement runs synchronously: the SDK awaits /settle before
returning the response, so a slow or unreachable facilitator adds up to 10
seconds (the per-call timeout) to the worst-case response time. Truly
non-blocking settlement requires runtime-specific primitives (Cloudflare
Workers and Vercel waitUntil) that are not portable across the runtimes
this SDK supports, and fire-and-forget risks dropped settlements when a
serverless process is killed after the response is sent. Blocking is the
safest cross-runtime default; if you need lower-latency settlement, plumb
the runtime's waitUntil into your handler and wrap the gate yourself.
Failure handling. If /settle fails (network blip, facilitator outage,
nonce already used), the failure is logged via console.error with prefix
[tool-sdk] gate.settle failed: and the response still returns 200 with
the handler's output. Operators replay failed settlements out-of-band using
the verified payment payload from logs.
The lower-level x402Gate accepts a verifyPayment callback for callers who
want to run their own facilitator or verify payments without an HTTP round-trip.
import { x402Gate } from "@opensea/tool-sdk"
const gate = x402Gate({
pricing: [
{
amount: "20000",
asset: "eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
recipient: "eip155:8453:0xYourAddress",
protocol: "x402",
},
],
verifyPayment: async (proof) => {
return validateX402ProofYourself(proof)
},
})If verifyPayment is omitted, the gate rejects every request with an X-Payment
header with a 501 error. Use payaiX402Gate (or cdpX402Gate) if you do not
have a reason to run your own facilitator.
Two helpers for callers of x402-gated tools — sign EIP-3009
TransferWithAuthorization payments and replay requests automatically.
Signs a USDC payment authorization and returns a base64-encoded X-Payment
header value. Requires a viem Account with signTypedData support (e.g.
privateKeyToAccount).
import { signX402Payment } from "@opensea/tool-sdk"
import { privateKeyToAccount } from "viem/accounts"
const account = privateKeyToAccount("0x...")
const xPayment = await signX402Payment({
account,
paymentRequirements: {
scheme: "exact",
network: "base",
maxAmountRequired: "10000",
payTo: "0xRecipient",
asset: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
},
})
const res = await fetch(toolUrl, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Payment": xPayment },
body: JSON.stringify(payload),
})Drop-in fetch wrapper that handles the 402 → sign → replay flow automatically. If the server does not return 402, the response is passed through unchanged.
Security: paidFetch trusts the server's 402 response to determine
the payment recipient, token, and amount. Use maxAmount,
allowedRecipients, and allowedAssets to constrain what gets signed.
By default, asset is validated against the known USDC contract address
for the network, and payTo is rejected if it is the zero address or a
known burn address.
import { paidFetch } from "@opensea/tool-sdk"
import { privateKeyToAccount } from "viem/accounts"
const account = privateKeyToAccount("0x...")
const res = await paidFetch("https://tool.example.com/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: "what is this NFT worth?" }),
account,
// Optional safety caps:
maxAmount: "100000", // reject if server asks for more than 0.10 USDC
allowedRecipients: ["0xYourTrustedPayee"], // reject unknown payTo addresses
// allowedAssets defaults to the known USDC contract per network
})
const data = await res.json()Gate your tool using the onchain access predicate system. The predicateGate middleware verifies SIWE auth, recovers the caller's address, and delegates the access decision to IToolRegistry.tryHasAccess — it works with ERC721OwnerPredicate, ERC1155OwnerPredicate, SubscriptionPredicate, CompositePredicate, or any future predicate automatically.
See docs/predicate-gating-guide.md for the full setup walkthrough.
ai@4 (Vercel AI SDK) ships its own jsonSchema() helper that expects a
JSON Schema object, not a Zod schema. If you pass a zod@4 schema to
generateObject's schema parameter it will typecheck but the return type
is unknown because ai@4 does not recognise Zod 4's schema brand.
The working pattern is to define a hand-written JSON Schema for ai, then
validate the result at runtime with Zod:
import { generateObject } from "ai"
import { jsonSchema } from "ai/json-schema"
import { z } from "zod/v4"
// 1. Hand-written JSON Schema for the AI SDK
const myJsonSchema = jsonSchema({
type: "object",
properties: {
name: { type: "string" },
score: { type: "number" },
},
required: ["name", "score"],
})
// 2. Matching Zod schema for runtime validation
const MySchema = z.object({
name: z.string(),
score: z.number(),
})
const { object } = await generateObject({
model,
schema: myJsonSchema,
prompt: "...",
})
// 3. Validate at runtime — `object` is typed as `unknown` from ai@4
const parsed = MySchema.parse(object)
// `parsed` is now fully typed as { name: string; score: number }import { toVercelHandler } from "@opensea/tool-sdk"
export default toVercelHandler(handler)import { toCloudflareHandler } from "@opensea/tool-sdk/cloudflare"
export default toCloudflareHandler(handler)import { toExpressHandler } from "@opensea/tool-sdk"
app.post("/api", toExpressHandler(handler))See the full ERC-8257 Tool Registry specification for details on manifest schema, origin binding, creator binding, and consumer verification.