Appearance
Agent Patterns
This page documents patterns for building reliable agents that use the Payments for AI Agents API. Each pattern addresses a common scenario and includes implementation guidance and the reasoning behind each decision.
Pattern 1 — Balance-aware spending
Problem: An agent is about to take an action that costs money (book a flight, pay for an API call). It should not assume the card is funded.
Solution: Always check the balance before spending, and top up proactively if the available balance is less than the expected cost plus a small buffer.
typescript
async function ensureFunds(expectedCost: number, token: string): Promise<void> {
const BUFFER_EUR = 5; // small buffer to cover rounding and fx differences
const result = await getBalance(token);
if (!result.ok) {
throw new Error(`Cannot check balance: ${result.errorCode}`);
}
const available = parseFloat(result.balance);
const required = expectedCost + BUFFER_EUR;
if (available < required) {
const topupAmount = (required - available).toFixed(2);
await topUpAndConfirm(topupAmount, token); // see Pattern 3
}
}Why a buffer? EUR exchange rates and network fees mean the actual charge can exceed the quoted cost by a small margin. A €5 buffer prevents a failed payment after a successful booking.
Pattern 2 — Threshold-based auto top-up
Problem: An agent runs on a schedule and needs to maintain a minimum balance. The user has instructed: "Keep my card above €50 at all times."
Solution: Check the balance on each run. If below the threshold, top up the difference to a target amount.
typescript
const THRESHOLD_EUR = 50;
const TARGET_EUR = 100;
async function maintainBalance(token: string): Promise<string> {
const result = await getBalance(token);
if (!result.ok) {
return `Balance check failed: ${result.errorCode} — ${result.error}`;
}
const balance = parseFloat(result.balance);
if (balance >= THRESHOLD_EUR) {
return `Balance is €${result.balance} — no top-up needed.`;
}
const topupAmount = (TARGET_EUR - balance).toFixed(2);
return await topUpAndConfirm(topupAmount, token);
}Why top up to a target, not just to the threshold? Topping up to exactly the threshold means the next run will immediately trigger another top-up. A target above the threshold reduces the number of top-up operations and keeps the card funded for longer.
Pattern 3 — Safe polling after top-up
Problem: The POST /topup-request response confirms the request was accepted, but balance updates can take up to 5 minutes. Reporting success before the balance updates misleads the user.
Solution: Record the balance before the top-up, then poll until it increases or the timeout is reached.
typescript
const POLL_INTERVAL_MS = 30_000; // 30 seconds — do not poll more frequently
const POLL_TIMEOUT_MS = 5 * 60_000; // 5 minutes
async function topUpAndConfirm(amount: string, token: string): Promise<string> {
// 1. Snapshot balance before the top-up
const before = await getBalance(token);
if (!before.ok) throw new Error(`Pre-top-up balance check failed: ${before.errorCode}`);
const balanceBefore = parseFloat(before.balance);
// 2. Submit the top-up request
const topupRes = await fetch(`${BASE_URL}/topup-request`, {
method: 'POST',
headers: authHeaders(token),
body: JSON.stringify({ amount }),
});
if (!topupRes.ok) {
const err = await topupRes.json() as any;
handleTopupError(err); // see Pattern 4 and Pattern 5
}
// 3. Poll until balance increases
const deadline = Date.now() + POLL_TIMEOUT_MS;
while (Date.now() < deadline) {
await sleep(POLL_INTERVAL_MS);
const current = await getBalance(token);
if (!current.ok) continue; // transient error — keep polling
const balanceNow = parseFloat(current.balance);
if (balanceNow > balanceBefore) {
const added = (balanceNow - balanceBefore).toFixed(2);
return `Top-up complete. Added €${added}. Balance is now €${current.balance}.`;
}
}
// 4. Timeout — top-up was accepted but not yet reflected
return (
`Top-up of €${amount} was accepted and is being processed. ` +
'The balance has not updated within 5 minutes — check again shortly.'
);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}Why 30-second intervals? Polling more frequently does not speed up settlement and adds unnecessary load. The balance typically updates within 1–3 minutes; 30-second intervals mean you will detect it within the same minute it lands.
Why not fail on timeout? The top-up was accepted by the API. A timeout means the settlement is taking longer than usual, not that it failed. Telling the user it failed when it will succeed in a few minutes is worse than telling them to check back shortly.
Pattern 4 — Spending limit escalation
Problem: The agent hits AI_TOPUP_LIMIT_EXCEEDED. The user configured a cumulative spending limit, and the agent has reached it. The agent cannot reset this limit.
Solution: Surface the issue immediately to the user and suspend autonomous top-up until the user resets the limit. Do not retry.
typescript
function handleTopupError(err: { errorCode: string; error: string }): never {
if (err.errorCode === 'AI_TOPUP_LIMIT_EXCEEDED') {
// This is a user-action-required error. Do not retry.
throw new UserActionRequiredError(
'Your agent spending limit has been reached. ' +
'To continue, please reset the limit in your Holyheld dashboard under Settings → Agentic access. ' +
'Autonomous top-ups are paused until you do.'
);
}
if (err.errorCode === 'AI_TOPUP_INSUFFICIENT_BALANCE') {
// The Holyheld main account does not have enough available balance.
throw new UserActionRequiredError(
'Your Holyheld account does not have enough available balance to top up the card. ' +
'Please add funds to your Holyheld account and try again.'
);
}
// All other errors
throw new Error(`Top-up failed: ${err.errorCode} — ${err.error}`);
}
class UserActionRequiredError extends Error {
constructor(message: string) {
super(message);
this.name = 'UserActionRequiredError';
}
}Catching and routing in the agent loop:
typescript
try {
await maintainBalance(token);
} catch (err) {
if (err instanceof UserActionRequiredError) {
await notifyUser(err.message); // send notification; pause the loop
await pauseAutonomousRuns(); // stop until user acknowledges
} else {
await logError(err); // log for ops; may retry on next run
}
}Why distinguish UserActionRequiredError? Most errors are transient and can be retried. AI_TOPUP_LIMIT_EXCEEDED and AI_TOPUP_INSUFFICIENT_BALANCE are permanent until the user takes action. Retrying them wastes time and can alarm the user with repeated failure notifications.
Pattern 5 — Insufficient funds escalation
Problem: The agent tries to top up but the Holyheld main account does not have enough available balance (AI_TOPUP_INSUFFICIENT_BALANCE).
Solution: Notify the user clearly and stop retrying. The agent cannot resolve this — only the user can add funds to their Holyheld account.
This is handled by handleTopupError in Pattern 4. The key guidance:
- Tell the user exactly what they need to do (add funds to Holyheld account), not just that there was an error.
- Do not retry. Each retry will return the same error.
- Do not attempt to top up a smaller amount automatically.
Amount formatting
The amount field in POST /topup-request must be a string matching ^\d+(\.\d{1,2})?$. When you calculate an amount programmatically, always format it before sending:
typescript
// ✅ Correct — always convert to a formatted string
const topupAmount = (TARGET_EUR - currentBalance).toFixed(2);
// e.g. 47.5 → "47.50"
// ❌ Wrong — sending a raw number
const topupAmount = TARGET_EUR - currentBalance;
// e.g. 47.5 — will be rejected (must be a string)
// ❌ Wrong — more than 2 decimal places
const topupAmount = (1 / 3).toString();
// "0.3333333..." — will be rejectedNever include a currency symbol:
typescript
// ❌ Wrong
const amount = '€50.00';
// ✅ Correct
const amount = '50.00';Card data handling
GET /card-data returns full payment card details. Treat it as short-lived secret material:
- Fetch card data only when the agent is actively completing a checkout flow.
- Never log
cardNumber,CVV, or the raw response body. - Do not cache card data between runs unless your security model explicitly requires it.
- Keep the tool local when exposing it through MCP so the card data does not leave the user's machine.
typescript
async function getCheckoutCardData(token: string) {
const res = await fetch(`${BASE_URL}/card-data`, {
headers: authHeaders(token),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.errorCode}: ${data.error}`);
return data.payload;
}Idempotency — do not retry top-ups blindly
POST /topup-request is not idempotent. Each call creates a new top-up request on the blockchain. If you receive a network error and cannot determine whether the request was accepted, do not immediately retry.
Safe retry sequence:
typescript
async function safeTopup(amount: string, token: string): Promise<void> {
// 1. Record balance before attempting the top-up
const before = await getBalance(token);
if (!before.ok) throw new Error('Cannot check balance');
const balanceBefore = parseFloat(before.balance);
// 2. Attempt the top-up
let topupAccepted = false;
try {
const res = await fetch(`${BASE_URL}/topup-request`, {
method: 'POST',
headers: authHeaders(token),
body: JSON.stringify({ amount }),
});
topupAccepted = res.ok;
} catch {
// Network error — we don't know if the request was accepted
}
if (!topupAccepted) {
// 3. Check whether the balance has already moved before deciding to retry
await sleep(5_000);
const check = await getBalance(token);
if (check.ok && parseFloat(check.balance) > balanceBefore) {
// Balance already moved — do not retry
return;
}
// Balance unchanged — safe to retry once
await topUpAndConfirm(amount, token);
}
}Anti-patterns
❌ Polling immediately after top-up
typescript
// Wrong — 200ms polling ignores the async nature of settlement
while (true) {
await sleep(200);
const balance = await getBalance(token);
...
}Use 30-second intervals. The balance will not update in milliseconds, and rapid polling puts unnecessary load on the API.
❌ Assuming 200 means the balance is already updated
typescript
// Wrong — treating the accepted response as a settled balance
const res = await fetch(`${BASE_URL}/topup-request`, { ... });
if (res.ok) {
console.log('Balance topped up successfully'); // ❌ not yet
}A 200 from POST /topup-request means the request was accepted, not that the balance has been updated. Always poll to confirm.
❌ Retrying on AI_TOPUP_LIMIT_EXCEEDED or AI_TOPUP_INSUFFICIENT_BALANCE
typescript
// Wrong — both of these require user action; retrying wastes time
for (let attempt = 0; attempt < 3; attempt++) {
const res = await topup(amount, token);
if (res.errorCode === 'AI_TOPUP_LIMIT_EXCEEDED') continue; // ❌
}These errors will not resolve on their own. Notify the user and stop.
❌ Sending the amount as a number
typescript
// Wrong — the API requires a string
body: JSON.stringify({ amount: 50.00 }) // ❌ sends 50 (number)
// Correct
body: JSON.stringify({ amount: '50.00' }) // ✅ sends "50.00" (string)❌ Topping up without recording the pre-top-up balance
typescript
// Wrong — no baseline to detect when the balance has changed
await fetch(`${BASE_URL}/topup-request`, { ... });
await sleep(POLL_INTERVAL_MS);
const balance = await getBalance(token); // how do you know it changed?Always record the balance before the top-up. Without a baseline you cannot reliably detect the update, especially if the user has multiple agents or concurrent activity on the account.
Related resources
- Top Up Card — full API reference including all error codes
- Get Card Data — endpoint reference for retrieving card details safely
- Error Reference — recovery guidance for every error code
- Build an MCP Server — wrap these patterns as Claude tools
