Give your messaging agent a wallet: integrating Agentcard into a Photon app
AI agents are getting good at deciding what to buy. The missing piece is letting them actually pay, safely, on each user's own account. Agentcard fixes that: it issues virtual cards to your users' agents, with real spend limits, so an agent can check out without you ever touching a raw card number.
This guide walks through wiring Agentcard into a Photon app — a Claude agent that lives on iMessage. By the end, a user will be able to text the agent "make me a card" and receive a real virtual card issued on their own Agentcard account. The three parts you'll implement — client registration, per-user OAuth, and MCP card issuance — are the same for any Agentcard company integration: Slack bot, web app, or voice agent. Only the messaging surface changes.
Prerequisites
Before you start:
- An Agentcard developer account, to register an OAuth client
- A Photon app with a TypeScript message handler
- The following npm packages installed:
@ai-sdk/anthropic,@ai-sdk/mcp,ai - A publicly reachable URL for your OAuth callback (a tunnel is enough for local development — see Part 2)
How it works
Every Agentcard company integration has the same three parts:
- A client. Your app registers one OAuth client with Agentcard.
- User auth. Each end user authorizes your app with "Connect with Agentcard" (OAuth 2.1 with PKCE). You get a token scoped to that user.
- Card issuance. Your agent calls the Agentcard MCP server with that user's token to create and manage cards.
Cards a user creates through your app are isolated to your client: you can see and manage the cards you issued for them, not their personal cards or another app's.
Part 1: Register your client
Agentcard's OAuth server supports dynamic client registration, so you can get a client ID with one call, no dashboard required:
curl -X POST https://mcp.agentcard.sh/register \
-H 'Content-Type: application/json' \
-d '{
"client_name": "My Photon Agent",
"redirect_uris": ["https://your-app.example.com/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"]
}'
# -> { "client_id": "..." }That gives you a public client (PKCE only, no secret), which is the right choice for distributed apps. If your app runs on a server that can safely hold a secret, create a confidential client instead and authenticate at the token endpoint with a client_secret:
agent-cards-admin oauth-clients create \
--name "My Photon Agent" \
--redirect-uri "https://your-app.example.com/callback"
# -> Client ID + Client Secret (shown once)Store these as AGENTCARD_OAUTH_CLIENT_ID and, for confidential clients, AGENTCARD_OAUTH_CLIENT_SECRET.
Tip: Before you write any code, fetch the discovery document. It is the source of truth for every endpoint and the supported auth methods:
curl https://mcp.agentcard.sh/.well-known/oauth-authorization-serverYou now have a client ID. In Part 2, you'll implement the per-user authorization flow.
Part 2: Connect with Agentcard
When a user first asks for a card, you send them a link to log into their Agentcard account. The flow is standard OAuth 2.1 with PKCE. Start by generating the PKCE values and building the authorization URL:
import { createHash, randomBytes } from "node:crypto";
const RESOURCE = "https://mcp.agentcard.sh/mcp"; // required on authorize and token
const b64url = (b: Buffer) =>
b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
// PKCE
const verifier = b64url(randomBytes(32));
const challenge = b64url(createHash("sha256").update(verifier).digest());
// The link you send the user
const authorizeUrl =
"https://mcp.agentcard.sh/authorize?" +
new URLSearchParams({
response_type: "code",
client_id: process.env.AGENTCARD_OAUTH_CLIENT_ID!,
redirect_uri: "https://your-app.example.com/callback",
code_challenge: challenge,
code_challenge_method: "S256",
resource: RESOURCE,
state: "<random csrf token>",
});The user clicks the link, verifies by email, and consents. Agentcard redirects to your /callback with a code. Exchange it for tokens in your callback handler:
const res = await fetch("https://mcp.agentcard.sh/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: "https://your-app.example.com/callback",
client_id: process.env.AGENTCARD_OAUTH_CLIENT_ID!,
code_verifier: verifier,
resource: RESOURCE,
// client_secret: process.env.AGENTCARD_OAUTH_CLIENT_SECRET // confidential only
}),
});
const { access_token, refresh_token } = await res.json();Store that token keyed by the user — in Photon, the conversation ID. That is the credential your agent will use on their behalf. Access tokens last about a day; refresh lazily with grant_type=refresh_token and persist the rotated refresh token you get back.
Getting a public callback URL: In a messaging app, your /callback must be reachable from the user's phone, so the small server that handles /connect and /callback needs a public URL. For local development, a tunnel is enough:
cloudflared tunnel --url http://localhost:8910
# -> https://something.trycloudflare.com (use this as your redirect base)You now have tokens stored per user. In Part 3, you'll use them to issue cards.
Part 3: Issue cards through the MCP server
With a user's token in hand, your agent talks to the Agentcard MCP server. The Vercel AI SDK has a native MCP client that converts the server's tools into model tools automatically, so the model can call create_card, list_cards, get_card_details, and more without any manual wiring:
import { anthropic } from "@ai-sdk/anthropic";
import { createMCPClient } from "@ai-sdk/mcp";
import { generateText, stepCountIs } from "ai";
const mcp = await createMCPClient({
transport: {
type: "http",
url: "https://mcp.agentcard.sh/mcp",
headers: { Authorization: `Bearer ${userAccessToken}` },
},
});
const tools = await mcp.tools(); // create_card, list_cards, get_card_details, ...
const result = await generateText({
model: anthropic("claude-opus-4-8"),
system: "You can manage the user's Agentcard virtual cards. Default new cards to $1.00.",
messages,
tools,
stopWhen: stepCountIs(8), // multi-step: create_card -> approve -> reply
});When a user says "make me a $5 card," the model calls create_card({ amount_cents: 500 }), the card is issued on their account, and the agent texts back the last four digits.
You now have all three parts. Part 4 shows how they fit together in Photon's message loop.
Part 4: Wire it into Photon
Photon gives you a unified message loop. The agent's logic per message is straightforward: check whether the user has connected, and branch accordingly:
for await (const [space, message] of app.messages) {
const token = tokenStore.get(space.id);
if (!token) {
// not connected yet: hand them the link
await message.reply(`Connect your Agentcard to get started: ${connectLink(space.id)}`);
continue;
}
// connected: let the model use the card tools with their token
const reply = await runAgent(space.id, message.content.text, token);
await message.reply(reply);
}tokenStore, connectLink, and runAgent are app-specific helpers: your token store holds the per-user tokens from Part 2, connectLink builds the authorization URL from Part 2 for a given conversation, and runAgent calls the MCP agent from Part 3.
No connection → send the link. Connected → expose the tools. That is the entire control flow.
Known pitfalls
These are the details that separate "compiles" from "works in production":
- The
resourceparameter is mandatory on both/authorizeand/token. It is the MCP server URL,https://mcp.agentcard.sh/mcp. Omit it and the request is rejected. - Confidential clients need the secret on refresh too, not just the first exchange. Miss it and users get a 401 about a day after connecting, when the first refresh runs. Send
client_secreton both calls. - Approval gates. Creating a card or revealing full card details can return an approval requirement with an
approval_id. Resolve it withapprove_request. Auto-approve creation if the user asked for it, but consider asking before revealing a full card number. - Card isolation is a feature, not a bug. A brand-new client sees none of the user's existing cards.
list_cardsreturning empty right after connecting is correct: your app only sees the cards it issues. - Never log full card numbers. Card details responses contain the PAN and CVV. Keep them out of your logs, because they are live payment credentials.
Test end to end
Don't trust "no errors." Prove the full round-trip works:
- Stand up the tunnel, register the client, and set your env vars.
- Text the agent, tap the connect link, and log in. You should see a "connected" page.
- Ask for a card. Confirm a real card is issued.
- Independently verify by calling
list_cardsandget_planwith the user's token. The card should be there, on the right account.
When that round-trip completes, you have a messaging agent that can pay on each user's own account, with real limits and full isolation.
Next steps
Give your agent a wallet. Your users will do the rest.