@zeroxyz/sdk
v0.29.0
Published
TypeScript SDK for Zero — search, fetch, and pay for AI capabilities (x402 + MPP).
Readme
@zeroxyz/sdk
TypeScript SDK for Zero — the search and payment layer for AI agents.
Zero indexes the API capabilities published across the web and gives an agent one interface to find them, call them, and pay for them. You search for a capability, call its URL with client.fetch(), and the SDK settles payment automatically — over x402 or MPP, in USDC — handing you back the response and the payment receipt together. The wallet is the agent's identity, so there are no per-service API keys to provision.
Install
pnpm add @zeroxyz/sdk
# npm install @zeroxyz/sdk
# yarn add @zeroxyz/sdkRequires Node 20.3+.
Quickstart
Search needs no credentials. This is the smallest thing that runs:
import { ZeroClient } from "@zeroxyz/sdk";
const client = new ZeroClient();
const { capabilities } = await client.search("current weather in tokyo");
for (const cap of capabilities) {
console.log(cap.id, cap.name, cap.method, cap.url, cap.availabilityStatus);
}Calling and paying for a capability needs a wallet. That's the next section.
Authentication
A client runs in one of three modes. Pick one at construction:
| Mode | You provide | Use it when |
| ----------- | ---------------------------------------- | --------------------------------------------------------------------------- |
| Account | A viem LocalAccount (your wallet key)| You hold the wallet and sign locally. The default for partners and backends.|
| Session | A Zero-issued access + refresh token | You act on behalf of a Zero user — e.g. forwarding a session your server holds. |
| None | — | Public routes only (search, capabilities.get). |
Passing both account and session is a configuration error — the two modes don't compose.
Account mode (bring your own key)
The common case: you have a private key and sign requests locally.
import { ZeroClient } from "@zeroxyz/sdk";
const client = ZeroClient.fromPrivateKey(
process.env.ZERO_PRIVATE_KEY as `0x${string}`,
);
const result = await client.fetch("https://example.com/weather?city=tokyo", {
maxPay: "0.01", // refuse to pay more than $0.01 USDC for this call
});
console.log(result.outcome); // "success" | "payment_failed" | ...
console.log(result.body); // parsed JSON when the response is JSON
console.log(result.payment); // { protocol, txHash, ... }, or null on a free callfromPrivateKey and fromMnemonic are convenience constructors. For any other signer — a KMS-backed account, a hardware wallet, a custom viem account — build the account yourself and pass it in:
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount(process.env.ZERO_PRIVATE_KEY as `0x${string}`);
const client = new ZeroClient({ account });Session mode (acting for a Zero user)
Use this when your server holds a Zero-issued token pair for a user and wants the SDK to sign with that user's Zero-managed wallet, resolved on the first paid call:
const client = new ZeroClient({
session: {
accessToken: userSession.access,
refreshToken: userSession.refresh,
onRefreshed: async ({ accessToken, refreshToken }) => {
await db.updateUserSession(userId, { accessToken, refreshToken });
},
},
});The SDK refreshes the access token automatically after a 401, and every refresh rotates the refresh token. onRefreshed is your one hook to persist the new pair — skip it and the next process restart will present a token the server has already revoked.
client.fetch()
client.fetch() is the call you'll reach for most. Given a URL, it:
Sends the request.
If the server answers
402 Payment Required(x402 or MPP), reads the payment challenge and pays it. x402 v2 supportsexactpayments (EIP-3009 or Permit2) and ceiling-priceduptopayments (Permit2) on Base/Base Sepolia; v1 body challenges use exact EIP-3009, and the legacyX-PAYMENT-REQUIREDheader is normalized for compatibility. Forupto, the wallet authorizes the seller's ceiling and the result reports the actual settled amount. The managed-wallet signer applies the same token, proxy, amount, and replay-window policy before signing Permit2. On the first Base mainnet Permit2 payment from a managed Zero wallet, the SDK checks the USDC allowance and, only when needed, asks Zero to relay a short-lived EIP-2612 permit for exactly that payment ceiling. Zero pays the Base gas, so the wallet needs no ETH; first use may add a few seconds, and a later call renews the bounded allowance only after the remaining amount becomes too small. Self-custody wallets and Base Sepolia keep the explicit Permit2-approval behavior. On a dual-protocol response, a fully valid, funded x402 USDC rail is preferred. If x402 is unsupported/malformed, its balance gate is short, Permit2 allowance cannot be established, or another x402 error occurs before a positive-value payment credential is sent while MPP is advertised, the fetch tries MPP before failing. Failures after the paid x402 replay starts remain terminal because settlement may already have happened; forcingpaymentProtocol: "x402"keeps strict x402 behavior. For x402 endpoints that use a zero-dollar identity challenge before the real charge, it handles one free identity stage followed by exactly one positive-value payment attempt.Replays the request with proof of payment.
Returns the body, status, and payment metadata together.
Records a run on Zero. With a
capabilityId, the run is attributed to that capability, keeping its reliability signal current and the call reviewable. Without one — a direct URL to a service Zero hasn't indexed — the run is still recorded, but unattributed: it stores only the hostname, the HTTP method, and a path template with identifier segments collapsed to{id}(POST /api/v1/images/{id}), plus the settlement reference if the call paid. The raw URL, query string, request body, and headers are never persisted. An unattributed run is still reviewable — pass itsrunIdtoclient.runs.review(); the review lands on the run and folds into the capability's rating once Zero indexes that URL. To see such runs inclient.runs.list()/client.runs.get(), passincludeUnattributed(off by default, because it makes the capability fields nullable).A run is recorded even when the call goes wrong after you paid. A settlement that lands while the upstream still returns
402, an MPP channel whose close is rejected, a body that fails mid-read, a callerabort()during the paid leg, and a stalecapabilityIdall still produce a run — carrying thepaymentTxHashwhen one exists. These used to be dropped, which meant USDC left the wallet with nothing on Zero's side pointing at what it bought. The correspondingwarnings(paid_but_still_402,mpp_session_close_failed, …) still fire; they flag the condition, they no longer discard the record. Typed errors (ZeroTimeoutError, abort) are still thrown — recording is a side-effect, not a swallow.
Default request headers. Two headers are filled in when you don't supply them, and a caller-supplied value (any casing) always wins. Content-Type is inferred from the body (application/json for a string or JSON-shaped bytes, application/octet-stream otherwise). User-Agent is set to zero-sdk/<version> — so the services you call see the SDK in their logs rather than the runtime's bare default. This is a client identity, not a crawler one: it marks the request as coming from an agent that is paying the service. Set your own User-Agent if you'd rather identify your application.
Endpoints that don't charge pass straight through: a 200 on the first request returns with payment: null and outcome: "success". No payment is attempted.
Building the request — search → get → fetch. A search() result carries the url and method, so a no-body GET capability can go straight to fetch(). For anything that takes a request body, call capabilities.get() first: its bodySchema, method, and headers are what tell you how to construct the call. Don't guess the body from the search summary. When present, exampleRequest is the single best artifact to copy — the bare, ready-to-send request body from a real successful call, already unwrapped from the stored transcript and validated against the current schema (it's null when there's no example or the stored one is stale, so fall back to bodySchema). The raw example ({ request, response }) is retained for the response sample.
Each search result also carries a token field (format: z_xxx.N) — a short server-issued attribution token that encodes the search context and the result's position. Pass it as capabilityId to fetch() (or as the id to capabilities.get()) instead of the raw uid/slug; the server uses it to attribute the run back to the originating search for ranking and analytics. Tokens expire when the session rolls, so use them within the same search session.
The HTTP-transport envelope. Some capabilities store their request contract as Zero's HTTP-transport envelope — { input: { type: "http", method, queryParams, body } } — rather than a bare body. You don't have to special-case it: fetch() detects an envelope body and normalizes it to the real wire request — it carries the method (from the envelope's input.method, never inferring POST from body-presence) and moves a GET's queryParams onto the URL query string. So passing the envelope straight through works:
// A GET capability whose schema is the envelope — fetch() routes it to
// GET https://…/weather/current?city=tokyo (not a POST).
await client.fetch(cap.url, {
body: JSON.stringify({ input: { type: "http", method: "GET", queryParams: { city: "tokyo" } } }),
});If you'd rather inspect the schema yourself, the SDK exports the pure helpers it uses: extractInputEnvelope(bodySchema, method) (the query-param / body schema nodes), unwrapTransportEnvelope(exampleRequest) (the inner request from a stored example), and normalizeTransportEnvelopeRequest(url, { method, body }) (the { url, method, body } the wire request should use).
Additional utilities exported from @zeroxyz/sdk:
inferSchema(value)— deterministic structural walk of a JSON value to a JSON-schema-ish draft (types + field names, no values). Useful for logging anonymized request/response shapes.tryParseJson(text)— returns parsed JSON ornullon failure (no throw).urlMatchesTemplate(url, template)— returnstruewhen a URL matches a path template likehttps://api.example.com/users/{id}. Mirrors the server-sidetemplateToRegexlogic.
A few further exports (display formatters, chain constants) exist so Zero's own surfaces (CLI, MCP plugin) render identically; they're marked @internal and aren't part of the supported partner contract.
const result = await client.fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ city: "tokyo" }),
maxPay: "0.01", // USDC ceiling; an over-cap challenge aborts before signing
paymentProtocol: "mpp", // optional: force "x402" or "mpp" when both are offered
capabilityId: "cap_abc", // attribute the run to a known capability
walletAddress: "0x…", // session mode: pay from this profile wallet (default: primary)
signal: controller.signal,
});
switch (result.outcome) {
case "success":
// 2xx/3xx — result.body / result.bodyRaw are populated.
break;
case "insufficient_funds":
// Wallet balance is too low to cover the challenge amount. No payment
// was attempted. Top off via `zero wallet fund` (CLI) or fund the
// wallet before retrying. result.error is a ZeroInsufficientFundsError
// with .have and .need fields.
break;
case "payment_failed":
// Couldn't pay: over your maxPay or an unrecognized 402 protocol.
// Nothing settled — generally safe to retry.
break;
case "payment_rejected":
// Paid, but the capability still returned 402 (a settlement/facilitator
// race). Reconcile via result.warnings and result.payment.
break;
case "payment_close_failed":
// MPP — charged, but the session-close voucher was rejected. The channel
// is orphaned; reconcile via result.payment.session and result.error.
break;
case "server_error":
// The capability returned 4xx/5xx (after payment, if any). See
// result.upstreamError.
break;
case "network_error":
// The request never reached the server.
break;
}
for (const warning of result.warnings ?? []) {
// e.g. FETCH_WARNINGS.bodyTruncated, FETCH_WARNINGS.mppSessionCloseFailed
}The FetchResult
fetch() always resolves to one object — it doesn't reject for expected runtime failures (those come back as an outcome; see Errors). The full shape:
| Field | Type | Notes |
| --- | --- | --- |
| outcome | FetchOutcome | The closed enum above — branch on this first. |
| ok | boolean | true for a usable 2xx capability result. It is false for an error-shaped 2xx body ({ error: … }, success: false, or ok: false) even though status remains 2xx. This can coexist with outcome: "payment_close_failed": the capability returned its result, but the MPP channel still needs reconciliation. |
| status | number \| null | Upstream HTTP status; null if the request never reached the server. |
| body | unknown | Parsed JSON when the response is JSON, otherwise the text (base64 for binary). |
| bodyRaw | string \| null | The response text up to maxResponseBytes, for hashing/forwarding. bodyEncoding: "base64" is set when binary; check warnings before writing it. |
| error | string? | Actionable failure detail. For payment_close_failed, this is the close-credential/request failure while body still contains the capability's successful response. |
| payment | PaymentResult \| null | { protocol, chain, txHash, amount, asset, … } when payment/proof negotiation ran; null on an ungated free call. For x402 upto, amount is the actual amount in the successful settlement receipt, authorizedAmount is the seller's ceiling, and settlementConfirmed: true also anchors a legal zero-charge settlement with no transaction hash. If that receipt is absent or undecodable, amount conservatively falls back to the ceiling and diagnostics include settlement_receipt_invalid. An MPP proof has amount: "0", txHash: null, diagnostics.paymentMode: "proof", and settlementState: "not_attempted" because its receipt authenticates wallet control but is not an on-chain settlement. For a successfully closed MPP session, amount is the close voucher's actual cumulative charge—not the refundable channel prefund—and session.cumulativeAmount carries the raw 6-decimal-unit value. On payment_close_failed, final settlement is unknown, so amount remains the conservatively at-risk prefund and session is the reconciliation handle. |
| settlementState | SettlementState? | Payment certainty: not_attempted, confirmed, unconfirmed (a payment path ran but no durable receipt was observed), or session_open (MPP work returned but cooperative close failed). Treat only confirmed as settled payment volume. |
| runId | string \| null | The recorded run. Set whenever a wallet is configured — including for a direct URL with no capabilityId, which records an unattributed run (see above). Pass it to runs.review() only when the run was attributed to a capability; reviewing an unattributed run is a 400. |
| latencyMs | number | End-to-end call latency. |
| upstreamError | UpstreamError? | Present on server_error — the capability's own error detail (status, message, snippetHash), including error-shaped HTTP 2xx responses. Carries a fixHint string for caller-fixable 4xx shapes (wrong HTTP method, unparseable JSON body, unrecognized/extra fields, content-type mismatch, missing required field) and for storefront gate refusals (identity required, plan required, verified email required, not accepting orders). Never set on a 402. |
| warnings | string[]? | Non-fatal notes (truncated body, MPP close failure, …) — keyed by FETCH_WARNINGS. |
| fetchId | string | Correlation id for this fetch, minted before the request leaves — so it exists even when the fetch dies half-way. It is carried on the run row and on Zero's analytics events, so a call can be traced end-to-end including when no run was recorded (where runId is null by definition). It is also the idempotency key: a retried report can never create a second run. |
| diagnostics | FetchDiagnostics? | Stable, body-safe failure context: selected paymentProtocol, paymentMode (charge, proof, session, or unknown), and fundingSource (wallet_balance, jit_bridge, not_required, or unknown), the primary failureStage / failureCode, every { stage, code } issue, and accumulated stageDurationsMs. A successful rail recovery keeps failureStage / failureCode null and sets paymentFallback to { from, to, stage, code }, so a caller can tell that x402 failed or was rejected even though MPP delivered the final result. The selected rail remains available even when payment failed before a PaymentResult existed. Codes are exported through FETCH_DIAGNOSTIC_CODES; stages through FETCH_STAGES. Issues may be non-terminal (for example, a recovered payment fallback, an unconfirmed receipt, or a run-recording failure on an otherwise usable response). |
| runTrackingSkipped | string[]? | Why a run wasn't recorded (e.g. no credentials) — keyed by FETCH_SKIP_REASONS. Surfaced for every execution, not just when you pass a capabilityId. |
| capabilityResolution | { resolved: boolean, … }? | Whether the URL auto-resolved to an indexed capability. resolved: false means the run was recorded unattributed. Present only when no capabilityId was passed and credentials are configured. |
capabilityId accepts the capability's uid (cap_…), slug, or an attribution token (z_xxx.N) from a search() result. Prefer the token when one is available — it lets the server tie the run back to the originating search for accurate ranking and analytics. Pass the uid or slug for direct fetches where no search was performed.
Extracting binary and artifact results
For a direct binary response, bodyEncoding === "base64" means bodyRaw is safe to decode with Buffer.from(result.bodyRaw, "base64"). Servers that omit Content-Type are byte-sniffed, so an audio/image payload is not silently UTF-8-corrupted. Always refuse to persist the decoded file when warnings includes FETCH_WARNINGS.bodyTruncated; raise maxResponseBytes and retry instead.
JSON-wrapped artifacts remain parsed in body. Read the seller's documented URL field (for example (result.body as { audio_url: string }).audio_url), or decode an inline { data, mime } payload with Buffer.from(data, "base64"). These wrapper fields are not rewritten by the SDK.
Run recording is version-tolerant
runs.create() degrades rather than dropping a run. /v1/runs validates strictly, so an API older than your SDK answers 400 to fields it doesn't know — the SDK retries without them (newest first: paymentFallback, then MPP paymentSession, then settlement/diagnostic fields, then fetchId/outcome, then requestUrl/requestMethod) so the run is still recorded. SDK and API can therefore be upgraded in either order, and a pinned SDK against a lagging self-hosted API won't silently lose runs. Current APIs persist PaymentResult.session as run.payment.session and the safe fallback trace as run.paymentFallback in runs.list() / runs.get(), so orphaned channels and recovered rail failures remain actionable after the original process exits.
const result = await client.fetch(url);
if (result.diagnostics?.paymentFallback) {
const { from, to, stage, code } = result.diagnostics.paymentFallback;
console.warn(`${from} fell back to ${to} at ${stage}: ${code}`);
}The outcome the SDK reports is also what the server classifies on. A capability can answer 200 and the call still have failed — an MPP close-fail, a dropped body, a caller abort. Those are recorded as settlement_failed / client_incomplete and are not counted as successes (nor as capability failures — the seller served the response; the fault is elsewhere). An error-shaped 2xx body is different: the capability itself returned a failure payload, so outcome: "server_error" is recorded as an upstream failure even though the HTTP status is 2xx.
maxPay
maxPay is your hard per-call ceiling, in USDC. The single positive-value challenge is checked before it is signed; for x402 upto, that challenge amount is the worst-case authorization ceiling, while the eventual charge may be lower. A preceding zero-value identity handshake does not consume the cap. An over-cap challenge aborts with outcome: "payment_failed", and a second positive-value challenge is refused before signing. Set maxPay on every paid call — it's the guardrail that keeps a mispriced or hostile endpoint from draining the wallet.
maxPay: "0" means "spend nothing", not "refuse the 402 protocol": free capabilities still work — a zero-amount identity/proof challenge authenticates and proceeds without a settlement attempt, while any positive charge aborts. The one exception is an MPP session capability with no seller-suggested deposit: it needs a positive channel deposit that "0" can't fund, so it fails with a message telling you to pass a small cap.
To put a human in the loop before any spend, read the price up front, show it to your user, and only call fetch() with maxPay once they approve. Prefer capabilities.get()'s pricing object when present: pricing.summary is the server-resolved display string (render it verbatim — don't re-derive from raw fields), pricing.kind classifies the shape (static | dynamic | session | metered | subscription | free | unknown), and pricing.primary.amountUsd / depositUsd give the structured numbers for sizing maxPay. cost.amount remains on every search result as the raw advertised scalar, but it flattens usage-priced caps — a dynamic cap's real cost scales with input.
Wallet & funding
The wallet you construct the client with is the agent's identity — paid calls draw USDC from it, and there are no per-service API keys to provision. Keeping it funded is the only operational task. Two funding models, both first-class:
- Partner-funded shared wallet (the common default). You hold one wallet on behalf of all your users, keep it topped up as a backend cost, and recoup it through your own billing. Per-user spend tracking and caps live in your app —
maxPayis the per-call guard. - End-user top-up. Each user funds the wallet directly; you surface a funding link when their balance runs low. Pairs with a per-user wallet.
The same two primitives serve both:
// The address this client acts as. Account mode resolves locally; session
// mode resolves the user's primary managed wallet (provisioning on first call).
await client.wallet.address();
// Balance in USDC. Sums Base + Tempo by default; pass a chain for just one.
// The default read also returns a per-chain `breakdown` for the chains that
// answered, e.g. { base: "10.50", tempo: "2.00" }.
const { amount, breakdown } = await client.wallet.balance(); // e.g. "12.50"
const onBase = await client.wallet.balance({ chain: "base" });
// A one-time hosted top-up link — Coinbase by default, or provider: "stripe".
// USDC settles on Base.
const url = await client.wallet.fundingUrl({ amount: "10" });
// Move USDC parked on Tempo (bridge buffers, MPP channel refunds) back to
// Base. A self-transfer — only the chain changes, minus ~$0.02 bridge fees.
// Defaults to the full Tempo balance; resolves once the bridge is initiated
// and the funds land on Base within seconds (poll balance() to observe).
// You rarely need this: paid calls do it automatically (see below).
await client.wallet.consolidate();
await client.wallet.consolidate({ amount: "1.50" });
// Session mode: target a specific (non-primary) wallet on the profile
await client.wallet.consolidate({ address: "0xabc..." });USDC can end up on either chain (x402 pays on Base, MPP pays on Tempo, and channel refunds land back on Tempo). client.fetch() handles the movement automatically in both directions: it bridges Base→Tempo when an MPP call needs funding, and bridges Tempo→Base just-in-time when an x402-only (or explicitly selected x402) payment finds Base short but the combined balance covers the charge (adds ~10s to that one call). For a default dual x402+MPP offer, a Base shortfall tries the already-advertised MPP rail before moving funds between chains. consolidate() is the manual override for when you want everything back on Base now.
Poll balance() to decide when to top up. Then either open fundingUrl() yourself to refill the shared wallet — from an ops dashboard, a low-balance alert, or alongside a direct USDC transfer to the wallet.address() result — or hand the link to the end user. Either way the link is single-use and burns when opened, so generate it at the moment of funding and don't pre-open one you intend to give to someone else.
In session mode (acting for a Zero user) wallets are managed server-side, and a few more methods apply:
await client.wallet.list(); // every wallet linked to the session user
await client.wallet.provision(); // create the user's managed wallet (idempotent)
// Link an existing self-custody wallet by private key (0x prefix optional).
// Pass makePrimary to have it pay for the user's calls from now on, and an
// optional nickname (owner-private display name, max 64 chars).
const imported = await client.wallet.import({
privateKey: "0x…",
makePrimary: true,
nickname: "Trading bot",
});
// Switch which linked wallet is primary, or unlink one. remove() rejects the
// primary wallet and any wallet holding a balance — reassign / drain first.
await client.wallet.setPrimary({ walletAddress: imported.walletAddress });
await client.wallet.remove({ walletAddress: "0x…" });
// Nickname a wallet (or clear with null). Nicknames are private to the
// authenticated user — they never appear on reviews or public surfaces.
await client.wallet.setNickname({ walletAddress: "0x…", nickname: "Cold storage" });
// Revoke / re-grant Zero's signing delegation per wallet (default: primary).
await client.wallet.revokeDelegation({ walletAddress: "0x…" });
await client.wallet.reauthorizeDelegation({ walletAddress: "0x…" });wallet.address() covers the common case (resolve — or provision — the primary managed wallet); reach for the methods above when you need the full wallet objects, explicit provisioning, or multi-wallet management. Imported wallets carry an importedAt timestamp on the returned wallet objects.
To pay from a non-primary wallet, pass walletAddress on the call itself — client.fetch(url, { walletAddress }) (or payments.pay / payMpp) resolves the named profile wallet for the payment intent's from, the managed signature, and the recorded run's attribution, and client.auth.sign* accept { walletAddress } too. The wallet must be a delegated Privy-embedded wallet on the session user's profile. Runs can likewise be filtered per wallet: client.runs.list({ walletAddress: ["0x…"] }) (default remains all profile wallets; an address not on the profile is a 400).
Namespaces
Beyond search() and fetch(), the client groups the rest of the API into resource namespaces:
// Search — ranked capabilities for a query (no auth required).
// No implicit price cap: omitting maxCost returns results at any price.
// Pass a value to cap costs (e.g. "0.05" for a tight cap, "0" for free-only).
const { capabilities } = await client.search("translate text to french", {
maxCost: "0.05", // optional: only results at or under this price
protocol: "x402", // optional: "x402" | "mpp"
});
// Capabilities — full detail for one capability by uid, slug, or attribution token
const cap = await client.capabilities.get("cap_abc"); // by uid
const cap2 = await client.capabilities.get("z_Ab12cd.1"); // by search token
// Seller access — discover credit-only storefronts and prepare a validated,
// same-origin credit purchase before passing it through client.fetch().
const access = await client.access.discoverCreditPlans(cap.url);
const plan = access.plans[0];
if (access.onlyCreditPlans && plan) {
const purchase = await client.access.prepareCreditPurchase({
resourceUrl: cap.url,
planId: plan.id,
amountUsd: plan.minimumUsd,
});
// Ask the user before durable seller credit is purchased.
await client.fetch(purchase.url, {
method: purchase.method,
body: purchase.body,
headers: { "content-type": "application/json", authorization: "Bearer …" },
maxPay: purchase.amountUsd,
});
}
// Wallet — balance, one-time funding URL, Tempo→Base consolidation, and
// (session mode) multi-wallet management: list / provision / import /
// setPrimary / remove / setNickname / revokeDelegation /
// reauthorizeDelegation. See "Wallet & funding" above.
const { amount } = await client.wallet.balance();
const fundUrl = await client.wallet.fundingUrl({ amount: "10" });
await client.wallet.consolidate(); // move Tempo USDC back to Base
// Runs — fetch() records these for you on paid calls; reach for them
// directly when you want to log or review a call yourself
const run = await client.runs.create({
capabilityId: "cap_abc",
status: 200,
latencyMs: 1234,
});
await client.runs.review({
runId: run.runId,
success: true,
accuracy: 5, // 1–5
value: 4, // 1–5
reliability: 5, // 1–5
});
// Bug reports
await client.bugReports.create({
category: "broken_execution", // see the BugReportCategory union for the full set
capabilityId: "cap_abc",
description: "Returns 502 on every call",
});
// Auth (session mode)
await client.auth.refresh(); // force a token refresh
await client.auth.logout(refreshToken); // revoke the session server-side
// Device-code login (RFC 8628 — for CLIs and other browserless flows)
const device = await client.auth.device.start();
console.log(device.verificationUri, device.userCode);
const grant = await client.auth.device.poll(device.deviceCode);
// Agent accounts (auth.md) — fully autonomous signup, no browser or human.
// Persist registration.claimToken + registration.identityAssertion securely:
// they are returned exactly once. `session` is a Zero session for the account.
const { registration, session } = await client.auth.agent.signup();
// A human can claim ownership of the account later. startClaim() returns a
// hosted URL; the page shows the HUMAN a pairing code (the reverse of the
// device flow) which they read back for completeClaim().
if (registration.claimToken) {
const attempt = await client.auth.agent.startClaim({
claimToken: registration.claimToken,
email: "[email protected]",
});
console.log(attempt.verificationUri);
const claimed = await client.auth.agent.completeClaim({
claimToken: registration.claimToken,
userCode: "1234-5678", // read back by the human
});
// claimed.status: "ok" | "not_confirmed" | "invalid_code" | "expired" —
// on "ok", exchange the fresh assertion for a post-claim session:
// exchangeAssertion(claimed.identityAssertion) → exchangeAtZero(accessToken)
}
// Identity assertion (ID-JAG) — sign in to a service that trusts Zero as an
// identity issuer, with no sign-up ceremony. Session mode on a claimed
// (human-owned) account only.
let outcome = await client.auth.identity.assert({ url: "https://svc.pay.zeroclick.io" });
if (outcome.status === "consent_required") {
// The issuer refuses to mint until the user's standing consent covers this
// audience — and consent can ONLY be granted by the user, in their browser,
// on the hosted approve page (there is no API for an agent to grant it to
// itself). File a request, surface approveUrl to the user, poll until they
// decide. Approved consent is standing: recorded once on the Zero account,
// it applies on every machine and surface, revocable via revokeGrant().
const req = await client.auth.identity.requestGrant({
audience: outcome.discovery.mintAudience,
resourceUrl: "https://svc.pay.zeroclick.io",
});
// show req.approveUrl to the user, then poll:
// await client.auth.identity.grantRequest(req.requestId) → status:
// "pending" | "approved" | "denied" | "expired"
outcome = await client.auth.identity.assert({ url: "https://svc.pay.zeroclick.io" });
}
if (outcome.status === "ok") {
// Persist registration.identityAssertion (+ refreshToken) securely; exchange
// it for short-lived bearer tokens whenever you call the service's API.
const { accessToken } = await client.auth.identity.exchange({
tokenEndpoint: outcome.discovery.tokenEndpoint,
assertion: outcome.registration.identityAssertion,
});
await client.fetch(serviceUrl, { headers: { authorization: `Bearer ${accessToken}` } });
// MPP-paid calls: the Payment credential takes Authorization on the paid
// retry. When the service's 402 advertises an alternate header for the
// bearer (auth.presentation.alternateHeader — ZeroClick storefronts send
// ZC-Agent-Authorization, also in outcome.discovery.alternateBearerHeader),
// client.fetch moves the Bearer there itself; otherwise it is dropped and
// result.warnings carries FETCH_WARNINGS.authorizationBearerDroppedByMpp.
}
// Other outcomes: "unsupported" / "issuer_not_enabled" (fall back to the
// service's own agent auth), "login_required" (re-authenticate at Zero and
// retry), "interaction_required" (the asserted email already has an account
// there — its owner confirms at outcome.claim.verificationUri, then
// completeClaim() finishes and returns a fresh assertion).
// Lower-level pieces: discover(url) reads the service's OAuth metadata
// (mintAudience honors a platform's shared-audience hint, so one consent
// covers all of its storefronts); mint({ audience }) calls Zero's
// token-exchange issuer directly; grants() / revokeGrant() list and revoke
// the account's standing consents.Errors from the AuthKit-hosted agent-auth surface throw ZeroAgentAuthError (status, body, and serverCode — the server's own error code, e.g. claim_expired).
Multi-tenant servers
A server that signs as a different end-user wallet on each request shouldn't build a fresh ZeroClient every call. Pass the wallet per call, or derive a reusable sub-client:
const base = new ZeroClient(); // shared transport config, no auth of its own
// Simplest: sign a single call as a given wallet.
await base.fetch(url, { account: tenantAccount, maxPay: "0.01" });
// Or hold a sub-client bound to that wallet. `withAccount` caches by address,
// so repeat calls for the same tenant reuse one instance.
const tenant = base.withAccount(tenantAccount);
await tenant.fetch(url, { maxPay: "0.01" });withSession(session) is the session-mode equivalent for per-user token pairs; unlike withAccount it isn't cached, since sessions are typically one per request. On a long-running server, call base.clearAccountCache(account?) when a tenant churns — pass an account to drop one entry, or omit it to clear the cache entirely.
Errors
The SDK throws typed errors instead of strings. Match them with instanceof:
import {
ZeroApiError, // a 4xx/5xx from Zero's own API
ZeroAuthError, // missing auth, failed refresh, expired session
ZeroInsufficientFundsError, // wallet balance too low; .have and .need in USDC
ZeroPaymentError, // payment failed before settlement
ZeroPermit2AllowanceError, // bounded Permit2 approval could not be established
ZeroSessionCloseFailedError, // MPP session-close voucher rejected
ZeroTimeoutError, // the configured timeout was exceeded
ZeroConfigurationError, // invalid ClientOptions
ZeroWalletError, // a wallet read or write failed
ZeroValidationError, // a response didn't match its expected schema
ZeroError, // base class for all of the above
} from "@zeroxyz/sdk";
try {
await client.fetch(url, { maxPay: "0.01" });
} catch (err) {
if (err instanceof ZeroAuthError) {
// re-auth and retry
} else if (err instanceof ZeroError) {
console.error(err.code, err.message);
} else {
throw err;
}
}client.fetch() reserves throwing for programmer errors — bad config, missing credentials, and the like. Expected runtime failures (insufficient_funds, network_error, payment_failed, an upstream 5xx) come back as a FetchResult with the matching outcome, so you handle them by branching on the result rather than by catching.
Timeouts and other thrown objects still carry the same correlation contract. Use fetchErrorContext(err) to read { fetchId, diagnostics } structurally (safe across bundled SDK copies/realms), or read those non-enumerable properties directly from an Error. The context contains no response body, request body, credential, or raw URL.
Logging
Pass a logger to observe SDK-level events — refreshes, recording failures, payment warnings:
new ZeroClient({
logger: (event) => {
if (event.level === "error") console.error(event.message, event.meta);
},
});Analytics (opt-in)
Off by default — new ZeroClient() emits nothing and does zero analytics work. Opt in to mirror the Zero CLI's telemetry from a raw SDK integration. The SDK builds structured events and hands each to your capture sink; it ships no analytics-vendor dependency, so you decide where and how events are handled.
Inspecting events with a capture sink
The simplest sink logs each structured event. Replace the function body with your own collector when you're ready to persist them:
import { ZeroClient, type SdkAnalyticsCapture } from "@zeroxyz/sdk";
const capture: SdkAnalyticsCapture = (event) => {
console.log("[zero-sdk event]", {
// Defaults to the wallet once resolved; null before the wallet is known.
distinctId: event.distinctId,
event: event.event,
properties: event.properties,
});
};
const client = new ZeroClient({
account,
analytics: {
enabled: true, // omitted or false → nothing is emitted
capture,
// Optional: tag your integration and/or pin the identity.
properties: { surface: "my_agent" },
// distinctId: "usr_123",
},
});The core SDK deliberately has no analytics-vendor dependency or built-in transport. Your application owns storage, delivery, batching, retries, and shutdown behavior.
Fetch events use canonical action names; use the reserved source: "sdk" property and optional adopter surface to distinguish SDK traffic from CLI/API traffic. Existing sdk_search and sdk_wallet_resolved names are unchanged:
| Event | When |
|---|---|
| sdk_search | client.search() returned. Carries result_count, search_id, filter opts, and the query (truncated to 200 chars). |
| fetch_executed | client.fetch() returned a FetchResult — success or a graceful failure. Carries fetch_id, outcome, derived error_class, payment rail/mode/chain and settlement_state, payment_fallback_from/to/stage/code, warnings, primary failure stage/code, all diagnostic codes, stage durations, run_id, and only a hash/length for an upstream error snippet. |
| fetch_error | client.fetch() threw before producing a result (schema validation, timeout, auth). Carries the same fetch_id, fallback fields, and stage diagnostics, plus error_class, status, and a body-safe error type/code label. |
| sdk_wallet_resolved | The signing wallet became known (account mode: at construction; session mode: when the managed account first resolves). Becomes the distinctId. |
No raw PII leaves the SDK: URLs are reduced to origin + path (query string and fragment dropped, so ?api_key=… never escapes), malformed URLs become <invalid-url>, thrown errors are reduced to a type/code label, and upstream error bodies are represented only by a client-side hash/length. Request/response bodies, appended run-tracking error messages, and request headers are never captured. Attribution keeps the URL path (matching the CLI), so don't embed secrets in a path.
Testing
The package ships mock helpers under @zeroxyz/sdk/testing for unit tests against a stubbed Zero API. They carry no runtime peer dependencies:
import { createTestClient, respondJson } from "@zeroxyz/sdk/testing";
const client = createTestClient({
routes: {
"GET /v1/capabilities/cap_abc": () =>
respondJson({ id: "cap_abc", name: "Test capability" }),
},
});
const cap = await client.capabilities.get("cap_abc");For end-to-end tests against a real payment flow, mock at the network boundary with MSW or run the call against your own staging facilitator.
