Skip to content

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 rejected

Never 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