Skip to main content
This guide takes the iMessage agent from Create an iMessage agent and connect the Vault and teaches it to buy things. The user texts what they want; the agent finds it at Amazon, Walmart, Target, Best Buy, DoorDash or another merchant through Agentcard’s Purchase API, shows the cart, and places the order when the user says so, paying with the card they stored in the Vault. The first guide covered the plumbing: Linq, Vercel eve, Redis, Agentcard credentials and webhooks. This one is about the code that makes the agent shop, and every part of it is in the repo you copy. What you end up with: a phone number where a user texts “buy me a 20 oz bag of whole bean coffee from Amazon”, gets a product, a picture and a price back within a minute, says yes, taps an approval on their phone, and reads “Order placed” in the thread twenty seconds later, with nothing else to type.

Before you start

Testing this end to end needs production credentials. The sandbox stops at the confirm (sandbox_mode): no approval link, no webhook, no order. See Going to production.
Finish the first guide. Everything it set up is reused here: the Vercel account and CLI, the Linq sandbox, the Agentcard organization and its client_id / client_secret, the Redis database. This agent is that agent plus a shopping loop.

Tools

linq

Linq

agentcard

Agentcard Purchase API

vercel

Vercel eve

How it works

A purchase runs as one user, so the agent needs a token for that user. The Vault link from the first guide is the sign-up: the user stores a card with a passkey, and the agent exchanges the linked session for the user’s connection tokens. No code, no account. Shopping is then a loop on one endpoint, POST /buy: an ask builds or refines a cart, a confirm with the cart’s hash places it. The user’s vaulted card pays, after an approval they tap on their phone, and a webhook tells the agent the moment they did. In the sandbox everything runs against the real merchant up to the confirm, which is declined with decline_code: "sandbox_mode" by design; the last two rows of the table only happen in production.

1. Copy the blueprint and deploy it

The repo is tiny-agent-company/purchase-imessage-agent, created from the first guide’s repo as a template.
Deploy it exactly as in the first guide (credentials, Vercel, Linq, Agentcard webhook), with three differences:
  • Two more variables: AGENTCARD_MODE=sandbox (the connect code is 111111, confirms end in sandbox_mode) and STORE_PREFIX=purchase (several agents can share one Redis).
  • The Agentcard webhook endpoint subscribes to the approval events too: ["vault.card_stored", "vault.session_linked", "checkout_authorization.approved", "checkout_authorization.declined", "checkout_authorization.expired"]. One endpoint per deployment.
  • One Linq sandbox line serves one agent at a time (Linq issues one sandbox per phone number and per email domain). To run this agent on the first guide’s line, move its subscription instead of creating one; the signing secret stays the same, so the same LINQ_WEBHOOK_SECRET goes on this project:
GET /api/partner/v3/webhook-subscriptions lists your subscriptions with their ids. The rest of this guide walks through the new code, in the order a purchase runs.

2. Connect users without login

Every /buy call runs as the user, with a user token. The first guide’s open Vault link already makes the user prove themselves on the vault page and store a card; POST /api/v2/vault_sessions/{id}/exchange turns that finished link into the user’s connection tokens, once, with the organization token. The user never receives a code and never creates an account. The order of events, all in agent/channels/agentcard.ts:
  1. A new user texts. buy returns not_connected, the agent calls create_vault_link, and the tool creates an open session (POST /api/v2/vault_sessions with {}), remembers vault session → eve session, and texts the url alone.
  2. The user stores a card. Agentcard delivers vault.session_linked (and, seconds later, vault.card_stored) with the vault_session_id.
  3. The webhook handler in agent/channels/agentcard.ts exchanges the session and stores the pair under the phone:
That is connectFromVaultSession in agent/lib/user.ts. The response is single use: one session, one pair, and a second call is refused with 410 already_exchanged, so the handler marks the session in Redis before calling and lets the two link events race for it. From then on userToken(phone) hands every tool a live token and rotates it through POST /api/v2/connect/refresh when the access token is within a minute of expiring; the exchange is never called again for that user. The [Agentcard] message the webhook sends into the conversation says whether the exchange connected the user, so the agent’s next sentence is right: “Your card is set up, want me to place it?”

When the user already has an Agentcard account

The link proves access to the vault, not to the account. A user who opens the link and signs in to an Agentcard account that already existed gets 403 account_verification_required on the exchange, and the code flow is the way in: connect/start texts a six-digit code to the number the conversation is with, connect/verify exchanges it for the same token pair, connect/consent records the consent. agent/tools/connect_user.ts:
agent/tools/verify_code.ts finishes it with connect/verify and saveConnection. The handler’s [Agentcard] message names this case, and the instructions tell the agent to call connect_user only then. In the sandbox no text is sent and the code is always 111111; on real phones some carriers drop the text, so connect_user accepts an email too. The fallback only: the code Agentcard texts from its own number to a user who already had an account The fallback only: the code Agentcard texts from its own number to a user who already had an account

3. Shop with /buy as the user

The whole shopping loop is one call, made with the user’s token rather than the organization’s. agent/tools/buy.ts:
A turn can take up to two minutes while the Purchase API works the merchant, so agentcardAs uses a 120-second timeout. The response’s status drives the agent: needs_input with a reply to relay and maybe a cart, order_placed with an order_id, or declined with a decline_code. A 409 means the cart moved since that hash was issued; the body carries the fresh cart, and the tool returns it as cart_changed so the agent shows it again instead of retrying blind. The tool hands the model a small, typed view of the response rather than the raw JSON:
Every product in catalog.items carries the merchant’s identifier as id and its photo as image_url. For the retail merchants the id is the product page URL (for Amazon, the /dp/ URL); for DoorDash it is an opaque item id, so the tool passes it on as url only when it starts with https:// and never sends anything else as a link. catalog stays on the conversation for fifteen minutes after the search and is null after that, so the tool keeps the last one per conversation in Redis for a day: the agent still needs the links and pictures when the user asks “which one was that?” the next morning. See the catalog object for the fields. iMessage renders a URL that stands alone in a message as a preview card, and mangles one with words glued to it. So the model never sees a URL: the tools send them. agent/tools/send_link.ts texts a product url from a tool result as its own bubble, and refuses anything that did not come from one. Pictures are a Linq media part with the image’s https URL; Linq fetches it and delivers a photo, no upload step. agent/lib/linq.ts:
That is all send_image does with a product’s image_url. A text part and a media part can share one message, but the blueprint sends the picture alone so the agent’s sentence never gets attached to it. For an image you hold as bytes rather than a URL, create an attachment first (POST /api/partner/v3/attachments returns an upload_url and an attachment_id) and send { "type": "media", "attachment_id": "…" } instead. send_link and send_image: the merchant page as its own bubble, the photo as its own message, then the agent's sentence send_link and send_image: the merchant page as its own bubble, the photo as its own message, then the agent’s sentence A returning user already has a card, and asking them to store another is the fastest way to lose them. For a connected user, before any Vault link, the agent lists their cards, as Checking for stored cards describes. agent/tools/list_cards.ts:
create_vault_link does the same check itself and refuses to send a link to a user who has cards, unless the agent passes force because the user asked to add one. When it does send one, the Vault session is bound to the connected user, so the card lands on the account /buy runs as:
For a user who is not connected yet there is nothing to list: the open session (no user_id) is the sign-up itself, and step 2 turns it into the connection. Vault → Stored cards in the dashboard: the same list list_cards reads per user Vault → Stored cards in the dashboard: the same list list_cards reads per user

6. Place the order and learn of the approval

The confirm is where the two halves meet. buy with confirm and payment_source: "vault" does not place anything yet: it comes back declined with decline_code: "vault_approval_required" and an approval_url. The tool texts that URL alone, and remembers which conversation is waiting on it:
The user taps the link and approves with Face ID or Touch ID. Two things happen at once on Agentcard’s side: the order is placed against the approval, and checkout_authorization.approved is delivered to the same webhook endpoint that receives the Vault events:
agent/channels/agentcard.ts looks the authorization up and wakes the paused conversation, the same attachSession(...).send the first guide uses for a stored card:
The note is a plain instruction the model acts on without asking the user anything: for approved, “call buy now with confirm <hash> on conversation <id>, then tell them the result”; declined and expired become one sentence to the user. The amount on the event is the approval ceiling, not necessarily the charge: tax and shipping settle later, and order.placed carries the final figure. The confirm, the approval link alone in its own bubble, and the order reported after the webhook, with nothing typed in between The confirm, the approval link alone in its own bubble, and the order reported after the webhook, with nothing typed in between Settings → Developers → Webhooks → Deliveries: checkout_authorization.approved delivered to the agent, 200 Settings → Developers → Webhooks → Deliveries: checkout_authorization.approved delivered to the agent, 200 Two details make this path reliable: The confirm may arrive while Agentcard is still placing. The approval starts the placement on Agentcard’s side, and the agent’s own confirm a second later can find that placement in flight. That confirm comes back with decline_code: "in_progress": not a failure, a “wait”. The tool waits, reads the conversation back, and reports the order that belongs to this checkout (last_checkout.order_id; orders are listed oldest first, so the first entry may be an earlier purchase):
A turn started by a webhook has no phone number. The Linq channel puts the sender’s number in the turn’s auth context; a turn that a webhook woke has no Linq auth at all, so phoneOf(ctx) would find nothing. agent/lib/user.ts remembers the phone per eve session on every text and reads it back on those turns:
Without it, the first thing the agent does after the approval, the confirm, fails on the one turn that matters.

7. Text it

From the phone you activated the Linq sandbox with:
The agent connects you first (sandbox: the code is 111111), then names a product and a price within about thirty seconds. Ask for a picture and the photo arrives as its own message. Say yes, and it shows the cart with the total and your address, then asks to place it. Connect by code, then the Purchase API finds the product and builds the cart Connect by code, then the Purchase API finds the product and builds the cart In the sandbox the confirm comes back sandbox_mode and the agent explains that the order stops there by design: The sandbox confirm: the loop ran for real up to the payment The sandbox confirm: the loop ran for real up to the payment Every conversation is in the Agentcard dashboard under Agents → Purchase → Conversations: what the user asked, what the Purchase API answered, the cart on the table, and each /buy request with its status. Vault → Checkout approvals: every approval link the agent sent, and whether the user approved it Vault → Checkout approvals: every approval link the agent sent, and whether the user approved it Agents → Purchase → Conversations: the same conversation from Agentcard's side Agents → Purchase → Conversations: the same conversation from Agentcard’s side

Going to production

This is the step that makes the purchase real: the approval link, the webhook and the order only exist with Live credentials. Sandbox and production are two sets of credentials on the same organization and two webhook endpoints. No code changes.
  1. In the Agentcard dashboard, open Settings → Developers → Credentials and switch Live on. The first time, the dashboard asks you to register your production app: a name, redirect URIs empty. You get a production Client ID and Production secret.
Register your app: the production client is created here, once Register your app: the production client is created here, once Settings → Developers → Credentials in Live mode: the production client id and secret Settings → Developers → Credentials in Live mode: the production client id and secret
  1. Exchange them for a production token and register a production webhook endpoint with the same five events. Endpoints belong to a mode: the token decides which one you create.
  2. Replace the four Agentcard variables on Vercel (AGENTCARD_CLIENT_ID, AGENTCARD_CLIENT_SECRET, AGENTCARD_WEBHOOK_SECRET, AGENTCARD_MODE=production) and deploy.
eve deploy rewrites .env.local from the Vercel project after each deploy, quoting every value. If you copy values out of that file into vercel env add, strip the quotes: a quoted client id is rejected as invalid_client, and a quoted webhook secret fails every signature check.
  1. To let anyone text the line, upgrade the Linq sandbox. The line, the token and the webhook stay.
In production the connect code arrives as a real text from Agentcard, the Vault link stores a real card, and the confirm pauses on the approval link. Measured on a live run: the tap, the webhook, the placement and “Order placed” in the thread took twenty-two seconds, with nothing typed after the tap.

If nothing comes back

Everything in the first guide’s checklist applies. Additions:
  • The card was stored but the agent still says the user is not connected: the exchange did not run or was refused. vercel logs shows the handler’s attempt; 409 not_linked means the event raced the link (the next event retries), 410 already_exchanged means the tokens were minted on an earlier call and Redis lost them (send a new link), 403 account_verification_required means the user signed in to an existing account and the agent must fall back to the code.
  • The agent asks for a code: only right when the webhook said the account already existed. verify_code returning no_attempt or wrong_code means the attempt expired (ten minutes) or the digits were wrong; in the sandbox only 111111 verifies. If the text never arrives, the agent asks for an email and sends the code there.
  • buy returns not_connected on every turn: the connection was revoked and the store forgot it. The agent reconnects with a new code.
  • The agent sends a Vault link to someone who already has a card: it skipped list_cards. The buy reply can say “need a card on file” when the Purchase agent has not looked at the vault; the tool result, not that prose, decides.
  • After the approval the agent says the store could not place the order: the confirm hit in_progress and the re-read found no order. Read GET /buy/conversations/{id} with the user’s token yourself: orders and last_checkout say what happened, and the authorization stays approved for fifteen minutes.
  • After the approval the agent says nothing at all: the webhook did not reach it. Check the endpoint’s deliveries in the dashboard, and that the turn it woke had a phone to answer to (No phone number on this conversation in vercel logs means the session’s phone was never remembered).
  • The approval link opens the Vault’s sign-in page instead of the approval: something was glued onto the URL. In the blueprint no URL passes through the model; keep it that way when you change the instructions.
  • A product link opens a 404: the URL was composed instead of taken from the Purchase API. send_link only accepts a URL from a tool result; keep the rule that a link is never typed into a reply.

Where to go next

  • Agentcard’s Purchase API for the full loop: addresses as data, price changes, tracking retail orders, multi-cart confirms.
  • The purchase object for every field POST /buy returns, including the in_progress decline code.
  • POST /api/v2/vault_sessions/{id}/exchange, the endpoint that turns a finished Vault link into the user’s tokens; its reference page is on its way to the API reference under Vault.
  • Create an iMessage agent and connect the Vault for the Vault link and the card-stored webhook this agent inherits.