npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@catena/sdk

v0.13.0

Published

Typed client SDK for the Catena agent API

Downloads

1,077

Readme

@catena/sdk

Build agents that can move money without bypassing policy or approvals.

@catena/sdk is the typed Node.js client for the Catena agent API. Use it to read financial data, manage counterparties, move money through policy-checked intents, and pay MPP or x402 HTTP 402 challenges.

Every Catena API request is signed. Every money movement follows Catena policy and approval rules.

Node.js 20+. ESM. Framework-free.

Install

npm install @catena/sdk

Contents

Quick start

Create a client with your linked agent's private key:

import { createCatenaClient } from "@catena/sdk"

const client = createCatenaClient({ privateKeyHex })

Inspect the available source accounts and counterparty rails:

const { accounts } = await client.listAccounts()
const { counterparties } = await client.listCounterparties()

Choose the intended IDs, then submit a send intent. counterpartyRailId is a rail ID, not a counterparty ID.

const sourceAccountId = "acct_..."
const counterpartyRailId = "cpr_..."

const intent = await client.submitIntent({
  action: {
    type: "send",
    accountId: sourceAccountId,
    counterpartyRailId,
    amount: "12.50",
    method: "ach",
  },
})

console.log(intent.status)

You may omit accountId when policy allows exactly one source account. USD intent amounts are decimal strings: "12.50" means $12.50, not cents or atomic units.

Common workflows

Read account data

const agent = await client.whoami()
const setup = await client.getSetupState()
const policy = await client.getPolicy()
const { accounts } = await client.listAccounts()

const account = accounts[0]
if (!account) throw new Error("No account is available")

const balance = await client.getAccountBalance(account.id)
const totalBalance = balance.balances?.total ?? balance.balance
const availableBalance = balance.balances?.available
const { transactions } = await client.listAccountTransactions(account.id, {
  limit: 25,
})
const { intents } = await client.listIntents({
  status: ["pending", "processing"],
  accountId: account.id,
})

Account figures are USD at par across assets. balances.available is aggregate unreserved funds across the networks this agent may use. It describes the account and does not determine whether a particular payment can be funded. It is zero while the account is not active, and so is every balances.byNetwork available. The deprecated flat balance field equals balances.total. If balances is absent on an older API, the available balance is unknown.

balances.byNetwork gives total and available for each network that the account holds and agents may use, so it says where the money sits:

for (const { network, available } of balance.balances?.byNetwork ?? []) {
  console.log(network, available.amount)
}

balances.byNetwork is absent until the money of the account is split by network. An empty array means the money is split, but no network of the account is open to agents. The entries can add up to less than balances.total, which also counts a network agents may not use. Their available values add up to balances.available.

balances.holdings is an optional breakdown by network and token. Each line has token (the CAIP-19 asset identifier), network, networkLabel, asset, assetLabel, total and available. Amounts are USD at par, like the headline. When holdings are absent, every figure is USDC. Holdings are omitted for bank accounts and until the money is split by network, and on older API deployments. The lines include only networks the agent may use. Their available values sum to balances.available; their total values sum to balances.total only when every account network is inside the agent network fence. Closed networks and inactive accounts have zero available; retired wallets contribute to total alone.

Account transaction amount, fee, and currency are USD. The amount is what the recipient received when tokenDetails is present; the fee includes the Catena fee, conversion cost, and customer-paid gas. A movement within the same account shows amount zero and its net cost as the fee. A net conversion credit can make the fee negative. Without token details, the recorded transaction amount and Catena fee are retained. Optional tokenDetails.debited and tokenDetails.credited carry one { asset, network, amount } entry per token and network that netted out of or into this account, with each amount in USD at par. asset uses the same lower-case token symbols as balance holdings. tokenDetails.paid, when present, names the destination token of a payment that converted another funding token. Older servers and rows without token provenance omit these details. Transfers name the other account as the counterparty from either account's perspective.

Each entry's status is open while agents can spend on that network, and closed while Catena has made the network temporarily unavailable. A closed entry keeps its total, which is safe, with available zero. Treat any value other than open as a network you cannot spend on now. An older API omits status, and reports only open networks.

policy.capabilities lists broad permissions. policy.policyCapabilities contains per-account rules. policy.counterpartyRules controls counterparty access, while kind and expiresAt identify an active temporary override.

getSetupState() returns an AgentSetupState with the authenticated agent, organization, policy readiness, accessible account count, signer registration and outstanding setup steps. organization.heldAssets lists additional assets beside USDC. Modern servers return an empty array when USDC alone is available, including failed server-side entitlement reads. Older servers may omit the field; treat omission as USDC alone. Future asset identifiers and setup-step values are accepted; unknown object fields are ignored.

const availableAssets = ["usdc", ...(setup.organization.heldAssets ?? [])]

Discovery describes organization choices, not authorization. Account access, network availability and current policy still apply. In particular, organization.closedNetworks identifies temporarily closed networks; asset admission does not open them.

For an on-chain deposit address:

const deposit = await client.getAccountDepositAddress(walletAccountId, {
  network: "base",
  asset: "usdc",
})

deposit.source is wallet or liquidation. x402, MPP, and viem require an account whose deposit address returns source: "wallet" on the chosen network.

Before a deposit-address or wallet-payee creation request with any explicit asset other than "usdc", the SDK reads fresh setup state. This includes different casing and unknown asset identifiers, which stay unchanged on the wire. If successful discovery omits organization.heldAssets, the SDK refuses locally with onchain_network_asset_disabled for deposits or counterparty_rail_asset_unavailable for payees, before requesting an address or creating an intent. If the field is present, even empty or without the requested asset, the SDK sends the request and lets the server validate, replay or refuse it. Such a server resolves an existing idempotent creation before checking current asset admission, so a retry with the same idempotencyKey can replay after entitlement changes.

Failed discovery preserves its original API, timeout, transport or response-validation error, including authentication, upgrade and retry guidance; no operation follows it. Explicit "usdc" and omitted wallet assets keep their existing behavior without this extra read. The server checks admission and supported network/token pairs for new operations, including changes after discovery. A successful discovery is never cached as permission for a later operation.

Manage counterparties

Create a counterparty with contact information:

const created = await client.submitIntent({
  action: {
    type: "create_counterparty",
    name: "Acme Vendor",
    email: "[email protected]",
  },
})

The action may require approval. Poll it to a final state, then narrow the runtime result before reading the new ID:

let result = created
while (result.status === "pending" || result.status === "processing") {
  await new Promise((resolve) => setTimeout(resolve, 2_000))
  result = await client.getIntent(result.id)
}

const counterparty = result.data?.counterparty

if (result.status === "completed" && result.data === null) {
  throw new Error(
    "Counterparty details are not visible under the current policy",
  )
}

if (
  result.status !== "completed" ||
  typeof counterparty !== "object" ||
  counterparty === null ||
  !("id" in counterparty) ||
  typeof counterparty.id !== "string"
) {
  throw new Error(`Counterparty is not ready: ${result.status}`)
}

const counterpartyId = counterparty.id

An email-only counterparty has no rails, and creating it does not send an email. Creation, replay and intent reads return data: null when the current policy excludes the counterparty, for every asset. The intent's ID and status remain readable. Null can also mean no result exists yet; it does not itself mean the operation failed.

Request those details through Catena. Bank details require a KYB-verified organization with fiat rails enabled.

await client.submitIntent({
  action: {
    type: "request_counterparty_details",
    counterpartyId,
    methods: { bank: true, wallet: false },
  },
})

The request follows policy and may wait for approval. A second request while an invitation is active returns HTTP 409 with counterparty_payment_request_conflict; error.details.invite identifies the existing invitation. Set at least one requested method to true. The request can go ahead for a counterparty the current policy does not list, but its response and reads then return data: null in every status.

You can also create a counterparty with a wallet rail already attached:

await client.submitIntent({
  action: {
    type: "create_counterparty",
    name: "Treasury",
    rail: {
      type: "wallet",
      walletAddress: "0x1111111111111111111111111111111111111111",
      network: "base",
    },
  },
})

Wallet rails accept an optional asset; omission means USDC. Use getSetupState() to discover additional choices before selecting one. Returned wallet rails preserve asset when present, including separate methods for two assets at one address. An absent asset means USDC. A rail ID alone does not independently establish a recipient address.

In an organization that holds OUSD, a send to an OUSD rail is paid from held OUSD or by converting USDC at approval; see Payments that convert. Anywhere else, it refuses with counterparty_rail_asset_unsupported before an intent exists; surface that refusal without substituting USDC.

Bank rails require a KYB-verified organization with fiat rails enabled.

await client.submitIntent({
  action: {
    type: "create_counterparty",
    name: "Acme Vendor",
    email: "[email protected]",
    rail: {
      type: "bank",
      bankName: "Example Bank",
      routingNumber: "021000021",
      accountNumber: "123456789",
      accountType: "checking",
      addressStreet: "1 Main Street",
      addressCity: "New York",
      addressState: "NY",
      addressPostalCode: "10001",
      addressCountry: "USA",
    },
  },
})

routingNumber must contain 9 digits. accountNumber accepts 4–17 digits. accountType is checking or savings. accountType, addressCountry, and the action-level email are optional; the remaining fields are required.

When sending, match the method to the rail: ach and wire require a bank rail; on-chain requires a wallet rail.

Transfer between accounts

const intent = await client.submitIntent({
  action: {
    type: "transfer",
    accountId: sourceAccountId,
    toAccountId: destinationAccountId,
    amount: "100.00",
    memo: "Treasury rebalance",
  },
})

ACH and wire send actions accept an optional memo, which is sent with the bank payment. All send actions accept an optional description for an internal Catena note. On-chain memo remains accepted for compatibility but is deprecated and is not sent to the blockchain. transfer accepts both fields.

Sends to another network

A send to a counterparty wallet on another network is an ordinary send. When the account cannot pay it from its balance on the rail's network, Catena moves the money between networks under the same intent, carried by a movement plan. Follow it with getIntent and read movement, as described in Payments a person approves.

Payments that convert

In an organization that holds OUSD, a payment can convert funds at approval: USDC on Base or Arc converted to pay an OUSD payee, or held OUSD converted to pay a USDC payee. Transfers between the organization's own accounts deliver USDC and OUSD as themselves on each source's network, without conversion or bridging. The amount is their combined dollar value. A destination that cannot receive the funds in kind is refused. Custodial accounts cannot transfer. Catena offers the approval as signing steps: one body to stamp for each conversion and one for the payment, each with its effects (what it does) and a description for a person to read.

submitIntent signs it like any other payment. Before it stamps anything, it checks every step against your action's amount, and against expected when you pass one. Only when every step passes does it stamp each one with your key, and it submits them together. If any step fails, or is in a form this client cannot read, nothing is signed and the error reports not-submitted with the intent's ID. submitIntent then cancels that intent, so the approval Catena prepared for it does not hold your wallet, and the error's cancellation says whether the cancel went through. A conversion is never signed on its own.

The result's steps lists each stamped step's id, description and effects. An IntentSubmitError carries the same list once the stamps exist, so a submit whose outcome is unknown still says what was signed. The description is Catena's wording; act on effects.

The check proves that every step it is given is valid and that together they pay what you asked. It cannot prove the list is complete, because the list comes from the same response as the steps. A payment whose conversion is left out can still verify when what remains pays on its own, as from held funds; Catena's submit refuses any approval that is missing a stamp for one of its steps.

A USDC payment comes as one step and is signed the same way. A server that offers no steps prepares one request to stamp, or a batch of digests with the payment they commit to, and submitIntent checks it before it signs:

  • An on-chain send or a transfer must move your amount, as a gas-sponsored USDC or OUSD transfer, as a relay-paid USDC transfer authorization valid for at most about a day, or as digests this client rebuilds from the payment they commit to. Who it pays is not checked: a counterparty is named by ID, and this client cannot resolve one to an address.
  • An x402 payment must be a USDC transfer authorization that pays the requirement's recipient its amount, or exactly the authorization you passed.
  • An mpp payment must be the one USDC transfer authorization its challenge determines.

Any other request is refused before anything is signed, with not-submitted: a login, an API key, a key import, a policy, a raw transaction, a hash to sign, a Solana send, a contract call that is not a transfer, a conversion, an ACH or wire send in either form (a wallet pays a bank account only through signing steps), and a signature for an action that pays nothing. The check of a single request is published as verifyPreparedPayment in @catena/sdk/movements.

Pass expected to bind the payment to what you know yourself:

await client.submitIntent({
  action: {
    type: "send",
    accountId,
    counterpartyRailId,
    amount: "25.00",
    method: "on-chain",
  },
  expected: { recipient: payeeAddress, network: "base", asset: "ousd" },
})
  • recipient: the 0x address the payment must reach.
  • network: the network it must land on, such as base.
  • asset: usdc or ousd.

Each field you give must match, or nothing is signed. Your amount is always checked. Without expected, the payment is checked against the destination Catena names for it, which is Catena's account of the payee's rail and which a rail ID alone cannot confirm. Pass the fields you know from somewhere else. An asset you leave out is taken from that destination.

expected binds only what this client signs:

  • A malformed expected throws a TypeError before anything is created: anything but a plain object (a Promise left unawaited, a Map, a class instance), an unknown field, a null field, a recipient that is not an address, a network other than base, base-sepolia, arc and arc-testnet, or an asset other than usdc and ousd.
  • Against a server that offers no steps, expected refuses before anything is signed, with not-submitted. A plain object with no field given is the same as leaving it out.
  • A payment completed without your signature, or approved by a person, is not checked against it.

Payments a person approves

A send or transfer over the organization's approval threshold parks for a person rather than being refused. Once a person approves it, an admin signs it in the Catena console. You do not finish it: poll it until it is done or blocked.

Poll the intent with getIntent and read movement. It is served on a read, not on the submitIntent result, and only for a payment carried by a movement plan. movement.nextStep is what status cannot tell you: a payment waiting on a person and one an admin is signing both read processing.

const action = {
  type: "send",
  accountId,
  counterpartyRailId,
  amount: "2500.00",
  method: "on-chain",
} as const

const submitted = await client.submitIntent({ action })

// `movement` is served by getIntent, not by submitIntent, so read it first.
let intent = await client.getIntent(submitted.id)
while (intent.movement?.nextStep === "wait") {
  await new Promise((resolve) => setTimeout(resolve, 30_000))
  intent = await client.getIntent(intent.id)
}
  • wait — a person is deciding it, an admin is signing it, or its signature is in flight. Read it again with getIntent.
  • done — the payment completed.
  • blocked — it ended without paying. reasons says why, and is empty when an approval lapsed.

movement.nextStep never reads continue. Sending the same request again on the same idempotency key hands back the payment's intent without signing anything. That is an answer, not a failure: keep polling.

A payment nobody had to approve is still yours to sign, and submitIntent hands you its stamp. If you restarted before signing it, send the original request again on the same idempotency key. Within about five minutes of the first send it answers movement_authorization_in_progress; after that it hands the payment back for you to sign.

One wallet signs one payment at a time. submitIntent signs as soon as it creates, so parallel calls from one wallet go through in turn: each waits while the payments ahead of it are signed. A send answers movement_wallet_approval_open when one other payment holds the wallet for more than a few seconds (a person approving in the console, say) or its wait passes 20 seconds. Nothing is held. The error's details.heldUntil says when the payment holding the wallet lets it go, and retryAfterSeconds how long that is; when Catena cannot tell, as while that payment is being signed, heldUntil is null and retryAfterSeconds is undefined. Wait that long, or a few seconds, then send the request again. details.intentCancelled says on which key. When it is true, the send's intent is cancelled, and its idempotency key answers with that cancelled intent from then on: use a new key (omit idempotencyKey and submitIntent makes one up). When it is false, the intent is still open: send it again on the same key, so the payment is not made twice. If the intent has been cancelled since, that resend returns it and nothing moved: start again on a new key. If submitIntent made that key up, nothing else can reach the intent, and a new key is safe; it counts against your limits until it expires. details.blockingIntentId names the payment holding the wallet when it is one of your own intents; if it is an attempt you have given up on, cancelIntent frees the wallet at once. If the refusal lasts more than a few minutes, report it and stop resending: a signing that has to be recovered can hold the wallet until an operator resolves it.

The SDK checks a movement stamp against the action you submitted before it signs. It is the one check that compares the payment against something Catena did not supply. A payment that would not pay the amount you asked for is refused before anything is signed. The amount is summed over every part that delivers, whether it is stated in a signed field or inside the call data of a raw transaction or a batched call. A call this client does not recognise is refused. The recipient is checked against an expected recipient you pass, and a payment signed as steps is checked against the destination Catena names for it. Otherwise the recipient is not checked, because a counterparty is named by ID and this client cannot resolve one to an address.

A movement is signed as pre-hashed digests, and the SDK rebuilds them locally rather than trust what it is handed — it will not sign digests it cannot check. It needs nothing beyond @catena/sdk for this: the hashing it uses ships inside the package rather than coming from your own viem, so your viem version cannot change what it checks. The same check is published as @catena/sdk/movements (verifyPreparedMovement, rebuildInstructionDigest) for an agent that signs some other way.

verifyPreparedMovement(prepared, { amount }) keeps the existing USDC review contract. Pass { amount, inKind: true } to refuse crossings when your own request transfers between different accounts. Use verifyMovementSteps from the same entry for an approval described as signing steps:

import { verifyMovementSteps } from "@catena/sdk/movements"

const verdict = verifyMovementSteps(steps, totals, {
  signerAddress: walletAddress,
  amount: "6",
  expected: {
    recipient: knownRecipientAddress,
    network: "base",
    asset: knownRecipientAsset,
  },
})

if (!verdict.ok) throw new Error(verdict.reason)
for (const step of verdict.steps) {
  console.log(step.id, step.description, step.effects)
}

Each step carries a prepared provider body, its ingredients, claimed effects and a display description. The verifier rebuilds raw digests, checks each conversion body against its ingredients, and independently derives every effect using pinned token, spender and delegate contracts. Unknown encodings, assets, networks, effects or body fields refuse. It checks body hashes, stated expiries, signer consistency, nonce order and the complete payment before returning detached snapshots of the verified bodies. It never signs or calls a provider. Stamp only verdict.steps[*].preparedBody after an ok verdict.

Every provider body must equal JSON.stringify(JSON.parse(body)) byte for byte. Duplicate keys, extra whitespace and alternate escapes refuse, preventing different JSON parsers from reading different activities. Compact key reordering is accepted and preserved in the verified snapshot. The legacy verifier and submitIntent enforce the same check, with the client refusing before stamping.

Effects describe token outputs, conversions, CCTP bridges, allowances, delegations and native gas. Decimal token quantities are exact, with the registry's pinned decimals. Payout effects must deliver the caller's amount to the reviewed destination. Optional expected recipient, network and asset constraints come from the caller; an omitted asset uses totals.destination.asset. Malformed expectations refuse rather than throw.

Pass ownAccounts: true only when your own action is a transfer between your organization's accounts: the payment must then arrive at every location totals.destinations lists, one per network and asset, and nowhere else. Mixed USDC and OUSD deliveries include an exact amount for every location. The verifier checks those amounts against the signed transfers and refuses swaps or bridges in a mixed delivery. Older clients refuse mixed plans before signing; update before submitting one. Without ownAccounts, totals.destinations, when present, must name exactly totals.destination. Each totals.sponsoredFees entry, a crossing fee Catena restores to your wallet, counts toward the ceiling only up to the fee your signed burns into that wallet on that network may take.

submitIntent also checks the account IDs in your own transfer action. When they name different accounts, it refuses any verified conversion or bridge before signing, including a transfer that delivers only USDC. It enforces this rule for both signing steps and older digest-only movement approvals. Network moves within the same account and payments to counterparties keep their existing review rules. This check needs no new field from the server; older clients continue to handle USDC-only approvals, and must update before signing mixed delivery plans.

Held funds may supplement conversion minima. Every conversion must fund a later token debit, and, per output network and asset, aggregate minima times 0.9 may not exceed those later debits. Each conversion's minimum must also be at least 90% of its input. Aggregate wallet debit, including bridges and rounded-up wallet-paid gas, stays within totals.maxTotalDebit; that ceiling is supplied by the caller of the verifier and is not automatically an independent spending instruction. The existing wallet gas bound remains 2 USDC.

The verifier proves consistency with the supplied signer and destination, not their ownership. A rail ID cannot independently establish an address. An execute-swap body has no signer address: its organization must match the raw batch's, whose signWith must equal the supplied signerAddress. Conversions must be sponsored and have no output-destination override. Quote IDs and stated expiries are not venue attestations or independent price quotes.

The step list has no separately trusted manifest. Omitting a conversion can still verify when the remainder is a valid held-funded payment; an independently valid extra conversion can also verify within all bounds. Insufficient held funds then cause execution to fail on chain. Server submission must enforce its persisted step set separately. Opaque ID renames, display descriptions and permutations of independent conversions do not establish authorization. The verifier checks SHA-256 of each body and a future preparation expiry, and keeps the opaque prepareToken verbatim; it cannot authenticate that token offline.

MovementStep, MovementStepEffect, MovementStepTotals, PreparedStepBody, VerifiedMovementStep, MovementStepsReviewVerdict and ExpectedDestination are exported from @catena/sdk/movements. submitIntent runs this same check before it stamps any step, and signs a conversion only as one of a server's signing steps, never as an approval's own request (see Payments that convert).

An approval does not wait forever. If no admin signs the payment before it lapses, it expires unpaid and has to be asked for again.

Request a temporary policy override

Policy overrides always wait for human approval. Each operation names the blocking limit by its scope. This example asks for a higher daily send limit shared across all accounts, for one hour:

const intent = await client.submitIntent({
  action: {
    type: "policy_override",
    operations: [
      {
        type: "increase_limit",
        capability: "send",
        limitType: "daily_amount",
        scope: {
          accounts: { type: "all" },
          counterparties: { type: "all" },
        },
        newLimit: { amount: "5000.00" },
      },
    ],
    durationSeconds: 3_600,
    reason: "Pay an approved vendor invoice",
  },
})

scope.accounts is { type: "all" } for one limit shared across all accounts combined, or { type: "account", accountId } for one account's own limit. In getPolicy(), a rule with accountAggregationScope: "across_accounts" is a shared limit, repeated on each account's row, and a block it causes says "for all accounts combined". A per_account rule is the limit of its row's account. Raising an account's own limit does not lift a block from a shared limit. For a transfer limit, the account is the source account that funds move out of. A send limit's scope also names its counterparties: { type: "all" }, or { type: "counterparty", counterpartyId } for a counterparty-scoped rule. A transfer limit's scope has no counterparties. When several limits block one action, include an operation for each.

Amount limits use per_transaction_amount, daily_amount, weekly_amount, or monthly_amount with newLimit: { amount } in USD. Count limits use hourly_count or daily_count with newLimit: { count }. capability is send or transfer. Amounts are positive decimal strings with up to six fractional digits; counts are positive integers. Overrides last from one minute to seven days, starting at approval. Submit 1–10 operations, each naming a different capability, limit type, and scope. reason must contain 1–280 characters.

Propose a policy

An agent with no policy, or one whose policy no longer fits its work, can propose a complete policy for itself. Every proposal waits for a person to approve or deny it, whatever its rules say, and grants nothing while it waits. Approval replaces the agent's current policy, and any temporary override, at that moment. This proposal lets the agent read and send from every account and caps its sends at 500 USD a day in total:

const intent = await client.submitIntent({
  action: {
    type: "policy_proposal",
    rationale: "Pay approved vendor invoices",
    policy: {
      name: "Vendor payments",
      description: "Pays approved vendor invoices",
      counterpartyAccess: { mode: "open" },
      permissions: [
        { type: "read", accounts: { mode: "all" } },
        { type: "send", accounts: { mode: "all" } },
      ],
      rules: {
        send: [
          {
            scope: {
              accounts: { type: "all" },
              counterparties: { type: "all" },
            },
            limit: { type: "daily_amount", threshold: { amount: "500.00" } },
            action: { type: "block" },
          },
        ],
        transfer: [],
      },
    },
  },
})

The SDK sends it as version 1 of the proposal format, with every threshold in USD. Callers of the REST API send schemaVersion: 1 and threshold: { amount, assetId: "USD" } themselves. policy.description is optional, up to 280 characters; approval gives the policy that description.

Access comes only from permissions and counterpartyAccess. read, send, and transfer each select accounts: { mode: "all" }, every account that is active when a person approves, or { mode: "selected", entries }, each entry naming one account by ID. Read is never implied: every account selected for send or transfer must be selected for read, and { mode: "all" } for either needs read on all; the server refuses a proposal that misses this. The SDK checks only field types and the structure it needs before sending a proposal, and passes fields and values it does not know to the server. parsePolicyProposal(value) runs that check on a proposal read from JSON and returns the typed action. counterpartyAccess is { mode: "open" } for every payable counterparty, or { mode: "restricted", counterparties } for named ones. requiresApproval on create_counterparty and request_counterparty_details governs those later actions under the approved policy; it does not change the proposal's own approval.

Rules limit sends and transfers and never grant access. A rule with scope.accounts: { type: "all" } is one combined limit over everything the agent sends from any account, not a limit per account. A rule with { type: "account", accountId } limits that account alone; repeat the rule for each account that needs its own. Every rule that applies must pass, so combined and per-account rules add up. A send rule names its counterparties: { type: "all" }, or { type: "counterparty", counterparty } with a reference { type: "existing", counterpartyId } or { type: "proposed", ref }. proposedCounterparties lists the counterparties approval creates, or reuses when an existing one matches every rail (approval refuses a proposal whose rails match only partly, or match different counterparties); the policy names each by its ref.

While the proposal waits, status is pending and proposal.reason says whether it can go ahead: null when a person could approve it now, awaiting_resources or temporarily_unavailable to wait, and referenced_resource_unavailable or proposal_invalid to fix it and submit again. It expires after 24 hours. A new proposal replaces the agent's pending one. Submitting again with the same idempotencyKey returns the original proposal, even one refused at approval, and never submits it again; a corrected proposal needs a new key, which the SDK generates when you pass none. Once a person approves it, read what it created:

import { policyProposalResult } from "@catena/sdk"

const result = policyProposalResult(await client.getIntent(intent.id))
// { policy: { id, name }, counterparties: [{ ref, id, name, outcome }] }

The server refuses a proposal it could never approve with policy_proposal_invalid and each problem in details.issues, and one naming an account or counterparty it cannot use with policy_proposal_reference_unavailable. An organization without proposals answers 403 feature_disabled. None of these records anything.

How intents work

Policy-controlled actions use submitIntent. Catena evaluates policy, collects any required approval, and co-signs the action when needed.

submitIntent returns the intent's current state. It does not guarantee that execution is finished.

| Status | Resolution | Meaning | | ------------ | ------------------- | -------------------------------------------------------- | | completed | completed | The action succeeded. | | pending | null | The intent is waiting for human approval. | | processing | null | Catena accepted the intent and is still working. | | blocked | blocked, denied | Policy or an operator stopped the intent. This is final. | | failed | expired, failed | The intent did not complete. This state is final. |

Use status for stable control flow. resolution is the exact terminal outcome and is null while the intent is nonterminal. The known resolution vocabulary is completed, blocked, denied, expired, failed, and cancelled; the SDK preserves future nonblank values so clients can forward them without rejecting the response.

Read reasons for a non-completed outcome and expiresAt for an approval deadline. For ordinary actions, poll pending and processing intents with a delay until they reach a final state. Treat only completed as success.

Two kinds of intent never reach a final state by polling alone. MPP and x402 payments: after approval, repeat the original paid request so its fresh challenge can use the approval. For these protocols, completed means Catena delivered the payment authorization, and on-chain settlement is verified separately. A send or transfer a person approved: it rests at processing while an admin signs it, and movement.nextStep reads wait until it is done or blocked — see Payments a person approves.

Cancel an intent while it is waiting for approval or approved but not yet executing:

const cancelled = await client.cancelIntent(intent.id)

Repeating a successful cancellation is safe. The method returns the same IntentResult as getIntent, with resolution: "cancelled". It cannot stop or reverse an executing payment; ineligible intents return HTTP 409 with intent_not_cancellable.

Result data

IntentResult.data is runtime data. Narrow it before use.

| Action | Result data when available | | ------------------------------ | ---------------------------------------------------------------- | | send, transfer | data.transaction | | create_counterparty | data.counterparty | | request_counterparty_details | data.counterpartyId, data.counterpartyName, data.invite | | x402, mpp | data.paymentCredential, data.transaction | | policy_override | data.policyOverride.policyId | | policy_proposal | data.policy, data.counterparties; see policyProposalResult |

metadata.dataUrl, when present, links to the relevant approval or result in the Catena console.

Source selection

For send, omit accountId only when policy allows exactly one source account. Zero eligible accounts returns HTTP 403 with policy_send_account_unavailable; multiple eligible accounts returns HTTP 400 with policy_send_account_required.

Catena pins the selected source to the intent. A later policy change does not silently switch accounts. Transfers always require accountId.

Idempotency

The SDK generates a fresh idempotency key for every submitIntent call unless you provide one. Use an explicit key when separate calls or processes must refer to the same logical operation.

Reuse a key only with the same action and source. The source account stays pinned even if policy changes. A different action or source returns HTTP 409. When intent.replayed is true, Catena returned the existing intent without starting the action again.

Protocol payments

| Use case | Import | | ------------------------------- | ------------------ | | Managed x402 payment loop | @catena/sdk/x402 | | Managed or custom MPP loop | @catena/sdk/mpp | | viem-compatible x402 account | @catena/sdk/viem | | Direct agent API payment intent | @catena/sdk |

Pay x402 challenges

The helper supports x402 v2 exact EIP-3009 payments on structurally valid EVM networks and token contracts. The connected Catena server decides which networks and canonical assets are payable. Native USDC on Base and Base Sepolia is supported where enabled. Arc support is rolling out. Wrap fetch to pay a compatible challenge and retry once with PAYMENT-SIGNATURE:

import { createCatenaClient } from "@catena/sdk"
import { wrapFetchWithX402Payment } from "@catena/sdk/x402"

const client = createCatenaClient({ privateKeyHex })
const fetchWithPayment = wrapFetchWithX402Payment(client, {
  accountId: walletAccountId,
  maxAtomicAmount: 1_000_000n,
})

const response = await fetchWithPayment("https://api.example.com/paid-thing")

maxAtomicAmount uses atomic USDC units, so 1_000_000n is 1 USDC. Ordinary responses and 402s without a decodable v2 challenge pass through untouched. A decoded but unpayable challenge throws X402PaymentError.

Request bodies must be replayable. Put a string, URLSearchParams, Blob, ArrayBuffer, typed array, or DataView in the init argument. Body-bearing Request objects and streams are rejected before payment.

The helper examines at most the first five exact candidates and stops at the first one Catena can pay.

For direct control, use decodePaymentRequired(response) and payX402Challenge(client, options). The latter returns a receipt containing the intent ID, selected requirements, and paymentSignature header value. It pays but does not retry the HTTP request for you.

A structurally valid challenge must use x402 v2, the exact scheme, an EVM CAIP-2 network, a valid token contract, and an EIP-3009 authorization. The amount must be a canonical atomic-unit integer, payTo must be a valid EVM address, and maxTimeoutSeconds must be from 1 to 3,600. The requirement must include an EIP-712 domain name and version. The server separately validates network support, the canonical asset and domain, and the saved counterparty.

Pay MPP challenges

Install the optional peers with the SDK:

npm install @catena/sdk mppx@^0.9.0 viem@^2.54.0

The managed wrapper recognizes evm/charge and usdc/charge for structurally valid EVM networks and token contracts. The connected Catena server decides which networks and canonical USDC contracts are payable. Native USDC on Base and Base Sepolia is supported where enabled. Arc support is rolling out:

import { createCatenaClient } from "@catena/sdk"
import {
  MppCounterpartyNotFoundError,
  wrapFetchWithMppPayment,
} from "@catena/sdk/mpp"

const client = createCatenaClient({ privateKeyHex })
const fetchWithPayment = wrapFetchWithMppPayment(client, {
  accountId: walletAccountId,
  maxAtomicAmount: 1_000_000n,
})

const response = await fetchWithPayment("https://api.example.com/paid-thing")

The wrapper tries up to five supported candidates, creates at most one credential, and retries the request once. onPayment runs after payment and before the retry. Its receipt contains no credential. Non-replayable bodies and cross-origin redirects are rejected before payment.

The receipt contains intentId, method, atomicAmount, network, recipient, and resourceUrl.

When a recipient is not saved, the server-resolved requiredRail can be passed directly to a deliberate counterparty intent:

try {
  await fetchWithPayment(url)
} catch (error) {
  if (error instanceof MppCounterpartyNotFoundError && error.requiredRail) {
    await client.submitIntent({
      action: {
        type: "create_counterparty",
        name: "Approved seller",
        rail: error.requiredRail,
      },
    })
  }
}

X402CounterpartyNotFoundError.requiredRail has the same contract.

Need custom transport, hooks, or candidate selection? Add Catena's methods to your own mppx client:

import { createCatenaClient } from "@catena/sdk"
import { catena } from "@catena/sdk/mpp"
import { Mppx } from "mppx/client"

const client = createCatenaClient({ privateKeyHex })

const mppx = Mppx.create({
  methods: [
    catena({
      client,
      accountId: walletAccountId,
      maxAtomicAmount: 1_000_000n,
    }),
  ],
  maxPaymentRetries: 1,
  polyfill: false,
})

const response = await mppx.fetch("https://api.example.com/paid-thing")

catena.charge(options) is an alias for catena(options). By default, mppx can make up to three payment attempts per fetch. Set maxPaymentRetries: 1 to allow one. Separate fetch calls can still pay again. maxAtomicAmount applies to each attempt, not the combined total.

With mppx 0.9, pass the HTTP method and body in the fetch init argument, not only in a Request. Buffer the body first; streams and raw FormData are not safe to replay.

The client forwards structurally valid EVM networks and token contracts to the server. The server decides which are payable. Native USDC on Base and Base Sepolia is supported where enabled. Arc support is rolling out. A challenge must also meet these rules:

  • method is evm or usdc, and intent is charge;
  • id and realm contain 1–256 characters, the serialized request is at most 16 KiB, and the full challenge is at most 32 KiB;
  • expires is UTC with a trailing Z, has at most three fractional digits, and leaves between 5 seconds and 1 hour;
  • the amount is a positive canonical integer of at most 78 digits and does not exceed maxAtomicAmount, when configured;
  • the currency and recipient are valid EVM addresses;
  • method details use 6 decimals and a single authorization credential; splits are unsupported, and an optional Permit2 address must be valid;
  • description is at most 1,024 characters and externalId is at most 256;
  • header is omitted or exactly Payment-Authorization;
  • digest uses sha-256=:<base64>: or sha-256=<base64> with canonical, padded standard Base64;
  • opaque, when present, is nonempty unpadded Base64url of at most 8 KiB;
  • addresses are lowercase or valid EIP-55;
  • description and externalId contain valid Unicode.

mppx may report No method found for challenges when one of these requirements fails, even if evm.charge or usdc.charge appears available.

Use a Catena wallet with viem

createCatenaAccount returns a viem Account for x402 clients that expect { address, signTypedData }:

npm install @catena/sdk viem@^2.21
import { createCatenaClient } from "@catena/sdk"
import { createCatenaAccount } from "@catena/sdk/viem"

const client = createCatenaClient({ privateKeyHex })
const account = await createCatenaAccount(client, {
  accountId: walletAccountId,
  network: "base",
})

Pass account to any x402 client that accepts a viem account or { address, signTypedData } signer.

A Catena wallet is custodial. The account signs only structurally valid x402 EIP-3009 TransferWithAuthorization data. The server decides which EVM networks and canonical token contracts are payable. signMessage, signTransaction, and raw-hash signing are unavailable.

The account must resolve to a deposit address whose source is wallet. network defaults to base; pass arc for an Arc wallet when enabled or base-sepolia for a testnet wallet. The network must match the wallet account.

Error handling

Branch on stable fields and error classes, not message text.

Core client errors

| Error | Recovery | | ------------------------- | --------------------------------------------------------------------------------- | | InvalidKeyError | Supply a valid linked P-256 private key. | | ApiError | Branch on code; inspect status, fields, and details. | | NetworkClosedError | Do not retry on network; use one of alternatives, or tell the user. | | InvalidApiResponseError | Treat the request as uncertain; reconcile before retrying. | | TimeoutError | Treat the request as uncertain until its operation-specific recovery is complete. | | IntentSubmitError | Branch on outcome; use intentId to reconcile an uncertain submission. |

The SDK combines the API's error summary and field explanations in message, identifying the affected request fields and what needs correction. fields preserves the individual explanations keyed by request field path, such as action.memo. The request key represents a request-wide issue rather than a field; its explanation appears in message without a field-path prefix. Malformed fields are ignored without discarding the error's message or code.

InvalidApiResponseError comes from a successful HTTP response, so the server accepted the request and may have acted. Reconcile a state-changing operation through its read API before retrying. If reconciliation is impossible and a retry is necessary, reuse the same persisted idempotencyKey; a new key can duplicate the operation.

For IntentSubmitError:

  • not-submitted means no money moved. Fix the cause and submit again with a new idempotency key. When submitIntent refused before signing anything, it cancelled the intent: cancellation is { outcome: "cancelled" }, or { outcome: "failed", cause } when the cancel did not go through. A failed cancel, or a refusal after something was signed, leaves the intent holding its wallet for about a minute and counting against your limits until it expires; cancelIntent(error.intentId) releases both at once. Earlier versions of this SDK do not cancel, so every intent they refuse holds its wallet and counts against your limits that way.
  • unknown means Catena may have acted. Poll getIntent(error.intentId) and submit again only after the intent becomes blocked or failed.

NetworkClosedError is the ApiError for onchain_network_closed: Catena has made network temporarily unavailable, with no known end. No money moved, money already on the network is safe, and payments already in progress complete. It has no retryAfterSeconds. Do not retry on that network, and do not back off and try again. Use one of alternatives, or tell the user when the list is empty. A submit it refuses is an IntentSubmitError with outcome not-submitted and this error as its cause. The x402 and MPP wrappers move on to the challenge's next candidate when they meet it, and throw it when every candidate's network is closed. A refusal whose details the SDK cannot read stays a plain ApiError with that code, so branch on code to catch both.

A timeout means the SDK stopped waiting, not necessarily that the server stopped working. For an exact submitIntent retry after a create timeout, you must have chosen and persisted an idempotencyKey before the first call. Retry the unchanged action with that key.

If an exact retry returns wallet_send_intent_not_executing, x402_intent_state_mismatch, or mpp_intent_state_mismatch, do not create a replacement payment. Reconcile the existing intent with the operator.

Payment errors use CAIP-2 network IDs. Missing-counterparty errors may also include a server-resolved requiredRail whose product network identifier can be passed directly to a create_counterparty intent. Known mappings are eip155:8453 to base, eip155:84532 to base-sepolia, and eip155:5042 to arc.

| Error | Recovery | | ------------------------------- | ----------------------------------------------------------------------------------------------------- | | X402PaymentError | Do not retry automatically. If no subclass provides a safe recovery path, escalate to an operator. | | X402NetworkMismatchError | Use an account on one of requiredNetworks. | | X402CounterpartyNotFoundError | Create a counterparty from requiredRail when present; otherwise resolve the rail manually. | | X402ApprovalPendingError | Approve the intent, then repeat the original request. | | X402RetryFailedError | Payment completed. Retry manually with receipt.paymentSignature; do not run the payment loop again. | | X402SubmitInterruptedError | Check getIntent(intentId) before paying again. |

A create-phase TimeoutError from the x402 wrapper means no payment was authorized, so the request can be run again. For an interrupted submission:

  • completed: reuse data.paymentCredential.value as PAYMENT-SIGNATURE;
  • processing: keep polling; and
  • blocked or failed: a new payment attempt is safe.

| Error | Recovery | | ------------------------------ | ----------------------------------------------------------------- | | MppFetchPaymentError | If intentId is present, reconcile it before paying again. | | MppNetworkMismatchError | Fund the payment from one of requiredNetworks. | | MppCounterpartyNotFoundError | Create a counterparty from requiredRail when present. | | MppApprovalPendingError | Approve the intent, then repeat the original request. | | MppPaymentDeclinedError | Treat the result as final. | | MppSubmitInterruptedError | Reconcile intentId before authorizing another payment. | | MppRetryFailedError | Payment completed. Reconcile receipt.intentId before any retry. |

An MppPaymentError from a custom mppx integration exposes intentId, status, reasons, and expiresAt. A completed MPP intent proves that Catena produced an authorization, not that the seller settled it or delivered the resource.

When mppx makes multiple attempts, the final error identifies only its own intent. Reconcile every known attempt before paying again.

| Error | Meaning | | ----------------------------------- | ------------------------------------------------------------------------------------ | | UnsupportedTypedDataError | Refused client-side; nothing was submitted. Inspect reason. | | UnsupportedAccountCapabilityError | The requested signing method is not available. | | NotAWalletAccountError | Choose an account whose deposit-address source is wallet. | | X402ApprovalPendingError | Approve the intent, then let the x402 tooling retry. | | X402PaymentDeclinedError | Policy blocked or failed the payment. This result is final. | | X402PaymentInFlightError | Reconcile intentId. Polling will not advance it; repeat the original paid request. | | X402SubmitInterruptedError | Reconcile intentId; do not pay again. | | X402PaymentSignatureUnusableError | Payment completed. Reconcile intentId; do not pay again. | | SignatureVerificationFailedError | Payment completed. Do not use the signature or pay again. |

data.paymentCredential.value is the Base64 x402 envelope used as the PAYMENT-SIGNATURE header. signTypedData returns the inner hex signature. They are not interchangeable.

API reference

Client methods

getSetupState() returns the authenticated AgentSetupState, including organization asset choices and outstanding setup steps.

| Method | Purpose | | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | whoami() | Return the linked agent. | | submitFeedback(body) | Send integration feedback to Catena operators. This has no money effect. | | getPolicy() | Return the agent's effective policy. | | listAccounts() | List accounts visible to the agent. | | getAccountBalance(accountId) | Return total and available balances, overall and per network. | | listAccountTransactions(accountId, params?) | List transactions with optional start, end, limit, and offset. | | getAccountDepositAddress(accountId, params) | Resolve an address for a network and asset. | | listCounterparties(params?) | List counterparties; address and network filters check wallet ownership within a network class. A wallet rail omits network when its configured destination is unavailable to the agent and cannot receive an agent send until that changes. | | submitIntent({ action, idempotencyKey? }) | Submit a policy-checked action. | | listIntents(params?) | List this agent's intent summaries with optional status, type, start, accountId, limit, and offset filters. | | getIntent(id) | Read the current intent state. | | cancelIntent(id) | Cancel an intent that is awaiting approval or approved but not yet executing. | | unlinkAgent() | Permanently revoke the current agent link and its active keys. | | reportSettlement(params) | Report an on-chain settlement. Managed payment wrappers call this automatically. |

Transaction ranges use inclusive ISO 8601 start and end timestamps. If start is after end, the request fails validation on end. limit defaults to 50 and is capped at 200; offset defaults to 0. reportSettlement accepts { intentId, txHash, receipt? }.

Intent history defaults to every status, newest first. Pass more than one status or type to include any of those values. List entries contain the stored context needed to identify work; call getIntent(id) for the full intent-specific result.

Intent actions

| Action | Purpose | | ------------------------------ | ------------------------------------------------- | | send | Send by ACH, wire, or on-chain rail. | | transfer | Transfer between Catena accounts. | | create_counterparty | Save a counterparty with contact or rail details. | | request_counterparty_details | Request bank or wallet details. | | x402 | Submit a low-level x402 payment requirement. | | mpp | Submit a low-level MPP charge. | | policy_override | Request a temporary increase to policy limits. | | policy_proposal | Propose a new policy for a person to approve. |

Use the x402 and MPP helpers for ordinary protocol payments. They parse the challenge, enforce replay safety, submit the intent, and handle the paid retry.

For direct intents, x402 requires accountId and paymentRequirements, with optional resource, authorization, and signedOffer. mpp requires accountId and challenge, with optional resource.

Payment helper options

| API | Required options | Optional options | | -------------------------- | ------------------------------ | ------------------------------------------- | | wrapFetchWithX402Payment | accountId | baseFetch, maxAtomicAmount, onPayment | | payX402Challenge | accountId, paymentRequired | resource, maxAtomicAmount | | wrapFetchWithMppPayment | accountId | baseFetch, maxAtomicAmount, onPayment | | catena / catena.charge | client, accountId | maxAtomicAmount | | createCatenaAccount | accountId | network |

baseFetch injects a custom transport. onPayment runs after Catena completes the payment and before the paid retry. If it throws, the payment remains complete and the wrapper throws a retry error carrying the receipt.

Client options

| Option | Purpose | | ---------------- | --------------------------------------------------------------------------- | | privateKeyHex | Linked 64-character P-256 private key. | | baseUrl | API origin. Defaults to the hosted Catena API. | | identityUrl | Stable signer identity URL. The default follows the key thumbprint. | | appInfo | { name, version?, url? } added to the User-Agent. | | defaultHeaders | Headers merged into every request. The SDK strips auth and sets User-Agent. | | fetch | Custom fetch transport for proxies, instrumentation, or tests. | | timeout | Per-exchange deadline in milliseconds. Defaults to 80,000. |

You can omit privateKeyHex and set CATENA_SECRET_KEY instead. An explicit value always wins, including when it is empty or invalid. The SDK validates the key at construction. It never stores credentials or reads CLI profiles.

Requests carry two P-256 signatures: the standard profile uses Signature-Input, Signature, Signature-Agent, and Content-Digest; the dedicated profile uses those four names with a Catena- prefix. Each signature covers the method, full URL, and its own digest and identity fields. Both share the key, timestamp, and nonce, so changing profiles cannot replay a request against the same API.

Existing APIs can verify the standard profile. Compatible APIs prefer the dedicated profile; an invalid or partial dedicated signature fails without falling back to the standard signature. Proxies that replace standard headers must preserve all four dedicated headers and the signed URL and body, and the API must support that profile. Authentication failures do not trigger another request with a different profile. Existing keys and links remain valid.

Each operation may make more than one HTTP exchange. Every exchange receives a fresh timeout; timeout is not an overall operation dea