npm.io
0.6.0 • Published 3d ago

@zeroxyz/sdk

Licence
MIT
Version
0.6.0
Deps
9
Size
988 kB
Vulns
0
Weekly
3.2K

@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/sdk

Requires 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 call

fromPrivateKey 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:

  1. Sends the request.
  2. If the server answers 402 Payment Required (x402 or MPP), reads the payment challenge and pays it.
  3. Replays the request with proof of payment.
  4. Returns the body, status, and payment metadata together.
  5. Records a run on Zero when you pass a capabilityId, so the capability's reliability signal stays current and the call stays reviewable.

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.

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).

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
  capabilityId: "cap_abc", // attribute the run to a known capability
  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 out-of-band via result.payment.session.
    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 iff status is 2xx. Convenience for the HTTP-success check; outcome === "success" is the same signal at the payment-and-HTTP level.
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 exact response text, for hashing/forwarding. bodyEncoding: "base64" is set when binary.
payment PaymentResult | null { protocol, chain, txHash, amount, asset, … } when a payment settled; null on a free call.
runId string | null The recorded run, for runs.review(). null unless you pass capabilityId (and a wallet is configured).
latencyMs number End-to-end call latency.
upstreamError UpstreamError? Present on server_error — the capability's own 4xx/5xx detail.
warnings string[]? Non-fatal notes (truncated body, MPP close failure, …) — keyed by FETCH_WARNINGS.
runTrackingSkipped string[]? Why a run wasn't recorded (e.g. no capabilityId) — keyed by FETCH_SKIP_REASONS.

capabilityId accepts the capability's uid (cap_…) or slug — i.e. the id/slug from a search() result or the uid/slug from capabilities.get().

maxPay

maxPay is your hard per-call ceiling, in USDC. The challenge amount is checked against it before anything is signed; an over-cap challenge aborts with outcome: "payment_failed" and no on-chain spend. Set it on every paid call — it's the guardrail that keeps a mispriced or hostile endpoint from draining the wallet.

To put a human in the loop before any spend, read the price up front (cost.amount is on every search result and on capabilities.get()), show it to your user, and only call fetch() with maxPay once they approve.

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 — maxPay is 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 configured address (null in session mode until the first paid call resolves it).
client.wallet.address;

// Balance in USDC. Sums Base + Tempo by default; pass a chain for just one.
const { amount } = 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" });

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 wallet.address — 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) the wallet is Zero-managed, and a couple 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)

To move funds from a user's own (BYO) wallet into their Zero-managed wallet, sign an EIP-3009 ReceiveWithAuthorization and relay it with client.wallet.migrateAuthorization(authorization) — Zero pays the gas and sweeps the USDC to Base.

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)
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 id
const cap = await client.capabilities.get("cap_abc");

// Wallet — balance + one-time funding URL. See "Wallet & funding" above.
const { amount } = await client.wallet.balance();
const fundUrl    = await client.wallet.fundingUrl({ amount: "10" });

// 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,    // 15
  value: 4,       // 15
  reliability: 5, // 15
});

// 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);

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
  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.

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);
  },
});

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.

Keywords