@agent-cards/commerce-sdk 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,6 +39,8 @@ agentcard.listen({
39
39
  });
40
40
  ```
41
41
 
42
+ 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.
43
+
42
44
  `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.
43
45
 
44
46
  `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.
@@ -134,12 +136,97 @@ console.log(await result.getText());
134
136
 
135
137
  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.
136
138
 
137
- The tool also hands `sendLink` a `product` link for each product a search finds, up to three 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`, Agentcard's id for it in `productId`, and its picture in `imageUrl`, or null when the store sent none. 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. The model reads `product_links_sent` and never a URL, so 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.
139
+ The tool also hands `sendLink` a `product` link for each product a search finds, up to three 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`, Agentcard's id for it in `productId`, and its picture in `imageUrl`, or null when the store sent none. 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.
138
140
 
139
141
  `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.
140
142
 
141
143
  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.
142
144
 
145
+ ## Answer each shopper message in its own turn
146
+
147
+ 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:
148
+
149
+ ```ts
150
+ const buy = buyTool({ agentcard, externalUserId: shopper.id }); // one per shopper, kept
151
+
152
+ async function onShopperMessage(message: string) {
153
+ const turn = buy.turn(message, { onWorking: (text) => textShopper(shopper.id, text) });
154
+ const runner = new Anthropic().beta.messages.toolRunner({
155
+ model: 'claude-opus-5-5',
156
+ max_tokens: 16000,
157
+ tools: [turn],
158
+ messages: turn.messages(history),
159
+ });
160
+ const answer = await runner;
161
+ history = runner.params.messages;
162
+ return { answer, links: turn.links, carts: turn.results.at(-1)?.carts ?? [] };
163
+ }
164
+ ```
165
+
166
+ `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:
167
+
168
+ - `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`.
169
+ - `turn.results` holds the whole answer of each call, and an `onResult` passed to `buy.turn` hears each one as it arrives.
170
+ - With `onWorking`, `buy.turn` sends the instant reply at once, before your model runs: the one `buy.instantAck` would return, or the one your `ack` function writes. You never call `instantAck` yourself. A slow call of the turn sends its reply to that `onWorking` too, and `turn.ack` holds the text the turn sent when it opened.
171
+ - An `onProgress` passed to `buy.turn` hears only that turn's stages.
172
+ - `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.
173
+
174
+ 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.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.
175
+
176
+ 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`.
177
+
178
+ ### Hand the app the turn in order
179
+
180
+ 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:
181
+
182
+ ```ts
183
+ const turn = buy.turn(message, {
184
+ onItem: (item) => (item.kind === 'text' ? sendText(shopper.id, item.text) : sendCard(shopper.id, item.link)),
185
+ onTyping: (typing) => showTyping(shopper.id, typing),
186
+ });
187
+ const runner = new Anthropic().beta.messages.toolRunner({ model: 'claude-opus-5-5', max_tokens: 16000, tools: [turn], messages: turn.messages(history), stream: true });
188
+ for await (const stream of runner) {
189
+ const paragraphs = turn.messageStream();
190
+ stream.on('text', (delta) => paragraphs.push(delta));
191
+ await stream.done();
192
+ await paragraphs.end();
193
+ }
194
+ ```
195
+
196
+ - 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.
197
+ - Messages arrive one at a time, like someone texting. Each one 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. Pass `pace: false` to send each message the moment it is ready, or a function that returns the pause in milliseconds.
198
+ - 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.
199
+ - `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.
200
+
201
+ ## Tell the model what Agentcard told the shopper
202
+
203
+ 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.
204
+
205
+ 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:
206
+
207
+ ```ts
208
+ const agentcard = new AgentcardClient({ clientId, clientSecret });
209
+ agentcard.listen({ onMessage: (shopperId, message) => postToChat(shopperId, message.text) });
210
+
211
+ const turn = buyTool({ agentcard, externalUserId: shopperId }).turn(message);
212
+ const runner = anthropic.beta.messages.toolRunner({ model: 'claude-opus-5-5', max_tokens: 16000, tools: [turn], messages: turn.messages(history) });
213
+ await runner;
214
+ history = runner.params.messages;
215
+ ```
216
+
217
+ How it works:
218
+
219
+ - 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.
220
+ - `buy.turn` reads the messages kept for its shopper when it opens. `turn.agentcardMessages` holds them, oldest first.
221
+ - `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.
222
+ - 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.
223
+ - 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.
224
+ - 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.
225
+
226
+ 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.
227
+
228
+ 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.
229
+
143
230
  ## Add the exact product the shopper picks
144
231
 
145
232
  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.
@@ -148,9 +235,9 @@ Each product in the model's view carries a `product_id`, Agentcard's id for it w
148
235
 
149
236
  ## Show the stores the shopper picks from
150
237
 
151
- 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 three 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`, and the delivery estimate in minutes in `etaMinutes`, or null when the store showed none. Show it as a card with an "Order here" button. `result.stores` carries the same stores for your code, each with its `url` and `linkSent`.
238
+ 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 three 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`.
152
239
 
153
- 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.
240
+ 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.
154
241
 
155
242
  `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.
156
243
 
@@ -169,7 +256,7 @@ const buy = buyTool({
169
256
  });
170
257
  ```
171
258
 
172
- The tool sends the reply when Agentcard has not answered a new request within 1.5 seconds, 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, and none on a confirm, on a pick of a product the shopper saw, or on an answer such as `yes`, `2`, `the first one` or a code. 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.
259
+ The tool sends the reply when Agentcard has not answered a new request within 1.5 seconds, 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 the shopper saw hears `adding it now`, a pick of a store or a place in a list, such as `the first one`, hears `got it, one moment`, and a confirm hears `on it, one moment`. 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.
173
260
 
174
261
  When the instant reply 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 own reply opens with the answer or the question rather than a second `on it`. It reads false on a turn that sent none, and when `onWorking` threw.
175
262
 
@@ -182,9 +269,11 @@ const ack = buy.instantAck(message);
182
269
  if (ack) await textShopper(shopper.id, ack);
183
270
  ```
184
271
 
185
- `instantAck` returns null for a message that starts no search: a confirmation or a short answer such as `yes`, `1` or `ok`, 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 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, and any message that answers the last answer's request for an address, shipping or guest details or a code. 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 as a pick of it. 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 sends `onWorking` a reply that names nothing, such as `give me a sec, checking`.
272
+ With `buy.turn`, pass `onWorking` to the turn instead: it sends the same reply the moment the turn opens, and calls `instantAck` for you.
186
273
 
187
- Without a buy tool, `instantAck(message, { previous, nextAction, asked })` from the package root decides the same way: `previous` is the reply you sent last, so the next one comes from another template, `nextAction` is the last answer's `next_action`, and `asked` is the last message the shopper read, so an answer to its question for an address, a name or a code gets none. Pass `ack: false` to the tool when you send it yourself. `ackFor` is the earlier name of the same function.
274
+ `instantAck` returns 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 sends `onWorking` a reply that names nothing and never says it is looking for anything, such as `working on it` or `one moment`, and when it returns null for a pick, such a call sends `adding it now` or `got it, one moment`.
275
+
276
+ Without a buy tool, `instantAck(message, { previous, nextAction, asked, stores })` from the package root decides the same way: `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: false` to the tool when you send it yourself. `ackFor` is the earlier name of the same function.
188
277
 
189
278
  ## Show what Agentcard is doing
190
279
 
@@ -213,7 +302,7 @@ for (const text of toMessages(reply)) await textShopper(shopper.id, text);
213
302
 
214
303
  `result.messages` from `buy.answer` holds Agentcard's own answer cut the same way, links included, so your code sends it without cutting it again.
215
304
 
216
- For a reply your model streams, `messageStream` hands over each paragraph the moment the blank line that ends it arrives, however the stream cuts the text. Push each piece of text, then call `end` for the last paragraph:
305
+ For a reply your model streams, `messageStream` hands over each paragraph once the blank line that ends it arrives, however the stream cuts the text. Push each piece of text, then call `end` for the last paragraph:
217
306
 
218
307
  ```ts
219
308
  import { messageStream } from '@agent-cards/commerce-sdk';
@@ -224,7 +313,9 @@ await stream.done();
224
313
  await paragraphs.end();
225
314
  ```
226
315
 
227
- `stream` stands for your model's streamed answer. When `onMessage` returns a promise, the stream waits for it before it sends the next paragraph, so the shopper reads them in order, and `end` resolves once every paragraph went out. After a send fails, no later paragraph goes out, and `end` rejects with that error. A paragraph inside a list waits for the first characters of the next line, which say whether the list goes on. Start a new `messageStream` for each model message, so one message never runs into the next.
316
+ `stream` stands for your model's streamed answer. `toMessages` and `messageStream` keep nothing between two texts, so a shopper's overlapping messages never mix in them: the paragraphs of one model message go to the `onMessage` of its own stream, and the links of that turn's tool calls are in its `turn.links`. When `onMessage` returns a promise, the stream waits for it before it sends the next paragraph, so the shopper reads them in order, and `end` resolves once every paragraph went out. After a send fails, no later paragraph goes out, and `end` rejects with that error. A paragraph inside a list waits for the first characters of the next line, which say whether the list goes on. Start a new `messageStream` for each model message, so one message never runs into the next.
317
+
318
+ A model often writes three paragraphs within 200 ms, and a shopper who gets them at once reads a wall, not a conversation. So `messageStream` spaces them like someone texting: the first goes out at once, and each one after it waits until `typingDelay` of it has passed since the one before, at most 1.5 seconds. Time the model spent writing counts toward the pause, so a slow model gets no added wait. Pass `onTyping` to show a typing indicator during each pause, `pace: false` to hand each paragraph over the moment it is cut, or `pace: (text) => ms` for your own pause. With `buy.turn`'s `onItem`, use `turn.messageStream()` instead, which spaces the model's messages after the cards and the instant reply too.
228
319
 
229
320
  ## Sign up a new shopper
230
321
 
@@ -309,6 +400,8 @@ const buy = buyTool({
309
400
  });
310
401
  ```
311
402
 
403
+ This `onResult` hears every call the tool makes. To show each message's carts beside that message, pass `onResult` to `buy.turn`, or read `turn.results` once the turn ends.
404
+
312
405
  A refusal the tool turns into an answer, such as the Vault link for a shopper with no card, reaches `onResult` too. A call that throws calls nothing. An `onResult` that throws costs only that call to it, and the model still reads the answer. If you wrap `buy.answer` yourself, hand its errors to `forModelError` before a model reads them, and every link is taken out.
313
406
 
314
407
  ## Buy with a shopper's own token
@@ -370,6 +463,8 @@ const webhook = agentcardWebhook({ secret: process.env.AGENTCARD_WEBHOOK_SECRET
370
463
  export const POST = webhook.fetch;
371
464
  ```
372
465
 
466
+ Pass `agentcard`, the client your buy tools share, and the handler keeps each message it delivered for that shopper's next turn, as `listen` does: `agentcardWebhook({ secret, agentcard, onMessage })`. "Tell the model what Agentcard told the shopper" shows how the model reads it.
467
+
373
468
  Subscribe your endpoint to each event below by name. A catch-all `*` alone does not turn `purchase.message` on. `SHOPPER_EVENT_TYPES` holds the three names:
374
469
 
375
470
  | Event | `kind` | When it arrives | Who wrote `text` |
package/dist/ack.d.ts CHANGED
@@ -11,8 +11,16 @@
11
11
  * `my mom`, and it never cuts a phrase short: a subject that runs long or carries brackets gets the
12
12
  * template without one. A reply to a question, such as `yes`, `2` or a code, and a pick of a product the
13
13
  * shopper already saw, such as `the first one`, `I'll take the KODAK AA ($13.49)` or `#2`, gets no
14
- * instant reply at all, because it starts no search and Agentcard answers it fast.
14
+ * instant reply at all, because it starts no search and Agentcard answers it fast. So does a pick of a
15
+ * store the shopper saw, such as `order here`, or `Order from Tacos El Grullense` as a store card sends it.
15
16
  */
17
+ /**
18
+ * True when the message names one of these stores and nothing else to buy: the store's whole name, or
19
+ * two words of it in a row, such as `el grullense` for `Tacos El Grullense`, alone or after a frame such
20
+ * as `order from` or `let's go with`. One word of a name, such as `tacos`, and a dish beside the name,
21
+ * such as `order tacos from el farolito`, name no store.
22
+ */
23
+ export declare function namesStore(text: string, storeNames: readonly string[] | null | undefined): boolean;
16
24
  /** True when the shopper's words pick a product they already saw, such as `the first one`, `2` or a card's title with its price. */
17
25
  export declare function isPickText(text: string): boolean;
18
26
  /**
@@ -25,6 +33,15 @@ export declare function isNewRequest(text: string): boolean;
25
33
  export declare function paraphrase(text: string): string | null;
26
34
  /** The template at `index` for these words, with the paraphrase when one is safe. */
27
35
  export declare function ackAt(index: number, text: string): string;
36
+ /** The reply at `index` for a message that is no search: it says Agentcard is working, and never that it is looking for anything. */
37
+ export declare function waitAt(index: number): string;
38
+ /** What a slow call that is not a new request is doing: adding a product the shopper picked, choosing a store, or going through with a cart. */
39
+ export type SlowStep = 'product' | 'pick' | 'confirm';
40
+ /**
41
+ * The reply a slow pick or confirm sends once Agentcard takes longer than the delay: fixed words that
42
+ * name nothing the shopper said and never say `looking for`, because the shopper already chose.
43
+ */
44
+ export declare function slowStepLine(step: SlowStep): string;
28
45
  /** A template other than `avoid`, picked at random. */
29
46
  export declare function nextTemplate(avoid: number, random?: () => number): number;
30
47
  export interface AckForOptions {
@@ -37,12 +54,25 @@ export interface AckForOptions {
37
54
  nextAction?: string | null;
38
55
  /**
39
56
  * The last message the shopper read from you or from Agentcard. When it asks for an address, a name,
40
- * a phone number, an email or a code, the next message answers it, so it gets no instant reply.
57
+ * a phone number, an email or a code, the next message answers it, so it gets no instant reply. When
58
+ * it asks any other question, such as `what size do you want?`, a short reply that asks for nothing
59
+ * new, such as `im a 37`, `medium`, `2`, `black` or `carne asada`, answers it and gets none either.
41
60
  */
42
61
  asked?: string | null;
62
+ /**
63
+ * The names of the stores the last purchase answer showed the shopper, its `stores`. A message that
64
+ * names one of them and nothing else, such as `Order from Tacos El Grullense` as a store card sends
65
+ * it, picks that store, so it gets no instant reply.
66
+ */
67
+ stores?: readonly string[] | null;
43
68
  }
44
69
  /** True when a message asks the shopper for their own details or a code, in a question or a request such as `send me your address`. */
45
70
  export declare function asksForDetails(message: string | null | undefined): boolean;
71
+ /**
72
+ * True when the shopper's words answer the question the last message asked, such as `what size do you
73
+ * want?` or `which meat?`: the message asked a question, and the reply is short and opens no new request.
74
+ */
75
+ export declare function answersQuestion(text: string, asked: string | null | undefined): boolean;
46
76
  /**
47
77
  * The instant reply for a shopper's message, such as `on it, looking for usb-c cables under $20`, to
48
78
  * send the moment the message arrives and before your model runs; or null for a message that starts no
package/dist/ack.js CHANGED
@@ -11,7 +11,8 @@
11
11
  * `my mom`, and it never cuts a phrase short: a subject that runs long or carries brackets gets the
12
12
  * template without one. A reply to a question, such as `yes`, `2` or a code, and a pick of a product the
13
13
  * shopper already saw, such as `the first one`, `I'll take the KODAK AA ($13.49)` or `#2`, gets no
14
- * instant reply at all, because it starts no search and Agentcard answers it fast.
14
+ * instant reply at all, because it starts no search and Agentcard answers it fast. So does a pick of a
15
+ * store the shopper saw, such as `order here`, or `Order from Tacos El Grullense` as a store card sends it.
15
16
  */
16
17
  /** Each template twice: with the paraphrase in place of `{x}`, and without one. */
17
18
  const TEMPLATES = [
@@ -22,6 +23,19 @@ const TEMPLATES = [
22
23
  ['searching for {x}, one moment', 'checking on that for you now'],
23
24
  ['checking what i can find for {x}', 'on it, give me a moment'],
24
25
  ];
26
+ /**
27
+ * What a slow call sends for a message that is no search, such as `yes` or `ok lets checkout`: words
28
+ * that say Agentcard is working and never that it is looking for anything. As many as `TEMPLATES`, so
29
+ * the same rotation never repeats one twice in a row, and none the same as a template's, so a wait and
30
+ * the next search's reply never read alike.
31
+ */
32
+ const WAITS = ['one moment', 'just a sec', 'working on it', 'on it, one sec', 'hang tight', 'one sec, working on it'];
33
+ /** Words a reply opens with before it says what it means, such as `ok` in `ok lets checkout` or `yes,` in `yes, sounds good`. */
34
+ const FILLER = /^(?:(?:ok|okay|k|kk|yes|yeah|yea|yep|yup|ya|sure|alright|all right|cool|great|perfect|nice|so|then|and|um|uh|oh|well|hmm|now)[\s,]+)+/;
35
+ /** A bare acknowledgement, such as `its ok`, `thats fine`, `sounds good` or `works for me`, after the filler comes off. */
36
+ const ACKNOWLEDGED = /^(?:ok|okay|k|kk|fine|alright|all right|cool|great|perfect|good|nice|sure|yes|yeah|yep|yup|right|correct|exactly|agreed|works|(?:that|this|it) works|(?:it|that|this)(?:'?s| is) (?:ok|okay|fine|good|great|perfect|cool|right|correct|alright|all right|all good|it)|its (?:ok|okay|fine|good|great|perfect|cool|right|correct|alright|all good)|thats (?:ok|okay|fine|good|great|perfect|cool|right|correct|alright|it|all)|sounds (?:good|great|fine|perfect|right|ok|okay)|looks (?:good|great|fine|perfect|right|ok|okay)|all good|all set|no worries|no problem|np|got it|gotcha|understood|makes sense|(?:works|fine|ok|okay|good) (?:for|by|with) me|i'?m good|im good|we'?re good)(?: (?:then|now|with me|for me|to me))?$/;
37
+ /** A step toward paying for the cart already on the table, such as `lets checkout`, `place the order` or `pay`, after the filler comes off. */
38
+ const CHECKOUT = /^(?:(?:let'?s|lets|i want to|we want to|i'?d like to|id like to|i'?m ready to|im ready to|ready to|can you|could you|can we|go ahead and|just|please)\s+)*(?:check ?out|pay|pay now|pay for (?:it|this|that|them|the order|my order|everything)|place (?:the |my |this |that |our )?order|place it|confirm(?: (?:the|my) (?:order|purchase|cart)| it)?|complete (?:the |my )?(?:order|purchase|checkout)|finish (?:the |my )?(?:order|purchase|checkout)|proceed(?: to (?:checkout|payment|pay))?|go ahead|go for it|buy (?:it|them|this|that|these|those)(?: now)?|order it|book it|submit (?:the |my )?order|send (?:the |my )?order|do it|go|ready|i'?m ready|im ready)(?: now| then| already)?$/;
25
39
  /** Whole replies that answer a question the shopper was asked, after `please` and `thanks` come off. */
26
40
  const ANSWERS = new RegExp('^(?:' +
27
41
  [
@@ -104,6 +118,15 @@ const PICK_FRAME = new RegExp([
104
118
  ].join('|'));
105
119
  /** A frame in front of a reference to a product, taken off before the reference is read: `can i get` in `can i get the second one`. */
106
120
  const ASK_FRAME = /^(?:can i (?:get|have)|could i (?:get|have)|can you (?:get|add|order|buy)|give me|get me|gimme|i need|let me (?:get|have)|buy|order|get)\s+/;
121
+ /** A reference to a store the shopper already saw: `order here`, `let's order from here`, `this place`, `from that restaurant`. */
122
+ const STORE_REF = /^(?:(?:let'?s|lets|i'?ll|ill|we'?ll|i want to|i'?d like to|id like to) )?(?:(?:order|get it|buy|go|eat|try) )?(?:from |at |with )?(?:here|there|this (?:store|place|restaurant|spot|shop|one)|that (?:store|place|restaurant|spot|shop|one)|the (?:first|second|third|last|other) (?:store|place|restaurant|spot|shop))$/;
123
+ /**
124
+ * Words around a store's name that only pick it: `order from` in `Order from Tacos El Grullense`, as a
125
+ * store card sends it, or `let's go with` and `please`. A dish named next to the store, as in
126
+ * `order tacos from el farolito`, is a search and stays.
127
+ */
128
+ const STORE_FRAME = /^(?:(?:i'?ll|ill|i will|we'?ll|we will|let'?s|lets|let me|i want to|i'?d like to|id like to|we want to|can i|can we|could i|could we|can you|could you|i'?d|id)\s+)?(?:(?:order|get|buy|grab|try|do|pick|choose|take|use|go with|go for|go)\s+)?(?:(?:food|something|some food)\s+)?(?:(?:from|at|with)\s+)?/;
129
+ const STORE_TAIL = /\s+(?:one|place|store|restaurant|it is|then|instead|today|tonight|now)$/;
107
130
  const PLACE = '(?:first|second|third|fourth|fifth|sixth|last|other|\\d{1,2}(?:st|nd|rd|th))';
108
131
  /** A reference to a product the shopper already saw: `that one`, `the blue one`, `the second`, `2nd option`, `#2`, `option 3`. */
109
132
  const PICK_REF = new RegExp(`^(?:(?:the|that|this) (?:[\\w'$.-]+ ){0,3}one\\b|(?:that|this|those|these|both|it)(?: ones?)?$|the ${PLACE}(?: (?:one|option|item|product|choice|result))?$|${PLACE}(?: (?:one|option|item|product|choice|result)\\b|$)|(?:option|number|item|choice|#) ?#?\\d{1,2}\\b|\\d{1,2}$)`);
@@ -122,7 +145,44 @@ function isPick(line, words) {
122
145
  if (price && price.index !== undefined && !BUDGET.test(line.slice(0, price.index)))
123
146
  return true;
124
147
  const said = words.replace(OPENER, '');
125
- return PICK_FRAME.test(said) || PICK_REF.test(said.replace(ASK_FRAME, ''));
148
+ const asked = said.replace(ASK_FRAME, '');
149
+ return PICK_FRAME.test(said) || PICK_REF.test(asked) || PICK_REF.test(asked.replace(/^(?:from|at) /, '')) || STORE_REF.test(said) || STORE_REF.test(asked);
150
+ }
151
+ /**
152
+ * The ways a message can read as a store's name: as said, with the frame that only picks a store off,
153
+ * and with a closing word such as `place` off too, so `tacos el grullense` for `Order from Tacos El
154
+ * Grullense, please`. Each comes before the next, so a store whose own name holds one of those words,
155
+ * such as `Go Fish` or `Ramen Place`, still matches whole.
156
+ */
157
+ function storeReadings(text) {
158
+ const words = plain(String(text ?? '')).replace(/\b(?:please|pls|thanks|thank you|thx|ty)\b/g, ' ').replace(/[^\p{L}\p{N}']+/gu, ' ').trim().replace(OPENER, '');
159
+ const framed = words.replace(STORE_FRAME, '');
160
+ const tidy = (reading) => reading.replace(/'/g, ' ').replace(/\s+/g, ' ').trim();
161
+ // A `the` the shopper said stays in one reading, so `order from the melt` reads two words of `The Melt - Market St`.
162
+ const readings = [words, framed, framed.replace(/^the\s+/, '')].flatMap((reading) => [reading, reading.replace(STORE_TAIL, '')]);
163
+ return [...new Set(readings.map(tidy))].filter((reading) => reading.length >= 3);
164
+ }
165
+ /**
166
+ * True when the message names one of these stores and nothing else to buy: the store's whole name, or
167
+ * two words of it in a row, such as `el grullense` for `Tacos El Grullense`, alone or after a frame such
168
+ * as `order from` or `let's go with`. One word of a name, such as `tacos`, and a dish beside the name,
169
+ * such as `order tacos from el farolito`, name no store.
170
+ */
171
+ export function namesStore(text, storeNames) {
172
+ const readings = storeReadings(text);
173
+ if (readings.length === 0)
174
+ return false;
175
+ return (storeNames ?? []).some((raw) => {
176
+ const name = String(raw ?? '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
177
+ if (name === '')
178
+ return false;
179
+ return readings.some((said) => {
180
+ if (said === name || said === name.replace(/^the /, ''))
181
+ return true;
182
+ // Part of a name counts only as two words or more the shopper said, so `burger` never picks `The Burger Joint`.
183
+ return said.includes(' ') && ` ${name} `.includes(` ${said} `);
184
+ });
185
+ });
126
186
  }
127
187
  /** True when the shopper's words pick a product they already saw, such as `the first one`, `2` or a card's title with its price. */
128
188
  export function isPickText(text) {
@@ -154,6 +214,8 @@ const PRIVATE = [
154
214
  ];
155
215
  /** Words a cut paraphrase never ends on. */
156
216
  const TAIL = /\s+(?:for|with|and|or|but|the|a|an|to|of|in|at|on|from|under|over|below|above|near|by|who|that|which|my|some|around|about)$/;
217
+ /** Words a paraphrase never opens with, so a reply never reads `looking for from …`. */
218
+ const LEADING_PREPOSITION = /^(?:from|at|in|on|to|for|of|with|by|near|into|onto|off|via)\b/;
157
219
  const MAX_WORDS = 8;
158
220
  const MAX_WORD_LENGTH = 24;
159
221
  /** The text as one plain line: lowercase, no quotes, and no dash between words. */
@@ -172,6 +234,9 @@ export function isNewRequest(text) {
172
234
  return false;
173
235
  if (hasPersonalNumber(line) || PERSONAL.some((pattern) => pattern.test(line)) || PLACE_ONLY.some((pattern) => pattern.test(words)))
174
236
  return false;
237
+ const bare = words.replace(FILLER, '');
238
+ if (bare === '' || ACKNOWLEDGED.test(bare) || CHECKOUT.test(bare))
239
+ return false;
175
240
  return !ANSWERS.test(words) && !STATUS.test(words) && !ABOUT.test(words) && !CART_COMMAND.test(words) && !CODE.test(words) && !CHITCHAT.test(words) && !isPick(line, words);
176
241
  }
177
242
  /** A short paraphrase of what the shopper asks for, safe to read back to them, or null when none is. */
@@ -187,8 +252,9 @@ export function paraphrase(text) {
187
252
  before = rest;
188
253
  rest = rest.replace(LEAD, '');
189
254
  }
190
- rest = rest.replace(/[\s,.:!?]+$/, '');
191
- if (NO_PARAPHRASE.test(rest))
255
+ // `order from tacos el grullense` names the store, never `from tacos el grullense`.
256
+ rest = rest.replace(/^(?:from|at)\s+/, '').replace(/[\s,]+(?:please|pls|thanks|thank you|thx|ty)$/, '').replace(/[\s,.:!?]+$/, '');
257
+ if (NO_PARAPHRASE.test(rest) || LEADING_PREPOSITION.test(rest))
192
258
  return null;
193
259
  const words = rest.split(' ').filter(Boolean);
194
260
  // A long or messy subject is never cut to fit: the template goes out without one.
@@ -218,6 +284,17 @@ export function ackAt(index, text) {
218
284
  const what = paraphrase(text);
219
285
  return what ? withWhat.replace('{x}', what) : without;
220
286
  }
287
+ /** The reply at `index` for a message that is no search: it says Agentcard is working, and never that it is looking for anything. */
288
+ export function waitAt(index) {
289
+ return WAITS[((index % WAITS.length) + WAITS.length) % WAITS.length];
290
+ }
291
+ /**
292
+ * The reply a slow pick or confirm sends once Agentcard takes longer than the delay: fixed words that
293
+ * name nothing the shopper said and never say `looking for`, because the shopper already chose.
294
+ */
295
+ export function slowStepLine(step) {
296
+ return step === 'product' ? 'adding it now' : step === 'confirm' ? 'on it, one moment' : 'got it, one moment';
297
+ }
221
298
  /** A template other than `avoid`, picked at random. */
222
299
  export function nextTemplate(avoid, random = Math.random) {
223
300
  const pick = Math.floor(random() * (TEMPLATES.length - 1));
@@ -249,6 +326,23 @@ export function asksForDetails(message) {
249
326
  const sentences = plain(String(message ?? '')).split(/(?<=[.!?\n])\s+/);
250
327
  return sentences.some((sentence) => DETAILS.test(sentence) && (/\?\s*$/.test(sentence) || /^(?:please |pls )?(?:send|tell|share|give|reply|type|enter|text|drop|let me know|i need|i'll need|what|which|where)\b/.test(sentence)));
251
328
  }
329
+ /** Words that open a new request rather than an answer, such as `find`, `can you get` or `what about`. */
330
+ const REQUEST_LEAD = /^(?:(?:ok|okay|so|actually|also|and|no|nah|wait|hmm|um|uh|instead|nevermind|never mind|nvm|hey|hi|hello|yo|just|now|then|pls|please)[\s,]+)*(?:find|search|look|looking|show|get me|get us|help me|help|i'?m looking|im looking|i'?m after|im after|can you|could you|would you|will you|can i|could i|i want|i need|i'?d like|id like|we want|we need|we'?d like|order|buy|book|reserve|grab|how about|what about|do you have|do they have|is there|are there|something|anything|another|different|also)\b/;
331
+ /** The most words a reply runs to when it only answers a question, as `im a 37` or `extra large please` does. */
332
+ const MAX_ANSWER_WORDS = 4;
333
+ /**
334
+ * True when the shopper's words answer the question the last message asked, such as `what size do you
335
+ * want?` or `which meat?`: the message asked a question, and the reply is short and opens no new request.
336
+ */
337
+ export function answersQuestion(text, asked) {
338
+ if (!/\?/.test(String(asked ?? '')))
339
+ return false;
340
+ // A decimal size, such as `9.5`, stays one word.
341
+ const words = plain(String(text ?? '')).replace(/[!?,;:]+|\.(?!\d)|(?<!\d)\./g, ' ').replace(/\b(?:please|pls|thanks|thank you|thx|ty)\b/g, ' ').replace(/\s+/g, ' ').trim();
342
+ if (!/[\p{L}\p{N}]/u.test(words) || words.split(' ').length > MAX_ANSWER_WORDS)
343
+ return false;
344
+ return !REQUEST_LEAD.test(words);
345
+ }
252
346
  /** The steps a shopper answers with their own details or a code, never with a new request. */
253
347
  const ANSWERED_STEPS = new Set(['shipping_details', 'guest_details', 'verification_code']);
254
348
  /**
@@ -269,9 +363,9 @@ const ANSWERED_STEPS = new Set(['shipping_details', 'guest_details', 'verificati
269
363
  export function instantAck(shopperText, options = {}) {
270
364
  if (typeof options?.nextAction === 'string' && ANSWERED_STEPS.has(options.nextAction))
271
365
  return null;
272
- if (asksForDetails(options?.asked))
366
+ if (asksForDetails(options?.asked) || answersQuestion(shopperText, options?.asked))
273
367
  return null;
274
- if (!isNewRequest(shopperText))
368
+ if (!isNewRequest(shopperText) || namesStore(shopperText, options?.stores))
275
369
  return null;
276
370
  return ackAt(nextTemplate(templateOf(options?.previous)), shopperText);
277
371
  }
@@ -9,7 +9,7 @@
9
9
  * Both hand the model the JSON text `buy.handle` returns, and a failed call
10
10
  * as the error's message with every link taken out.
11
11
  */
12
- import type { BuyTool } from './buy-tool.js';
12
+ import type { BuyTool, BuyToolTurn } from './buy-tool.js';
13
13
  /** @deprecated The buy tool is Anthropic's runnable tool already. Kept for one release. */
14
14
  export type AnthropicBuyTool = BuyTool;
15
15
  /** @deprecated Pass the buy tool itself: `toolRunner({ tools: [buy] })`. Returns the same object, for one release. */
@@ -34,5 +34,5 @@ export interface OpenRouterToolkit<T> {
34
34
  tool: (config: any) => T;
35
35
  z: ZodLike;
36
36
  }
37
- /** The buy tool for OpenRouter's `callModel({ tools: [toOpenRouterTool(buy, { tool, z })] })`. */
38
- export declare function toOpenRouterTool<T>(buy: BuyTool, toolkit: OpenRouterToolkit<T>): T;
37
+ /** The buy tool, or one turn of it from `buy.turn`, for OpenRouter's `callModel({ tools: [toOpenRouterTool(buy, { tool, z })] })`. */
38
+ export declare function toOpenRouterTool<T>(buy: BuyTool | BuyToolTurn, toolkit: OpenRouterToolkit<T>): T;
package/dist/adapters.js CHANGED
@@ -2,7 +2,7 @@
2
2
  export function toAnthropicTool(tool) {
3
3
  return tool;
4
4
  }
5
- /** The buy tool for OpenRouter's `callModel({ tools: [toOpenRouterTool(buy, { tool, z })] })`. */
5
+ /** The buy tool, or one turn of it from `buy.turn`, for OpenRouter's `callModel({ tools: [toOpenRouterTool(buy, { tool, z })] })`. */
6
6
  export function toOpenRouterTool(buy, toolkit) {
7
7
  const { tool, z } = toolkit ?? {};
8
8
  if (typeof tool !== 'function' || !z || typeof z.object !== 'function') {