@agent-cards/commerce-sdk 0.1.0 → 0.5.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/LICENSE CHANGED
@@ -35,4 +35,4 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
35
35
  IMPLIED. IN NO EVENT SHALL AGENTCARD CORPORATION BE LIABLE FOR ANY CLAIM,
36
36
  DAMAGES OR OTHER LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
37
37
 
38
- Questions: legal@agentcard.sh
38
+ Questions: legal@agentcard.com
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @agent-cards/commerce-sdk
2
2
 
3
- Give your agent one tool that buys for your shoppers at real stores, and tell each shopper what happened to their purchase in your agent's voice. The buy tool sends what the shopper says to Agentcard, and Agentcard finds the products, builds the cart and places the order once the shopper approves it. Agentcard then sends your webhook endpoint a signed `purchase.message` event at each moment a shopper should hear about, such as an order going through or an approval link about to expire. The package checks the signature and hands you the message.
3
+ Give your agent one tool that buys for your shoppers at real stores, and tell each shopper what happened to their purchase in your agent's voice. The buy tool sends what the shopper says to Agentcard, and Agentcard finds the products, builds the cart and places the order once the shopper approves it. Agentcard then sends your webhook endpoint a signed event at each moment a shopper should hear about, such as a card saved, an order going through or an approval link about to expire. The package checks the signature and hands you a message ready to post into that shopper's chat.
4
4
 
5
5
  ## Install
6
6
 
@@ -41,7 +41,7 @@ const answer = await new Anthropic().beta.messages.toolRunner({
41
41
 
42
42
  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.
43
43
 
44
- The model sends `ask`, `confirm` and `new_order`, 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.
44
+ The model sends `ask`, `confirm`, `new_order` and `product_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.
45
45
 
46
46
  ## Use the tool in any framework
47
47
 
@@ -101,10 +101,67 @@ console.log(await result.getText());
101
101
 
102
102
  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.
103
103
 
104
- The tool also hands `sendLink` a `product` link for each product a search finds, up to twenty 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`, and its id in `productId`. Every price the tool writes, in a link or in the model's view, uses the currency Agentcard names for the cart or the product, in that currency's own minor units: `1331` is `$13.31` in USD, `CA$13.31` in CAD and `¥1,331` in JPY. A price whose currency Agentcard does not name shows with no symbol. The link arrives beside the reply, and you decide how to show it: as a card, in a list, or as a text with the URL. The model reads `product_links_sent` and never a URL, so a shopper who asks for links hears that the links are there. When the next answer in the conversation shows the same products, the tool sends none of their links again. A product link your `sendLink` throws on counts as not sent, and the call goes on.
104
+ 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`, its id in `productId`, and its picture in `imageUrl`, or null when the store sent none. Every price the tool writes, in a link or in the model's view, uses the currency Agentcard names for the cart or the product, in that currency's own minor units: `1331` is `$13.31` in USD, `CA$13.31` in CAD and `¥1,331` in JPY. A price whose currency Agentcard does not name shows with no symbol. The link arrives beside the reply, and you decide how to show it: as a card, in a list, or as a text with the URL. The model reads `product_links_sent` and never a URL, so a shopper who asks for links hears that the links are there. When the next answer in the conversation shows the same products, the tool sends none of their links again. A product link your `sendLink` throws on counts as not sent, and the call goes on.
105
105
 
106
106
  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.
107
107
 
108
+ ## Add the exact product the shopper picks
109
+
110
+ 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.
111
+
112
+ `result.pick` says what happened: the product's id, and `in_cart`, whether the cart now holds it. The model reads the same `pick` in its view, by the `product_id` it sent. A pick the cart does not hold names no next step, so the model tells the shopper what Agentcard said rather than asking for a card or a yes for an older cart. A product Agentcard no longer holds, because the products are more than 15 minutes old or a newer search replaced them, adds nothing, and the model reads that it should search again with the shopper's words.
113
+
114
+ ## Tell the shopper you are on it
115
+
116
+ 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`:
117
+
118
+ ```ts
119
+ const buy = buyTool({
120
+ agentcard,
121
+ externalUserId: shopper.id,
122
+ sendLink: (link) => textShopper(shopper.id, link.url),
123
+ onWorking: (text) => textShopper(shopper.id, text),
124
+ });
125
+ ```
126
+
127
+ The tool sends the reply when Agentcard has not answered a new request within 1.5 seconds, so a quick answer arrives alone. It sends one for the calls the model makes in one turn, where a call that starts within 15 seconds of the last one ending counts as the same turn, and none on a confirm, on a pick of a product the shopper saw, or on an answer such as `yes`, `2`, `the first one` or a code. The words rotate through six templates, and one tool never sends the same template twice in a row. A short paraphrase of the request goes in when the tool can read one: it stops before any link, email address, phone number, street address or long number, and a request that starts with one gets a reply that names nothing, such as `looking for that now`. The call waits for `onWorking`, so the shopper reads the reply before any link and before the model answers. An `onWorking` that throws costs only the instant reply.
128
+
129
+ 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.
130
+
131
+ The model takes about two seconds to call the tool after the shopper's message arrives. To answer sooner, send `ackFor(message)` the moment the message arrives, before your model runs, and pass `ack: false` to the tool so the shopper reads one reply. `ackFor` returns null for a message that starts no search, and takes the reply you sent last as `{ previous }`, so the next one comes from another template:
132
+
133
+ ```ts
134
+ import { ackFor } from '@agent-cards/commerce-sdk';
135
+
136
+ const ack = ackFor(message, { previous: lastAck });
137
+ if (ack) await textShopper(shopper.id, ack);
138
+ ```
139
+
140
+ ## Send each paragraph as its own message
141
+
142
+ A chat app reads best when each paragraph arrives as its own message. `toMessages` cuts text at its blank lines, trims each message and leaves out the empty ones. A list whose items blank lines separate stays one message, and so does a fenced code block:
143
+
144
+ ```ts
145
+ import { toMessages } from '@agent-cards/commerce-sdk';
146
+
147
+ for (const text of toMessages(reply)) await textShopper(shopper.id, text);
148
+ ```
149
+
150
+ `result.messages` from `buy.answer` holds Agentcard's own answer cut the same way, links included, so your code sends it without cutting it again.
151
+
152
+ For a reply your model streams, `messageStream` hands over each paragraph the moment the blank line that ends it arrives, however the stream cuts the text. Push each piece of text, then call `end` for the last paragraph:
153
+
154
+ ```ts
155
+ import { messageStream } from '@agent-cards/commerce-sdk';
156
+
157
+ const paragraphs = messageStream({ onMessage: (text) => textShopper(shopper.id, text) });
158
+ stream.on('text', (delta) => paragraphs.push(delta));
159
+ await stream.done();
160
+ await paragraphs.end();
161
+ ```
162
+
163
+ `stream` stands for your model's streamed answer. When `onMessage` returns a promise, the stream waits for it before it sends the next paragraph, so the shopper reads them in order, and `end` resolves once every paragraph went out. After a send fails, no later paragraph goes out, and `end` rejects with that error. A paragraph inside a list waits for the first characters of the next line, which say whether the list goes on. Start a new `messageStream` for each model message, so one message never runs into the next.
164
+
108
165
  ## Sign up a new shopper
109
166
 
110
167
  A shopper who has not connected to your company yet gets a Vault link on their first call. The tool asks Agentcard for the link and hands it to `sendLink`, and the model reads `needs_card`. The shopper connects and adds a card on the Vault page, and the next call buys. Agentcard returns the same link while it has time left, so a shopper who asks twice gets one link.
@@ -206,7 +263,55 @@ The tool sends each purchase request once, because a purchase turn runs against
206
263
 
207
264
  `buy.answer` throws the `AgentcardApiError` itself. `buy.run`, `buy.execute` and `buy.handle` throw the same message with every link taken out, and keep the original error on `error.cause` for your logs.
208
265
 
209
- ## Mount the handler
266
+ ## Hear from Agentcard
267
+
268
+ Agentcard tells your webhook endpoint about each moment a shopper should hear about, even when the shopper is not chatting: a card saved, an approval about to expire, an order placed, a delivery on its way. `agentcardWebhook` checks each delivery's signature, skips an event it already handled, and calls `onMessage` with your own id for the shopper and a message ready to post into that shopper's chat:
269
+
270
+ ```ts
271
+ import express from 'express';
272
+ import { agentcardWebhook } from '@agent-cards/commerce-sdk';
273
+
274
+ const app = express();
275
+ app.post('/agentcard/webhooks', express.raw({ type: 'application/json' }), agentcardWebhook({
276
+ secret: process.env.AGENTCARD_WEBHOOK_SECRET ?? '', // returned once, when you created the endpoint
277
+ onMessage: (shopperId, message) => postToChat(shopperId, message.text),
278
+ }));
279
+ ```
280
+
281
+ `postToChat` stands for your own code. `shopperId` is the `externalUserId` you gave the buy tool, so the message lands in the chat the purchase started in. In a Next.js route handler, export the same handler's `fetch`:
282
+
283
+ ```ts
284
+ const webhook = agentcardWebhook({ secret: process.env.AGENTCARD_WEBHOOK_SECRET ?? '', onMessage: (shopperId, message) => postToChat(shopperId, message.text) });
285
+ export const POST = webhook.fetch;
286
+ ```
287
+
288
+ Subscribe your endpoint to each event below by name. A catch-all `*` alone does not turn `purchase.message` on. `SHOPPER_EVENT_TYPES` holds the three names:
289
+
290
+ | Event | `kind` | When it arrives | Who wrote `text` |
291
+ |---|---|---|---|
292
+ | `purchase.message` | `card_added` | The shopper saved a card from the card link your app sent, or picked one they keep. | Agentcard |
293
+ | `purchase.message` | `order_placed` | The shopper approved, and Agentcard placed the order. | Agentcard |
294
+ | `purchase.message` | `order_failed` | The order failed after the shopper approved. | Agentcard |
295
+ | `purchase.message` | `order_not_placed_yet` | The shopper approved, and the order is not placed yet. | Agentcard |
296
+ | `purchase.message` | `order_unconfirmed` | The store's reply to the order was lost, so the order may exist, and Agentcard is confirming it. | Agentcard |
297
+ | `purchase.message` | `approval_expiring` | The approval link expires soon. `text` carries the link. | Agentcard |
298
+ | `purchase.message` | `approval_expired` | The approval expired before the shopper answered, and no charge went through. | Agentcard |
299
+ | `purchase.message` | `card_link_unfinished` | The shopper has not added a card from their card link. `text` carries the link again. | Agentcard |
300
+ | `purchase.message` | `card_link_expired` | The card link expired before the shopper added a card. | Agentcard |
301
+ | `purchase.message` | `cart_idle` | A cart sat, and nobody sent it for approval. | Agentcard |
302
+ | `order.confirmed` | `order_confirmed` | The retailer accepted the order on its side, with its own order number and delivery window. | The package |
303
+ | `order.updated` | `order_updated` | The order reached a new delivery stage, named in `facts.orderStatus`: `confirmed`, `courier_assigned`, `courier_at_store`, `ready_for_pickup`, `picked_up`, `arriving`, `delivered` or `canceled`. | The package |
304
+
305
+ Every message carries `text`, ready to send as is, `note` for your agent, `facts` with the values one by one, `externalUserId`, `userId`, `conversationId` when Agentcard names the buy conversation, and `eventId`. A kind Agentcard adds later still carries `text`.
306
+
307
+ The handler answers when `onMessage` is done: `200` when it returns, `500` when it throws, and Agentcard then delivers the event again, so a failed post is not lost. It remembers the last 10,000 events it finished, so a redelivery of one runs nothing, and a second delivery that lands while the first still runs waits for its answer. That memory belongs to one process: when more than one server takes deliveries, also skip a `message.eventId` you already stored. A message whose link or window closed before it arrived (`isStale`) reaches no callback. A message for a shopper you gave Agentcard no id for, and every other event, such as `vault.card_stored` or `order.placed`, goes to `onEvent`. Agentcard waits 10 seconds for the answer, so keep `onMessage` short: post the text, or queue the agent turn, then return.
308
+
309
+ To read an event without the handler, `readShopperMessage(event)` returns the same message, or null for an event that carries none.
310
+
311
+ ## Mount the purchase message handler
312
+
313
+ `purchaseAgentWebhook` is the handler from earlier versions. It hands you `purchase.message` events only, and leaves deduplication to you. New code takes `agentcardWebhook`.
314
+
210
315
 
211
316
  Subscribe your webhook endpoint to `purchase.message`, then mount the handler on its route with `express.raw`:
212
317
 
@@ -279,6 +384,8 @@ The facts are `merchantName`, `totalCents`, `currency`, `cardBrand`, `cardLast4`
279
384
  | `card_link_unfinished` | The shopper has not added a card from their card link. `text` carries the link again. |
280
385
  | `card_link_expired` | The card link expired before the shopper added a card. |
281
386
  | `cart_idle` | A cart sat, and nobody sent it for approval. |
387
+ | `order_unconfirmed` | The store's reply to the order was lost, and Agentcard is confirming it. |
388
+ | `card_added` | The shopper saved a card from the card link your app sent. |
282
389
 
283
390
  Agentcard can add a kind later. A kind your code does not know still carries `text`, ready to send as is.
284
391
 
package/dist/ack.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The instant reply a shopper reads while Agentcard works on their request, such as `on it, looking
3
+ * for usb-c cables under $20`. A search can take Agentcard most of a minute, and a shopper who hears
4
+ * nothing in that time asks again. The words come from fixed templates, rotated so one shopper never
5
+ * reads the same one twice in a row, with a short paraphrase of the request when one can be read
6
+ * safely from it. No model writes them, so they cost nothing and arrive at once.
7
+ *
8
+ * The paraphrase never carries a link, an email address, a phone number, a street address or a long
9
+ * number: the text stops before the first one, and a request that starts with one gets the template
10
+ * without a paraphrase. A reply to a question, such as `yes`, `2`, `the first one` or a code, gets no
11
+ * instant reply at all, because it starts no search.
12
+ */
13
+ /** True when the shopper's words start a request, and false for an answer to a question, a pick or a code. */
14
+ export declare function isNewRequest(text: string): boolean;
15
+ /** A short paraphrase of what the shopper asks for, safe to read back to them, or null when none is. */
16
+ export declare function paraphrase(text: string): string | null;
17
+ /** The template at `index` for these words, with the paraphrase when one is safe. */
18
+ export declare function ackAt(index: number, text: string): string;
19
+ /** A template other than `avoid`, picked at random. */
20
+ export declare function nextTemplate(avoid: number, random?: () => number): number;
21
+ export interface AckForOptions {
22
+ /** The instant reply this shopper read last, so the next one comes from another template. */
23
+ previous?: string;
24
+ }
25
+ /**
26
+ * The instant reply for a shopper's message, to send the moment it arrives and before your model
27
+ * runs, or null for a message that starts no search, such as `yes`, `2` or a code. The buy tool
28
+ * sends one on its own through `onWorking`, so pass `ack: false` to the tool when you send this one.
29
+ *
30
+ * ```ts
31
+ * ackFor('can you find me usb-c cables under $20'); // 'on it, looking for usb-c cables under $20', or another template
32
+ * ackFor('yes'); // null
33
+ * ```
34
+ */
35
+ export declare function ackFor(text: string, options?: AckForOptions): string | null;
package/dist/ack.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The instant reply a shopper reads while Agentcard works on their request, such as `on it, looking
3
+ * for usb-c cables under $20`. A search can take Agentcard most of a minute, and a shopper who hears
4
+ * nothing in that time asks again. The words come from fixed templates, rotated so one shopper never
5
+ * reads the same one twice in a row, with a short paraphrase of the request when one can be read
6
+ * safely from it. No model writes them, so they cost nothing and arrive at once.
7
+ *
8
+ * The paraphrase never carries a link, an email address, a phone number, a street address or a long
9
+ * number: the text stops before the first one, and a request that starts with one gets the template
10
+ * without a paraphrase. A reply to a question, such as `yes`, `2`, `the first one` or a code, gets no
11
+ * instant reply at all, because it starts no search.
12
+ */
13
+ /** Each template twice: with the paraphrase in place of `{x}`, and without one. */
14
+ const TEMPLATES = [
15
+ ['on it, looking for {x}', 'on it, looking for that now'],
16
+ ['give me a sec, checking for {x}', 'give me a sec, checking'],
17
+ ['looking for {x} now', 'looking for that now'],
18
+ ['got it, finding {x}', 'got it, one moment while i look'],
19
+ ['searching for {x}, one moment', 'checking on that for you now'],
20
+ ['checking what i can find for {x}', 'on it, give me a moment'],
21
+ ];
22
+ /** Whole replies that answer a question the shopper was asked, after `please` and `thanks` come off. */
23
+ const ANSWERS = new RegExp('^(?:' +
24
+ [
25
+ 'hi', 'hey', 'hello', 'yo', 'sup', 'lol', 'haha', 'hmm', 'y', 'ya', 'yes', 'yeah', 'yea', 'yep', 'yup', 'sure', 'ok', 'okay', 'k', 'kk', 'no', 'nope', 'nah',
26
+ 'confirm(?:ed)?', 'approve[ds]?(?: it)?', 'i approved(?: it)?', 'done', 'added(?: it)?', 'i added(?: it| the card| a card)?',
27
+ 'go', 'go ahead', 'do it', '(?:buy|order|place|book) it', 'sounds good', 'perfect', 'great', 'cool', 'nice',
28
+ 'cancel', 'stop', 'never ?mind', 'nvm', 'wait', 'hold on', 'same', 'either', 'any', 'whatever',
29
+ '(?:the )?(?:first|second|third|fourth|fifth|last|other|\\d{1,2}(?:st|nd|rd|th)?)(?: one)?', '(?:that|this) one',
30
+ '(?:option|number|no|#) ?\\d{1,2}',
31
+ ].join('|') +
32
+ ')$');
33
+ /** A code or a number on its own, such as `111111` or `4 2 1 9`. */
34
+ const CODE = /^[a-z]{0,3}\d[\d ]*$/;
35
+ /** Words that start a request and say nothing about what the shopper wants, taken off the front. */
36
+ const LEAD = /^(?:hey|hi|hello|yo|ok|okay|so|also|and|um|uh|actually|just|please|pls|can you|could you|would you|will you|can i (?:get|have)|could i (?:get|have)|i want to|i want|i need to|i need|i would like|i'?d like|id like|i'?m looking for|im looking for|looking for|get|find|order|buy|book|grab|reserve|search for|look for|look up|show|me|us)\b[\s,]*/;
37
+ /** A request that reads as a question or a change to the purchase: the template goes out without a paraphrase. */
38
+ const NO_PARAPHRASE = /^(?:what|where|when|why|how|who|which|is|are|was|were|do|does|did|can|could|will|would|should|has|have|change|make|swap|switch|add|remove|drop|cancel|update|use|try|send|deliver|ship|pay|charge|check|tell|give|put|set|move|keep|skip|replace|return|refund|track|reorder|pick|choose|select|go|let|help|see|compare)\b/;
39
+ /** Where the text stops: before the first link, contact detail, address, long number or delivery clause. */
40
+ const PRIVATE = [
41
+ /https?:\/\/|www\./,
42
+ /\S+@\S+/,
43
+ /[\w-]+\.[a-z]{2,}\b/,
44
+ /\+?\d[\d\s().-]{6,}\d/,
45
+ /\d{5,}/,
46
+ /(?:^|\s)\d{3,}\s+(?!(?:packs?|count|ct|pcs?|pieces?|oz|ml|l|g|kg|gb|tb|mah|w|watts?|inch(?:es)?|in|ft|feet|mm|cm|lbs?|dollars?|bucks|thread)\b)[a-z]/,
47
+ /\b\d+\s+(?:[a-z]+\s+){1,2}(?:st|street|ave|avenue|rd|road|blvd|boulevard|dr|drive|ln|lane|way|ct|court|pl|place|ter|terrace|hwy|highway)\b/,
48
+ /\b(?:deliver(?:ed)? (?:it )?to|ship(?:ped)? (?:it )?to|send (?:it )?to|bring (?:it )?to|to my (?:place|house|home|apartment|apt|office|work)|my (?:address|number|phone|email|name|card)|address is|call me|text me|named|called|apt|apartment|unit|suite)\b/,
49
+ ];
50
+ /** Words a cut paraphrase never ends on. */
51
+ const TAIL = /\s+(?:for|with|and|or|but|the|a|an|to|of|in|at|on|from|under|over|below|above|near|by|who|that|which|my|some|around|about)$/;
52
+ const MAX_WORDS = 8;
53
+ /** The text as one plain line: lowercase, no quotes, and no dash between words. */
54
+ function plain(text) {
55
+ return text.toLowerCase().replace(/[‘’]/g, "'").replace(/["“”`]/g, '').replace(/\s+[-–—]+\s+|[–—]/g, ' ').replace(/\s+/g, ' ').trim();
56
+ }
57
+ /** True when the shopper's words start a request, and false for an answer to a question, a pick or a code. */
58
+ export function isNewRequest(text) {
59
+ const words = plain(String(text ?? '')).replace(/[!?.,;:]+/g, ' ').replace(/\b(?:please|pls|thanks|thank you|thx|ty)\b/g, ' ').replace(/\s+/g, ' ').trim();
60
+ if (!/[\p{L}\p{N}]/u.test(words))
61
+ return false;
62
+ return !ANSWERS.test(words) && !CODE.test(words);
63
+ }
64
+ /** A short paraphrase of what the shopper asks for, safe to read back to them, or null when none is. */
65
+ export function paraphrase(text) {
66
+ let rest = plain(String(text ?? '')).split(/[.!?;\n](?:\s|$)/)[0] ?? '';
67
+ for (const pattern of PRIVATE) {
68
+ const at = rest.search(pattern);
69
+ if (at >= 0)
70
+ rest = rest.slice(0, at);
71
+ }
72
+ for (let before = ''; before !== rest;) {
73
+ before = rest;
74
+ rest = rest.replace(LEAD, '');
75
+ }
76
+ rest = rest.replace(/[\s,.:!?]+$/, '');
77
+ if (NO_PARAPHRASE.test(rest))
78
+ return null;
79
+ const words = rest.split(' ').filter(Boolean);
80
+ let short = words.slice(0, MAX_WORDS).join(' ');
81
+ while (TAIL.test(short))
82
+ short = short.replace(TAIL, '');
83
+ short = short.replace(/[\s,]+$/, '');
84
+ return /[a-z]{2}/.test(short) && isNewRequest(short) ? short : null;
85
+ }
86
+ /** The template at `index` for these words, with the paraphrase when one is safe. */
87
+ export function ackAt(index, text) {
88
+ const [withWhat, without] = TEMPLATES[index % TEMPLATES.length];
89
+ const what = paraphrase(text);
90
+ return what ? withWhat.replace('{x}', what) : without;
91
+ }
92
+ /** A template other than `avoid`, picked at random. */
93
+ export function nextTemplate(avoid, random = Math.random) {
94
+ const pick = Math.floor(random() * (TEMPLATES.length - 1));
95
+ return avoid < 0 || pick < avoid ? pick : pick + 1;
96
+ }
97
+ /** The template an earlier instant reply came from, or -1. */
98
+ function templateOf(reply) {
99
+ if (!reply)
100
+ return -1;
101
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
102
+ return TEMPLATES.findIndex(([withWhat, without]) => reply === without || new RegExp(`^${escape(withWhat).replace('\\{x\\}', '.+')}$`).test(reply));
103
+ }
104
+ /**
105
+ * The instant reply for a shopper's message, to send the moment it arrives and before your model
106
+ * runs, or null for a message that starts no search, such as `yes`, `2` or a code. The buy tool
107
+ * sends one on its own through `onWorking`, so pass `ack: false` to the tool when you send this one.
108
+ *
109
+ * ```ts
110
+ * ackFor('can you find me usb-c cables under $20'); // 'on it, looking for usb-c cables under $20', or another template
111
+ * ackFor('yes'); // null
112
+ * ```
113
+ */
114
+ export function ackFor(text, options = {}) {
115
+ if (!isNewRequest(text))
116
+ return null;
117
+ return ackAt(nextTemplate(templateOf(options?.previous)), text);
118
+ }
@@ -14,13 +14,16 @@ import type { BuyTool } from './buy-tool.js';
14
14
  export type AnthropicBuyTool = BuyTool;
15
15
  /** @deprecated Pass the buy tool itself: `toolRunner({ tools: [buy] })`. Returns the same object, for one release. */
16
16
  export declare function toAnthropicTool(tool: BuyTool): BuyTool;
17
- /** The three zod builders the OpenRouter adapter calls; `z` from zod 3 or 4 has them. */
17
+ /** The zod builders the OpenRouter adapter calls; `z` from zod 3 or 4 has them. */
18
18
  export interface ZodLike {
19
19
  object(shape: Record<string, unknown>): {
20
20
  strict(): unknown;
21
21
  };
22
22
  string(): {
23
23
  describe(description: string): unknown;
24
+ nullable(): {
25
+ describe(description: string): unknown;
26
+ };
24
27
  };
25
28
  boolean(): {
26
29
  describe(description: string): unknown;
package/dist/adapters.js CHANGED
@@ -16,6 +16,7 @@ export function toOpenRouterTool(buy, toolkit) {
16
16
  ask: z.string().describe(properties.ask.description),
17
17
  confirm: z.boolean().describe(properties.confirm.description),
18
18
  new_order: z.boolean().describe(properties.new_order.description),
19
+ product_id: z.string().nullable().describe(properties.product_id.description),
19
20
  }).strict(),
20
21
  // OpenRouter's SDK encodes what execute returns as JSON, so the view goes back as an object and reaches the model as the same text.
21
22
  execute: async (input) => JSON.parse(await buy.execute(input)),
@@ -22,7 +22,7 @@
22
22
  * A turn that outlived Agentcard's budget gets a read every 5 seconds for up
23
23
  * to a minute, until the turn ends.
24
24
  */
25
- import { AgentcardClient, type BuyCart, type BuyDeliveryAddress, type FetchLike } from './client.js';
25
+ import { AgentcardClient, type BuyCart, type BuyFlightItinerary, type BuyDeliveryAddress, type BuyNextAction, type BuyPick, type FetchLike } from './client.js';
26
26
  import { type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolStandardSchema, type OpenAIBuyTool } from './definition.js';
27
27
  import { type BuyToolModelView } from './model-view.js';
28
28
  /**
@@ -40,7 +40,7 @@ export type BuyToolStatus = 'needs_input' | 'needs_card' | 'approval_sent' | 'pl
40
40
  * card. `approval`: the page where they approve this purchase. `store_login`:
41
41
  * the page where they sign in to the store named in `store`, before Agentcard
42
42
  * can buy there. `product`: a product's page at its store, with its `title`,
43
- * `price` and `productId`.
43
+ * `price`, `productId` and `imageUrl`.
44
44
  */
45
45
  export interface BuyToolLink {
46
46
  kind: 'vault' | 'approval' | 'store_login' | 'product';
@@ -55,10 +55,15 @@ export interface BuyToolLink {
55
55
  price?: string | null;
56
56
  /** On a product link: Agentcard's id for the product. */
57
57
  productId?: string;
58
+ /** On a product link: the product's picture, or null when Agentcard sent none. Show it beside the title. */
59
+ imageUrl?: string | null;
58
60
  }
59
61
  /** A product the last search found. */
60
62
  export interface BuyToolProduct {
63
+ flight?: BuyFlightItinerary;
61
64
  id: string;
65
+ /** Agentcard's link-free id for the product, which the model passes as `product_id` when the shopper picks it; null from an older API. */
66
+ ref: string | null;
62
67
  name: string;
63
68
  /** The price in the minor unit of `currency`. */
64
69
  priceCents: number | null;
@@ -80,6 +85,12 @@ export interface BuyToolResult {
80
85
  status: BuyToolStatus;
81
86
  /** Agentcard's answer for the shopper, links included. */
82
87
  reply: string;
88
+ /**
89
+ * The same answer as the separate messages a shopper reads one at a time, links included: each
90
+ * message Agentcard sent (the purchase answer's `messages`), cut at its blank lines by `toMessages`.
91
+ * An answer the tool wrote itself, such as `placing`, is `reply` cut the same way.
92
+ */
93
+ messages: string[];
83
94
  /** The cart on the table, or null. */
84
95
  cart: BuyCart | null;
85
96
  /** Every cart on the table, oldest first. */
@@ -97,6 +108,10 @@ export interface BuyToolResult {
97
108
  order_id: string | null;
98
109
  decline_code: string | null;
99
110
  error_code: string | null;
111
+ /** The one step the shopper takes next, as Agentcard named it, or null. Only that step's link is in `links`. */
112
+ next_action: BuyNextAction | null;
113
+ /** The product the model's `product_id` picked, by its catalog id, and whether the cart now holds it; null when the call picked none. */
114
+ pick: BuyPick | null;
100
115
  /** Every cart hash in this answer. */
101
116
  hashes: string[];
102
117
  /** Agentcard's answer as it came: the purchase answer, or the body of the refusal. */
@@ -125,6 +140,18 @@ export interface BuyToolOptions {
125
140
  fetch?: FetchLike;
126
141
  /** The pause between two reads of the conversation. Default: a timer. */
127
142
  wait?: (ms: number) => Promise<void>;
143
+ /**
144
+ * Sends the shopper an instant reply, such as `on it, looking for usb-c cables under $20`, while
145
+ * Agentcard works on a new request. It goes out when Agentcard has not answered within
146
+ * `ackDelayMs`, once for the calls the model makes in one turn, and never on a confirm, a pick or a
147
+ * reply such as `yes`, `2` or a code. The call waits for it before it returns, so the shopper reads
148
+ * it before any link or the model's answer. One that throws costs nothing but the instant reply.
149
+ */
150
+ onWorking?: (message: string) => void | Promise<void>;
151
+ /** `false` sends no instant reply. A function writes it from the shopper's words instead, and an empty answer sends none. */
152
+ ack?: false | ((ask: string) => string | null | undefined);
153
+ /** How long Agentcard has to answer before the instant reply goes out. Default 1500. */
154
+ ackDelayMs?: number;
128
155
  }
129
156
  /**
130
157
  * The buy tool, in the shape every agent framework takes, because their field