@agent-cards/commerce-sdk 0.10.0 → 0.11.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
@@ -150,7 +150,7 @@ A shopper can send a second message before the first is answered. The `sendLink`
150
150
  const buy = buyTool({ agentcard, externalUserId: shopper.id }); // one per shopper, kept
151
151
 
152
152
  async function onShopperMessage(message: string) {
153
- const turn = buy.turn(message, { onWorking: (text) => textShopper(shopper.id, text) });
153
+ const turn = buy.turn(message, { onReaction: (emoji) => reactToShopperMessage(shopper.id, emoji) });
154
154
  const runner = new Anthropic().beta.messages.toolRunner({
155
155
  model: 'claude-opus-5-5',
156
156
  max_tokens: 16000,
@@ -167,11 +167,11 @@ async function onShopperMessage(message: string) {
167
167
 
168
168
  - `turn.links` holds each link the turn's calls handed over, in order: the Vault, approval and store login links, each product's store page, and each store card's page. The model reads that each one went out, so send them with the reply. Pass `sendLink` to `buy.turn` as well to send each one the moment the tool hands it over, such as down a server-sent event stream. A link your `sendLink` throws on stays out of `turn.links`.
169
169
  - `turn.results` holds the whole answer of each call, and an `onResult` passed to `buy.turn` hears each one as it arrives.
170
- - With `onWorking`, `buy.turn` sends the instant reply at once, before your model runs: the one `buy.instantAck` would return, or the one your `ack` function writes. You never call `instantAck` yourself. A slow call of the turn sends its reply to that `onWorking` too, and `turn.ack` holds the text the turn sent when it opened.
170
+ - 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
171
  - An `onProgress` passed to `buy.turn` hears only that turn's stages.
172
172
  - `turn.agentcardMessages` holds each message `agentcard.listen` or `agentcardWebhook` posted into this shopper's chat that the model has not read yet, and `turn.messages(history)` hands them to the model.
173
173
 
174
- A callback you leave out of `buy.turn` falls back to the one the tool was built with, so a tool whose `sendLink` texts the shopper keeps texting, and `turn.links` lists the same links. The turn is the tool, with the same name, schema and methods, so pass it to every framework the way you pass `buy`: `tools: [turn]`, `tools: { buy: turn }`, `tools: [turn.openai]` with `turn.handle`, or `toOpenRouterTool(turn, { tool, z })`. `turn.ack`, `turn.links` and `turn.results` stay out of the tool's JSON, so a tool runner sends the API the same fields. Build the tool once per shopper and open a turn from it for each message: the turns share the shopper's conversation, so a card a shopper already holds from an earlier turn is not sent again.
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.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
175
 
176
176
  A call made on `buy` itself still works as before: it belongs to the turn the tool reads from its timing, and its links go to the tool's `sendLink`.
177
177
 
@@ -245,35 +245,62 @@ The tool sends each store page once for the calls the model makes in one turn. A
245
245
 
246
246
  ## Tell the shopper you are on it
247
247
 
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`:
248
+ 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
249
 
250
250
  ```ts
251
251
  const buy = buyTool({
252
252
  agentcard,
253
253
  externalUserId: shopper.id,
254
254
  sendLink: (link) => textShopper(shopper.id, link.url),
255
+ onReaction: (emoji) => reactToShopperMessage(shopper.id, emoji), // emoji is always '👀'
256
+ });
257
+ ```
258
+
259
+ 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 👀:
260
+
261
+ - A message that starts a search gets it the moment `buy.turn` opens, before your model runs.
262
+ - 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.
263
+ - 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.
264
+ - 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.
265
+
266
+ 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.
267
+
268
+ 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.
269
+
270
+ ### Acknowledge in text, or not at all
271
+
272
+ 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`:
273
+
274
+ ```ts
275
+ const buy = buyTool({
276
+ agentcard,
277
+ externalUserId: shopper.id,
278
+ ack: 'text',
255
279
  onWorking: (text) => textShopper(shopper.id, text),
256
280
  });
257
281
  ```
258
282
 
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.
283
+ `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
284
 
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.
285
+ 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
286
 
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.
287
+ 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
288
 
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:
289
+ 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
290
 
267
291
  ```ts
292
+ const reaction = buy.instantReaction(message); // '👀' or null
293
+ if (reaction) await reactToShopperMessage(shopper.id, reaction);
294
+ // In text mode instead:
268
295
  const ack = buy.instantAck(message);
269
296
  if (ack) await textShopper(shopper.id, ack);
270
297
  ```
271
298
 
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.
299
+ 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
300
 
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`.
301
+ `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
302
 
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.
303
+ 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
304
 
278
305
  ## Show what Agentcard is doing
279
306
 
@@ -470,12 +497,15 @@ Subscribe your endpoint to each event below by name. A catch-all `*` alone does
470
497
  | Event | `kind` | When it arrives | Who wrote `text` |
471
498
  |---|---|---|---|
472
499
  | `purchase.message` | `card_added` | The shopper saved a card from the card link your app sent, or picked one they keep. | Agentcard |
500
+ | `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
501
  | `purchase.message` | `order_placed` | The shopper approved, and Agentcard placed the order. | Agentcard |
474
502
  | `purchase.message` | `order_failed` | The order failed after the shopper approved. | Agentcard |
475
503
  | `purchase.message` | `order_not_placed_yet` | The shopper approved, and the order is not placed yet. | Agentcard |
476
504
  | `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
505
  | `purchase.message` | `approval_expiring` | The approval link expires soon. `text` carries the link. | Agentcard |
478
506
  | `purchase.message` | `approval_expired` | The approval expired before the shopper answered, and no charge went through. | Agentcard |
507
+ | `purchase.message` | `approval_declined` | The shopper declined the approval, and no charge went through. | Agentcard |
508
+ | `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
509
  | `purchase.message` | `card_link_unfinished` | The shopper has not added a card from their card link. `text` carries the link again. | Agentcard |
480
510
  | `purchase.message` | `card_link_expired` | The card link expired before the shopper added a card. | Agentcard |
481
511
  | `purchase.message` | `cart_idle` | A cart sat, and nobody sent it for approval. | Agentcard |
@@ -561,11 +591,16 @@ The facts are `merchantName`, `totalCents`, `currency`, `cardBrand`, `cardLast4`
561
591
  | `order_not_placed_yet` | The shopper approved, and the order is not placed yet. |
562
592
  | `approval_expiring` | The approval link expires soon. `text` carries the link. |
563
593
  | `approval_expired` | The approval expired before the shopper answered, and no charge went through. |
594
+ | `approval_declined` | The shopper declined the approval, and no charge went through. |
595
+ | `payment_declined` | The shopper approved, and the payment was refused before the order went in. `facts.reason` says why. |
564
596
  | `card_link_unfinished` | The shopper has not added a card from their card link. `text` carries the link again. |
565
597
  | `card_link_expired` | The card link expired before the shopper added a card. |
566
598
  | `cart_idle` | A cart sat, and nobody sent it for approval. |
567
599
  | `order_unconfirmed` | The store's reply to the order was lost, and Agentcard is confirming it. |
568
600
  | `card_added` | The shopper saved a card from the card link your app sent. |
601
+ | `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
+ | `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
+ | `store_connect_expired` | The sign-in link expired before the shopper finished signing in. `text` offers a new one. |
569
604
 
570
605
  Agentcard can add a kind later. A kind your code does not know still carries `text`, ready to send as is.
571
606
 
package/dist/ack.d.ts CHANGED
@@ -89,5 +89,22 @@ 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;
92
109
  /** The earlier name of `instantAck`, kept for apps that call it. */
93
110
  export declare function ackFor(text: string, options?: AckForOptions): string | null;
package/dist/ack.js CHANGED
@@ -369,6 +369,25 @@ export function instantAck(shopperText, options = {}) {
369
369
  return null;
370
370
  return ackAt(nextTemplate(templateOf(options?.previous)), shopperText);
371
371
  }
372
+ /**
373
+ * The reaction the tool puts on a shopper's message to say it is on it: 👀, as a chat app shows a
374
+ * tapback on the shopper's own bubble. It names nothing the shopper said, so it can never read a
375
+ * request back wrong.
376
+ */
377
+ export const ACK_REACTION = '👀';
378
+ /**
379
+ * The reaction for a shopper's message, `👀`, or null for a message that starts no search. It reads the
380
+ * message exactly as `instantAck` does, so a pick, an address, an answer to a question, a step toward
381
+ * checkout or an acknowledgement such as `its ok` gets none.
382
+ *
383
+ * ```ts
384
+ * instantReaction('can you find me usb-c cables under $20'); // '👀'
385
+ * instantReaction('yes'); // null
386
+ * ```
387
+ */
388
+ export function instantReaction(shopperText, options = {}) {
389
+ return instantAck(shopperText, options) === null ? null : ACK_REACTION;
390
+ }
372
391
  /** The earlier name of `instantAck`, kept for apps that call it. */
373
392
  export function ackFor(text, options = {}) {
374
393
  return instantAck(text, options);
@@ -152,8 +152,9 @@ export interface BuyToolResult {
152
152
  /** True when Agentcard tells the shopper how the order went, so the shopper need not reply. */
153
153
  shopper_need_not_reply: boolean;
154
154
  /**
155
- * True when `onWorking` delivered this turn's instant reply, such as `got it, finding a taco`, so the
156
- * shopper already heard that the request is being worked on. Absent or false when none went out.
155
+ * True when this turn's acknowledgement reached the shopper: the 👀 `onReaction` put on their message,
156
+ * or the instant reply `onWorking` sent, such as `got it, finding a taco`. The shopper already knows
157
+ * the request is being worked on. Absent or false when none went out.
157
158
  */
158
159
  shopper_already_told_looking?: boolean;
159
160
  conversation_id: string | null;
@@ -173,6 +174,14 @@ export interface BuyToolResult {
173
174
  /** Agentcard's answer as it came: the purchase answer, or the body of the refusal. */
174
175
  envelope: Record<string, unknown>;
175
176
  }
177
+ /**
178
+ * How the tool acknowledges a shopper's message while Agentcard works on it:
179
+ * - `reaction`, the default: a 👀 on the shopper's own message, through `onReaction`. No text goes out.
180
+ * - `text`: an instant reply, such as `on it, looking for usb-c cables under $20`, through `onWorking`,
181
+ * for SMS, or any chat that shows no reactions.
182
+ * - `none`: nothing.
183
+ */
184
+ export type BuyAckMode = 'reaction' | 'text' | 'none';
176
185
  export interface BuyToolOptions {
177
186
  /** A client built with your company's credentials. Build it once and share it across every shopper's tool. */
178
187
  agentcard?: AgentcardClient;
@@ -198,9 +207,19 @@ export interface BuyToolOptions {
198
207
  fetch?: FetchLike;
199
208
  /** The pause between two reads of the conversation. Default: a timer. */
200
209
  wait?: (ms: number) => Promise<void>;
210
+ /**
211
+ * Reacts to the shopper's message with `emoji`, always `👀`, to say the tool is on it: show it on the
212
+ * shopper's own bubble, as a tapback. It fires once per message: the moment `buy.turn` opens on a
213
+ * search, or, for a message that got none, such as `yes`, once a call passes `ackDelayMs`. Never on a
214
+ * pick, an address, an answer to a question or a step toward checkout when the turn opens. With it,
215
+ * the tool sends no text acknowledgement at all, unless `ack` is `'text'`. One that throws costs
216
+ * nothing but the reaction.
217
+ */
218
+ onReaction?: (emoji: string) => void | Promise<void>;
201
219
  /**
202
220
  * Sends the shopper an instant reply, such as `on it, looking for usb-c cables under $20`, while
203
- * Agentcard works on a new request. It goes out when Agentcard has not answered within
221
+ * Agentcard works on a new request, when the tool acknowledges in text: with `ack: 'text'`, or with
222
+ * no `onReaction`. It goes out when Agentcard has not answered within
204
223
  * `ackDelayMs`, once for the calls the model makes in one turn. A slow pick gets `adding it now`
205
224
  * for a product or `got it, one moment` for a store or a place in a list. A slow confirm gets
206
225
  * `on it, one moment`. These replies name nothing the shopper said. A code gets no reply.
@@ -208,9 +227,16 @@ export interface BuyToolOptions {
208
227
  * answer. One that throws costs nothing but the instant reply.
209
228
  */
210
229
  onWorking?: (message: string) => void | Promise<void>;
211
- /** `false` sends no instant reply. A function writes it from the shopper's words instead, and an empty answer sends none. A turn whose message `instantAck` sent nothing for gets the built-in reply that names nothing. */
212
- ack?: false | ((ask: string) => string | null | undefined);
213
- /** How long Agentcard has to answer before the instant reply goes out. Default 1500. */
230
+ /**
231
+ * How the tool acknowledges a message (`BuyAckMode`). Left out, it reacts through `onReaction` when
232
+ * the tool or the turn has one, and otherwise sends text through `onWorking`, as 0.9 did. `'text'`
233
+ * sends text even with an `onReaction`, and `'none'`, or `false`, sends nothing. A function is
234
+ * `'text'` with your own words: it writes the instant reply from the shopper's words, and an empty
235
+ * answer sends none. A turn whose message `instantAck` sent nothing for gets the built-in reply that
236
+ * names nothing.
237
+ */
238
+ ack?: BuyAckMode | false | ((ask: string) => string | null | undefined);
239
+ /** How long Agentcard has to answer before the instant reply or the reaction goes out. Default 1500. */
214
240
  ackDelayMs?: number;
215
241
  /**
216
242
  * Called with the whole answer once per call, after its links went out and before the model reads
@@ -292,6 +318,14 @@ export interface BuyTool {
292
318
  * message that picks nothing, such as `yes`, a slow call still sends `onWorking` a reply that names nothing.
293
319
  */
294
320
  instantAck(shopperText: string): string | null;
321
+ /**
322
+ * The reaction for a shopper's message, `👀`, or null for one that starts no search, read as
323
+ * `instantAck` reads it. Call it the moment the message arrives, before your model runs, and put the
324
+ * emoji it returns on the shopper's message. When it returns one, the tool sends no reaction or text of
325
+ * its own for that turn, and the model reads `shopper_already_told_looking`. With `buy.turn` and
326
+ * `onReaction`, the turn does this for you.
327
+ */
328
+ instantReaction(shopperText: string): string | null;
295
329
  /**
296
330
  * Opens the turn of one shopper message and returns the tool for that turn: pass it to your model in
297
331
  * place of the tool, as `tools: [turn]`. Each call the model makes through it hands its links to this
@@ -299,8 +333,9 @@ export interface BuyTool {
299
333
  * keeps it in `turn.results`, and sends this turn's instant reply to its `onWorking`. A shopper who
300
334
  * sends a second message while the first still runs gets a second turn, and no link, cart or reply
301
335
  * of one reaches the other. Call it the moment the message arrives, before your model runs: with an
302
- * `onWorking`, the instant reply `instantAck` would return goes out at once. An option left out falls
303
- * back to the one the tool was built with.
336
+ * `onReaction`, a search gets its 👀 at once, and with only an `onWorking`, the instant reply
337
+ * `instantAck` would return goes out at once. An option left out falls back to the one the tool was
338
+ * built with.
304
339
  */
305
340
  turn(shopperText: string, options?: BuyToolTurnOptions): BuyToolTurn;
306
341
  }
@@ -342,7 +377,9 @@ export interface BuyToolTurnOptions {
342
377
  * `turn.links` beside the turn's reply.
343
378
  */
344
379
  sendLink?: (link: BuyToolLink) => void | Promise<void>;
345
- /** Sends this turn's instant reply: at once when `turn` opens on a new request, or once a slow call passes `ackDelayMs`. */
380
+ /** Reacts 👀 to this turn's message: at once when `turn` opens on a new request, or once a slow call passes `ackDelayMs`. */
381
+ onReaction?: (emoji: string) => void | Promise<void>;
382
+ /** Sends this turn's instant reply in text, when the tool acknowledges in text: at once when `turn` opens on a new request, or once a slow call passes `ackDelayMs`. */
346
383
  onWorking?: (message: string) => void | Promise<void>;
347
384
  /** Called with the whole answer of each call this turn makes, after its links went out and before the model reads it. */
348
385
  onResult?: (result: BuyToolResult) => void | Promise<void>;
@@ -355,9 +392,11 @@ export interface BuyToolTurnOptions {
355
392
  * belong to this turn alone. `ack`, `links` and `results` are read-only, and JSON leaves them out, so a
356
393
  * tool runner that sends the tool to its API sends the same fields as before.
357
394
  */
358
- export interface BuyToolTurn extends Omit<BuyTool, 'instantAck' | 'turn'> {
359
- /** The instant reply this turn sent to `onWorking` when it opened, or null when it sent none. */
395
+ export interface BuyToolTurn extends Omit<BuyTool, 'instantAck' | 'instantReaction' | 'turn'> {
396
+ /** The instant reply this turn sent to `onWorking` when it opened, or null when it sent none, as with a reaction. */
360
397
  readonly ack: string | null;
398
+ /** The reaction this turn put on the shopper's message, `👀`, when it opened or once a call ran slow, or null while it put none or when `onReaction` threw. */
399
+ readonly reaction: string | null;
361
400
  /**
362
401
  * The messages Agentcard posted into this shopper's chat that the model has not read yet, oldest
363
402
  * first, such as `card_added` once they saved a card: each one `agentcard.listen`'s or
package/dist/buy-tool.js CHANGED
@@ -35,7 +35,7 @@ import { AgentcardApiError, AgentcardClient, } from './client.js';
35
35
  import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
36
36
  import { forModel, forModelError, money, pickId, productCardKey, shownProducts, shownStores, stripLinks } from './model-view.js';
37
37
  import { messageStream, paceOf, sleep, toMessages, typingHook } from './messages.js';
38
- import { ackAt, answersQuestion, instantAck as instantAckOf, isNewRequest, isPickText, namesStore, nextTemplate, slowStepLine, waitAt } from './ack.js';
38
+ import { ACK_REACTION, ackAt, answersQuestion, instantAck as instantAckOf, isNewRequest, isPickText, namesStore, nextTemplate, slowStepLine, waitAt } from './ack.js';
39
39
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
40
40
  /**
41
41
  * The model's messages for a turn: the history, what Agentcard told the shopper, then the shopper's
@@ -535,13 +535,31 @@ export function buyTool(options) {
535
535
  const who = client.company ? { externalUserId } : {};
536
536
  if (o.onWorking !== undefined && typeof o.onWorking !== 'function')
537
537
  throw new TypeError('Pass onWorking as a function that sends the shopper a message.');
538
- if (o.ack !== undefined && o.ack !== false && typeof o.ack !== 'function')
539
- throw new TypeError('Pass ack as false, or as a function that writes the instant reply from the ask.');
538
+ if (o.onReaction !== undefined && typeof o.onReaction !== 'function')
539
+ throw new TypeError('Pass onReaction as a function that puts the emoji on the shopper\'s message.');
540
+ if (o.ack !== undefined && o.ack !== false && o.ack !== 'reaction' && o.ack !== 'text' && o.ack !== 'none' && typeof o.ack !== 'function') {
541
+ throw new TypeError("Pass ack as 'reaction', 'text' or 'none', as false, or as a function that writes the instant reply from the ask.");
542
+ }
540
543
  const ackDelayMs = o.ackDelayMs ?? ACK_DELAY_MS;
541
544
  if (typeof ackDelayMs !== 'number' || !Number.isFinite(ackDelayMs) || ackDelayMs < 0)
542
545
  throw new TypeError('Pass ackDelayMs as a number of milliseconds, 0 or more.');
543
- const onWorking = o.ack === false ? undefined : o.onWorking;
544
546
  const writeAck = typeof o.ack === 'function' ? o.ack : null;
547
+ /** The mode `ack` names, or null when it names none and the callbacks a turn holds decide. */
548
+ const ackMode = o.ack === undefined ? null : o.ack === false || o.ack === 'none' ? 'none' : o.ack === 'reaction' ? 'reaction' : 'text';
549
+ /**
550
+ * Where a turn acknowledges, from the callbacks it holds: a reaction when it can react and `ack` asks
551
+ * for no text, else text. At most one of the two comes back, so a shopper never gets a 👀 and a line.
552
+ */
553
+ const ackRoutes = (working, reacting) => {
554
+ if (ackMode === 'none')
555
+ return { onWorking: undefined, onReaction: undefined };
556
+ if (ackMode === 'text')
557
+ return { onWorking: working, onReaction: undefined };
558
+ if (ackMode === 'reaction' || reacting)
559
+ return { onWorking: undefined, onReaction: reacting };
560
+ return { onWorking: working, onReaction: undefined };
561
+ };
562
+ const { onWorking, onReaction } = ackRoutes(o.onWorking, o.onReaction);
545
563
  /** Calls running now, when the last one ended, and the template the instant reply used last. */
546
564
  let running = 0;
547
565
  let lastEndedAt = Number.NEGATIVE_INFINITY;
@@ -556,8 +574,10 @@ export function buyTool(options) {
556
574
  picked: null,
557
575
  ackOut: null,
558
576
  ackDelivered: false,
577
+ reaction: null,
559
578
  sendLink,
560
579
  onWorking,
580
+ onReaction,
561
581
  onResult,
562
582
  onProgress,
563
583
  links: null,
@@ -609,25 +629,53 @@ export function buyTool(options) {
609
629
  appAck = { at: Date.now(), said: text !== null, picked };
610
630
  return text;
611
631
  };
632
+ /** The app's own reaction: the turn its message opens sends no reaction or reply of its own. */
633
+ const instantReaction = (shopperText) => (instantAck(shopperText) === null ? null : ACK_REACTION);
634
+ /**
635
+ * Puts the 👀 on the turn's message. Every call of the turn waits for it before it hands over a link,
636
+ * and one that throws costs only the reaction: `turn.reaction` goes back to null, and the model reads
637
+ * that the shopper was not told.
638
+ */
639
+ const react = (t, reacting) => {
640
+ t.acked = true;
641
+ t.reaction = ACK_REACTION;
642
+ let sent;
643
+ try {
644
+ sent = reacting(ACK_REACTION);
645
+ }
646
+ catch (error) {
647
+ sent = Promise.reject(error);
648
+ }
649
+ t.ackOut = Promise.resolve(sent).then(() => { t.ackDelivered = true; }, () => { t.reaction = null; });
650
+ };
612
651
  /**
613
652
  * Holds the instant reply for a new request until `ackDelayMs` passes. `cancel` stops one that has
614
653
  * not gone out, and frees the turn's reply for a later call.
615
654
  */
616
655
  const holdAck = (ask, t, step = null) => {
617
656
  const onWorking = t.onWorking;
618
- if (!onWorking || t.acked)
657
+ const reacting = t.onReaction;
658
+ if ((!onWorking && !reacting) || t.acked)
619
659
  return null;
620
660
  // A confirm, or a pick of what the shopper saw, gets fixed words that never say `looking for`.
621
661
  const slow = step ?? t.picked ?? (!t.neutral && (namesShown(ask) || isPickText(ask)) ? 'pick' : null);
622
662
  // A short answer to the question the last reply asked, such as `medium` after `what size?`, is no
623
663
  // search: like `yes`, a slow call still tells the shopper, in words that look for nothing.
624
664
  const neutral = t.neutral || answersQuestion(ask, lastReply);
625
- if (!slow && !neutral && !isNewRequest(ask))
665
+ // A slow call of any message may get the 👀, which reads nothing back; only a text reply needs one of these.
666
+ if (!reacting && !slow && !neutral && !isNewRequest(ask))
626
667
  return null;
627
668
  t.acked = true;
628
669
  let held = true;
629
670
  const timer = setTimeout(() => {
630
671
  held = false;
672
+ // With reactions, a slow call puts the message's one 👀 on it, and never adds a line of text.
673
+ if (reacting) {
674
+ react(t, reacting);
675
+ return;
676
+ }
677
+ if (!onWorking)
678
+ return;
631
679
  let text;
632
680
  if (slow) {
633
681
  text = slowStepLine(slow);
@@ -1149,7 +1197,7 @@ export function buyTool(options) {
1149
1197
  const own = options ?? {};
1150
1198
  if (typeof own !== 'object')
1151
1199
  throw new TypeError('Pass the turn options as an object, or leave them out.');
1152
- for (const key of ['sendLink', 'onWorking', 'onResult', 'onProgress', 'onItem']) {
1200
+ for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onItem']) {
1153
1201
  if (own[key] !== undefined && typeof own[key] !== 'function')
1154
1202
  throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
1155
1203
  }
@@ -1168,7 +1216,8 @@ export function buyTool(options) {
1168
1216
  const state = {
1169
1217
  ...toolTurn(++modelTurn),
1170
1218
  sendLink: (link) => feed.push({ kind: 'link', link }, () => (onItem ? onItem({ kind: 'link', link: { ...link } }) : baseSendLink?.(link))),
1171
- onWorking: acks ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
1219
+ // A reaction goes to onReaction even with onItem: it sits on the shopper's bubble, not in the turn's sequence.
1220
+ ...ackRoutes(acks ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined, own.onReaction ?? o.onReaction),
1172
1221
  onResult: own.onResult ?? onResult,
1173
1222
  onProgress: own.onProgress ?? onProgress,
1174
1223
  links: [],
@@ -1178,11 +1227,12 @@ export function buyTool(options) {
1178
1227
  const text = (message, skip) => feed.push({ kind: 'text', text: message }, () => onItem?.({ kind: 'text', text: message }), skip);
1179
1228
  let ack = null;
1180
1229
  const working = state.onWorking;
1181
- if (working) {
1230
+ const reacting = state.onReaction;
1231
+ if (working || reacting) {
1182
1232
  const message = String(shopperText ?? '');
1183
1233
  const read = ackOfMessage(message);
1184
1234
  let text = read.text;
1185
- if (text !== null && writeAck) {
1235
+ if (text !== null && writeAck && working) {
1186
1236
  try {
1187
1237
  text = writeAck(message) ?? null;
1188
1238
  }
@@ -1199,7 +1249,11 @@ export function buyTool(options) {
1199
1249
  // A message that starts no search, such as `yes`: a slow call still tells the shopper, in words that name nothing.
1200
1250
  state.neutral = true;
1201
1251
  }
1202
- else if (text !== null) {
1252
+ else if (reacting) {
1253
+ // A search gets its 👀 the moment the turn opens, and no text: the reaction reads nothing back.
1254
+ react(state, reacting);
1255
+ }
1256
+ else if (text !== null && working) {
1203
1257
  ack = text;
1204
1258
  state.acked = true;
1205
1259
  let sent;
@@ -1258,6 +1312,7 @@ export function buyTool(options) {
1258
1312
  agentcardMessages: { get: () => [...(handed ?? kept)], enumerable: false },
1259
1313
  messages: { value: messagesFor, enumerable: false },
1260
1314
  ack: { value: ack, enumerable: false },
1315
+ reaction: { get: () => state.reaction, enumerable: false },
1261
1316
  links: { get: () => [...links], enumerable: false },
1262
1317
  results: { get: () => [...results], enumerable: false },
1263
1318
  items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
@@ -1274,5 +1329,5 @@ export function buyTool(options) {
1274
1329
  },
1275
1330
  });
1276
1331
  };
1277
- return toolOf(answer, { instantAck, turn }, {});
1332
+ return toolOf(answer, { instantAck, instantReaction, turn }, {});
1278
1333
  }
@@ -11,7 +11,7 @@ export const BUY_TOOL_DESCRIPTION = [
11
11
  'Buy what this shopper asks for at real stores.',
12
12
  "Put the shopper's own words in `ask`.",
13
13
  'Tell the shopper what `reply` says, in your own voice, except the products and stores it lists that reach the shopper as cards.',
14
- "When `shopper_already_told_looking` is true, the shopper was already told you are working on their request, so never acknowledge it again: open with the answer or the question.",
14
+ "When `shopper_already_told_looking` is true, the shopper already knows you are working on their request, from a reply or a 👀 on their message, so never acknowledge it again: open with the answer or the question.",
15
15
  'Show the shopper the cart before you confirm it.',
16
16
  'Ask the shopper for exactly one thing per message: the step `next_action` names, even when `reply` lists more. Never mention a later step, and never ask for the next one until the shopper has done this one.',
17
17
  'When `next_action` is null and `reply` is about something other than a cart, ask for no checkout step: a cart from an earlier request gets one short line at most, after the answer to what the shopper just asked, and never a question about confirming or placing it.',
@@ -32,6 +32,12 @@ export const BUY_TOOL_DESCRIPTION = [
32
32
  'Store links reach the shopper on their own, as cards that show each store\'s name and delivery time. When `store_links_sent` is above zero, never list those stores again: ask which one in one short line.',
33
33
  'When `store_links_sent` is 0 and you offer the `stores`, name each one by its `name`, never only "their links are there".',
34
34
  'When `pick.in_cart` is false, the product the shopper picked is not in the cart: tell the shopper what `reply` says, and never ask them to confirm the cart.',
35
+ "Talk to the shopper only about their order, in their terms: what is in the cart, what was found and what they can do next. Leave out how it was done, even when `reply` says it: never mention this tool, `reply`, a field, a system, \"my end\" or what you can or can't see, the searches run, the stores checked or not, a timeout, account setup, or a link that did not go out. When parts of the result disagree, say what you are sure of and ask, never which part said what.",
36
+ 'Say as fact only what the result shows: never promise what a store will do later, such as confirm a size when the order goes through.',
37
+ 'Never tell the shopper to remove, delete or replace a card or to change a setting, even when `reply` says to.',
38
+ 'Name each link by what it is for, from `next_action`: the link to add a card, to sign in to the store, or to approve the payment.',
39
+ 'Within a step, ask for one detail per message, such as the delivery address before the name for the label.',
40
+ 'Write every sentence in your own style, casing included, even where `reply` is written another way.',
35
41
  ].join(' ');
36
42
  /** A fresh copy of the tool's definition, so a caller that edits one never changes another. */
37
43
  export function buyToolDefinition() {
package/dist/index.d.ts CHANGED
@@ -5,8 +5,8 @@ export { agentcardWebhook, purchaseAgentWebhook, type AgentcardWebhookOptions, t
5
5
  export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, type BuyStreamHandlers, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartLine, type BuyCartFeeLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, type BuyStoreChoice, type BuyStorePick, type BuyStores, } from './client.js';
6
6
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolInputCheck, type BuyToolStandardSchema, type OpenAIBuyTool, } from './definition.js';
7
7
  export { toMessages, messageStream, typingDelay, type MessagePace, type MessageStream, type MessageStreamOptions, } from './messages.js';
8
- export { buyTool, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolProduct, type BuyToolStore, type BuyToolTurn, type BuyToolTurnItem, type BuyToolTurnOptions, type BuyToolTurnMessage, } from './buy-tool.js';
9
- export { instantAck, ackFor, type AckForOptions } from './ack.js';
8
+ export { buyTool, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolProduct, type BuyToolStore, type BuyToolTurn, type BuyToolTurnItem, type BuyToolTurnOptions, type BuyToolTurnMessage, type BuyAckMode, } from './buy-tool.js';
9
+ export { instantAck, instantReaction, ackFor, ACK_REACTION, type AckForOptions } from './ack.js';
10
10
  export { forModel, forModelError, stripLinks, type BuyToolModelView, type BuyToolModelCart, type BuyToolModelProduct, type BuyToolModelStore, } from './model-view.js';
11
11
  export { toAnthropicTool, toOpenRouterTool, type AnthropicBuyTool, type OpenRouterToolkit, type ZodLike, } from './adapters.js';
12
12
  export { listenToAgentcard, type AgentcardListener, type ListenOptions, type ListenCursorStore, type StreamFetch, } from './listen.js';
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_U
6
6
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, } from './definition.js';
7
7
  export { toMessages, messageStream, typingDelay, } from './messages.js';
8
8
  export { buyTool, } from './buy-tool.js';
9
- export { instantAck, ackFor } from './ack.js';
9
+ export { instantAck, instantReaction, ackFor, ACK_REACTION } from './ack.js';
10
10
  export { forModel, forModelError, stripLinks, } from './model-view.js';
11
11
  export { toAnthropicTool, toOpenRouterTool, } from './adapters.js';
12
12
  export { listenToAgentcard, } from './listen.js';
package/dist/message.d.ts CHANGED
@@ -12,12 +12,17 @@ import { type AgentcardEvent } from './verify.js';
12
12
  * - `order_unconfirmed`: the store's reply to the order was lost, so the order may exist; Agentcard is confirming it.
13
13
  * - `approval_expiring`: the approval link expires soon; `text` carries the link.
14
14
  * - `approval_expired`: the approval expired before the shopper answered, and nothing was charged.
15
+ * - `approval_declined`: the shopper declined the approval, and nothing was charged.
16
+ * - `payment_declined`: the shopper approved, and the payment was refused before the order went in; `facts.reason` says why. Nothing was charged.
15
17
  * - `card_link_unfinished`: the shopper has not added a card from their card link; `text` carries the link again.
16
18
  * - `card_link_expired`: the card link expired before the shopper added a card.
17
19
  * - `cart_idle`: a cart sat without being sent for approval; `facts.nudgeNumber` counts the nudges.
18
20
  * - `card_added`: the shopper saved a card from the card link your app sent, and can check out with it.
21
+ * - `store_connected`: the shopper signed in to their own account at the store from the sign-in link your app sent, so the order can go through on it.
22
+ * - `store_connect_failed`: the shopper's sign-in to the store did not go through, and the link no longer works.
23
+ * - `store_connect_expired`: the sign-in link expired before the shopper finished signing in.
19
24
  */
20
- export type PurchaseMessageKind = 'order_placed' | 'order_failed' | 'order_not_placed_yet' | 'order_unconfirmed' | 'approval_expiring' | 'approval_expired' | 'card_link_unfinished' | 'card_link_expired' | 'cart_idle' | 'card_added';
25
+ export type PurchaseMessageKind = 'order_placed' | 'order_failed' | 'order_not_placed_yet' | 'order_unconfirmed' | 'approval_expiring' | 'approval_expired' | 'approval_declined' | 'payment_declined' | 'card_link_unfinished' | 'card_link_expired' | 'cart_idle' | 'card_added' | 'store_connected' | 'store_connect_failed' | 'store_connect_expired';
21
26
  /** The message's values one by one, for a company that writes its own copy. A fact that does not apply is absent. */
22
27
  export interface PurchaseFacts {
23
28
  merchantName?: string;
@@ -28,7 +33,7 @@ export interface PurchaseFacts {
28
33
  cardBrand?: string;
29
34
  cardLast4?: string;
30
35
  orderId?: string;
31
- /** Why an order failed or was not placed, in words. */
36
+ /** Why an order failed or was not placed, or a payment was declined, in words. */
32
37
  reason?: string;
33
38
  approvalUrl?: string;
34
39
  /** When the approval link stops working, as an ISO 8601 timestamp. */
@@ -66,6 +66,12 @@ export interface BuyToolModelView {
66
66
  shopper_already_told_looking: boolean;
67
67
  /** True when the shopper already has the link this answer needed. */
68
68
  link_sent: boolean;
69
+ /**
70
+ * Which links went out with this answer: `store_login` is the store's own login page (the shopper
71
+ * logs in to their account there), `vault` is the page where they add a card, `approval` the page
72
+ * where they approve the payment. Name the link by what it is: a store login is never a card link.
73
+ */
74
+ links_sent: Array<'vault' | 'approval' | 'store_login'>;
69
75
  /** How many of the products above the shopper holds as cards, each with its name, price and picture; 0 when none went out. */
70
76
  product_links_sent: number;
71
77
  /** The product this call's `product_id` picked, and whether the cart now holds it; null when the call picked none. */
@@ -4,8 +4,14 @@ const MAX_PRODUCTS = 20;
4
4
  const MARKDOWN_LINK = /\[([^\]]*)\]\([^)]*\)/g;
5
5
  const SCHEME_URL = /\b[a-z][a-z0-9+.-]*:\/\/[^\s<>"'`)\]]*/gi;
6
6
  const WWW_URL = /\bwww\.[^\s<>"'`)\]]*/gi;
7
- /** A host followed by a path, a query or a fragment: `vault.agentcard.sh/a/1`, `vault.agentcard.sh?token=abc`. */
8
- const HOST_PATH = /\b(?:[a-z0-9-]+\.)+[a-z]{2,}[/?#][^\s<>"'`)\]]*/gi;
7
+ /**
8
+ * A host followed by a path, a query or a fragment: `vault.agentcard.sh/a/1`, `vault.agentcard.sh?token=abc`. A `?` that
9
+ * only punctuation follows before a space, a closing bracket or quote, or the end starts no query: in "at glossier.com?
10
+ * Say yes" it is the question's mark, and the host reads as its site's name (BARE_HOST). A `?` that more of an address
11
+ * follows starts one, punctuation or not (`glossier.com?.ref=x`), and a `#` always starts a fragment
12
+ * (`glossier.com#:~:text=Balm`).
13
+ */
14
+ const HOST_PATH = /\b(?:[a-z0-9-]+\.)+[a-z]{2,}(?:[/#]|\?(?![.,;:!?]*(?:[\s<>"'`)\]]|$)))[^\s<>"'`)\]]*/gi;
9
15
  /**
10
16
  * A bare host on a common top-level domain, such as `agentcard.sh` or `amazon.com`, which a chat app
11
17
  * turns into a link. The list keeps words with a dot, such as `Node.js`, and an email address's host.
@@ -163,6 +169,9 @@ export function forModel(result) {
163
169
  shopper_need_not_reply: result.shopper_need_not_reply === true,
164
170
  shopper_already_told_looking: result.shopper_already_told_looking === true,
165
171
  link_sent: result.link_sent === true,
172
+ links_sent: result.link_sent === true
173
+ ? [...new Set((result.links ?? []).map((link) => link.kind).filter((kind) => kind === 'vault' || kind === 'approval' || kind === 'store_login'))]
174
+ : [],
166
175
  // A card per product, so two dishes on a restaurant's one page are two cards.
167
176
  product_links_sent: new Set(products.filter(held).map(productCardKey)).size,
168
177
  // The id the model sent, never the catalog id, which is a store page for a retail product.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/commerce-sdk",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Give your agent one tool that buys for your shoppers at real stores, and post each purchase update Agentcard sends into the shopper's chat, over a connection your server opens or a webhook endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",