@curless/agentbank-sdk
v0.11.0
Published
Official buyer/agent SDK for agentbank — OAuth, agent + customer management, Wallets, and the Pay spend-control plane.
Readme
@curless/agentbank-sdk
Official buyer-side SDK for agentbank — agent-commerce payments.
Two things live here:
- The buyer's wallet — a person signs in, binds a card, sets their own spend limits, and mints a one-off credential to pay a merchant with. This is what most integrators come for.
- The Pay spend-control plane — agent OAuth, agent/customer management, authorize/capture against an agent's budget.
npm install @curless/agentbank-sdkInstall this package, not
@curless/agentbank-core. Core is the shared kernel (crypto, errors, HTTP) that this package is built on; it arrives as a transitive dependency and has nothing in it you should be calling.
The buyer's wallet
The wallet is independent of any merchant. It holds the buyer's card and the buyer's limits, and it issues a credential; it never talks to a shop. The credential is what travels.
Sign in
The buyer signs in in their own browser, not in your app — verifying an email means clicking a link in an inbox, and tokenizing a real card means Stripe.js on a page. Neither fits in a chat window or a CLI. So sign-in is the RFC 8628 device flow: you get a URL, you show it, you wait.
The wallet and the gateway are two origins: createBuyerSession takes the
wallet's (https://wallet.curless.ai), while Agentbank and the merchant
clients take the gateway's (https://mcp.curless.ai).
import { createBuyerSession } from '@curless/agentbank-sdk';
// The WALLET's origin — not the gateway's. The buyer wallet is its own service
// (wallet.curless.ai); the gateway (mcp.curless.ai) serves nothing under
// /v1/buyer and answers 401 there.
const wallet = createBuyerSession({ baseUrl: 'https://wallet.curless.ai' });
const login = await wallet.startDeviceLogin();
console.log(`Open ${login.verificationUriComplete} to sign in`);
const user = await login.wait(); // resolves when they finish in the browserwait() polls for you and tells pending / slow_down / expired / denied
apart — an expired sign-in is a different sentence from one still in progress.
The device code itself never leaves the returned object: it is the bearer
credential that collects the session, so a caller who only needs a URL never
sees it.
There is also wallet.login(email, password) for a deployment configured with
a single account.
Pay for something
// The merchant priced this and opened a checkout; you have its id and total.
const credential = await wallet.payCredential({
amount: 192_000, // minor units — €1,920.00
currency: 'EUR',
merchantRef: merchantId, // checked against the buyer's own allowlist
});
// Hand credential.token to that merchant's checkout as the payment token.
// You never see a card number, and neither does the merchant.The credential is a Stripe Shared Payment Token: one seller, one currency, one maximum, fifteen minutes. It cannot be replayed against a different merchant or a larger sum.
The buyer's own limits are evaluated here, before Stripe is asked for anything. A refusal is the buyer's limit talking, not a payment failure — worth saying to them in those words:
try {
await wallet.payCredential({ amount: 192_000, currency: 'EUR', merchantRef });
} catch (err) {
if (AgentbankError.is(err) && err.status === 403) {
// "this is over the daily limit you set" — not "the payment failed"
}
}The rest of the wallet
await wallet.me(); // who this is + spendPolicy + spentToday/Month
await wallet.setLimits({ dailyLimit: 50_000, merchantAllowlist: ['sinocare'] });
await wallet.balance();
await wallet.orders({ limit: 20 });
await wallet.cards(); // { cards, unavailable? } ← see below
await wallet.bindCard('pm_card_visa');
await wallet.unbindCard('pm_123');
await wallet.enrollUnionPayCard(); // { enrollUrl, status:'pending' } ← a URL, not a card
await wallet.unionPayCards(); // { cards } ← only ones UnionPay confirmed
await wallet.setDefaultCard({ rail: 'uaag', id: 'uaagcard_1' }); // the card decides the rail
await wallet.logout(); // revokes server-side, then forgets itwallet.fetch<T>(path, init) is the escape hatch for anything not wrapped
above — it carries the session and the same 401/403 handling. Reach for a
method first: the /v1/buyer/* paths are ours to change, and the methods are
the part we keep.
UnionPay binds differently, and the difference matters to what you tell the person. bindCard takes a Stripe reference the browser already tokenised. enrollUnionPayCard() takes nothing and gives back a URL: UnionPay captures the card on their own page, with 3DS and a passkey, so no card detail ever reaches this SDK. ⚠️ The card is not bound when the promise resolves — it exists only once UnionPay's callback lands, which may be a minute later or never if the page is abandoned. Show the person enrollUrl, then read unionPayCards(); never report success from the enrol call.
One default, and the card decides how the payment is made. setDefaultCard
takes a rail with the id, because those are the same choice: a stripe card
is charged by this wallet, which mints a one-time credential against it and
enforces the buyer's own limits at that moment; a uaag card is never charged
here at all — the agent is handed the card, UnionPay authenticates the
cardholder on their page, and the merchant charges what UnionPay issues.
⚠️ So payCredential refuses outright while the default is on a rail this
wallet cannot charge, and names the way out. It does not quietly fall back to
another card: the buyer chose this one, and paying with a different card while
reporting it as theirs is the worst answer available.
id is required on uaag and optional on stripe, which is the rule rather
than an inconsistency — a UnionPay card is the default only because someone
picked it, while a cleared stripe choice falls back to the first bound card.
cards() returns unavailable for a reason. An empty cards with no
unavailable means the buyer has bound none. An empty cards with it means
we could not read them. Flatten the two and you tell someone their cards are
gone, and watch them bind another.
setLimits is a change: send only what changes, null removes a limit,
everything else stays. And it only tightens — the session belongs to an
assistant, and the limits are what stand between it and the card. Lowering or
adding a limit applies at once (applied); raising or removing one, or widening
an allowlist, does not apply at all and comes back as needsConfirmation.url,
the wallet's limits page filled in, for the person to check and save themselves.
const r = await wallet.setLimits({ dailyLimit: 90_000 });
if (r.needsConfirmation) show(r.needsConfirmation.url); // nothing changed yetEvery wallet request has a deadline (createBuyerSession({ baseUrl, timeoutMs }),
30 s by default): a stalled wallet fails with status === 0, code timeout,
rather than hanging like a payment still in flight.
Which card pays this one
import { cardChoice, isUnionPayCard } from '@curless/agentbank-sdk';
const choice = await cardChoice(wallet); // { cards: [{ id, label, charges }], selectedId }
// show choice.cards with choice.selectedId preselected, let the person pick, then:
if (isUnionPayCard(choice, pickedId)) {
// UnionPay: the merchant charges — see @curless/agentbank-protocols/uaag
} else {
await wallet.payCredential({ amount, currency, merchantRef, cardId: pickedId });
}The preselection is the buyer's default, resolved the way the payer resolves it —
never "the first card we managed to read". Picking a different card here does not
change the default. charges: false marks a card the wallet does not charge (UnionPay):
the button above it must not promise a charge. Structural types only, so any object with
me / cards / unionPayCards works.
Session state
- Only a 401 ends the session. A 403 does not: hitting a limit you set yourself must not sign you out, or you cannot reach the session you would need to raise it.
wallet.current()returns the signed-in user ornull, no round-trip.- Calls made while signed out throw with code
buyer_not_logged_in; calls made after expiry throwbuyer_session_expired. They are deliberately different strings, because "your session expired" rendered as "you have no orders" is the same bug twice.
Which checkout a request reaches
A buyer client that opens merchant checkouts sends an idempotency key with each open. One rule for all of them: key = H(buyer secret, cart, attempt).
import { CheckoutBook, buyerSecret, canonicalLines, openCheckoutFor } from '@curless/agentbank-sdk';
const secret = await buyerSecret(SERVER_KEY, sessionId); // stable per buyer, unguessable
const book = new CheckoutBook<{ checkoutId: string; status: string }>(); // ≤ 20 carts, 24 h idle
const lines = canonicalLines(cart.lines, shopifyIdTail); // merged, sorted, ids normalised
const checkout = await openCheckoutFor({
book,
secret,
lines,
open: (idempotencyKey) => shop.openCheckout({ lines: cart.lines, idempotencyKey }),
isOver: (c) => c.status !== 'open', // paid / expired / canceled
});- The same request reaches the same open checkout (a double tap, a host re-firing a hung call).
book.retire(lines)once a checkout is over: the next purchase is a new one. The checkout stays findable bybook.get(checkoutId)(the lastkeep, 50 by default) — to show its receipt again, or to tell a checkout this buyer opened from one it never did. A replayed checkout that comes back already over (the book lives in memory; after a restart the attempt is 0 again) is retired and opened once more.- A tool that opens AND pays in one call uses
book.settled(lines)instead: the paid checkout stays the answer forreplayMs(60 s), so a re-fired call cannot open and pay a second one. - A client that cannot see a checkout's status on the open passes
offerMsbelow the shop's checkout lifetime: an unpaid checkout older than that is not keyed on again.
The Pay spend-control plane
Admin (manage agents, fund, approve)
import { Agentbank } from '@curless/agentbank-sdk';
const ab = new Agentbank({
baseUrl: 'https://mcp.curless.ai',
apiKey: 'agb_admin_...', // an agentbank:admin / pay:admin key
});
const agent = await ab.agents.create({
name: 'procurement-bot',
spendPolicy: { perTransactionLimit: 50_00, dailyLimit: 500_00, approvalRequiredAbove: 100_00 },
});
await ab.pay.deposit({ amount: 1000_00, currency: 'USD' });
await ab.pay.fundAgent(agent.id, { amount: 500_00, currency: 'USD' });
const cred = await ab.agents.issueCredential(agent.id); // cred.secret shown onceAgent (spend, with auto-managed token)
const agentClient = Agentbank.withClientCredentials({
baseUrl: 'https://mcp.curless.ai',
clientSecret: cred.secret,
});
const auth = await agentClient.pay.authorize({
agentId: agent.id,
merchantRef: 'acme.example',
amount: 12_00,
idempotencyKey: 'order-123',
});
// auth.status: 'authorized' | 'pending_approval' | 'denied'
if (auth.status === 'authorized') await agentClient.pay.capture(auth.id);SpendPolicy is one type across both halves — an agent's budget and a buyer's
wallet limits are the same shape, evaluated by the same code on the server.
Notes
- Amounts are minor-unit integers (USD = cents, EUR = cents).
- Never send a card number. Cards are bound by Stripe reference (
pm_…/tok_…); a PAN reaching a server is a compliance incident, and this API refuses one rather than storing it. - Errors throw
AgentbankErrorwith.status+.code— including transport failures:status === 0with codetimeout/aborted/network_errormeans no HTTP response happened. One catch type. UseAgentbankError.is(err), notinstanceof, so the guard survives two copies of the package in one dependency tree. - Timeouts: every request has a 30s deadline by default. Tune per client
(
new Agentbank({ ..., timeoutMs })) or per request (RequestOptions.timeoutMs;0disables).RequestOptions.signalaccepts anAbortSignalfor caller-side cancellation. - ESM-only; Node ≥ 18 (uses global
fetch). - Pass
fetchin the constructor to inject a custom implementation (tests, proxies).
Prefer not to write code?
npx -y @curless/agentbank-mcp is this wallet as an MCP server — ten tools,
no configuration, the buyer signs in at runtime. Same session, same limits.
