Skip to main content

Run an Agent on Card Data

Build an agent that answers questions about a customer's saved card without the card ever reaching your application or the model.

In this guide, you create a Reactor that answers a customer's question about their saved card, such as whether it can pay for an order. Your application sends the question and a token expression for the card. Basis Theory replaces the expression with the card inside the Reactor, your code summarizes the card, and the model answers from that summary. Neither your application nor the model receives the card number or security code.

You need Node.js to install the Basis Theory CLI, and an API key for a model provider with an OpenAI-compatible API. The examples use OpenRouter; to use another provider, change the values in the Reactor's configuration.

Getting Started​

To get started, you will need to create a Basis Theory Account and a TEST Tenant.

Be sure to use your work email (e.g., john.doe@yourcompany.com)

Provisioning Resources​

Management Application​

The CLI uses a Management Application to create the Reactor.

You will need a Management Application to provision resources. Click here to create one using the Basis Theory Customer Portal.

This will create an application with the following Access Controls:

  • Permissions: reactor:create, reactor:read
Save the key from the created Management Application as it will be used later in this guide.

Private Application​

Your application uses a Private Application to create a test card token and invoke the Reactor.

You will need a Private Application to allow your backend to call Basis Theory APIs. Click here to create one using the Basis Theory Customer Portal.

This will create an application with the following Access Controls:

  • Permissions: token:create, reactor:invoke
Save the key from the created Private Application as it will be used later in this guide.

Create a Card Token​

In production, your application already holds card tokens, for example from Elements or inbound proxies. For this guide, create one with a test card using the Create Token API:

Create a Card Token
curl 'https://api.test.basistheory.com/tokens' \
-X POST \
-H 'BT-API-KEY: <PRIVATE_API_KEY>' \
-H 'Content-Type: application/json' \
--data '{
"type": "card",
"data": {
"number": "4242424242424242",
"expiration_month": 12,
"expiration_year": 2030,
"cvc": "123"
}
}'
Response
{
"id": "1766865a-623c-461f-9512-26e49dda29a6",
"type": "card",
"tenant_id": "77cb0024-123c-4c7f-8f7d-0c1b3f3f6f2a",
"data": {
"number": "XXXXXXXXXXXX4242",
"expiration_month": 12,
"expiration_year": 2030
},
"card": {
"bin": "42424242",
"last4": "4242",
"expiration_month": 12,
"expiration_year": 2030,
"brand": "visa",
"funding": "credit"
},
"containers": [
"/pci/high/"
],
"created_at": "2026-10-01T18:15:51.9121194+00:00"
}

Save the token id. You pass it to the Reactor in a token expression.

Write the Agent​

The Reactor receives the request body as event.req. The caller sends the card as {{ token: <CARD_TOKEN_ID> }}, so event.req.card holds the full token: the card number, expiration, and security code in data, and the card details, such as brand and last four digits, in card. The summarizeCard function reduces it to the facts the agent reasons about, and that summary is all the model sees. One of those facts, security_code_on_file, comes from the card data: Basis Theory deletes a card's security code one hour after it is collected by default, so a checkout that requires it may need to ask the customer again.

Pick a framework. Each tab contains the complete Reactor code and the dependencies to install with it.

reactor.js
module.exports = async function (event) {
const { generateText } = await import("ai");
const { createOpenAICompatible } = await import("@ai-sdk/openai-compatible");
const { req, configuration } = event;

const provider = createOpenAICompatible({
name: "model-provider",
baseURL: configuration.MODEL_BASE_URL,
apiKey: configuration.MODEL_API_KEY,
});

const { text } = await generateText({
model: provider.chatModel(configuration.MODEL),
maxOutputTokens: 500,
system:
"You answer questions about a customer's saved card in one or two sentences. Use only the card summary provided.",
prompt: `Card summary: ${JSON.stringify(summarizeCard(req.card))}\n\nQuestion: ${req.question}`,
});

return { res: { statusCode: 200, body: { answer: text } } };
};

// Derive the facts the model needs. The card number and security code never leave this function.
function summarizeCard(token) {
const { brand, funding, last4, expiration_month, expiration_year } = token.card;
const now = new Date();
const expired =
expiration_year < now.getUTCFullYear() ||
(expiration_year === now.getUTCFullYear() &&
expiration_month < now.getUTCMonth() + 1);

return {
brand,
funding,
last4,
expiration: `${String(expiration_month).padStart(2, "0")}/${expiration_year}`,
expired,
security_code_on_file: Boolean(token.data.cvc),
};
}
package.json
{
"dependencies": {
"@ai-sdk/openai-compatible": "3.0.62",
"ai": "7.0.126",
"zod": "4.6.5"
}
}

Put the model provider settings in a .env file. The CLI stores them in the Reactor's encrypted configuration, and the code reads them from event.configuration.

.env
MODEL_BASE_URL=https://openrouter.ai/api/v1
MODEL_API_KEY=<MODEL_API_KEY>
MODEL=openai/gpt-5.4-mini

Create the Reactor​

Install the Basis Theory CLI, point it at your test tenant, and create the Reactor with the Node.js runtime. The --timeout 30 flag gives the agent the maximum time a synchronous Reactor allows.

Create the Reactor
npm install -g @basis-theory-labs/cli
export BT_MANAGEMENT_KEY=<MANAGEMENT_API_KEY>
export BT_API_BASE_URL=https://api.test.basistheory.com

bt reactors create \
--name "Card Assistant" \
--code ./reactor.js \
--package-json ./package.json \
--configuration ./.env \
--image node24 \
--timeout 30 \
--no-async \
--warm-concurrency 0 \
--resources standard

When the CLI asks for permissions, press Enter. This Reactor does not call Basis Theory APIs, so it needs none. The CLI waits while Basis Theory installs the dependencies and scans them for vulnerabilities, which takes a few minutes, then prints the Reactor id:

CLI Output
Reactor created successfully!
id: 99c67603-7c3d-47a2-9dc0-b4765509e713

Save the Reactor id.

Invoke the Agent​

Invoke the Reactor using the Invoke Reactor API with your Private Application key. Send the question and the card as a token expression, using the token id from Create a Card Token:

Invoke the Reactor
curl 'https://api.test.basistheory.com/reactors/<REACTOR_ID>/react' \
-X POST \
-H 'BT-API-KEY: <PRIVATE_API_KEY>' \
-H 'Content-Type: application/json' \
--data '{
"question": "Can the customer use this card for a $250.00 order from a merchant that accepts Visa and Mastercard?",
"card": "{{ token: <CARD_TOKEN_ID> }}"
}'
Response
{
"answer": "Yes — this Visa credit card should be usable for a $250.00 order, since it’s not expired and the merchant accepts Visa."
}

In testing, the first invocation of each example after provisioning took 8 to 27 seconds, most of it spent loading the framework, and later invocations took about 2 seconds. Leave room for that cold start within the 30-second limit; Choose an Agent Framework covers ways to reduce it.

How Token Expressions Reach the Reactor​

Before your code runs, Basis Theory finds every token expression in the request body, checks that the calling application may use the token, and replaces the expression with the token's data. Your code reads the result from event.req. What it receives depends on the expression:

Expressionevent.req.card receives
{{ token: <CARD_TOKEN_ID> }}The full token, including id, data (card number, expiration, and security code), and card (brand, funding, last four, and other card details)
{{ token: <CARD_TOKEN_ID> | json: "$.data" }}Only the card data: number, expiration_month, expiration_year, and cvc
{{ token: <CARD_TOKEN_ID> | json: "$.card" }}Only the card details, without the card number or security code

Send the narrowest expression the task allows. If your code never reads the card data, json: "$.card" keeps the card number and security code out of the Reactor entirely.

The calling application's permissions decide which tokens it can send. An application with reactor:invoke can send any token in the tenant. To restrict a caller to the tokens it handles, create its application with an access rule that grants token:use on those containers instead. When an expression references a token the caller cannot use, or one that does not exist, Basis Theory rejects the request before your code runs:

Response
{
"title": "Invalid reactor args",
"status": 400,
"detail": "Failed to detokenize some tokens: 00000000-0000-4000-8000-000000000000. Tokens were not found."
}

Give the Model Narrow Tools​

When the model should decide what it needs to know, give it tools instead of putting the summary in the prompt. Each tool runs in the Reactor, closes over the card, and returns a derived fact. The model can ask for the card summary or for a yes-or-no check against the brands a merchant accepts, but no tool returns the card number, so nothing the model is told to do can reveal it.

This Vercel AI SDK example uses the same package.json and .env as the Vercel AI SDK tab above. Other frameworks define tools the same way, with a schema for the input and a function that runs in the Reactor.

reactor.js
module.exports = async function (event) {
const { generateText, stepCountIs, tool } = await import("ai");
const { createOpenAICompatible } = await import("@ai-sdk/openai-compatible");
const { z } = await import("zod");
const { req, configuration } = event;

const provider = createOpenAICompatible({
name: "model-provider",
baseURL: configuration.MODEL_BASE_URL,
apiKey: configuration.MODEL_API_KEY,
});

// Each tool closes over the card and returns derived facts, never the card data itself.
const tools = {
get_card_summary: tool({
description: "Get the brand, funding type, last four digits, expiration, and whether the security code is on file for the saved card.",
inputSchema: z.object({}),
execute: async () => summarizeCard(req.card),
}),
check_card_accepted: tool({
description: "Check whether the saved card can pay a merchant that accepts the given brands.",
inputSchema: z.object({
accepted_brands: z.array(z.enum(["visa", "mastercard", "american-express", "discover"])),
}),
execute: async ({ accepted_brands }) => {
const { brand, expired } = summarizeCard(req.card);
if (expired) return { accepted: false, reason: "card_expired" };
if (!accepted_brands.includes(brand)) return { accepted: false, reason: "brand_not_accepted" };
return { accepted: true };
},
}),
};

const result = await generateText({
model: provider.chatModel(configuration.MODEL),
maxOutputTokens: 500,
system: "You help a customer pay with their saved card. Use the tools, then answer in one or two sentences.",
prompt: req.message,
tools,
stopWhen: stepCountIs(4),
});

return {
res: {
statusCode: 200,
body: {
answer: result.text,
tools_called: result.steps.flatMap((step) => step.toolCalls.map((call) => call.toolName)),
},
},
};
};

function summarizeCard(token) {
const { brand, funding, last4, expiration_month, expiration_year } = token.card;
const now = new Date();
const expired =
expiration_year < now.getUTCFullYear() ||
(expiration_year === now.getUTCFullYear() &&
expiration_month < now.getUTCMonth() + 1);

return {
brand,
funding,
last4,
expiration: `${String(expiration_month).padStart(2, "0")}/${expiration_year}`,
expired,
security_code_on_file: Boolean(token.data.cvc),
};
}

Create it with the same bt reactors create command, then invoke it with a message:

Invoke the Reactor
curl 'https://api.test.basistheory.com/reactors/<REACTOR_ID>/react' \
-X POST \
-H 'BT-API-KEY: <PRIVATE_API_KEY>' \
-H 'Content-Type: application/json' \
--data '{
"message": "The merchant accepts Mastercard and American Express. Can I pay with my saved card?",
"card": "{{ token: <CARD_TOKEN_ID> }}"
}'
Response
{
"answer": "No — your saved card isn’t accepted by this merchant. The merchant accepts Mastercard and American Express, but your card’s brand doesn’t match those options.",
"tools_called": [
"check_card_accepted"
]
}

tools_called records which tools the model used, which you can store for auditing. stopWhen: stepCountIs(4) caps the loop so the agent finishes within the Reactor timeout.

Next Steps​

  • Agents and Browsers gives an agent in a Reactor a cloud browser session and the tools to complete a checkout with the card.
  • Choose an Agent Framework lists the runtime limits to check before you bring another framework.