Building an agent that pays with Bonded

The agent SDK and the MCP server: a session key, vouchers on a tab, float payments, escrow jobs.

How an agent pays

An agent pays with its session key, a Solana keypair that belongs to the agent and to nothing else. Its operator creates a vault for that key with a policy (see Operators). From then on the agent can:

  • Pay on a tab: sign a 96-byte voucher with each request to a merchant it has a credit line with. The merchant settles the latest voucher later.
  • Pay from the float: spend its allowance directly on open-web x402 calls.
  • Pay a listed merchant: send USDC from the vault's balance to a merchant on the vault's list.
  • Pay in escrow: fund a job against a merchant's signed quote; release it on delivery or dispute it.

The program bounds every one of them. Direct payments and escrow are checked against the vault's policy, a tab against its line's limit, and the float against its allowance and the vault's window budget. The session key can't spend outside those bounds, whatever the agent is told to do.

There are two ways to give an agent these payments: the agent SDK, a TypeScript library for your own code, and the MCP server, which exposes the same payments as tools to an MCP host such as Claude.

The session key

Generate the keypair on the machine the agent runs on, and keep the file there:

solana-keygen new --no-bip39-passphrase -o session.json
solana-keygen pubkey session.json

The second command prints the public key. Send it to the operator, who pastes it as Session key when creating the vault. The operator never needs the secret, and the agent never needs the operator's.

A new session key

If the key is replaced, the operator rotates it in two steps:

  1. Manage this vault › Rotate the session key. Direct payments, escrow and the float move to the new key at once, and the old key's allowance is revoked in the same transaction.
  2. Move the line to the new key, on each line. A line switches 72 hours after the operator asks, so the merchant can still settle what the old key signed.

Until a line switches, sign that line's vouchers with the old key: keep a second agent built on the old session file for its tabs. The SDK refuses a key the line doesn't take yet with SIGNER_MISMATCH, before anything is signed. Once the line has switched, the new key signs its vouchers too.

Install the SDK

The packages are served by this site. Install the SDK with its peer libraries:

npm install https://bonded.cash/packages/bonded-agent-sdk-0.1.0.tgz @solana/[email protected] @noble/[email protected] @noble/[email protected]

It runs on Node 22 or later.

Connect

The SDK pins the program and the network it may sign for, independently of the API that prepares transactions. Take both from the service's values below and keep them in your own configuration. The SDK checks both against the API and against its own RPC before every signature, and refuses to sign for anything else.

import fs from 'node:fs'
import { Connection } from '@solana/web3.js'
import { createAgentHttpClient, createBondedAgent, createLocalSessionSigner } from '@bonded/agent-sdk'

const keypair = Uint8Array.from(JSON.parse(fs.readFileSync('session.json', 'utf8')))

const agent = createBondedAgent({
  client: createAgentHttpClient({ baseUrl: '<API>' }),
  rpc: new Connection(process.env.SOLANA_RPC_URL!, 'confirmed'),
  programId: process.env.BONDED_PROGRAM_ID!,
  genesisHash: process.env.BONDED_GENESIS_HASH!,
  authenticationOrigin: 'https://bonded.cash',
  signer: createLocalSessionSigner(keypair.subarray(0, 32)),
  arbiterAllowlist: [],
})

await agent.login()
  • SOLANA_RPC_URL is your own RPC endpoint for the same network: a provider URL, or the network's public endpoint.
  • login() signs the API's sign-in message after checking it names this site and this key.
  • createRemoteSessionSigner('turnkey' | 'crossmint' | 'coinbase', publicKey, transport) keeps the key in a signing service instead of a file: you supply the transport, and the SDK verifies every signature it returns.

Create one client per agent and keep it for the life of the process: it holds the session.

Pay on a tab

fetchWithTab wraps fetch. It sends the request, and when the merchant answers 402 with a Bonded tab offer, it checks the offer, signs the voucher and sends the request again with the voucher attached:

const response = await agent.fetchWithTab(
  'https://api.example-merchant.com/v1/search',
  { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Bonded-Line': lineId }, body: JSON.stringify({ q: 'solana rpc' }) },
  { requestKey: crypto.randomUUID(), maxCharge: '25000', maxRequestBytes: 65536 },
)
  • X-Bonded-Line names the credit line the operator opened to this merchant. The agent finds it with GET /v1/lines?vaultId=<vault>&merchantId=<merchant>, signed in with its session key; the operator also sees it in the app's Lines list.
  • requestKey identifies this request. Reuse it to retry the same request; the merchant serves the stored result instead of charging again.
  • maxCharge is the most this one request may add to the tab, in USDC base units (25000 = $0.025).
  • Before signing, the SDK checks the URL, method, network, mint, merchant, the request's digest, the counters and the charge. A merchant can't make it sign anything else.

The SDK remembers the vouchers it signed for each line. An agent that restarts and keeps tabs open supplies voucherStore and requestAuthorizationStore (load and save callbacks backed by your own database) so it picks up where it left off.

Pay from the float

The float is an allowance on the vault's USDC account, approved to the session key. The agent spends it on open-web x402 calls with its own x402 exact client: the session key signs a top-level TransferChecked from the vault's token account as its delegate. The SDK keeps the allowance topped up:

await agent.refillFloat(vaultId, 'refill-2026-10-16-01')

refillFloat books what the float spent against the window budget and tops the allowance back up to the float cap, within the budget. Anyone may call it.

Pay a listed merchant

await agent.confirm(await agent.pay({ vaultId, merchantId, amount: '1000000', nonce: '1', idempotencyKey: 'order-8812' }))
  • pay sends USDC from the vault's balance (not the float) to a merchant on the vault's list, within that merchant's caps and the window budget. If the vault has a co-signer and the amount is above its threshold, the payment needs the co-signer's approval first:

    1. await agent.paymentIntent({ vaultId, merchantId, amount, nonce }) returns its intentHash and approvalId.
    2. Send the operator the merchant, the amount and the nonce. The operator approves them under Approvals in the app.
    3. Call pay with the same values. It finds the approval itself and attaches it.

    Before the approval exists, pay fails with APPROVAL_REQUIRED; the error carries intentHash and approvalId.

Each action method returns the transaction's status once it's submitted; agent.confirm(status) waits until the network confirms it. Idempotency keys are 8 to 128 characters of letters, digits and ._:-; retry with the same key and the action is sent once.

Amounts are decimal strings in USDC base units: six decimals, so '1000000' is $1.

Pay in escrow

For a job that's delivered later, the merchant sends a signed quote. The SDK verifies it and shows you what funding it will cost before anything is signed:

const review = await agent.describeEscrow({ vaultId, merchantId, quote })
// review: principal, fee, totalDebit, intentHash, approvalId, resolveSeconds, timeoutOutcome ('refund')

const { escrowId } = await agent.confirm(
  await agent.payEscrow({ vaultId, merchantId, quote, maximumTotalDebit: review.totalDebit, idempotencyKey: 'job-0551' }),
)

// after the merchant marks it delivered:
await agent.acceptEscrow(escrowId, 'job-0551-accept')
// or, if the quote names an arbiter, within the 48-hour dispute window:
await agent.disputeEscrow(escrowId, 'job-0551-dispute')

A job whose totalDebit is above the vault's co-sign threshold needs the co-signer's approval first, as for pay: send the operator the merchant, totalDebit and intentHash, and payEscrow attaches the approval once it exists.

If the merchant doesn't mark the job delivered by its deadline, the payment refunds to the vault. An arbiter named in a quote must be in your arbiterAllowlist.

The MCP server

The MCP server gives an MCP host the same payments as tools, inside ceilings the operator sets and the model can't change. Add it to your host's configuration; for Claude Desktop or Claude Code:

{
  "mcpServers": {
    "bonded": {
      "command": "npx",
      "args": ["-y", "https://bonded.cash/packages/bonded-mcp-0.1.0.tgz"],
      "env": {
        "BONDED_API_URL": "<API>",
        "BONDED_AUTH_ORIGIN": "https://bonded.cash",
        "SOLANA_RPC_URL": "https://your-rpc.example",
        "BONDED_PROGRAM_ID": "<Program>",
        "BONDED_GENESIS_HASH": "<Genesis hash>",
        "BONDED_SESSION_KEYPAIR": "/home/agent/bonded/session.json",
        "BONDED_MCP_BUDGET_PATH": "/home/agent/bonded/budget.json",
        "BONDED_MCP_PER_CALL_CAP": "1000000",
        "BONDED_MCP_WINDOW_BUDGET": "20000000",
        "BONDED_MCP_WINDOW_SECONDS": "86400",
        "BONDED_MERCHANT_ORIGINS": "[\"https://api.example-merchant.com\"]",
        "BONDED_ARBITER_ALLOWLIST": "[]"
      }
    }
  }
}
VariableSets
BONDED_API_URLThe Bonded API: <API>.
BONDED_AUTH_ORIGINThe sign-in origin: https://bonded.cash.
SOLANA_RPC_URLYour RPC endpoint for the same network.
BONDED_PROGRAM_ID, BONDED_GENESIS_HASHThe pins, from the service's values below.
BONDED_SESSION_KEYPAIRThe session key file from solana-keygen.
BONDED_MCP_BUDGET_PATHA file where the server keeps its spending reservations across restarts.
BONDED_MCP_PER_CALL_CAPThe most one tool call may spend, in base units.
BONDED_MCP_WINDOW_BUDGET, BONDED_MCP_WINDOW_SECONDSThe most all tool calls together may spend per window.
BONDED_MERCHANT_ORIGINSThe merchant origins the fetch tool may call, as a JSON array.
BONDED_ARBITER_ALLOWLISTArbiters a quote may name, as a JSON array of public keys.
BONDED_MERCHANT_HEADERSOptional: headers to send to each merchant origin (its API key, say), as a JSON object keyed by origin.

The tools: bonded_verify_network, bonded_login, bonded_fetch_tab (give it the lineId of the line to pay on), bonded_refill_float, bonded_pay, bonded_describe_escrow, bonded_pay_escrow, bonded_escrow, bonded_accept_escrow and bonded_dispute_escrow.

The model never supplies a spending ceiling: every spending tool reserves its amount from the shared window budget before it signs, and the vault's own policy applies on chain as well. Responses from merchants come back to the model marked as merchant data; the ceilings hold whatever a response says.

Errors

SDK errors are AgentSdkError with a code:

CodeMeans
PROGRAM_MISMATCH, GENESIS_MISMATCHThe API or the RPC answered for another program or network than the pins. Nothing was signed.
SIGNER_MISMATCHThe transaction's signer isn't this session key, or the key isn't the one the line takes vouchers from yet (see A new session key).
INVALID_CHALLENGEA sign-in message or a merchant's 402 offer didn't check out.
CHARGE_EXCEEDEDThe merchant asked for more than maxCharge.
APPROVAL_REQUIREDThe payment is above the vault's co-sign threshold and its approval doesn't exist yet. The error carries intentHash and approvalId: ask the operator to approve, then send the same payment again.
INVALID_QUOTE, ARBITER_NOT_ALLOWEDAn escrow quote's signature or fields failed, or it names an arbiter you haven't allowed.
SIMULATION_REFUSEDThe program would refuse the action; the message says why.
INVALID_TRANSACTION, INVALID_INPUTA prepared transaction differed from the action, or an argument was malformed.
CONFIRMATION_TIMEOUTconfirm waited and the action is still pending. Call agent.confirm again with the same status; don't resubmit it under a new idempotency key.

API errors arrive as BondedApiError with the codes listed in the API reference.

The service's values

API—
Network—
Program—
Genesis hash—
USDC mint—

These values are published here when the program is live on Solana.

NextAPI