@agent-cards/commerce-sdk 0.10.0 → 0.13.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,7 +39,7 @@ 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.
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. 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".
43
43
 
44
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.
45
45
 
@@ -76,7 +76,7 @@ const answer = await new Anthropic().beta.messages.toolRunner({
76
76
 
77
77
  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.
78
78
 
79
- The model sends `ask`, `confirm`, `new_order`, `product_id` and `store_id`, and nothing else. 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.
79
+ 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.
80
80
 
81
81
  ## Use the tool in any framework
82
82
 
@@ -136,12 +136,48 @@ console.log(await result.getText());
136
136
 
137
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.
138
138
 
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.
139
+ 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. 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.
140
140
 
141
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.
142
142
 
143
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.
144
144
 
145
+ ## Open each step in a pop-up, and close it when it is done
146
+
147
+ 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.
148
+
149
+ 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:
150
+
151
+ | Link | `completed` | `declined` | `failed` | `expired` |
152
+ | --- | --- | --- | --- | --- |
153
+ | `vault` | `card_added` | | | `card_link_expired` |
154
+ | `store_login` | `store_connected` | | `store_connect_failed` | `store_connect_expired` |
155
+ | `approval` | `order_placed`, `order_not_placed_yet`, `order_unconfirmed`, `order_confirmed`, `order_failed`, `payment_declined` | `approval_declined` | | `approval_expired` |
156
+
157
+ 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.
158
+
159
+ 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:
160
+
161
+ ```ts
162
+ import { actionWindows } from '@agent-cards/commerce-sdk/browser';
163
+
164
+ const windows = actionWindows({
165
+ onEnded: (step, outcome) => markStepDone(step.id, outcome),
166
+ onClosed: (step) => keepOfferingStep(step.id),
167
+ });
168
+
169
+ // The link's own click handler, so the browser counts the tap as the reason for the pop-up. When the
170
+ // browser blocks it, `open` returns null and the link opens the way it would have, as a new tab.
171
+ stepLink.addEventListener('click', (event) => {
172
+ if (windows.open(link)) event.preventDefault();
173
+ });
174
+
175
+ // Each shopper message your server forwards from `listen` or the webhook, with its `kind` and `facts`.
176
+ onShopperMessage((message) => windows.settle(message));
177
+ ```
178
+
179
+ `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. It keeps its opener on purpose: a window opened with `noopener` hands back no handle, so the chat could neither close it nor tell when the shopper closed it. `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.
180
+
145
181
  ## Answer each shopper message in its own turn
146
182
 
147
183
  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:
@@ -150,7 +186,7 @@ A shopper can send a second message before the first is answered. The `sendLink`
150
186
  const buy = buyTool({ agentcard, externalUserId: shopper.id }); // one per shopper, kept
151
187
 
152
188
  async function onShopperMessage(message: string) {
153
- const turn = buy.turn(message, { onWorking: (text) => textShopper(shopper.id, text) });
189
+ const turn = buy.turn(message, { onReaction: (emoji) => reactToShopperMessage(shopper.id, emoji) });
154
190
  const runner = new Anthropic().beta.messages.toolRunner({
155
191
  model: 'claude-opus-5-5',
156
192
  max_tokens: 16000,
@@ -167,14 +203,39 @@ async function onShopperMessage(message: string) {
167
203
 
168
204
  - `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
205
  - `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.
206
+ - 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.
171
207
  - An `onProgress` passed to `buy.turn` hears only that turn's stages.
172
208
  - `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
209
 
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.
210
+ 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.
175
211
 
176
212
  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
213
 
214
+ ### Show Agentcard's reply as written
215
+
216
+ 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:
217
+
218
+ ```ts
219
+ const turn = buy.turn(message, { replyAsWritten: true, onMessage: (text) => sendText(shopper.id, text) });
220
+ const runner = new Anthropic().beta.messages.toolRunner({ model: 'claude-sonnet-5-5', max_tokens: 16000, tools: [turn], messages: turn.messages(history), stream: true });
221
+ let written: string | null = null;
222
+ for await (const stream of runner) {
223
+ written = turn.replyAsWritten;
224
+ if (written !== null) {
225
+ stream.abort(); // the reply says it already: no second model call
226
+ break;
227
+ }
228
+ const paragraphs = turn.messageStream();
229
+ stream.on('text', (delta) => paragraphs.push(delta));
230
+ await stream.done();
231
+ await paragraphs.end();
232
+ }
233
+ if (written !== null) await turn.say(written);
234
+ history = written !== null ? [...runner.params.messages, { role: 'assistant', content: written }] : runner.params.messages;
235
+ ```
236
+
237
+ Any other answer, such as an approval sent, 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.
238
+
178
239
  ### Hand the app the turn in order
179
240
 
180
241
  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:
@@ -194,9 +255,11 @@ for await (const stream of runner) {
194
255
  ```
195
256
 
196
257
  - 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.
258
+ - 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.
198
259
  - 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
260
  - `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.
261
+ - 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.
262
+ - 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.
200
263
 
201
264
  ## Tell the model what Agentcard told the shopper
202
265
 
@@ -227,6 +290,57 @@ With a webhook, pass the client to the handler: `agentcardWebhook({ secret, agen
227
290
 
228
291
  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
292
 
293
+ ## Take the next step once the shopper finishes one outside the chat
294
+
295
+ 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`:
296
+
297
+ ```ts
298
+ /** Each shopper's model runs, in order: the chat's own turns should wait on it too. */
299
+ const runs = new Map<string, Promise<void>>();
300
+
301
+ agentcard.listen({
302
+ onMessage: (shopperId, message) => {
303
+ const step = buyFor(shopperId).nextStep(message);
304
+ if (!step) return postToChat(shopperId, message.text);
305
+ // Run it without awaiting, so the listener moves on to the next event. One shopper's runs go in order, after
306
+ // their last one, so an older run never writes its history over a newer one.
307
+ // A turn that fails is released, so its text reaches later turns and a redelivery can take the step again.
308
+ const run = (runs.get(shopperId) ?? Promise.resolve()).then(() => runModel(shopperId, step)).catch(() => {
309
+ step.release();
310
+ agentcard.delivered(shopperId, message);
311
+ postToChat(shopperId, message.text);
312
+ });
313
+ runs.set(shopperId, run);
314
+ },
315
+ });
316
+
317
+ async function runModel(shopperId: string, turn: BuyToolTurn) {
318
+ const runner = anthropic.beta.messages.toolRunner({ model: 'claude-opus-5-5', max_tokens: 16000, tools: [turn], messages: turn.messages(histories.get(shopperId)) });
319
+ const answer = await runner;
320
+ histories.set(shopperId, runner.params.messages);
321
+ postToChat(shopperId, textOf(answer), turn.links);
322
+ }
323
+ ```
324
+
325
+ `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:
326
+
327
+ | The shopper finished | The next message |
328
+ | --- | --- |
329
+ | Saving a card, after their yes stopped for it | The approval link: `i sent you a link to approve the payment for your chicken salad. it works for about 15 minutes, and the most you'll be charged is $19.02.` |
330
+ | Saving a card, with no yes yet | 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?` |
331
+ | 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.` |
332
+ | 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 |
333
+ | 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` |
334
+
335
+ The turn moves the purchase only up to the shopper's own decision, and the tool holds it there whatever the model calls:
336
+
337
+ - 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.
338
+ - 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. It goes in once, for that cart's hash, so Agentcard refuses it if the cart changed. A shopper message after the yes that is not a plain go-ahead, such as "wait, make it two", takes it back. Any other confirm the model sends is refused by the tool, and nothing reaches Agentcard.
339
+ - The approval in the Vault is still the shopper's: the confirm sends the approval link, and the order goes in only once they approve it.
340
+ - A shopper who was not connected yet when they asked gets their own first ask run, in their words, once their card is saved.
341
+
342
+ `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.
343
+
230
344
  ## Add the exact product the shopper picks
231
345
 
232
346
  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.
@@ -235,7 +349,7 @@ Each product in the model's view carries a `product_id`, Agentcard's id for it w
235
349
 
236
350
  ## Show the stores the shopper picks from
237
351
 
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`.
352
+ 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`.
239
353
 
240
354
  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.
241
355
 
@@ -245,35 +359,62 @@ The tool sends each store page once for the calls the model makes in one turn. A
245
359
 
246
360
  ## Tell the shopper you are on it
247
361
 
248
- A first search can take Agentcard most of a minute. Pass `onWorking`, and the shopper reads an instant reply while Agentcard looks, 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`:
362
+ 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:
249
363
 
250
364
  ```ts
251
365
  const buy = buyTool({
252
366
  agentcard,
253
367
  externalUserId: shopper.id,
254
368
  sendLink: (link) => textShopper(shopper.id, link.url),
369
+ onReaction: (emoji) => reactToShopperMessage(shopper.id, emoji), // emoji is always '👀'
370
+ });
371
+ ```
372
+
373
+ 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 👀:
374
+
375
+ - A message that starts a search gets it the moment `buy.turn` opens, before your model runs.
376
+ - 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.
377
+ - A call that Agentcard has not answered within 1.5 seconds 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.
378
+ - 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.
379
+
380
+ 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.
381
+
382
+ 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.
383
+
384
+ ### Acknowledge in text, or not at all
385
+
386
+ 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`:
387
+
388
+ ```ts
389
+ const buy = buyTool({
390
+ agentcard,
391
+ externalUserId: shopper.id,
392
+ ack: 'text',
255
393
  onWorking: (text) => textShopper(shopper.id, text),
256
394
  });
257
395
  ```
258
396
 
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.
397
+ `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.
260
398
 
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.
399
+ In text mode, 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.
262
400
 
263
- Pass `ack: false` to send none, `ack: (ask) => text` to write your own from the shopper's words, where an empty answer sends none, and `ackDelayMs` to change the 1.5 seconds.
401
+ 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.5 seconds for either mode.
264
402
 
265
- The model takes several seconds to call the tool after the shopper's message arrives. To answer sooner, call `buy.instantAck(message)` the moment the message arrives, before your model runs, and send the text it returns:
403
+ 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:
266
404
 
267
405
  ```ts
406
+ const reaction = buy.instantReaction(message); // '👀' or null
407
+ if (reaction) await reactToShopperMessage(shopper.id, reaction);
408
+ // In text mode instead:
268
409
  const ack = buy.instantAck(message);
269
410
  if (ack) await textShopper(shopper.id, ack);
270
411
  ```
271
412
 
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.
413
+ 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.
273
414
 
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`.
415
+ `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 `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
416
 
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.
417
+ 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.
277
418
 
278
419
  ## Show what Agentcard is doing
279
420
 
@@ -315,7 +456,7 @@ await paragraphs.end();
315
456
 
316
457
  `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
458
 
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.
459
+ Each paragraph goes out the moment it is cut. To space them like someone texting, pass `pace: true`: 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, or `pace: (text) => ms` for your own pause. With `buy.turn`'s `onItem`, use `turn.messageStream()` instead, which keeps the model's messages after the cards and the instant reply too.
319
460
 
320
461
  ## Sign up a new shopper
321
462
 
@@ -470,12 +611,15 @@ Subscribe your endpoint to each event below by name. A catch-all `*` alone does
470
611
  | Event | `kind` | When it arrives | Who wrote `text` |
471
612
  |---|---|---|---|
472
613
  | `purchase.message` | `card_added` | The shopper saved a card from the card link your app sent, or picked one they keep. | Agentcard |
614
+ | `purchase.message` | `store_connected` | The shopper signed in to their own account at the store from the sign-in link your app sent. | Agentcard |
473
615
  | `purchase.message` | `order_placed` | The shopper approved, and Agentcard placed the order. | Agentcard |
474
616
  | `purchase.message` | `order_failed` | The order failed after the shopper approved. | Agentcard |
475
617
  | `purchase.message` | `order_not_placed_yet` | The shopper approved, and the order is not placed yet. | Agentcard |
476
618
  | `purchase.message` | `order_unconfirmed` | The store's reply to the order was lost, so the order may exist, and Agentcard is confirming it. | Agentcard |
477
619
  | `purchase.message` | `approval_expiring` | The approval link expires soon. `text` carries the link. | Agentcard |
478
620
  | `purchase.message` | `approval_expired` | The approval expired before the shopper answered, and no charge went through. | Agentcard |
621
+ | `purchase.message` | `approval_declined` | The shopper declined the approval, and no charge went through. | Agentcard |
622
+ | `purchase.message` | `payment_declined` | The shopper approved, and the payment was refused before the order went in, such as a declined card. `facts.reason` says why. No charge went through. | Agentcard |
479
623
  | `purchase.message` | `card_link_unfinished` | The shopper has not added a card from their card link. `text` carries the link again. | Agentcard |
480
624
  | `purchase.message` | `card_link_expired` | The card link expired before the shopper added a card. | Agentcard |
481
625
  | `purchase.message` | `cart_idle` | A cart sat, and nobody sent it for approval. | Agentcard |
@@ -561,11 +705,19 @@ The facts are `merchantName`, `totalCents`, `currency`, `cardBrand`, `cardLast4`
561
705
  | `order_not_placed_yet` | The shopper approved, and the order is not placed yet. |
562
706
  | `approval_expiring` | The approval link expires soon. `text` carries the link. |
563
707
  | `approval_expired` | The approval expired before the shopper answered, and no charge went through. |
708
+ | `approval_declined` | The shopper declined the approval, and no charge went through. |
709
+ | `payment_declined` | The shopper approved, and the payment was refused before the order went in. `facts.reason` says why. |
564
710
  | `card_link_unfinished` | The shopper has not added a card from their card link. `text` carries the link again. |
565
711
  | `card_link_expired` | The card link expired before the shopper added a card. |
566
712
  | `cart_idle` | A cart sat, and nobody sent it for approval. |
567
713
  | `order_unconfirmed` | The store's reply to the order was lost, and Agentcard is confirming it. |
568
714
  | `card_added` | The shopper saved a card from the card link your app sent. |
715
+ | `store_connected` | The shopper signed in to their own account at the store from the sign-in link your app sent, such as DoorDash, so the order can go through on it. |
716
+ | `store_connect_failed` | The shopper's sign-in to the store did not go through, and the link no longer works. `text` offers a new one. |
717
+ | `store_connect_expired` | The sign-in link expired before the shopper finished signing in. `text` offers a new one. |
718
+ | `cart_ready` | The store's checkout built the cart an answer named as still being built (`cart_building`). `text` gives the store's own total, and nothing is paid until the shopper says yes to it. |
719
+ | `cart_needs_input` | That checkout asks the shopper something first, such as a size, a delivery address, a sign-in to the store or an approval of the most the order can cost. Their reply continues the cart. |
720
+ | `cart_failed` | That checkout built no cart, and nothing was ordered or charged. |
569
721
 
570
722
  Agentcard can add a kind later. A kind your code does not know still carries `text`, ready to send as is.
571
723
 
package/dist/ack.d.ts CHANGED
@@ -89,5 +89,34 @@ export declare function answersQuestion(text: string, asked: string | null | und
89
89
  * ```
90
90
  */
91
91
  export declare function instantAck(shopperText: string, options?: AckForOptions): string | null;
92
+ /**
93
+ * The reaction the tool puts on a shopper's message to say it is on it: 👀, as a chat app shows a
94
+ * tapback on the shopper's own bubble. It names nothing the shopper said, so it can never read a
95
+ * request back wrong.
96
+ */
97
+ export declare const ACK_REACTION = "\uD83D\uDC40";
98
+ /**
99
+ * The reaction for a shopper's message, `👀`, or null for a message that starts no search. It reads the
100
+ * message exactly as `instantAck` does, so a pick, an address, an answer to a question, a step toward
101
+ * checkout or an acknowledgement such as `its ok` gets none.
102
+ *
103
+ * ```ts
104
+ * instantReaction('can you find me usb-c cables under $20'); // '👀'
105
+ * instantReaction('yes'); // null
106
+ * ```
107
+ */
108
+ export declare function instantReaction(shopperText: string, options?: AckForOptions): string | null;
109
+ /**
110
+ * True when the shopper's words leave a yes they already gave standing: an acknowledgement such as
111
+ * `ok` or `sounds good`, a go-ahead such as `lets place it`, or a report that they took the step they
112
+ * were sent, such as `done` or `signed in`. False for anything else, `wait`, `no`, `cancel`, a
113
+ * question or a new request among them, which a yes given before it does not cover.
114
+ *
115
+ * ```ts
116
+ * keepsYes('ok done'); // true
117
+ * keepsYes('wait, make it two'); // false
118
+ * ```
119
+ */
120
+ export declare function keepsYes(shopperText: string): boolean;
92
121
  /** The earlier name of `instantAck`, kept for apps that call it. */
93
122
  export declare function ackFor(text: string, options?: AckForOptions): string | null;
package/dist/ack.js CHANGED
@@ -67,6 +67,13 @@ const CART_COMMAND = new RegExp('^(?:(?:ok|okay|so|actually|wait|and|um|uh)[\\s,
67
67
  ')\\b');
68
68
  /** Small talk that asks for nothing to be bought, after `please` and `thanks` come off. */
69
69
  const CHITCHAT = /^(?:(?:hi|hey|hello|yo|hiya|howdy|sup)(?: there| again)?|good (?:morning|afternoon|evening|night)|g(?:oo)?d ?night|how are (?:you|u)(?: doing)?(?: today)?|how'?s it going|what'?s up|whats up|wassup|who are (?:you|u)|what (?:can|do) (?:you|u) do|are (?:you|u) (?:a )?(?:bot|real|human|there)|you there|test(?:ing)?|bye|see (?:you|ya)|later|love (?:it|you|u)|awesome|amazing|wow|omg|ha(?:ha)+|lmao|:\)|ok cool|cool thanks|so much|very much|a lot)$/;
70
+ /**
71
+ * A question about what the agent can buy or where it shops, such as `what stores can you buy from`,
72
+ * `where can you shop` or `which stores do you support`: the model answers it in a line or two, with no
73
+ * search, so no instant reply says it is looking. A question that names a product, such as `which stores
74
+ * can you buy aa batteries from`, is a search and keeps its reply.
75
+ */
76
+ const CAPABILITY = /^(?:(?:ok|okay|so|and|but|hey|hi|um|uh|also|wait)[\s,]+)*(?:(?:what|which|where)(?: (?:kinds?|types?|sorts?) of)?(?: (?:other|else|all|the))?(?: (?:stores?|shops?|places?|sites?|websites?|merchants?|retailers?|restaurants?|brands?|products?|items?|things?|stuff))?(?: else)? (?:can|do|could|will|are) (?:you|u)(?: (?:able to|even|actually|also))? (?:buy|shop|order|get|purchase|do|use|support|work with|deliver|cover|access|sell|carry|offer|stock)(?: (?:from|at|on|in|with|for me|for us|to))?(?: for me)?|(?:what|which) (?:stores?|shops?|merchants?|retailers?|sites?) (?:are )?(?:supported|available|connected|on here)|(?:what|which) (?:stores?|shops?|merchants?|retailers?) do (?:you|u) (?:have|know|use|support|work with)|what (?:else )?(?:do|can) (?:you|u) (?:offer|sell|carry|get (?:me|us)|buy (?:me|us)|order (?:me|us)|find (?:me|us)))(?: (?:right now|here|today))?$/;
70
77
  /** Unit words a number names a product by, such as `10000 mah`, so it is never read as a zip code. */
71
78
  const UNIT = '(?:packs?|count|ct|pcs?|pieces?|oz|ml|l|g|kg|gb|tb|mb|mah|w|watts?|v|volts?|lumens?|lm|inch(?:es)?|in|ft|feet|mm|cm|lbs?|dollars?|bucks|rpm|dpi|btu|cal|calories|thread)';
72
79
  /**
@@ -237,7 +244,7 @@ export function isNewRequest(text) {
237
244
  const bare = words.replace(FILLER, '');
238
245
  if (bare === '' || ACKNOWLEDGED.test(bare) || CHECKOUT.test(bare))
239
246
  return false;
240
- return !ANSWERS.test(words) && !STATUS.test(words) && !ABOUT.test(words) && !CART_COMMAND.test(words) && !CODE.test(words) && !CHITCHAT.test(words) && !isPick(line, words);
247
+ return !ANSWERS.test(words) && !STATUS.test(words) && !ABOUT.test(words) && !CART_COMMAND.test(words) && !CODE.test(words) && !CHITCHAT.test(words) && !CAPABILITY.test(words) && !isPick(line, words);
241
248
  }
242
249
  /** A short paraphrase of what the shopper asks for, safe to read back to them, or null when none is. */
243
250
  export function paraphrase(text) {
@@ -369,6 +376,46 @@ export function instantAck(shopperText, options = {}) {
369
376
  return null;
370
377
  return ackAt(nextTemplate(templateOf(options?.previous)), shopperText);
371
378
  }
379
+ /**
380
+ * The reaction the tool puts on a shopper's message to say it is on it: 👀, as a chat app shows a
381
+ * tapback on the shopper's own bubble. It names nothing the shopper said, so it can never read a
382
+ * request back wrong.
383
+ */
384
+ export const ACK_REACTION = '👀';
385
+ /**
386
+ * The reaction for a shopper's message, `👀`, or null for a message that starts no search. It reads the
387
+ * message exactly as `instantAck` does, so a pick, an address, an answer to a question, a step toward
388
+ * checkout or an acknowledgement such as `its ok` gets none.
389
+ *
390
+ * ```ts
391
+ * instantReaction('can you find me usb-c cables under $20'); // '👀'
392
+ * instantReaction('yes'); // null
393
+ * ```
394
+ */
395
+ export function instantReaction(shopperText, options = {}) {
396
+ return instantAck(shopperText, options) === null ? null : ACK_REACTION;
397
+ }
398
+ /**
399
+ * True when the shopper's words leave a yes they already gave standing: an acknowledgement such as
400
+ * `ok` or `sounds good`, a go-ahead such as `lets place it`, or a report that they took the step they
401
+ * were sent, such as `done` or `signed in`. False for anything else, `wait`, `no`, `cancel`, a
402
+ * question or a new request among them, which a yes given before it does not cover.
403
+ *
404
+ * ```ts
405
+ * keepsYes('ok done'); // true
406
+ * keepsYes('wait, make it two'); // false
407
+ * ```
408
+ */
409
+ export function keepsYes(shopperText) {
410
+ const words = plain(String(shopperText ?? '')).replace(/[!?.,;:]+/g, ' ').replace(/\b(?:please|pls|thanks|thank you|thx|ty)\b/g, ' ').replace(/\s+/g, ' ').trim();
411
+ if (words === '')
412
+ return false;
413
+ const bare = words.replace(FILLER, '');
414
+ if (bare === '')
415
+ return true;
416
+ // A report after filler ("well signed in", "k connected") is read on its bare words too (review of #9220).
417
+ return ACKNOWLEDGED.test(bare) || CHECKOUT.test(bare) || STATUS.test(words) || STATUS.test(bare) || /^(?:done|added(?: it)?|i added(?: it| the card| a card)?)$/.test(bare);
418
+ }
372
419
  /** The earlier name of `instantAck`, kept for apps that call it. */
373
420
  export function ackFor(text, options = {}) {
374
421
  return instantAck(text, options);
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The steps a shopper takes outside the chat: adding a card (`vault`), signing in to a store
3
+ * (`store_login`) and approving a payment (`approval`). Each one is a link the buy tool hands over,
4
+ * and each one ends with a shopper message Agentcard sends: this file says which message ends which
5
+ * link, and how. It imports nothing, so a browser bundle can use it as well as a server.
6
+ */
7
+ /** A link the shopper opens to do something outside the chat. */
8
+ export type ActionLinkKind = 'vault' | 'store_login' | 'approval';
9
+ /** The kinds of link that are a step outside the chat. */
10
+ export declare const ACTION_LINK_KINDS: readonly ActionLinkKind[];
11
+ /**
12
+ * How a step ended:
13
+ * - `completed`: the shopper finished it: a card saved, a store signed in to, a payment approved.
14
+ * An approval stays `completed` when the order then failed or the payment was refused, since the
15
+ * page has nothing more to do; the message itself tells the shopper what happened next.
16
+ * - `declined`: the shopper declined the payment on the approval page.
17
+ * - `failed`: the step did not go through, and its link no longer works (a store sign-in that failed).
18
+ * - `expired`: the link expired before the shopper finished.
19
+ */
20
+ export type ActionLinkOutcome = 'completed' | 'declined' | 'failed' | 'expired';
21
+ /**
22
+ * For each kind of step, the shopper message kinds that end it, and how. Every other message kind,
23
+ * such as `card_link_unfinished` or `approval_expiring`, leaves the step open.
24
+ */
25
+ export declare const ACTION_LINK_ENDINGS: Readonly<Record<ActionLinkKind, Readonly<Record<string, ActionLinkOutcome>>>>;
26
+ /** Whether a link is a step outside the chat: a `vault`, `store_login` or `approval` link with a page to open. */
27
+ export declare function isActionLink<T extends {
28
+ kind: string;
29
+ url?: string | null;
30
+ }>(link: T): link is T & {
31
+ kind: ActionLinkKind;
32
+ url: string;
33
+ };
34
+ /**
35
+ * A stable id for a step's link, such as `vault_1x9k2qd`: the same for the same link every time it is
36
+ * handed over, on the server and in the browser, and with no part of the URL in it, so it is safe in
37
+ * a log, a DOM key or a window name. The buy tool puts it on each step link as `id`.
38
+ */
39
+ export declare function actionLinkId(link: {
40
+ kind: string;
41
+ url: string;
42
+ }): string;
43
+ /** What `actionLinkOutcome` reads of a shopper message: its kind, and the link URLs and the store its facts name, when they name them. */
44
+ export interface ActionLinkMessage {
45
+ kind: string;
46
+ facts?: {
47
+ approvalUrl?: string;
48
+ cardLinkUrl?: string;
49
+ merchantName?: string;
50
+ } | null;
51
+ }
52
+ /**
53
+ * How `message` ends the step `link` is, or null when it leaves it open: a message about another
54
+ * kind of step, a reminder such as `card_link_unfinished`, a message whose facts name another link of
55
+ * the same kind (`facts.approvalUrl` or `facts.cardLinkUrl`), or one about another store: a store
56
+ * login or an approval link that names its `store` stays open when `facts.merchantName` names a
57
+ * different one, since a shopper can sign in to two stores, or approve two stores' carts, at once.
58
+ * A message whose facts name neither ends the shopper's open step of its kind.
59
+ */
60
+ export declare function actionLinkOutcome(link: {
61
+ kind: string;
62
+ url?: string | null;
63
+ store?: string | null;
64
+ }, message: ActionLinkMessage): ActionLinkOutcome | null;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The steps a shopper takes outside the chat: adding a card (`vault`), signing in to a store
3
+ * (`store_login`) and approving a payment (`approval`). Each one is a link the buy tool hands over,
4
+ * and each one ends with a shopper message Agentcard sends: this file says which message ends which
5
+ * link, and how. It imports nothing, so a browser bundle can use it as well as a server.
6
+ */
7
+ /** The kinds of link that are a step outside the chat. */
8
+ export const ACTION_LINK_KINDS = ['vault', 'store_login', 'approval'];
9
+ /**
10
+ * For each kind of step, the shopper message kinds that end it, and how. Every other message kind,
11
+ * such as `card_link_unfinished` or `approval_expiring`, leaves the step open.
12
+ */
13
+ export const ACTION_LINK_ENDINGS = {
14
+ vault: { card_added: 'completed', card_link_expired: 'expired' },
15
+ store_login: { store_connected: 'completed', store_connect_failed: 'failed', store_connect_expired: 'expired' },
16
+ approval: {
17
+ order_placed: 'completed',
18
+ order_not_placed_yet: 'completed',
19
+ order_unconfirmed: 'completed',
20
+ order_confirmed: 'completed',
21
+ order_failed: 'completed',
22
+ payment_declined: 'completed',
23
+ approval_declined: 'declined',
24
+ approval_expired: 'expired',
25
+ },
26
+ };
27
+ /** Whether a link is a step outside the chat: a `vault`, `store_login` or `approval` link with a page to open. */
28
+ export function isActionLink(link) {
29
+ return ACTION_LINK_KINDS.includes(link.kind) && typeof link.url === 'string' && link.url !== '';
30
+ }
31
+ /** FNV-1a, 32 bits, written out so the id reads the same in every runtime, with no crypto import. */
32
+ function fnv1a(text) {
33
+ let hash = 0x811c9dc5;
34
+ for (let i = 0; i < text.length; i++) {
35
+ hash ^= text.charCodeAt(i);
36
+ hash = Math.imul(hash, 0x01000193) >>> 0;
37
+ }
38
+ return hash;
39
+ }
40
+ /**
41
+ * A stable id for a step's link, such as `vault_1x9k2qd`: the same for the same link every time it is
42
+ * handed over, on the server and in the browser, and with no part of the URL in it, so it is safe in
43
+ * a log, a DOM key or a window name. The buy tool puts it on each step link as `id`.
44
+ */
45
+ export function actionLinkId(link) {
46
+ return `${link.kind}_${fnv1a(`${link.kind} ${link.url}`).toString(36)}`;
47
+ }
48
+ /** A store's name reduced to its letters and digits, so "Walmart" and "walmart.com" read alike. */
49
+ const storeKey = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, '');
50
+ /** Whether two names are the same store: one reduced name holds the other. */
51
+ function sameStore(a, b) {
52
+ const x = storeKey(a);
53
+ const y = storeKey(b);
54
+ return x === '' || y === '' || x.includes(y) || y.includes(x);
55
+ }
56
+ /**
57
+ * How `message` ends the step `link` is, or null when it leaves it open: a message about another
58
+ * kind of step, a reminder such as `card_link_unfinished`, a message whose facts name another link of
59
+ * the same kind (`facts.approvalUrl` or `facts.cardLinkUrl`), or one about another store: a store
60
+ * login or an approval link that names its `store` stays open when `facts.merchantName` names a
61
+ * different one, since a shopper can sign in to two stores, or approve two stores' carts, at once.
62
+ * A message whose facts name neither ends the shopper's open step of its kind.
63
+ */
64
+ export function actionLinkOutcome(link, message) {
65
+ if (!isActionLink(link))
66
+ return null;
67
+ const outcome = ACTION_LINK_ENDINGS[link.kind][message.kind];
68
+ if (!outcome)
69
+ return null;
70
+ const named = link.kind === 'approval' ? message.facts?.approvalUrl : link.kind === 'vault' ? message.facts?.cardLinkUrl : undefined;
71
+ if (named && named !== link.url)
72
+ return null;
73
+ const store = message.facts?.merchantName;
74
+ if (link.store && store && !sameStore(link.store, store))
75
+ return null;
76
+ return outcome;
77
+ }