npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@agent-cards/commerce-sdk

v0.24.2

Published

Give your agent one tool that buys for your shoppers at real stores, and post each purchase update Agentcard sends into the shopper's chat, over a connection your server opens or a webhook endpoint.

Readme

@agent-cards/commerce-sdk

Give your agent one tool that buys for your shoppers at real stores, and tell each shopper what happened to their purchase in your agent's voice. The buy tool sends what the shopper says to Agentcard, and Agentcard finds the products, builds the cart and places the order once the shopper approves it. Agentcard then tells your server about each moment a shopper should hear about, such as a card saved, an order going through or an approval link about to expire, and the package hands you a message ready to post into that shopper's chat. Your server opens the connection to Agentcard, so you host no webhook endpoint.

Install

npm install @agent-cards/commerce-sdk

The package runs on Node 18 or later and has no dependencies.

Hear from Agentcard without webhooks

Call listen once when your server starts. Your server opens one connection to Agentcard with your company credentials, and Agentcard sends every event for your company down it, so you need no public URL, no webhook secret and no signature check:

import { AgentcardClient } from '@agent-cards/commerce-sdk';

const agentcard = new AgentcardClient({ clientId: process.env.AGENTCARD_CLIENT_ID ?? '', clientSecret: process.env.AGENTCARD_CLIENT_SECRET ?? '' });
agentcard.listen({
  onMessage: (shopperId, message) => postToChat(shopperId, message.text),
  onEvent: (event) => console.log(event.type, event.data),
});

postToChat stands for your own code. onMessage gets each message for one of your shoppers under your own id for them, the externalUserId you gave the buy tool, with the same ready-to-send text as agentcardWebhook: a card saved (card_added), an order placed, an approval about to expire, a delivery on its way. onEvent gets every other event Agentcard sends your company, such as vault.card_stored, checkout_authorization.approved or transfer.completed, as the same envelope a webhook endpoint would receive, with the same id. Without onMessage, onEvent gets every event. AGENTCARD_EVENT_TYPES lists every type, and events: ['order.*', 'purchase.message'] keeps only the ones you name.

The listener keeps itself connected. It reconnects after a dropped connection, waiting longer after each failure up to 30 seconds, and reconnects when Agentcard sends no heartbeat for 45 seconds. Each reconnect resumes after the last event it handled, so nothing that happened while it was away is lost, and it skips an event it already handled. Agentcard keeps each event for the stream for 72 hours. Where the stream does not get through, the listener reads the same events every two seconds instead.

Events run one at a time, in the order Agentcard recorded them. The listener waits for your callback before it hands over the next event, and a callback that throws runs again, up to five times, before the listener passes the error to onError and moves on.

A new process starts from the moment it connects. To resume where the last process stopped, pass a cursor store. After a resume, Agentcard hands over the last ten seconds of events again, in case one of them arrived late. The listener saves the cursor with the id of each event it handled in that window, so a new process skips the ones it already handled:

agentcard.listen({
  onMessage: (shopperId, message) => postToChat(shopperId, message.text),
  cursor: { load: () => redis.get('agentcard:cursor'), save: (cursor) => redis.set('agentcard:cursor', cursor) },
});

Once onMessage returns, the client keeps the message for that shopper's next buy.turn, and turn.messages(history) hands it to your model before the shopper's words, so the model knows the shopper saved a card or got an order. "Tell the model what Agentcard told the shopper" shows it. A message whose advances is true ends a step the shopper took outside the chat, such as saving a card: "Take the next step once the shopper finishes one outside the chat" shows how your agent moves on without waiting for their "ok".

listen returns { ready, close }: ready resolves once the listener reaches Agentcard, and close() stops it after the callback that is running returns. Run one listener per company and mode. Two listeners each hear every event, so with two, skip a message.eventId you already stored. Sandbox credentials hear sandbox events, live credentials live ones, and a shopper message is always live.

listen needs a process that keeps running, such as a Node server, a worker or a container. On a host that ends each request after a few seconds, such as a serverless function, take the events at a webhook endpoint instead, as "Hear from Agentcard at a webhook endpoint" shows.

Give your agent the buy tool

Build one client with your company's credentials and one buy tool per shopper, then pass the tool to your agent as it is:

import Anthropic from '@anthropic-ai/sdk';
import { AgentcardClient, appInstructions, buyTool } from '@agent-cards/commerce-sdk';

const agentcard = new AgentcardClient({
  clientId: process.env.AGENTCARD_CLIENT_ID ?? '',
  clientSecret: process.env.AGENTCARD_CLIENT_SECRET ?? '',
});

const buy = buyTool({
  agentcard,
  externalUserId: shopper.id, // your own id for the shopper
  sendLink: (link) => textShopper(shopper.id, link.url),
});

const answer = await new Anthropic().beta.messages.toolRunner({
  model: 'claude-opus-5-5',
  max_tokens: 16000,
  system: appInstructions({ markdown: true }),
  tools: [buy],
  messages: [{ role: 'user', content: 'two packs of AA batteries from Amazon' }],
});

shopper and textShopper stand for your own code. The model calls buy with the shopper's words, shows the shopper the cart, and confirms the cart only after the shopper says yes.

Build the client once and keep it. Every shopper's tool shares the client's access token, and the client renews the token a minute before it expires. Keep each shopper's tool as well. Agentcard keeps each purchase between calls, so a tool built for every message buys the same way, but it sends the same product links again each time Agentcard shows the same products.

The model sends ask, confirm, new_order, product_id and store_id, and nothing else. A product link the shopper sends, such as a buy.trychannel3.com link, goes in ask exactly as sent, and Agentcard buys that exact product from it. Pass deliveryAddress when you already hold the shopper's address: the order goes there, and Agentcard never asks the shopper for it. The model can change neither the address nor the shopper.

Give your model Agentcard's instructions

appInstructions() returns the system prompt Agentcard recommends for your model: what Agentcard can do for the shopper, in the words Agentcard's own buy agent uses, how to hand a request to the buy tool, and how to write for your app. Put it first in your system prompt, and add only what is about your app, such as its name:

const system = [appInstructions({ markdown: true, textingStyle: true }), 'You are the shopping chat in Acme.'].join('\n\n');

Pass markdown: true when your app shows the model's words as Markdown, so a shopper who asks for a table gets one; leave it out and the model writes plain text, with lists as • lines. Pass textingStyle: true when your app is a chat that writes in lowercase, as you pass it to buyTool. The text ships inside the package and changes with each release; APP_INSTRUCTIONS_VERSION names the version you run.

Use the tool in any framework

Pass buy as it is. Each framework reads its own fields from the same object, so you pick no adapter:

| Framework | Pass the tool | Who runs each call | |---|---|---| | Anthropic SDK, client.beta.messages.toolRunner | tools: [buy] | The tool runner. | | Vercel AI SDK 7 | tools: { buy } | The SDK. | | OpenAI SDK, OpenRouter's chat API, and any API that takes OpenAI's format | tools: [buy.openai] | Your loop, through buy.handle. | | OpenRouter's agent SDK, callModel | tools: [toOpenRouterTool(buy, { tool, z })] | The SDK. |

In your own loop, hand each tool call to buy.handle and give the model what it returns. Here the OpenAI SDK talks to OpenRouter's chat API:

import OpenAI from 'openai';

const openrouter = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey: process.env.OPENROUTER_API_KEY });
const messages: OpenAI.ChatCompletionMessageParam[] = [{ role: 'user', content: 'two packs of AA batteries from Amazon' }];
const send = async () => (await openrouter.chat.completions.create({ model: 'anthropic/claude-opus-5.5', messages, tools: [buy.openai] })).choices[0].message;

let reply = await send();
while (reply.tool_calls?.length) {
  messages.push(reply);
  for (const call of reply.tool_calls) {
    if (call.type !== 'function') continue;
    const content = await buy.handle(call.function.arguments).catch((error) => error.message);
    messages.push({ role: 'tool', tool_call_id: call.id, content });
  }
  reply = await send();
}
console.log(reply.content); // the model's answer for the shopper

buy.handle takes the model's arguments as an object or as their JSON text, and returns the JSON text the model reads, with every link taken out. A call with input outside the schema rejects before anything reaches Agentcard. A call that fails rejects with a message that names no link, so the model reads error.message as it is.

For a framework with its own tool format, declare buy.definition, which holds the name, the description and the input schema, and run the same three lines for each tool call.

OpenRouter's agent SDK accepts only tools that its own tool builds from a zod schema, so that SDK keeps an adapter. Pass your own tool and z, and the package still installs no dependencies:

import { OpenRouter, tool } from '@openrouter/agent';
import { z } from 'zod';
import { toOpenRouterTool } from '@agent-cards/commerce-sdk';

const result = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }).callModel({
  model: 'anthropic/claude-opus-5.5',
  input: 'two packs of AA batteries from Amazon',
  tools: [toOpenRouterTool(buy, { tool, z })],
});
console.log(await result.getText());

toAnthropicTool(buy) is no longer needed, and returns buy itself, so code that still calls it keeps working.

Send the shopper each link

A purchase needs a link at three moments: the shopper approves the purchase on an approval page, a new shopper adds a card on a Vault page, and a shopper signs in to a store's own account on its login page before Agentcard can buy there. Each link's kind says which: approval, vault or store_login, and a store_login link names the store in store. The tool hands each link to sendLink and waits for it, so the shopper holds the link before the model answers. The model reads link_sent: true and never the link itself, because a tool runner puts each answer straight into the model's context, and a model that holds a link writes it into the chat.

The tool also hands sendLink a product link for each product a search finds, up to eight per answer. The link carries the product's page at its store in url, the product's name in title, its price in price, such as $12.99, or from $10.69 for a product priced by a size or another choice the shopper makes, such as a burger whose patty count sets its price, Agentcard's id for it in productId, what taking it does in action (reserve for a restaurant table, book for a flight, room, class or ticket, buy for anything else, so a button can say Reserve instead of Buy this), its picture in imageUrl, or null when the store sent none, and every photo of the product in images, imageUrl first and at most eight, so a card can show the others as thumbnails that swap the main picture. images holds one entry for a product with one photo and none for a product with none. The link names the store that sells the product in store, such as Walmart, and the store's own website in storeDomain, such as walmart.com or jadeyoga.com, or null when Agentcard knows none, such as a restaurant's: show storeDomain on the card as where the product comes from, never the host of url, which can be a tracking link. Every price the tool writes, in a link or in the model's view, uses the currency Agentcard names for the cart or the product, in that currency's own minor units: 1331 is $13.31 in USD, CA$13.31 in CAD and ¥1,331 in JPY. A price whose currency Agentcard does not name shows with no symbol. The link arrives beside the reply, and you decide how to show it: as a card, in a list, or as a text with the URL. Each product gets its own link, so the three dishes a restaurant's reply offers are three cards, though they share the store's one page. A flight has no page at a store, so each flight a search finds is a product link whose url is null, with the airline, the route and its times in title: show it as a card with no link to open, and hand its productId back as the shopper's pick. The model reads product_links_sent and never a URL. The tool's description tells it that the shopper already sees those products as cards, so its reply never lists them again by name or price: it writes one short line, such as which one they want, and a shopper who asks for links hears that the links are there. When a later answer in the conversation only stands beside the same products, as a cart answer does, the tool sends none of their links again. When a later turn's reply presents them again, as the answer to a repeated ask does, the tool sends their links again, so every product the reply names arrives with its link. A product link your sendLink throws on counts as not sent, and the call goes on.

productId holds no link: it is the same product_id the model reads for that product, and the page is url. An answer that only adds a product the shopper picked lists that product alone, so it sends no card for a product nobody offered on that turn, and the pages the search already sent still count as sent on the answers after it.

Without sendLink, result.links still carries each Vault, approval and store login link for your code, each product in result.products carries its url, and the model still never reads one.

Open each step in a pop-up, and close it when it is done

A Vault, approval or store login link is a step the shopper takes outside the chat. Each one carries a stable id, such as vault_1x9k2qd: the same every time the same link is handed over, on your server and in the browser, with no part of the URL in it, so it is safe as a DOM key or in a log. actionLinkId(link) reads it from a link that has none.

Each step ends with a shopper message: actionLinkOutcome(link, message) says how a message ended the step, or null when it left it open. ACTION_LINK_ENDINGS is the whole table:

| Link | completed | declined | failed | expired | | --- | --- | --- | --- | --- | | vault | card_added | | | card_link_expired | | store_login | store_connected | | store_connect_failed | store_connect_expired | | approval | order_placing, order_placed, order_not_placed_yet, order_unconfirmed, order_confirmed, order_failed, payment_declined | approval_declined | | approval_expired |

A reminder, such as card_link_unfinished or approval_expiring, leaves the step open. An approval is completed once the shopper approved, even when the order then failed or the payment was refused: the approval page has nothing more to do, and the message tells the shopper what happened. A message whose facts name another link of the same kind (facts.approvalUrl, facts.cardLinkUrl) leaves this one open, and so does one about another store: a store login link names its store in store, and so does an approval link when the answer names the cart's store, so DoorDash's store_connected (facts.merchantName: 'DoorDash') never ends a Locale sign-in the shopper has open beside it.

In the browser, actionWindows from @agent-cards/commerce-sdk/browser opens each step in a pop-up window centered over the chat, so the shopper never leaves it, and closes the pop-up the moment the message that ends the step arrives. The browser entry imports no Node module, so any bundler takes it:

import { actionWindows } from '@agent-cards/commerce-sdk/browser';

const windows = actionWindows({
  onEnded: (step, outcome) => markStepDone(step.id, outcome),
  onClosed: (step) => keepOfferingStep(step.id),
});

// The link's own click handler, so the browser counts the tap as the reason for the pop-up. When the
// browser blocks it, `open` returns null and the link opens the way it would have, as a new tab. For a
// link it refuses, `open` throws, and the handler cancels the tap so the link never opens at all.
stepLink.addEventListener('click', (event) => {
  try {
    if (windows.open(link)) event.preventDefault();
  } catch (error) {
    event.preventDefault();
    console.error(error);
  }
});

// Each shopper message your server forwards from `listen` or the webhook, with its `kind` and `facts`.
onShopperMessage((message) => windows.settle(message));

markStepDone, keepOfferingStep and onShopperMessage stand for your own code. settle closes each open pop-up whose step the message ends and returns [{ step, outcome }]. A pop-up the shopper closes before the step ends reaches onClosed, checked every 500 milliseconds (pollMs): the step is still open, so keep its link on screen, and a tap opens it again. Tapping a step whose pop-up is open brings that window to the front instead of opening a second one. A phone's browser opens the pop-up as a tab, and settle closes that tab the same way, since the chat opened it. The pop-up is 480 by 760 pixels by default (width, height), kept inside the screen. The pop-up has no opener: open sets its opener to null before its page loads, and keeps the window itself so it can close it. open opens only an https: page, and a store sign-in only on Agentcard's own hosts (ACTION_LINK_HOSTS); for any other link it throws ActionLinkRefusedError with a reason of not_https or host_not_allowed and opens nothing. The buy tool never hands over such a link. dispose() stops watching and leaves every pop-up open, so a chat that remounts never shuts the page a shopper is typing a card into. The remounted chat holds no handle on those pop-ups: the browser no longer finds a pop-up with no opener by its name, so a tap on the same step opens a new window, and the old one stays open until the shopper closes it.

Keep the answer when the shopper's connection drops

A phone that loses its connection while a reply streams back would show the shopper an error, though the turn finished on your server. Serve the turn with turnStream and read it in the browser with streamTurn, and the shopper gets the answer anyway. On your server:

import { readTurn, turnStream, isTurnId } from '@agent-cards/commerce-sdk';

// POST /chat: the browser names the turn in `x-turn-id`.
const turnId = request.headers.get('x-turn-id');
if (!isTurnId(turnId)) return Response.json({ error: 'Name the turn.' }, { status: 400 });
const { stream, finished } = turnStream({ store, key: `${shopperId}:${turnId}`, run: (send) => runTurn(send) });
waitUntil(finished);
return new Response(stream, { headers: { 'content-type': 'text/event-stream' } });

// GET /chat/turn?id=...: the turn's record, for the browser to pick up where its stream stopped.
return Response.json(await readTurn(store, `${shopperId}:${id}`));

store is yours, such as Redis: load(key) and save(key, record, ttlSeconds). runTurn stands for your agent's turn, and send(event, data) takes each event the chat shows, the last one included, such as done. The turn runs to its end after the browser leaves, and finished resolves once its record is saved: hand it to your host's waitUntil. The record is kept for ten minutes (ttlSeconds). Put your shopper's id in the key, so no shopper reads another's turn. When one turn at a time runs per shopper, answer a message sent during a turn with a 409 whose body names that turn, { "error": "...", "turn_id": "..." }.

In the browser:

import { streamTurn, readTurnResponse } from '@agent-cards/commerce-sdk/browser';

const { refused } = await streamTurn({
  start: (turnId) => fetch('/chat', { method: 'POST', headers: { 'x-turn-id': turnId }, body }),
  read: (turnId) => fetch(`/chat/turn?id=${turnId}`).then(readTurnResponse),
  onEvent: (event, data) => show(event, data),
});

onEvent hears each event once, in order. When the stream drops, before its first event or after it, streamTurn reads the turn's record every 1.5 seconds (pollMs) and hands over the events the stream did not carry, until the turn's last one. A 409 that names a running turn waits for that turn's answer the same way. Any other refusal comes back in refused, the response, for you to show its error. A record that never appears within 15 seconds (startWaitMs) means the message never reached your server, and streamTurn throws the connection's error.

Answer each shopper message in its own turn

A shopper can send a second message before the first is answered. The sendLink, onWorking and onResult you pass to buyTool hear every call, whichever message the call answers, so a callback that hands each link to "the message running now" gives the first message's cards to the second. Open a turn for each message instead, and pass the turn to your model in place of the tool:

const buy = buyTool({ agentcard, externalUserId: shopper.id }); // one per shopper, kept

async function onShopperMessage(message: string) {
  const turn = buy.turn(message, { onReaction: (emoji) => reactToShopperMessage(shopper.id, emoji) });
  const runner = new Anthropic().beta.messages.toolRunner({
    model: 'claude-opus-5-5',
    max_tokens: 16000,
    tools: [turn],
    messages: turn.messages(history),
  });
  const answer = await runner;
  history = runner.params.messages;
  return { answer, links: turn.links, carts: turn.results.at(-1)?.carts ?? [] };
}

history stands for the shopper's earlier messages. turn.messages(history) is the history, then what Agentcard told the shopper that the history doesn't hold yet, then message. Every call the model makes through turn belongs to that message alone:

  • turn.links holds each link the turn's calls handed over, in order: the Vault, approval and store login links, each product's store page, and each store card's page. The model reads that each one went out, so send them with the reply. Pass sendLink to buy.turn as well to send each one the moment the tool hands it over, such as down a server-sent event stream. A link your sendLink throws on stays out of turn.links.
  • turn.results holds the whole answer of each call, and an onResult passed to buy.turn hears each one as it arrives.
  • With onReaction, buy.turn puts a 👀 on a message that starts a search at once, before your model runs, and turn.reaction holds it. With only onWorking, it sends the text reply buy.instantAck would return, or the one your ack function writes, and turn.ack holds that text. You never call instantReaction or instantAck yourself. A slow call of the turn acknowledges through the same callback.
  • An onProgress passed to buy.turn hears only that turn's stages.
  • turn.agentcardMessages holds each message agentcard.listen or agentcardWebhook posted into this shopper's chat that the model has not read yet, and turn.messages(history) hands them to the model.

A callback you leave out of buy.turn falls back to the one the tool was built with, so a tool whose sendLink texts the shopper keeps texting, and turn.links lists the same links. The turn is the tool, with the same name, schema and methods, so pass it to every framework the way you pass buy: tools: [turn], tools: { buy: turn }, tools: [turn.openai] with turn.handle, or toOpenRouterTool(turn, { tool, z }). turn.ack, turn.reaction, turn.links and turn.results stay out of the tool's JSON, so a tool runner sends the API the same fields. Build the tool once per shopper and open a turn from it for each message: the turns share the shopper's conversation, so a card a shopper already holds from an earlier turn is not sent again.

A call made on buy itself still works as before: it belongs to the turn the tool reads from its timing, and its links go to the tool's sendLink.

Show Agentcard's reply as written

Most answers only ask the shopper something: which product, which size, which store, or a yes to a cart's total. Agentcard's reply already says that, so a second model call that rewords it costs the shopper one more model round trip and changes nothing. Pass replyAsWritten: true, to buyTool for every turn or to buy.turn for one, and turn.replyAsWritten holds Agentcard's reply, with every link taken out, whenever the turn's last answer only asks. Show it and end the turn without calling your model again:

const turn = buy.turn(message, { replyAsWritten: true, onMessage: (text) => sendText(shopper.id, text) });
const runner = new Anthropic().beta.messages.toolRunner({ model: 'claude-sonnet-5-5', max_tokens: 16000, tools: [turn], messages: turn.messages(history), stream: true });
let written: string | null = null;
for await (const stream of runner) {
  written = turn.replyAsWritten;
  if (written !== null) {
    stream.abort(); // the reply says it already: no second model call
    break;
  }
  const paragraphs = turn.messageStream();
  stream.on('text', (delta) => paragraphs.push(delta));
  await stream.done();
  await paragraphs.end();
}
if (written !== null) await turn.say(written);
history = written !== null ? [...runner.params.messages, { role: 'assistant', content: written }] : runner.params.messages;

An answer whose next step is a link the turn handed over gets the same short line every time, whoever wrote the reply, so sending the card link, the sign-in link or the approval link takes no model either: add your card to place the order, sign in to DoorDash to place your order, or approve the payment to place your order (up to $17.89), with the most the shopper can be charged in the parentheses. Show it with turn.say() and the link follows it. When the shopper's own message just put the cart together, a line about the cart comes first, as its own message (1 Chicken Salad from Souvla, $17.43); after a yes or a saved card, the ask is the whole message. stepAsWritten(result) returns that line for one answer, or null when the answer is not a link step, its link did not go out, a card or approval step has no cart to place, or the answer also showed products.

Any other answer, such as an order placed or a refusal, leaves turn.replyAsWritten null, and your model words it. So does a line the tool writes itself for your model, such as the note that the cart changed or a buy.nextStep turn's ask for the shopper's yes: only a reply Agentcard sent goes out as written. replyAsWritten(result) applies the same rule to one answer, for a loop that reads turn.results itself. Leave the option off to have your model write every answer.

Every list in the reply comes as bullet lines that start with • , the choices Agentcard writes one per line with no marker included, such as the breads after which bread do you want?. A numbered list of the answer's products or stores keeps its numbers, since the shopper may answer number 3. Each message onText hears comes the same way. Pass textingStyle: true, to buyTool or buy.turn, to get the reply and those messages in a chat's texting style as well: lowercase at the start of each sentence and for "I", no markdown and no spaced dashes, with the bullets kept. textingStyle(text) and asBullets(text) do the same to any text.

An answer that asks the shopper to choose, such as a sandwich's bread, a shirt's size or a store's own size question, carries result.choice: each group with every value, its option id and its extra price in price_delta_cents. The reply shown as written lists each value on its own • line under the question, with its extra price, and so does one more onText message when the streamed reply named the values inside a sentence. withChoice(text, result.choice) lists them under any text of your own.

Let Agentcard answer shopping first

Send the shopper's message to Agentcard before your model runs, and run your model only when the message is yours:

const turn = buy.turn(message, {
  onItem: (item) => (item.kind === 'text' ? sendText(shopper.id, item.text) : sendCard(shopper.id, item.link)), // your code
});
if (await turn.handled()) {
  // A newer message from the shopper stopped this turn: that message's turn answers and keeps the exchange.
  if (turn.stopped) return;
  // Agentcard answered the shopper: keep the exchange, so your model reads it on the next message.
  const said = turn.items.flatMap((item) => (item.kind === 'text' ? [item.text] : [])).join('\n\n');
  history = [...turn.messages(history), { role: 'assistant', content: said }];
  return;
}
// Otherwise run your model on turn.messages(history), as before.

Agentcard answers a request to buy or book, with or without a verb (Printer paper, one ream), a pick of what it just showed (a card's tap, the second one, 1 and 3), an answer to its question (the address, a code, medium) and a yes to a total. When its reply only asks the shopper something, the turn sends it through onItem, after the cards and the card, sign-in or approval link it handed over, and handled() resolves true: no model call before the search, and none after it.

Every other message comes back from Agentcard as yours in about 0.1 to 0.2 seconds, with no search, and handled() resolves false: small talk, a question, a change of mind such as no or wait, and anything about the account or an order. An answer your model has to word, such as an order placed or a refusal, resolves false too, and turn.messages(history) hands your model that answer with the shopper's message, so it words it and calls the tool no second time.

handled() sends one call, once, and a second call resolves as the first did. A turn buy.nextStep opened resolves false and sends nothing: take it as "Take the next step once the shopper finishes one outside the chat" shows.

You no longer need direct or replyAsWritten for this. They still work as they did: direct: true sets turn.direct to the call the tool's own rule reads in a message (directCall(text, context)), and replyAsWritten: true sets turn.replyAsWritten, for an app that sends the call itself. An app that never calls handled() sends nothing until its model calls the tool.

Hand the app the turn in order

A turn reaches the shopper as messages and links that interleave: the instant reply, the cards a search found, then your model's words about them. Pass onItem to buy.turn, and the turn hands your app all of them as one sequence, one item at a time, in the order they happened. Show each item below the one before it:

const turn = buy.turn(message, {
  onItem: (item) => (item.kind === 'text' ? sendText(shopper.id, item.text) : sendCard(shopper.id, item.link)),
  onTyping: (typing) => showTyping(shopper.id, typing),
});
const runner = new Anthropic().beta.messages.toolRunner({ model: 'claude-opus-5-5', max_tokens: 16000, tools: [turn], messages: turn.messages(history), stream: true });
for await (const stream of runner) {
  const paragraphs = turn.messageStream();
  stream.on('text', (delta) => paragraphs.push(delta));
  await stream.done();
  await paragraphs.end();
}
  • A text item is the instant reply, or a message your model wrote through turn.messageStream() or turn.say(text). A link item is each link the tool hands over, with the fields sendLink would get. A message of Agentcard's that onText takes holds its place in turn.items as a text item too, and reaches onText alone.
  • Each message goes out the moment it is ready. Pass pace: true to space them like someone texting: each one then waits until typingDelay of it has passed since the item before it went out, 300 ms plus 12 ms a character, at most 1.5 seconds. Time your model spent writing counts toward the pause, so a slow model gets no added wait, and links never wait. onTyping hears true when a pause starts and false when its message goes out. A function returns your own pause in milliseconds.
  • With onItem, the turn sends no link to a sendLink and no instant reply to an onWorking, so a tool built with those for other turns sends nothing twice. Passing onItem beside sendLink or onWorking to the same turn throws.
  • turn.items holds every item that went out, in order, so an app that answers the whole turn at once returns it as is. A turn without onItem keeps turn.items as well, unpaced.
  • A step link, the vault, approval or store_login page the shopper opens to go on, comes after the message that asks for it: the turn holds it until your model's next message stream ends (or turn.say is called), then sends it right after that message, so the shopper reads "add your card to place the order" and then taps. Product and store cards go out the moment the tool hands them over. A held link also goes out when the turn's next call starts, or after 20 seconds, so a model that writes nothing still sends it. turn.say and the stream's end resolve once the held links went out too, and reject when one failed to send, so your app never answers without a link the shopper needs; a link that failed to go out at the next call or the 20 seconds rejects the next reply that ends.
  • Without onItem, pass onMessage beside sendLink, and write the model's text through turn.messageStream(): onMessage hears each message, paced, and sendLink hears each step link after the message that asks for it. An app that streams the model through the package's own messageStream gets each link the moment the tool hands it over, before the words about it.

Keep one purchase per chat

A shopper who writes to your agent in several chats at once, such as group chats or threads, would otherwise continue one purchase from all of them. Build one tool per shopper and chat, and name the chat with threadId, your own id for it. Agentcard then keeps a purchase per chat: each chat's carts, questions and approvals stay in that chat, while the shopper keeps one Vault card, one store sign-in and one address book.

const buy = buyTool({ agentcard, externalUserId: shopper.id, threadId: chat.id }); // one per shopper and chat

A message about one chat's purchase carries that chat as threadId, so post it into that chat. One about the shopper alone, such as a saved card, carries threadId: null. A chat's tool takes a next step only for its own chat's messages and the shopper's own, and a tool built without threadId only for purchases opened under no thread and the shopper's own.

Choose how long a reply waits for stores

A search that asks several stores writes its reply once the stores answered or a wait ran out. Choose that wait with storeWaitMs, and choose with lateResults what happens to a store that answers after the reply:

// A streaming chat: cards show as each store answers, the reply waits up to 25 s (99.7% of stores' first products), and a later store's cards stream below it.
const buy = buyTool({ agentcard, externalUserId: shopper.id, storeWaitMs: 25000, lateResults: 'stream' });
// A texting app: the reply waits up to 20 s (96% of stores' first products) and is the turn's last word.
const sms = buyTool({ agentcard, externalUserId: shopper.id, storeWaitMs: 20000, lateResults: 'hold' });

Agentcard ends every store search at 30 s, so a storeWaitMs above 30000 waits no longer than 30000 does.

  • lateResults: 'stream' sends a late store's cards after the reply, in the same turn, the way a slower hotel supplier's cards come: to sendLink, onProducts and onItem, with the one message that says what they added. The turn's onResult lists them in result.products after the answer's own.
  • lateResults: 'hold' keeps a late store's cards out of the turn, so a texting app never sends a card after the reply.
  • buy.turn(message, { storeWaitMs, lateResults }) sets them for one turn, and each one left out keeps the tool's. An app that calls client.buy itself sends store_wait_ms and late_results.
  • Left out, the tool sends neither, and Agentcard keeps its own wait. A wait that is not a whole number of milliseconds, 0 or more, throws a TypeError before any call.

Tell the model what Agentcard told the shopper

Agentcard posts messages into the shopper's chat between turns, through listen or the webhook: Your Visa ending 3132 is saved, so you're ready to check out., an order placed, an approval about to expire. The shopper reads them, so your model has to know them too. A model that never saw card_added answers "ok lets checkout" with "did you save your card?" and never calls the tool.

Build the listener and every buy tool on the same AgentcardClient. Then pass turn.messages(history) as the model's messages, and save what the run ends with as the next history:

const agentcard = new AgentcardClient({ clientId, clientSecret });
agentcard.listen({ onMessage: (shopperId, message) => postToChat(shopperId, message.text) });

const turn = buyTool({ agentcard, externalUserId: shopperId }).turn(message);
const runner = anthropic.beta.messages.toolRunner({ model: 'claude-opus-5-5', max_tokens: 16000, tools: [turn], messages: turn.messages(history) });
await runner;
history = runner.params.messages;

How it works:

  • Once your onMessage returns, the client keeps the message for that shopper. A message whose onMessage threw every time never reached the chat, so the client does not keep it.
  • buy.turn reads the messages kept for its shopper when it opens. turn.agentcardMessages holds them, oldest first.
  • turn.messages(history) returns a new array: history, then Agentcard's text as the assistant's words, then the shopper's message as { role: 'user', content: message }. The chat showed Agentcard's message in your agent's voice, so the model reads it as its own. Every link is taken out of the model's copy, as forModel takes them out of an answer, and the message onMessage got keeps them.
  • A message stays kept until a history you pass to turn.messages holds it, and then the client forgets it. So when a turn fails and you keep the history you had, the next turn hands the message to the model again. Once you saved a history that holds it, no turn hands it over a second time.
  • When the last message in history is the assistant's plain text, Agentcard's words join that message, so no provider reads two assistant messages in a row. That covers a string, or text parts as the Anthropic API, the Vercel AI SDK and OpenAI's chat completions write them. After anything else, such as a tool result, a tool call or an OpenAI Responses item, they come as an assistant message of their own. history itself is never changed.
  • The Anthropic SDK's messages, the Vercel AI SDK's messages, OpenAI's chat completions messages and the Responses API's input all take what it returns.

With a webhook, pass the client to the handler: agentcardWebhook({ secret, agentcard, onMessage }). For a message you deliver some other way, call agentcard.delivered(shopperId, message) once it reaches the chat. The client keeps each message once per eventId, so a message both the listener and the webhook carry reaches the model once.

The messages live in the client's memory, at most 20 per shopper. So the listener, or the webhook, and the turn have to run in one process on one client. When your listener runs in one process and your model in another, such as a serverless chat route, keep each message where your chat history lives as onMessage delivers it, as an assistant message, and the model reads it from the history.

Take the next step once the shopper finishes one outside the chat

Some steps happen outside the chat: the shopper saves a card from the Vault link, signs in to DoorDash from the sign-in link, or declines the approval. Agentcard tells your server the moment each one ends, and its message reads like Your Visa ending 4242 is saved, so you're ready to check out.. Posted as is, it leaves the shopper to say "ok" or "done" before anything moves. Take the next step for them instead: when message.advances is true, open buy.nextStep(message) and run your model on it, and post the model's answer in place of message.text:

/** Each shopper's model runs, in order: the chat's own turns should wait on it too. */
const runs = new Map<string, Promise<void>>();

agentcard.listen({
  onMessage: (shopperId, message) => {
    const step = buyFor(shopperId).nextStep(message);
    if (!step) return postToChat(shopperId, message.text);
    // Run it without awaiting, so the listener moves on to the next event. One shopper's runs go in order, after
    // their last one, so an older run never writes its history over a newer one.
    // A turn that fails is released, so its text reaches later turns and a redelivery can take the step again.
    const run = (runs.get(shopperId) ?? Promise.resolve()).then(() => runModel(shopperId, step)).catch(() => {
      step.release();
      agentcard.delivered(shopperId, message);
      postToChat(shopperId, message.text);
    });
    runs.set(shopperId, run);
  },
});

async function runModel(shopperId: string, turn: BuyToolTurn) {
  const runner = anthropic.beta.messages.toolRunner({ model: 'claude-opus-5-5', max_tokens: 16000, tools: [turn], messages: turn.messages(histories.get(shopperId)) });
  const answer = await runner;
  histories.set(shopperId, runner.params.messages);
  postToChat(shopperId, textOf(answer), turn.links);
}

buyFor stands for the buy tool you keep for each shopper, and textOf for the text of the model's answer. The turn is a buy.turn in every way but one: no shopper wrote anything, so turn.messages(history) ends with the event, marked as written by the system, and the instruction to take the next step now in one short message, never "you're all set, what's next?". The shopper reads one message that does the next thing:

| The shopper finished | The next message | | --- | --- | | Saving a card, after their yes stopped for it | The approval link: approve the payment to place your order (up to $19.02) | | Saving a card, after picking the product and answering only the checkout's questions | The approval link: approve the payment to place your order (up to $221.00) | | Saving a card, with no yes and no pick standing | The total and the ask for the yes: your Souvla chicken salad comes to $17.43 with fees. should i go ahead and place the order? | | Signing in to the store, with a card still to add | The add-card link: next, add a card with the link i sent. it works for about 24 hours. | | Signing in to the store, with a card on file | The total and the ask for the yes, or the approval link after a yes that stopped for the sign-in | | Signing in or saving a card, after a yes, when the store priced its fees and tax in since | The new total and the ask for the yes, with no link: The cart changed after your yes: it comes to $17.89 with tax and shipping now. Nothing was paid. Say yes to pay $17.89. | | Declining the approval | you declined the payment, so you haven't been charged. your chicken salad is still in the cart, so just say yes if you want a new approval link, or tell me what to change |

With direct and replyAsWritten on the tool, a step needs no model when its answer is a link: send step.run(step.direct.input) first, and when step.replyAsWritten is set, post it, with step.links, in place of the model's answer. When it is null, as for the ask for a yes, run your model as above.

The turn moves the purchase only up to the shopper's own decision, and the tool holds it there whatever the model calls:

  • No call of the turn sends an ask to Agentcard, which could read the model's words as the shopper's go-ahead. Each call reads where the purchase stands and answers with the next step: the card link the sign-in held back, or the cart and the ask for the shopper's yes.
  • A confirm goes in only for the yes the shopper already gave in the chat, such as "lets place it", when that confirm stopped for the card or the sign-in they just finished, and the shopper is never asked for that yes again. It goes on past each step, the sign-in and then the card, as long as the cart holds the same items, in the same quantities and at the same prices, at the same store. Each confirm names the cart's hash as it stands, and in carried the hash of the cart the shopper said yes to: Agentcard pays only that cart, so a total that moved because the store priced its fees, tax or delivery in comes back with the new total and the ask for the shopper's yes, and nothing is paid. A cart whose items, quantities or store changed, a shopper message after the yes that is not a plain go-ahead, such as "wait, make it two", and a declined approval each take the yes back, and the shopper is asked again. Any other confirm the model sends is refused by the tool, and nothing reaches Agentcard.
  • A shopper who picked what is in the cart, by tapping a product's card or asking to buy it, and since answered only the checkout's questions (an address, a phone number, a name, an email, a yes to an address read back to them) or said a plain go-ahead, gave the same go-ahead: saving the card or signing in to the store confirms that cart by its hash as it stands, with no ask for a yes (yes_carried.picked). The confirm names in carried the cart the chat showed, and a cart the store priced again since comes back with its total and the ask for a yes. A cart whose items, quantities, item prices or store changed since the pick, any other message, such as "actually wait" or a question, and a declined approval take it back.
  • The approval in the Vault is still the shopper's: the confirm sends the approval link, which names the most they can be charged, and the order goes in only once they approve it.
  • A shopper who was not connected yet when they asked gets their own first ask run, in their words, once their card is saved.

buy.nextStep returns null when there is no step to take: a message whose advances is false, such as order_placed, whose text is already the whole message; a message for another shopper, or one whose link closed; a message it already took a turn for; and one that arrives while a call for the shopper's own message is still running, since that call's answer tells the model where the purchase stands. Post message.text then. NEXT_STEP_KINDS lists the kinds that advance: card_added, store_connected and approval_declined. Once buy.nextStep opens a turn, the client never hands the message's text to a later turn as words the shopper read, since they never read it. When your agent cannot finish the turn, call step.release() before you post message.text: the text then reaches later turns once agentcard.delivered records it, and a redelivery of the event opens a turn again.

Add the exact product the shopper picks

Each product in the model's view carries a product_id, Agentcard's id for it with no link in it. When the shopper picks one of those products, by its name, its place in the list or a tap on its card that sends its title, the model passes that product_id, and Agentcard adds exactly that product from the products it showed, with no new search. Your app sends no id of its own: a "Buy this" button that sends the shopper's words, such as I'll take the KODAK AA Alkaline Batteries (24 Pack) ($13.49), is enough, because the model reads the product_id beside that name in its last result. Keep each tool result in the model's history, as a tool runner does, so the model still holds those ids on the shopper's next message. Your own code can pick too: a card's button can call buy.answer with the link's productId as product_id, and Agentcard adds that product.

result.pick says what happened: the product's id, and in_cart, whether the cart now holds it. The model reads the same pick in its view, by the product_id it sent. A pick the cart does not hold names no next step, so the model tells the shopper what Agentcard said rather than asking for a card or a yes for an older cart. A product Agentcard no longer holds, because the products are more than 15 minutes old or a newer search replaced them, adds nothing, and the model reads that it should search again with the shopper's words.

Show the stores the shopper picks from

A delivery order often starts with a choice of store: the shopper asks for a taco, and Agentcard answers with the taquerias near them that deliver. Each store the reply names reaches sendLink as a store link, up to eight per answer, in the order the reply names them. The link carries the store's page at the merchant in url, its name in title, Agentcard's id for it in storeId, the delivery estimate in minutes in etaMinutes, or null when the store showed none, and the picture the store's card shows at the merchant in imageUrl, or null when it shows none. Show it as a card with the picture and an "Order here" button. result.stores carries the same stores for your code, each with its url and linkSent.

The model reads each store in stores by its store_id, name and eta_minutes, never its page, and store_links_sent says how many store pages the shopper holds. When the shopper picks a store, by its name, its place in the list or a tap on its card, the model passes that store_id, and Agentcard selects exactly that store before its agent answers, so the products it finds next are from that store. A card's button can call buy.answer with the link's storeId as store_id. A shopper who answers only with a place in the list, such as the second one or 2, picks the store at that place in the last answer's list, whether or not the model passed the id. A store pick starts no search, so it gets no instant reply: a card tap that sends Order from Tacos El Grullense, the store's name alone or with please, and order here or this place all read as a pick of a store the last answer listed. A dish beside the store's name, as in order tacos from el farolito, is a search.

result.store_pick says what happened: the store's id and name, and selected, whether the merchant took the selection; when it did not, Agentcard's agent selects the store itself. The model reads store_pick by the store_id it sent. A store Agentcard no longer holds, because the stores are more than 15 minutes old or a newer search replaced them, selects nothing, and the model reads that it should search again with the shopper's words.

The tool sends each store page once for the calls the model makes in one turn. A later turn whose reply presents the stores again gets their pages again, so every store the reply names arrives with its card. A store link your sendLink throws on counts as not sent. Agentcard lists stores only for an answer whose reply presents them as a choice: a turn that searched stores on its way to a menu, or one that shows a cart, lists none.

Tell the shopper you are on it

A first search can take Agentcard most of a minute. Pass onReaction, and the shopper sees a 👀 on their own message while Agentcard looks, the way a friend reacts to a text before answering it. Show it as a tapback on the shopper's bubble:

const buy = buyTool({
  agentcard,
  externalUserId: shopper.id,
  sendLink: (link) => textShopper(shopper.id, link.url),
  onReaction: (emoji) => reactToShopperMessage(shopper.id, emoji), // emoji is always '👀'
});

A reaction is the default acknowledgement. It names nothing the shopper said, so it never reads a request back wrong, and the chat holds only real answers. Each message gets at most one 👀:

  • A message that starts a search gets it the moment buy.turn opens, before your model runs.
  • A pick of a product or a store the shopper saw, an address or other details, an answer to a question such as yes, 2 or a code, a step toward checkout such as ok lets checkout, and an acknowledgement such as its ok get none when the turn opens, because Agentcard answers them fast.
  • A call that Agentcard has not answered within 1 second puts the 👀 on a message that has none yet: a slow yes, a pick, or a confirm that is still placing the order. It sends no text.
  • A turn that runs long, even past a minute, still gets no text line. A search often takes 10 to 40 seconds, so a line at any wait short enough to help would land on most searches and bring back the chatter the reaction replaces. To show a long wait, pass onProgress and show its stage, such as checking_out, as a status line or a typing indicator.

With onItem on buy.turn, the 👀 still goes to onReaction and never joins the turn's items, because it sits on the shopper's bubble rather than in the agent's sequence. Without an onReaction, the text instant reply is the turn's first text item, as before.

When the 👀 went out, the model reads shopper_already_told_looking: true in that turn's answers, and the tool's description tells it not to acknowledge the request again, so its reply opens with the answer or the question rather than on it. It reads false on a turn that reacted with nothing, and when onReaction threw. An onReaction that throws costs only the reaction.

Acknowledge in text, or not at all

For SMS, or any chat that shows no reactions, acknowledge in text. Pass onWorking and no onReaction, or set ack: 'text', and the shopper reads an instant reply instead, such as on it, looking for usb-c cables under $20 or give me a sec, checking for a table for 2 in north beach:

const buy = buyTool({
  agentcard,
  externalUserId: shopper.id,
  ack: 'text',
  onWorking: (text) => textShopper(shopper.id, text),
});

ack takes 'reaction', 'text' or 'none'. Left out, the tool reacts when the tool or the turn has an onReaction, and sends text through onWorking otherwise, so an app built on 0.9 with only onWorking keeps its text replies. With both callbacks, the shopper gets the 👀 and never the text too. 'none', or false, sends nothing.

In text mode, a streamed search's reply is Agentcard's own line, written from the conversation while it searches, such as one sec, checking target for la croix, and it reaches onWorking before the cards or not at all. A search buy.turn sends straight that has shown the shopper nothing by twice ackDelayMs, because Agentcard wrote no line or its line was dropped, sends one moment once, and a line of Agentcard's that comes after it is dropped. When your model's run fails before it wrote or called anything, call turn.end() before you show your error, so no wait line follows it. When turn.stopped is true after a call, the shopper sent a new message while this turn ran and Agentcard stopped the turn for it: end the turn there, with no model call, no error and no saved history, because the new message's turn answers. With stream: false, the tool sends its own reply when Agentcard has not answered a new request within ackDelayMs, 1 second by default, so a quick answer arrives alone. It sends one for the calls the model makes in one turn, where a call that starts within 15 seconds of the last one ending counts as the same turn. A slow confirm or pick never hears looking for: a pick, of a product, a store or a place in a list such as the first one, hears got it, one moment, a confirm hears on it, one moment, and any other answer hears one moment. A turn opened with buy.turn that has shown the shopper nothing, no line and no card, after the delay sends that line once. None of them names anything the shopper said, and a code hears nothing. The words rotate through six templates, and one tool never sends the same template twice in a row. A short paraphrase of the request goes in when the tool can read one: it stops before any link, email address, phone number, street address or long number, and a request that starts with one gets a reply that names nothing, such as looking for that now. The call waits for onWorking, so the shopper reads the reply before any link and before the model answers. An onWorking that throws costs only the instant reply.

Pass ack: (ask) => text to write your own text from the shopper's words, where an empty answer sends none, and ackDelayMs to change the 1 second for either mode.

The model takes several seconds to call the tool after the shopper's message arrives. Without buy.turn, react sooner by calling buy.instantReaction(message) the moment the message arrives, before your model runs, and put the emoji it returns on the message. In text mode, call buy.instantAck(message) and send the text it returns:

const reaction = buy.instantReaction(message); // '👀' or null
if (reaction) await reactToShopperMessage(shopper.id, reaction);
// In text mode instead:
const ack = buy.instantAck(message);
if (ack) await textShopper(shopper.id, ack);

With buy.turn, pass onReaction or onWorking to the turn instead: it acknowledges the moment the turn opens, and calls instantReaction or instantAck for you. When either returns a 👀 or a reply, the tool sends none of its own for that turn.

instantReaction and instantAck return null for a message that starts no search: a confirmation or a short answer such as yes, 1 or ok, a step toward paying for the cart such as ok lets checkout, place the order or pay, an acknowledgement such as its ok, thats fine or sounds good, a pick of a product the shopper already saw, such as the first one, #2, I want the blue one or a card's title with its price, a pick of a store, such as order here or a store card's Order from Tacos El Grullense, a code, small talk such as hi or how are you, a message that carries the shopper's own details, such as a name with a phone number, an email address, a street address, a bare house number and street such as 1801 wedemeyer, or a zip code, any message that answers the last answer's request for an address, shipping or guest details or a code, and a short reply right after any other question the last answer asked, such as im a 37 after what size do you want?, or carne asada after which meat?. A reply that opens a new request, such as find me black running shoes, or that runs past four words is still a search. The reply never reads those details back, never repeats the shopper's own first-person words, and names the request only when it reads as a short, whole phrase; otherwise it says on it, give me a moment or another reply that names nothing. The tool also reads a message that quotes the name of a product its last answer showed, or names one of the stores its last answer listed and nothing else to buy, as a pick of it. A request for a store the shopper did not see, such as order from chipotle, reads back as looking for chipotle, never looking for from chipotle. When it returns a reply, the tool sends none of its own through onWorking for that turn, and the model reads shopper_already_told_looking: true, so the shopper hears one acknowledgement. When it returns null for a message that picks nothing, such as yes, a call that runs past ackDelayMs still acknowledges: with a 👀 in reaction mode, and in text mode with an onWorking reply that names nothing and never says it is looking for anything, such as one moment, and when it returns null for a pick, such a call sends got it, one moment.

Without a buy tool, instantReaction(message, { nextAction, asked, stores }) and instantAck(message, { previous, nextAction, asked, stores }) from the package root decide the same way, and ACK_REACTION is the 👀 instantReaction returns: previous is the reply you sent last, so the next one comes from another template, nextAction is the last answer's next_action, asked is the last message the shopper read, so an answer to its question for an address, a name or a code, or a short answer to any other question it asked, gets none, and stores holds the names of the stores the last answer listed, so a message that picks one gets none. Pass ack: 'none' to the tool when you acknowledge yourself. ackFor is the earlier name of the same function.

Show what Agentcard is doing

The tool streams every call, so each part of an answer reaches your app the moment Agentcard produces it: the stage the call reached, the products a search found, each message Agentcard writes and the cart it builds. Pass a callback for each part you show:

const buy = buyTool({
  agentcard,
  externalUserId: shopper.id,
  sendLink: (link) => textShopper(shopper.id, link.url),
  onStatus: ({ text }) => showStatus(shopper.id, text),
  onFound: ({ shown, found, more }) => showCount(shopper.id, `${shown} of ${found} shown`, more),
  onProducts: (products, { merchantName }) => showProducts(shopper.id, merchantName, products),
  onText: (text) => textShopper(shopper.id, text),
  onCart: (cart, { carts }) => showCart(shopper.id, cart, carts),
});
  • onStatus hears each step the moment Agentcard starts it, as a line to show in place of loading dots: searching Target and Walmart, adding it to your cart, working through the store's checkout. step names the step, such as searching or adding_to_cart, for an app that writes its own words. A call that only writes its reply sends none, so keep your dots until the first line comes.
  • onFound hears how many products the searches found, after the cards it counts: shown cards of found products, such as "8 of 23 shown", each time the count grows. more holds the products past the cards as product links, in the order they were found, for a "show more" that runs no new search. The model never reads them.
  • buyTool({ candidates: true }) asks for the products behind the cards on every ask. The answer then carries found_total and up to 16 candidates, and every product carries rank and why, such as { code: 'named_store', text: 'At the store you named' }, for an app that ranks the products again itself. The model never reads them.
  • onProgress still hears the stage, searching, building_cart, checking_out or working.
  • onAvailability hears whether the places a booking's cards show have room at the time the shopper asked for. A place's card can go out before Agentcard's live search knows: its availability reads checking, so show it without its choose button. The update names each card by its productId with available (its open times in slots, empty when none were named), unavailable (full then) or unknown (the search could not say). Change the card in place, and offer it as a choice only when it reads available. A call that ends with a card still checking sends unknown for it. Every other card, a retail product's included, carries no availability.
  • onProducts hears the products a search found the moment it answers, before Agentcard writes its reply, with the conversation, when the search ran (asOf) and the store's name. With sendLink, each product's card goes out then too, a few seconds before the answer, and the answer sends only the cards it adds. Pass earlyCards: false to send only the cards the answer names, with the answer: the reply often names only some of a search's products.
  • onText hears each message Agentcard writes, links included, the moment it is written, and after the cards of the search it is about.
  • onCart hears the cart a confirm would place, or null, and every cart on the table, each time a call builds or changes one, before the message that announces it.

buy.turn takes the same callbacks, and each one left out falls back to the tool's. A turn's onText hears each message in its place in the turn, after the instant reply and the cards, paced by pace, and turn.items lists it; onItem does not hear it, so an app that passes both hears each message once.

When your app shows Agentcard's messages through onText, the model reads shopper_already_read_reply: true in that call's answer and adds nothing to them, as it reads shopper_already_told_looking after an instant reply: end the turn without a second model call, as with replyAsWritten, or let the model add one short line for what the reply left out. turn.replyAsWritten stays null for that answer, so the reply never reaches the shopper twice, and a step link the turn holds for your model's next message goes out right after Agentcard's ask instead.

A turn built with replyAsWritten and earlyCards that hears messages through onMessage or onItem and passes no onText gets the same once a reply is written while a slower source's cards are still coming, such as a second hotel supplier's: Agentcard's messages go out through that callback th