@curless/agentbank-sdk
v0.4.1
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.
import { createBuyerSession } from '@curless/agentbank-sdk';
const wallet = createBuyerSession({ baseUrl: 'https://mcp.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.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.
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 replaces the policy rather than merging it — an omitted field
clears that limit. Read me() first and spread if you mean to change one.
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.
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.
