@agent-cards/commerce-sdk 0.11.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:
@@ -175,6 +211,31 @@ A callback you leave out of `buy.turn` falls back to the one the tool was built
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
 
@@ -342,7 +456,7 @@ await paragraphs.end();
342
456
 
343
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.
344
458
 
345
- 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.
346
460
 
347
461
  ## Sign up a new shopper
348
462
 
@@ -601,6 +715,9 @@ The facts are `merchantName`, `totalCents`, `currency`, `cardBrand`, `cardLast4`
601
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. |
602
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. |
603
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. |
604
721
 
605
722
  Agentcard can add a kind later. A kind your code does not know still carries `text`, ready to send as is.
606
723
 
package/dist/ack.d.ts CHANGED
@@ -106,5 +106,17 @@ export declare const ACK_REACTION = "\uD83D\uDC40";
106
106
  * ```
107
107
  */
108
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;
109
121
  /** The earlier name of `instantAck`, kept for apps that call it. */
110
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) {
@@ -388,6 +395,27 @@ export const ACK_REACTION = '👀';
388
395
  export function instantReaction(shopperText, options = {}) {
389
396
  return instantAck(shopperText, options) === null ? null : ACK_REACTION;
390
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
+ }
391
419
  /** The earlier name of `instantAck`, kept for apps that call it. */
392
420
  export function ackFor(text, options = {}) {
393
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
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * `@agent-cards/commerce-sdk/browser`: the part of the package that runs in the shopper's browser.
3
+ * It imports no Node module, so any bundler takes it.
4
+ *
5
+ * `actionWindows` opens each step the shopper takes outside the chat (adding a card, signing in to a
6
+ * store, approving a payment) in a pop-up window over the chat, and closes it the moment the shopper
7
+ * message that ends the step arrives. On a phone, where a browser opens the pop-up as a tab, it
8
+ * closes that tab the same way.
9
+ */
10
+ import { type ActionLinkKind, type ActionLinkMessage, type ActionLinkOutcome } from './action-links.js';
11
+ export { ACTION_LINK_ENDINGS, ACTION_LINK_KINDS, actionLinkId, actionLinkOutcome, isActionLink, type ActionLinkKind, type ActionLinkMessage, type ActionLinkOutcome, } from './action-links.js';
12
+ /** The part of a window `window.open` returns that `actionWindows` uses. */
13
+ export interface PopupLike {
14
+ readonly closed: boolean;
15
+ close(): void;
16
+ focus(): void;
17
+ }
18
+ /** The part of the page's `window` that `actionWindows` uses. */
19
+ export interface WindowLike {
20
+ open(url: string, target: string, features: string): PopupLike | null;
21
+ readonly screenX?: number;
22
+ readonly screenY?: number;
23
+ readonly outerWidth?: number;
24
+ readonly outerHeight?: number;
25
+ readonly screen?: {
26
+ readonly availWidth?: number;
27
+ readonly availHeight?: number;
28
+ };
29
+ setInterval(handler: () => void, ms: number): unknown;
30
+ clearInterval(id: unknown): void;
31
+ }
32
+ /** A step's link, as the buy tool hands it over (`BuyToolLink`) or as an app passes it on: its kind and its page. */
33
+ export interface ActionLinkLike {
34
+ kind: string;
35
+ url?: string | null;
36
+ /** The store a store login or an approval is for, as the buy tool names it, so a message about another store leaves it open. */
37
+ store?: string | null;
38
+ /** The link's `actionLinkId`, when the buy tool put one on it. Read from the link when absent. */
39
+ id?: string;
40
+ }
41
+ /** A step open in a pop-up. */
42
+ export interface ActionWindow {
43
+ /** The link's `actionLinkId`. */
44
+ readonly id: string;
45
+ readonly kind: ActionLinkKind;
46
+ readonly url: string;
47
+ /** The store the step is for, when the link named one. */
48
+ readonly store: string | null;
49
+ /** False once the window closed, by the shopper or by `actionWindows`. */
50
+ readonly open: boolean;
51
+ }
52
+ export interface ActionWindowsOptions {
53
+ /** The page's window. Defaults to `globalThis.window`; pass one to test with a fake. */
54
+ window?: WindowLike;
55
+ /** The pop-up's size in CSS pixels, kept inside the screen. Defaults to 480 by 760. */
56
+ width?: number;
57
+ height?: number;
58
+ /** How often to check whether the shopper closed a pop-up, in milliseconds. Defaults to 500. */
59
+ pollMs?: number;
60
+ /**
61
+ * Hears a step that ended while its pop-up was open, after `settle` closed the pop-up. Show the step
62
+ * as done here.
63
+ */
64
+ onEnded?: (step: ActionWindow, outcome: ActionLinkOutcome) => void;
65
+ /**
66
+ * Hears a pop-up the shopper closed before the step ended. The step is still open: keep offering
67
+ * its link, so the shopper can open it again.
68
+ */
69
+ onClosed?: (step: ActionWindow) => void;
70
+ }
71
+ export interface ActionWindows {
72
+ /**
73
+ * Opens the step's page in a pop-up centered over the chat, or brings its pop-up to the front when
74
+ * one is open already. Returns null when the link is not a step with a page, or when the browser
75
+ * blocked the pop-up: then let the link open the way it would have, as a new tab. Call it from the
76
+ * tap's click handler, so the browser counts the tap as the reason for the pop-up:
77
+ *
78
+ * ```js
79
+ * a.addEventListener('click', (event) => { if (windows.open(link)) event.preventDefault(); });
80
+ * ```
81
+ */
82
+ open(link: ActionLinkLike): ActionWindow | null;
83
+ /**
84
+ * Hands over a shopper message (`ShopperMessage`, or any object with its `kind` and `facts`, whose
85
+ * `merchantName` keeps one store's message from closing another store's step).
86
+ * Each open pop-up whose step the message ends closes, and `onEnded` hears it. Returns the steps it
87
+ * ended, with how each ended; an empty list when the message ends none.
88
+ */
89
+ settle(message: ActionLinkMessage): {
90
+ step: ActionWindow;
91
+ outcome: ActionLinkOutcome;
92
+ }[];
93
+ /** The open pop-up for the link, or null. */
94
+ get(link: ActionLinkLike): ActionWindow | null;
95
+ /**
96
+ * Stops watching every pop-up, and leaves each one as it is: a chat that reloads or remounts never
97
+ * shuts the page a shopper is typing a card into. Call it when the chat goes away.
98
+ */
99
+ dispose(): void;
100
+ }
101
+ /**
102
+ * Opens each step outside the chat in a pop-up and closes it when the step ends. One per chat page.
103
+ * The pop-up keeps its opener, deliberately: a window opened with `noopener` hands back no handle,
104
+ * so the chat could neither close it nor tell when the shopper closed it.
105
+ */
106
+ export declare function actionWindows(options?: ActionWindowsOptions): ActionWindows;