Guide11 min read

Build an Agentic Ad-Buying Platform (Where Every Agent Gets a Card With an Issuer-Enforced Ceiling)

Build a multi-tenant ad-buying platform on Naïve where each client's agent gets its own funded virtual card with a hard, issuer-enforced ceiling — the spend cap the prompt can't cross.

Guide
TL;DR
  • An autonomous ad buyer has to spend real money cloud, ad networks, data, tools. The moment you let an LLM agent touch a shared corporate card, one runaway prompt can drain your float and every charge is indistinguishable from yours.
  • Naïve ships the hard part as a primitive: per-agent virtual cards issued against a KYC-verified cardholder, each with a spending_limit_cents ceiling the card network enforces at authorization time not a number you check in your own code.
  • Each client is a tenant user under an Account Kit that gates the cards primitive at execution time. Card issuance and top-up are approval-gated, so minting money or raising a cap freezes for a human.
  • A card is assigned to exactly one agent. Only the assigned agent can pull the PAN/CVC, and unassigning revokes it instantly the credential is scoped, not shared.
  • Enforcement composes and runs server-side: a kit with cards disabled makes every card call throw forbidden (primitive_disabled_by_kit); a swipe over the remaining balance is declined at the network; a prepaid card over $150 is rejected as invalid_input; and every charge is logged per client for reconciliation.
  • You write the media-buying logic. Naïve enforces who the cardholder is, how much each agent can spend, which agent holds the card, and what needs a human before funds move.

Most agent demos stop at the point where money changes hands. They can research a campaign, draft the creative, even pick the audience — then they hand a human a to-do: go enter a card number on the ad network. The autonomous part ends exactly where the spend begins.

An ad-buying platform can't stop there. Buying media is the job:

  • Cloud, data, and tooling the agent needs to run.
  • Ad spend on real networks — Google Ads, Meta, wherever the client's audience is.
  • Recurring subscriptions the campaign depends on.

So the agent has to spend real money, and that is exactly where a naive build falls apart. The moment you have more than one client, handing agents a shared corporate card means:

  • Every agent charge is indistinguishable from yours — reconciliation and per-client P&L become guesswork.
  • There is no per-agent ceiling — one runaway or prompt-injected agent can spend the entire limit before you notice.
  • There is no cardholder of record per client — high-risk merchant categories decline the card.

That plumbing — verified cardholders, per-agent cards, network-enforced spend caps — is the product. It is also exactly what Naïve ships as a primitive: cards. This is a developer tutorial: every code block runs against the current @usenaive-sdk/server, with signatures pulled straight from the docs.

Architecture: each client is a tenant user under an Account Kit that gates the cards primitive. A managed virtual card is issued against a KYC cardholder with a spending_limit_cents ceiling, assigned to one agent. The Visa/Mastercard network authorizes each swipe against the remaining balance; every charge is logged per client.

What we're building

  • A multi-tenant backend where each client gets one or more buyer agents, each holding its own virtual card with its own hard ceiling.
  • Issue — the agent asks for a card; issuance is approval-gated, so a human signs off before money is minted. Funding is a one-time checkout; from then on the agent operates inside the ceiling.
  • Scope — the card is assigned to exactly one agent. Only that agent can pull the PAN/CVC to spend; unassigning revokes it instantly.
  • Spend & prove — the agent buys at any Visa-accepting merchant, then logs every charge for per-client reconciliation.
  • The moat lever is execution-time spend authority: a ceiling the card network enforces at swipe time, a KYC cardholder of record, per-agent assignment, kit gating, and approval on issuance and top-up.

The primitives in play, all from the docs:

PrimitiveRole in the platformDocs
UsersOne tenant user per client — the isolation boundarynaive.users
Account KitsThe plan: is cards enabled, and what needs approvalnaive.accountKits
CardsPer-agent virtual cards with an issuer-enforced ceilingforUser(id).cards
KYC / VerificationThe cardholder of record behind managed virtual cards/cards/cardholder
ApprovalsHuman-in-the-loop on issuing cards and raising capsforUser(id).approvals
LogsPer-client audit trail of every chargeforUser(id).logs

The card runs on real rails — a managed virtual card on the Visa/Mastercard network, funded through a hosted checkout — so it spends anywhere your client's ad networks accept a card:

Funding is a one-time hosted checkout; the card is a managed virtual card on the Visa/Mastercard network; the agent spends at any Visa-accepting merchant, including Google Ads and Meta.

Step 0: Install and authenticate

You need a Naïve workspace key (nv_sk_...) from Studio → Settings → API keys. The SDK is server-only — keys are server secrets, never shipped to the browser.

npm install @usenaive-sdk/server
import { Naive, NaiveError, isPendingApproval } from "@usenaive-sdk/server";
 
const naive = new Naive({ apiKey: process.env.NAIVE_API_KEY! });

Two surfaces matter, and the split is the whole design:

  • naive.* (the root client) is the control planenaive.users, naive.accountKits — plus the company-level cardholder.
  • naive.forUser(id).* is the data plane (cards, approvals, logs) bound to one client.

Read naive.forUser(clientId) as: "issue and operate a card, as this client, under this client's plan." Acme's buyer agent is naive.forUser(acmeId); it has no path to Globex's cards.

Step 1: The plan — an Account Kit that gates spend

An Account Kit is a reusable policy template. Cards are a Tools primitive, so the kit decides whether a client's agent can issue cards at all — and whether issuing one or topping it up needs a human first.

// The buyer plan: cards on, but minting money and raising caps freeze for a human.
const buyerKit = await naive.accountKits.create({
  name: "Ad Buyer",
  primitives_config: {
    cards: { enabled: true, requiresApproval: true }, // create + top-up freeze
  },
});

Cards default to requiring approval for agent calls, so requiresApproval: true is the safe, explicit default here (docs). Calls on the account's own default agent profile still execute without approval — it is agent actions under a gated kit that freeze.

Step 2: Provision a client and its cardholder

Each client is a tenant user. Tenant users never sign into Naïve — you manage them entirely through the SDK.

const acme = await naive.users.create({
  external_id: "client_acme",     // your stable id for the client
  email: "ops@acme.example",
  label: "Acme Inc",
  account_kit_id: buyerKit.id,
});
 
const acmeClient = naive.forUser(acme.id);

Managed virtual cards — the full virtual Visa with no spending maximum — are issued against a cardholder, which you create once per company. This registers the identity of record for KYC compliance, and it is what makes the card accepted at real merchants (docs).

// One-time, per company. Prepaid gift cards do NOT need this step.
const res = await fetch("https://api.usenaive.ai/v1/cards/cardholder", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.NAIVE_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    firstName: "John",
    lastName: "Doe",
    email: "john@acme.com",
    billingLine1: "123 Main St",
    billingCity: "San Francisco",
    billingState: "CA",
    billingPostalCode: "94105",
    dobDay: 15,
    dobMonth: 6,
    dobYear: 1990,
  }),
});
const { cardholder } = await res.json();
Compose this with /kyc and /formation when the cardholder should be a verified principal inside a company you formed through Naïve.

Step 3: Issue a scoped card

Now the agent asks for a card. Issuance is the high-consequence moment — you are minting spending power — so cards.create is approval-gated by our kit. It returns either the created card or a PendingApproval (HTTP 202); it does not throw (errors).

const created = await acmeClient.cards.create({
  name: "Acme — Search Ads",
  spendingLimitCents: 250000, // $2,500 hard ceiling on this card
});
 
if (isPendingApproval(created)) {
  // Frozen for a human. Approving replays the action and issues the card.
  await acmeClient.approvals.approve(created.approval_id);
}

Under requiresApproval, the flow is deliberate: the agent requests the card, a human approves the budget, and only then is a card object created — carrying a checkout_url for its initial funding.

// After approval, the created card carries a checkout_url for one-time funding.
const card = await acmeClient.cards.get(created.id);
// card.status === "pending_payment" until funded

Funding is a one-time hosted checkout — open the checkout_url to load the card. After payment, issue it and confirm it went active:

await fetch(`https://api.usenaive.ai/v1/cards/${card.id}/check-payment`, {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.NAIVE_API_KEY}` },
});
// On success the card is issued automatically and status becomes "active".

The card now has a fixed ceiling. From here the agent operates inside it with no further human touch — until it wants more budget, which brings us back through approval (Step 6).

Step 4: Assign the card to one agent, then spend at execution time

A client can run several buyer agents — search, social, retargeting — each needing its own card and its own ceiling. Assignment scopes a card to exactly one agent: only the assigned agent can pull the credentials.

// Bind this card to one buyer agent.
await fetch(`https://api.usenaive.ai/v1/cards/${card.id}/assign`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.NAIVE_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ agent_id: searchBuyerAgentId }),
});

When the assigned agent is ready to place a buy, it pulls the full card credentials at the moment of the call — the PAN lives in memory only for the request, the same way you'd reveal any short-lived secret:

const details = await fetch(
  `https://api.usenaive.ai/v1/cards/${card.id}/details`,
  { headers: { Authorization: `Bearer ${process.env.NAIVE_API_KEY}` } },
).then((r) => r.json());
 
// details.number / details.cvc / details.exp_month / details.exp_year
// details.spent_cents / details.remaining_cents  ← the live ceiling
// Hand these to the ad network's billing form. The card works anywhere Visa is accepted.

Unassigning is instant revocation — remove the agent and it can no longer reach the card:

await fetch(
  `https://api.usenaive.ai/v1/cards/${card.id}/assign/${searchBuyerAgentId}`,
  { method: "DELETE", headers: { Authorization: `Bearer ${process.env.NAIVE_API_KEY}` } },
);

Step 5: Log every charge for reconciliation

The card ceiling stops overspend; the log is how you prove and reconcile spend. After every purchase, the agent records a structured transaction next to the raw network charge:

await fetch(`https://api.usenaive.ai/v1/cards/${card.id}/log-transaction`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.NAIVE_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    amount_cents: 48000,
    merchant_name: "Google Ads",
    description: "Acme Q3 search campaign — top-of-funnel",
    agent_id: searchBuyerAgentId,
  }),
});

Every card operation also emits an activity event scoped to the tenant user. Pull one client's spend history — there is no way to accidentally read another tenant's events (docs):

const { events } = await acmeClient.logs.query({ limit: 50 });
// Acme's card issuance, top-ups, and charges only — your per-client P&L, for free.

Step 6: The four walls around autonomous spend

Here is the part you can't get from "a corporate card in an env var." Four independent controls bound every dollar, and they run inside Naïve, at execution time — not as a check you remembered to write.

Four walls around autonomous spend: the kit gate returns forbidden before issuance; the issuer ceiling declines an over-limit swipe at the network; assignment scopes credentials to one agent; and create/top-up freeze for approval before funds move.

Wall 1 — the kit gate. Spend authority is a plan feature. Put a client on a kit with cards disabled and every card call fails server-side, before a card is ever created:

const suspendedKit = await naive.accountKits.create({
  name: "Suspended",
  primitives_config: { cards: { enabled: false } },
});
await naive.accountKits.assignUser(suspendedKit.id, acme.id);
 
try {
  await acmeClient.cards.create({ name: "blocked", spendingLimitCents: 10000 });
} catch (err) {
  if (err instanceof NaiveError && err.code === "forbidden") {
    console.log(err.hint); // "primitive_disabled_by_kit"
  }
}

Wall 2 — the issuer ceiling. The spending_limit_cents on the card is enforced by the card network at authorization time. A swipe that would exceed the card's remaining_cents is declined at the network — there is no "try again harder" for a misaligned agent. This is the wall the prompt cannot argue past, because your code isn't in the loop; the issuer is.

Wall 3 — assignment. Credentials are scoped to the assigned agent. An agent that isn't assigned to a card can't pull its PAN, and unassigning (Step 4) revokes access instantly. One compromised buyer agent can't reach another agent's card.

Wall 4 — the approval gate. Minting a card or raising its ceiling is approval-gated. Topping up goes through the same isPendingApproval path as issuance:

// Raising a client's budget is a human decision, not an agent decision.
const topup = await fetch(`https://api.usenaive.ai/v1/cards/${card.id}/top-up`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.NAIVE_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ amount_cents: 100000 }),
}).then((r) => r.json());
// Returns a checkout_url for the top-up payment — a human funds the raise.

The four compose:

  • Kit gate → spend authority toggled per client with one config edit, applied on the next call.
  • Issuer ceiling → the hard cap, enforced by the network even if the agent is compromised.
  • Assignment → the credential is scoped to one agent and revocable instantly.
  • Approval gate → minting money and raising caps wait for a human.

Step 7: Why identity is the real unlock

A card that merchants decline is useless to an autonomous buyer. Naïve's cards run on real rails, and that requires a real principal:

  • Managed virtual cards (provider: "managed_virtual") are full virtual Visa/Mastercard with no spending maximum, issued against the KYC cardholder you set up in Step 2. This is what an ad-buying platform runs on.
  • Prepaid gift cards (provider: "prepaid_gift", the default) need no cardholder but are capped at $150.00 — good for sandboxing a new agent or a one-off experiment with a hard cap that can't bleed into your runway.

The provider choice is also enforced. Ask for a prepaid card over the cap, or a managed card with no cardholder, and the API rejects it up front (docs):

try {
  await acmeClient.cards.create({ name: "too big", spendingLimitCents: 20000 });
  // with provider "prepaid_gift": > $150.00
} catch (err) {
  if (err instanceof NaiveError && err.code === "invalid_input") {
    console.log(err.hint); // prepaid max, or "create a cardholder first"
  }
}

That is the difference between "we let agents spend" and "an autonomous agent holds a network-accepted card, bound to a verified cardholder, with an issuer-enforced spending ceiling."

What you didn't build

Count the infrastructure you skipped — the part that is normally the whole product:

  • A card-issuing ledger and network integration — managed virtual cards on the Visa/Mastercard rails, funded through hosted checkout. (Cards.)
  • A KYC cardholder of record so cards aren't declined at real merchants. (Cardholder + /kyc.)
  • A network-enforced spend ceiling per card, declined at authorization — not a check in your code. (spending_limit_cents.)
  • Per-agent credential scoping with instant revocation. (Assignment.)
  • A human-in-the-loop gate on issuing cards and raising caps. (Approvals.)
  • A per-client audit log of every charge, ready for reconciliation. (Logs + log-transaction.)

You wrote the media-buying logic. Naïve enforced who the cardholder is, how much each agent can spend, which agent holds the card, and what needs a human before funds move — at execution time, server-side, per client.

Ship it

  • Model your clients as tenant users and give them an Ad Buyer Account Kit with cards enabled and requiresApproval on.
  • Set up the company cardholder once, then issue a managed virtual card per agent with a spendingLimitCents ceiling.
  • Assign each card to one buyer agent; pull the PAN at execution time and revoke by unassigning.
  • Trust the card network to enforce the ceiling and the kit to gate the primitive — not your prompt.

Start from the Cards guide, the Account Kits guide, and the Approvals guide. Get a workspace key in Naïve Studio and give your agents money they can't misspend.

Frequently Asked Questions
Why can't I just give my agent a corporate card number in an environment variable?+
You can, and it works right up until it doesn't. A shared corporate card means every agent charge is your charge — reconciliation is a nightmare, and there is no per-agent ceiling: a prompt-injected or misaligned agent can spend the whole limit before you notice. The fix is a card scoped to one agent with its own hard cap, issued against a verified cardholder of record so it is accepted at real merchants. Naïve ships that as the cards primitive: naive.forUser(clientId).cards.create({ spendingLimitCents }) issues a managed virtual card on the Visa network, bound to a KYC cardholder, with a ceiling the issuer enforces at swipe time. A swipe over the remaining balance is declined at the network — there is no way for the agent to try again harder.
What actually stops the agent from overspending?+
The spending_limit_cents on the card is enforced by the card issuer at authorization time, not by a check in your code path. When the agent presents the card at a merchant, the network authorizes the swipe against the card's remaining balance; anything over it is declined. That is the difference between spend controls you remember to write and spend controls that hold even when the agent is compromised. On top of the ceiling, issuing the card and topping it up are approval-gated in the Account Kit, so a human signs off before any new money is available, and the whole cards primitive can be switched off per client with one config edit.
Do I need KYC to issue cards to agents?+
For managed virtual cards — the full virtual Visa/Mastercard with no spending maximum — yes: you create a cardholder once per company, which registers the identity of record for KYC compliance, then issue cards against it. This is what makes the card accepted at real merchants and high-risk categories. If you only need small, capped experiments you can use prepaid gift cards (the default provider), which require no cardholder but are limited to $150.00 per card. Cards compose with Naïve's /kyc verification and /formation so the cardholder is a verified principal inside a formed company.
How does a card get scoped to a single agent?+
After a card is active, you assign it to an agent with POST /v1/cards/:id/assign { agent_id }. Assignment controls which agents can access the card's credentials — only the assigned agent can pull the PAN/CVC via the details endpoint. You can list assignments and remove an assignment at any time; unassigning revokes the agent's access to that card immediately. This is how a single client can run several agents (a search buyer, a social buyer, a retargeting buyer) each with its own card and its own ceiling, with no shared credential between them.
How is issuance approval-gated, and what does the agent see?+
Cards default to requiring approval for agent calls. When the agent calls cards.create, the SDK returns either the created card or a PendingApproval (HTTP 202 with status pending_approval and an approval_id) — it does not throw. You detect it with the typed isPendingApproval helper, then a human approves via approvals.approve(approval_id), which replays the frozen action and issues the card. Card top-up is gated the same way. Calls made on the account's own default agent profile execute without approval; agent calls under a gated kit freeze.
How do I keep a clean audit trail of agent spend?+
After every purchase the agent calls log-transaction with the amount, merchant name, and a free-text reason, which writes a structured record next to the raw network transaction. Every card operation and charge also emits an activity event scoped to the tenant user, so naive.forUser(clientId).logs.query({ limit }) returns that one client's spend history with no way to accidentally read another tenant's events. The structured log is what makes month-end reconciliation, per-client P&L, and spend audits work without building a logging pipeline.
NT
Naïve Team

Building the autonomous company infrastructure.

Keep reading