@agent-cards/commerce-sdk 0.2.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.
@@ -1,17 +1,23 @@
1
1
  /**
2
2
  * The buy tool as a model sees it: one name, one description, and a strict
3
- * JSON Schema with three required fields. The delivery address and the
3
+ * JSON Schema with four required fields, `product_id` null unless the shopper
4
+ * picks a product they were shown. The delivery address and the
4
5
  * shopper's id are construction options, never model inputs, so a model can
5
6
  * neither change where an order goes nor buy for someone else.
6
7
  */
7
8
  export declare const BUY_TOOL_NAME = "buy";
8
9
  /** What the model reads before it calls the tool. Every sentence is a rule the model follows. */
9
10
  export declare const BUY_TOOL_DESCRIPTION: string;
10
- /** What the model sends: all three fields, every time. */
11
+ /**
12
+ * What the model sends: `ask`, `confirm` and `new_order` every time, and `product_id`, null unless the
13
+ * shopper picks a product from the last result. Your own code may leave `product_id` out.
14
+ */
11
15
  export interface BuyToolInput {
12
16
  ask: string;
13
17
  confirm: boolean;
14
18
  new_order: boolean;
19
+ /** The `product_id` of the product the shopper picked from the last result's `products`, or null. */
20
+ product_id?: string | null;
15
21
  }
16
22
  /** A type, not an interface, so it fits the SDKs' own input schema types, which take any key. */
17
23
  export type BuyToolInputSchema = {
@@ -29,6 +35,10 @@ export type BuyToolInputSchema = {
29
35
  type: 'boolean';
30
36
  description: string;
31
37
  };
38
+ product_id: {
39
+ type: ['string', 'null'];
40
+ description: string;
41
+ };
32
42
  };
33
43
  required: Array<keyof BuyToolInput>;
34
44
  additionalProperties: false;
@@ -42,8 +52,9 @@ export interface BuyToolDefinition {
42
52
  export declare function buyToolDefinition(): BuyToolDefinition;
43
53
  /**
44
54
  * The model's input, checked against the schema: an object, or its JSON text,
45
- * with `ask` a string and `confirm` and `new_order` booleans, and nothing
46
- * else. Throws a TypeError whose message tells the model what to send.
55
+ * with `ask` a string, `confirm` and `new_order` booleans, `product_id` a
56
+ * string, null or left out, and nothing else. Throws a TypeError whose message
57
+ * tells the model what to send.
47
58
  */
48
59
  export declare function parseBuyToolInput(value: unknown): BuyToolInput;
49
60
  /** The buy tool in OpenAI's function-calling format, which the OpenAI SDK, OpenRouter's chat API and most other model APIs take. */
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The buy tool as a model sees it: one name, one description, and a strict
3
- * JSON Schema with three required fields. The delivery address and the
3
+ * JSON Schema with four required fields, `product_id` null unless the shopper
4
+ * picks a product they were shown. The delivery address and the
4
5
  * shopper's id are construction options, never model inputs, so a model can
5
6
  * neither change where an order goes nor buy for someone else.
6
7
  */
@@ -22,6 +23,8 @@ export const BUY_TOOL_DESCRIPTION = [
22
23
  'After `needs_card` with a cart, Agentcard keeps the cart, so set `confirm` to true once the shopper says they added the card.',
23
24
  "After `needs_card` with no cart, call this tool again with the shopper's request once they say they added a card.",
24
25
  'Set `new_order` to true only when the shopper drops the cart.',
26
+ "When the shopper picks one of the `products` in your last result, by its name, its place in the list or a tap that names it, set `product_id` to that product's `product_id`, so Agentcard adds exactly that product; otherwise set it to null.",
27
+ '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.',
25
28
  ].join(' ');
26
29
  /** A fresh copy of the tool's definition, so a caller that edits one never changes another. */
27
30
  export function buyToolDefinition() {
@@ -37,17 +40,22 @@ export function buyToolDefinition() {
37
40
  description: 'True only when the shopper just said yes to the cart in your last result, says they added the card for a cart that waits, says they approved, or asks for the approval link again. False otherwise.',
38
41
  },
39
42
  new_order: { type: 'boolean', description: 'True only when the shopper drops the cart to start a new order. False otherwise.' },
43
+ product_id: {
44
+ type: ['string', 'null'],
45
+ description: "The `product_id` of the product the shopper picked from the `products` in your last result. Null when they picked none.",
46
+ },
40
47
  },
41
- required: ['ask', 'confirm', 'new_order'],
48
+ required: ['ask', 'confirm', 'new_order', 'product_id'],
42
49
  additionalProperties: false,
43
50
  },
44
51
  };
45
52
  }
46
- const FIELDS = new Set(['ask', 'confirm', 'new_order']);
53
+ const FIELDS = new Set(['ask', 'confirm', 'new_order', 'product_id']);
47
54
  /**
48
55
  * The model's input, checked against the schema: an object, or its JSON text,
49
- * with `ask` a string and `confirm` and `new_order` booleans, and nothing
50
- * else. Throws a TypeError whose message tells the model what to send.
56
+ * with `ask` a string, `confirm` and `new_order` booleans, `product_id` a
57
+ * string, null or left out, and nothing else. Throws a TypeError whose message
58
+ * tells the model what to send.
51
59
  */
52
60
  export function parseBuyToolInput(value) {
53
61
  let input = value;
@@ -65,14 +73,18 @@ export function parseBuyToolInput(value) {
65
73
  const fields = input;
66
74
  const extra = Object.keys(fields).find((key) => !FIELDS.has(key));
67
75
  if (extra !== undefined)
68
- throw new TypeError(`The buy tool takes only ask, confirm and new_order, and ${extra} is not one of them.`);
76
+ throw new TypeError(`The buy tool takes only ask, confirm, new_order and product_id, and ${extra} is not one of them.`);
69
77
  if (typeof fields.ask !== 'string')
70
78
  throw new TypeError("Set ask to the shopper's words, as a string.");
71
79
  if (typeof fields.confirm !== 'boolean')
72
80
  throw new TypeError('Set confirm to true or false.');
73
81
  if (typeof fields.new_order !== 'boolean')
74
82
  throw new TypeError('Set new_order to true or false.');
75
- return { ask: fields.ask, confirm: fields.confirm, new_order: fields.new_order };
83
+ const productId = fields.product_id;
84
+ if (productId !== undefined && productId !== null && typeof productId !== 'string') {
85
+ throw new TypeError("Set product_id to the picked product's product_id, as a string, or to null.");
86
+ }
87
+ return { ask: fields.ask, confirm: fields.confirm, new_order: fields.new_order, product_id: typeof productId === 'string' && productId.trim() !== '' ? productId.trim() : null };
76
88
  }
77
89
  /** A fresh copy of the tool in OpenAI's function-calling format: plain JSON, so an SDK that sends it as is sends exactly this. */
78
90
  export function openAIBuyTool() {
package/dist/handler.d.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
- * A Node or Express `(req, res)` handler for an Agentcard webhook endpoint. It
3
- * reads the raw bytes, checks the signature, hands the event to your callback,
4
- * and answers once the callback is done: 200 when it returns, 500 when it
5
- * throws, so Agentcard delivers the event again instead of losing it.
2
+ * Handlers for an Agentcard webhook endpoint, for a Node or Express `(req, res)`
3
+ * route and for a route that takes a web `Request`, such as a Next.js route
4
+ * handler. Each reads the raw bytes, checks the signature, hands the event to
5
+ * your callback, and answers once the callback is done: 200 when it returns,
6
+ * 500 when it throws, so Agentcard delivers the event again instead of losing it.
6
7
  */
7
8
  import { type AgentcardEvent, type VerifyOptions } from './verify.js';
8
9
  import { type PurchaseMessage } from './message.js';
10
+ import { type ShopperMessage } from './shopper-message.js';
9
11
  /** The parts of a Node or Express request the handler reads. */
10
12
  export interface WebhookRequest {
11
13
  headers: Record<string, string | string[] | undefined>;
@@ -35,6 +37,23 @@ export interface PurchaseAgentWebhookOptions extends Pick<VerifyOptions, 'tolera
35
37
  */
36
38
  onError?: (error: unknown, event: AgentcardEvent | undefined) => void | Promise<void>;
37
39
  }
40
+ /** The parts of a web `Request` the fetch handler reads. */
41
+ export interface WebhookFetchRequest {
42
+ headers: {
43
+ get(name: string): string | null;
44
+ };
45
+ /** The body as a stream, read chunk by chunk so a body past the size cap is never held whole. */
46
+ body?: {
47
+ getReader(): {
48
+ read(): Promise<{
49
+ done: boolean;
50
+ value?: Uint8Array;
51
+ }>;
52
+ cancel(reason?: unknown): Promise<void>;
53
+ };
54
+ } | null;
55
+ arrayBuffer(): Promise<ArrayBuffer>;
56
+ }
38
57
  /**
39
58
  * A `(req, res)` handler for your Agentcard webhook endpoint. A delivery with a
40
59
  * missing, malformed, stale or wrong signature gets a 400 and reaches no
@@ -43,6 +62,40 @@ export interface PurchaseAgentWebhookOptions extends Pick<VerifyOptions, 'tolera
43
62
  * callback is done: 200 when it returns, and 500 when it throws, so Agentcard
44
63
  * delivers the event again. Agentcard waits 10 seconds for an answer, so keep
45
64
  * the callback short. Agentcard delivers at least once, and again after a 500,
46
- * so handle each `message.eventId` once.
65
+ * so handle each `message.eventId` once. `agentcardWebhook` does that for you.
47
66
  */
48
67
  export declare function purchaseAgentWebhook(secret: string, onMessage: (message: PurchaseMessage, event: AgentcardEvent) => void | Promise<void>, opts?: PurchaseAgentWebhookOptions): (req: WebhookRequest, res: WebhookResponse) => Promise<void>;
68
+ export interface AgentcardWebhookOptions extends PurchaseAgentWebhookOptions {
69
+ /** The secret Agentcard returned once, when you created the endpoint. */
70
+ secret: string;
71
+ /**
72
+ * Receives each message for one of your shoppers, under your own id for them: post
73
+ * `message.text` into that shopper's chat, or hand `message.note` to your agent. Runs once per
74
+ * event, never for a stale message, and the delivery answers 500 when it throws, so Agentcard
75
+ * delivers the event again.
76
+ */
77
+ onMessage: (externalUserId: string, message: ShopperMessage, event: AgentcardEvent) => void | Promise<void>;
78
+ /**
79
+ * Receives every verified event that is no message for one of your shoppers: another event type,
80
+ * such as `vault.card_stored`, and a shopper message for a shopper you gave Agentcard no id for.
81
+ */
82
+ onEvent?: (event: AgentcardEvent) => void | Promise<void>;
83
+ }
84
+ /** The handler `agentcardWebhook` returns: a Node or Express `(req, res)` handler, with `fetch` for a web `Request`. */
85
+ export interface AgentcardWebhookHandler {
86
+ (req: WebhookRequest, res: WebhookResponse): Promise<void>;
87
+ /** The same handler for a web `Request`, such as a Next.js route handler's: `export const POST = webhook.fetch`. */
88
+ fetch(request: WebhookFetchRequest): Promise<Response>;
89
+ }
90
+ /**
91
+ * One handler for every Agentcard event your shoppers should hear about. It checks each delivery's
92
+ * signature, skips an event this process already handled, reads the message the event carries for
93
+ * a shopper
94
+ * (`readShopperMessage`), drops a message whose link or window has closed (`isStale`), and calls
95
+ * `onMessage` with your own id for the shopper and the message, Agentcard's own text included.
96
+ * Every other verified event goes to `onEvent`. The answer waits for the callback: 200 when it
97
+ * returns, 500 when it throws, and an event whose callback threw runs again on the next delivery.
98
+ * The handled events live in this process's memory: with more than one server, deduplicate on
99
+ * `message.eventId` in your own storage too.
100
+ */
101
+ export declare function agentcardWebhook(opts: AgentcardWebhookOptions): AgentcardWebhookHandler;
package/dist/handler.js CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
- * A Node or Express `(req, res)` handler for an Agentcard webhook endpoint. It
3
- * reads the raw bytes, checks the signature, hands the event to your callback,
4
- * and answers once the callback is done: 200 when it returns, 500 when it
5
- * throws, so Agentcard delivers the event again instead of losing it.
2
+ * Handlers for an Agentcard webhook endpoint, for a Node or Express `(req, res)`
3
+ * route and for a route that takes a web `Request`, such as a Next.js route
4
+ * handler. Each reads the raw bytes, checks the signature, hands the event to
5
+ * your callback, and answers once the callback is done: 200 when it returns,
6
+ * 500 when it throws, so Agentcard delivers the event again instead of losing it.
6
7
  */
7
8
  import { AgentcardSignatureError, InvalidEventBodyError, toleranceOf, verifyAgentcardWebhook, } from './verify.js';
8
9
  import { readPurchaseMessage } from './message.js';
10
+ import { isStale, readShopperMessage } from './shopper-message.js';
9
11
  /** A signed Agentcard delivery is a few kilobytes; a body past this size did not come from Agentcard. */
10
12
  const MAX_BODY_BYTES = 1024 * 1024;
11
13
  const RAW_BODY_GONE = 'The webhook request reached the handler with its body already parsed and no raw bytes kept, so the signature cannot be checked. ' +
@@ -95,6 +97,132 @@ async function readRawBody(req) {
95
97
  return undefined;
96
98
  return readStream(req);
97
99
  }
100
+ /** Hands an error to `onError`, or to `console.error`. A reporter that throws or rejects never takes the process down. */
101
+ function reporter(onError) {
102
+ return (error, event) => {
103
+ try {
104
+ const reported = onError ? onError(error, event) : console.error('Agentcard webhook:', error);
105
+ // A reporter that rejects must not leave an unhandled rejection, which ends a plain Node server's process.
106
+ if (reported && typeof reported.then === 'function')
107
+ reported.then(undefined, () => { });
108
+ }
109
+ catch {
110
+ // A failing reporter must never take the process down with it.
111
+ }
112
+ };
113
+ }
114
+ function checkSecret(secret) {
115
+ if (typeof secret !== 'string' || secret === '') {
116
+ throw new TypeError('Pass the webhook endpoint secret Agentcard returned when you created the endpoint.');
117
+ }
118
+ }
119
+ /** Verifies one raw delivery and runs its callbacks. The answer waits for them, so a callback that fails gets the event delivered again. */
120
+ async function answer(raw, signature, secret, verifyOpts, dispatch, report) {
121
+ let event;
122
+ try {
123
+ event = verifyAgentcardWebhook(raw, signature, secret, verifyOpts);
124
+ }
125
+ catch (error) {
126
+ if (error instanceof AgentcardSignatureError)
127
+ return { status: 400, body: { error: 'invalid_signature', reason: error.reason } };
128
+ if (error instanceof InvalidEventBodyError)
129
+ return { status: 400, body: { error: 'invalid_body' } };
130
+ report(error, undefined);
131
+ return { status: 500, body: { error: 'verification_failed' } };
132
+ }
133
+ if (await dispatch(event))
134
+ return { status: 200, body: { received: true } };
135
+ return { status: 500, body: { error: 'callback_failed' } };
136
+ }
137
+ /** The `(req, res)` handler around one delivery's answer. It never rejects: an unhandled rejection would end a plain Node server's process. */
138
+ function nodeHandler(handleRaw, report) {
139
+ const handle = async (req, res) => {
140
+ let raw;
141
+ try {
142
+ raw = await readRawBody(req);
143
+ }
144
+ catch (error) {
145
+ if (error instanceof BodyTooLargeError)
146
+ return send(res, 413, { error: 'body_too_large' });
147
+ return send(res, 400, { error: 'unreadable_body' });
148
+ }
149
+ if (raw === undefined) {
150
+ report(new Error(RAW_BODY_GONE), undefined);
151
+ return send(res, 500, { error: 'raw_body_unavailable' });
152
+ }
153
+ const outcome = await handleRaw(raw, headerValue(req.headers['agentcard-signature']));
154
+ send(res, outcome.status, outcome.body);
155
+ };
156
+ return async (req, res) => {
157
+ try {
158
+ await handle(req, res);
159
+ }
160
+ catch (error) {
161
+ report(error, undefined);
162
+ }
163
+ };
164
+ }
165
+ /**
166
+ * The body of a web request, read chunk by chunk. Past the size cap it stops reading, cancels the
167
+ * stream and throws, so a caller with no signature cannot make the server hold a large body.
168
+ */
169
+ async function readFetchBody(request) {
170
+ if (!request.body || typeof request.body.getReader !== 'function')
171
+ return new Uint8Array(await request.arrayBuffer());
172
+ const reader = request.body.getReader();
173
+ const chunks = [];
174
+ let size = 0;
175
+ for (;;) {
176
+ const { done, value } = await reader.read();
177
+ if (done)
178
+ break;
179
+ if (!value)
180
+ continue;
181
+ size += value.length;
182
+ if (size > MAX_BODY_BYTES) {
183
+ await reader.cancel().catch(() => { });
184
+ throw new BodyTooLargeError();
185
+ }
186
+ chunks.push(value);
187
+ }
188
+ const out = new Uint8Array(size);
189
+ let offset = 0;
190
+ for (const chunk of chunks) {
191
+ out.set(chunk, offset);
192
+ offset += chunk.length;
193
+ }
194
+ return out;
195
+ }
196
+ /** A web `Response` with a JSON body, built from the runtime's own `Response`. */
197
+ function webResponse(outcome) {
198
+ return new Response(JSON.stringify(outcome.body), { status: outcome.status, headers: { 'content-type': 'application/json' } });
199
+ }
200
+ /** The web `Request` handler around one delivery's answer. It never rejects. */
201
+ function fetchHandler(handleRaw, report) {
202
+ return async (request) => {
203
+ try {
204
+ const declared = Number(request.headers.get('content-length'));
205
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES)
206
+ return webResponse({ status: 413, body: { error: 'body_too_large' } });
207
+ let raw;
208
+ try {
209
+ raw = await readFetchBody(request);
210
+ }
211
+ catch (error) {
212
+ if (error instanceof BodyTooLargeError)
213
+ return webResponse({ status: 413, body: { error: 'body_too_large' } });
214
+ return webResponse({ status: 400, body: { error: 'unreadable_body' } });
215
+ }
216
+ if (raw.length > MAX_BODY_BYTES)
217
+ return webResponse({ status: 413, body: { error: 'body_too_large' } });
218
+ return webResponse(await handleRaw(raw, request.headers.get('agentcard-signature') ?? undefined));
219
+ }
220
+ catch (error) {
221
+ report(error, undefined);
222
+ return webResponse({ status: 500, body: { error: 'handler_failed' } });
223
+ }
224
+ };
225
+ }
98
226
  /**
99
227
  * A `(req, res)` handler for your Agentcard webhook endpoint. A delivery with a
100
228
  * missing, malformed, stale or wrong signature gets a 400 and reaches no
@@ -103,28 +231,15 @@ async function readRawBody(req) {
103
231
  * callback is done: 200 when it returns, and 500 when it throws, so Agentcard
104
232
  * delivers the event again. Agentcard waits 10 seconds for an answer, so keep
105
233
  * the callback short. Agentcard delivers at least once, and again after a 500,
106
- * so handle each `message.eventId` once.
234
+ * so handle each `message.eventId` once. `agentcardWebhook` does that for you.
107
235
  */
108
236
  export function purchaseAgentWebhook(secret, onMessage, opts = {}) {
109
- if (typeof secret !== 'string' || secret === '') {
110
- throw new TypeError('Pass the webhook endpoint secret Agentcard returned when you created the endpoint.');
111
- }
237
+ checkSecret(secret);
112
238
  if (typeof onMessage !== 'function') {
113
239
  throw new TypeError('Pass onMessage, the function that receives each purchase message.');
114
240
  }
115
241
  const verifyOpts = { toleranceSeconds: toleranceOf(opts) };
116
- const report = (error, event) => {
117
- try {
118
- const reported = opts.onError ? opts.onError(error, event) : console.error('Agentcard webhook:', error);
119
- // A reporter that rejects must not leave an unhandled rejection, which ends a plain Node server's process.
120
- if (reported && typeof reported.then === 'function')
121
- reported.then(undefined, () => { });
122
- }
123
- catch {
124
- // A failing reporter must never take the process down with it.
125
- }
126
- };
127
- /** Runs the callback for one verified event. True when the delivery is done, false when the callback threw. */
242
+ const report = reporter(opts.onError);
128
243
  const dispatch = async (event) => {
129
244
  let message;
130
245
  try {
@@ -147,44 +262,90 @@ export function purchaseAgentWebhook(secret, onMessage, opts = {}) {
147
262
  return false;
148
263
  }
149
264
  };
150
- const handle = async (req, res) => {
151
- let raw;
265
+ return nodeHandler((raw, signature) => answer(raw, signature, secret, verifyOpts, dispatch, report), report);
266
+ }
267
+ /** How many handled event ids the handler remembers, the oldest forgotten first. */
268
+ const HANDLED_EVENTS_KEPT = 10_000;
269
+ /**
270
+ * One handler for every Agentcard event your shoppers should hear about. It checks each delivery's
271
+ * signature, skips an event this process already handled, reads the message the event carries for
272
+ * a shopper
273
+ * (`readShopperMessage`), drops a message whose link or window has closed (`isStale`), and calls
274
+ * `onMessage` with your own id for the shopper and the message, Agentcard's own text included.
275
+ * Every other verified event goes to `onEvent`. The answer waits for the callback: 200 when it
276
+ * returns, 500 when it throws, and an event whose callback threw runs again on the next delivery.
277
+ * The handled events live in this process's memory: with more than one server, deduplicate on
278
+ * `message.eventId` in your own storage too.
279
+ */
280
+ export function agentcardWebhook(opts) {
281
+ if (opts === null || typeof opts !== 'object')
282
+ throw new TypeError('Pass { secret, onMessage }.');
283
+ checkSecret(opts.secret);
284
+ if (typeof opts.onMessage !== 'function') {
285
+ throw new TypeError('Pass onMessage, the function that receives each message for a shopper.');
286
+ }
287
+ const secret = opts.secret;
288
+ const verifyOpts = { toleranceSeconds: toleranceOf(opts) };
289
+ const report = reporter(opts.onError);
290
+ // The events this process finished, and the ones it is running. A redelivery of a finished event
291
+ // runs nothing, and one that lands while the first delivery still runs waits for its answer.
292
+ const finished = new Set();
293
+ const running = new Map();
294
+ const finish = (eventId) => {
295
+ finished.add(eventId);
296
+ if (finished.size > HANDLED_EVENTS_KEPT)
297
+ finished.delete(finished.values().next().value);
298
+ };
299
+ /** Runs the callback for one event. True when the delivery is done, false when the callback threw. */
300
+ const run = async (event) => {
301
+ let message;
152
302
  try {
153
- raw = await readRawBody(req);
303
+ message = readShopperMessage(event);
154
304
  }
155
305
  catch (error) {
156
- if (error instanceof BodyTooLargeError)
157
- return send(res, 413, { error: 'body_too_large' });
158
- return send(res, 400, { error: 'unreadable_body' });
306
+ // A message the package cannot read reads the same way on every delivery, so it is not sent again.
307
+ report(error, event);
308
+ finish(event.id);
309
+ return true;
159
310
  }
160
- if (raw === undefined) {
161
- report(new Error(RAW_BODY_GONE), undefined);
162
- return send(res, 500, { error: 'raw_body_unavailable' });
311
+ // A message whose link or window closed before it arrived reaches no callback, whichever one it would reach.
312
+ if (message && isStale(message)) {
313
+ finish(event.id);
314
+ return true;
163
315
  }
164
- let event;
165
316
  try {
166
- event = verifyAgentcardWebhook(raw, headerValue(req.headers['agentcard-signature']), secret, verifyOpts);
317
+ if (message?.externalUserId) {
318
+ await opts.onMessage(message.externalUserId, message, event);
319
+ }
320
+ else if (opts.onEvent) {
321
+ await opts.onEvent(event);
322
+ }
167
323
  }
168
324
  catch (error) {
169
- if (error instanceof AgentcardSignatureError)
170
- return send(res, 400, { error: 'invalid_signature', reason: error.reason });
171
- if (error instanceof InvalidEventBodyError)
172
- return send(res, 400, { error: 'invalid_body' });
173
- report(error, undefined);
174
- return send(res, 500, { error: 'verification_failed' });
325
+ // Not marked finished, so the next delivery of the event runs the callback again.
326
+ report(error, event);
327
+ return false;
175
328
  }
176
- // The answer waits for the callback, so a callback that fails gets the event delivered again.
177
- if (await dispatch(event))
178
- return send(res, 200, { received: true });
179
- return send(res, 500, { error: 'callback_failed' });
329
+ finish(event.id);
330
+ return true;
180
331
  };
181
- // The handler never rejects: an unhandled rejection would end a plain Node server's process.
182
- return async (req, res) => {
332
+ const dispatch = async (event) => {
333
+ if (finished.has(event.id))
334
+ return true;
335
+ const inFlight = running.get(event.id);
336
+ if (inFlight)
337
+ return inFlight;
338
+ const attempt = run(event);
339
+ running.set(event.id, attempt);
183
340
  try {
184
- await handle(req, res);
341
+ return await attempt;
185
342
  }
186
- catch (error) {
187
- report(error, undefined);
343
+ finally {
344
+ running.delete(event.id);
188
345
  }
189
346
  };
347
+ const handleRaw = (raw, signature) => answer(raw, signature, secret, verifyOpts, dispatch, report);
348
+ const handler = nodeHandler(handleRaw, report);
349
+ handler.fetch = fetchHandler(handleRaw, report);
350
+ return handler;
190
351
  }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  export { verifyAgentcardWebhook, AgentcardSignatureError, type AgentcardSignatureReason, type AgentcardEvent, type VerifyOptions, } from './verify.js';
2
2
  export { readPurchaseMessage, type PurchaseMessage, type PurchaseMessageKind, type PurchaseFacts, } from './message.js';
3
- export { purchaseAgentWebhook, type PurchaseAgentWebhookOptions, type WebhookRequest, type WebhookResponse, } from './handler.js';
4
- export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, } from './client.js';
3
+ export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, type ShopperMessage, type ShopperMessageKind, type ShopperFacts, type ShopperEventType, type OrderStatus, } from './shopper-message.js';
4
+ export { agentcardWebhook, purchaseAgentWebhook, type AgentcardWebhookOptions, type AgentcardWebhookHandler, type PurchaseAgentWebhookOptions, type WebhookFetchRequest, type WebhookRequest, type WebhookResponse, } from './handler.js';
5
+ export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, } from './client.js';
5
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';
6
7
  export { toMessages, messageStream, type MessageStream, type MessageStreamOptions, } from './messages.js';
7
8
  export { buyTool, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolProduct, } from './buy-tool.js';
9
+ export { ackFor, type AckForOptions } from './ack.js';
8
10
  export { forModel, stripLinks, type BuyToolModelView, type BuyToolModelCart, type BuyToolModelProduct, } from './model-view.js';
9
11
  export { toAnthropicTool, toOpenRouterTool, type AnthropicBuyTool, type OpenRouterToolkit, type ZodLike, } from './adapters.js';
package/dist/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  export { verifyAgentcardWebhook, AgentcardSignatureError, } from './verify.js';
2
2
  export { readPurchaseMessage, } from './message.js';
3
- export { purchaseAgentWebhook, } from './handler.js';
3
+ export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, } from './shopper-message.js';
4
+ export { agentcardWebhook, purchaseAgentWebhook, } from './handler.js';
4
5
  export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, } from './client.js';
5
6
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, } from './definition.js';
6
7
  export { toMessages, messageStream, } from './messages.js';
7
8
  export { buyTool, } from './buy-tool.js';
9
+ export { ackFor } from './ack.js';
8
10
  export { forModel, stripLinks, } from './model-view.js';
9
11
  export { toAnthropicTool, toOpenRouterTool, } from './adapters.js';
package/dist/message.d.ts CHANGED
@@ -9,13 +9,15 @@ import { type AgentcardEvent } from './verify.js';
9
9
  * - `order_placed`: the shopper approved, and Agentcard placed the order.
10
10
  * - `order_failed`: the order failed after the approval, and nothing more happens for that approval.
11
11
  * - `order_not_placed_yet`: the shopper approved, and the order is not placed yet.
12
+ * - `order_unconfirmed`: the store's reply to the order was lost, so the order may exist; Agentcard is confirming it.
12
13
  * - `approval_expiring`: the approval link expires soon; `text` carries the link.
13
14
  * - `approval_expired`: the approval expired before the shopper answered, and nothing was charged.
14
15
  * - `card_link_unfinished`: the shopper has not added a card from their card link; `text` carries the link again.
15
16
  * - `card_link_expired`: the card link expired before the shopper added a card.
16
17
  * - `cart_idle`: a cart sat without being sent for approval; `facts.nudgeNumber` counts the nudges.
18
+ * - `card_added`: the shopper saved a card from the card link your app sent, and can check out with it.
17
19
  */
18
- export type PurchaseMessageKind = 'order_placed' | 'order_failed' | 'order_not_placed_yet' | 'approval_expiring' | 'approval_expired' | 'card_link_unfinished' | 'card_link_expired' | 'cart_idle';
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';
19
21
  /** The message's values one by one, for a company that writes its own copy. A fact that does not apply is absent. */
20
22
  export interface PurchaseFacts {
21
23
  merchantName?: string;
@@ -20,6 +20,8 @@ export interface BuyToolModelCart {
20
20
  total: string;
21
21
  }
22
22
  export interface BuyToolModelProduct {
23
+ /** What the model passes as `product_id` when the shopper picks this product: Agentcard's ref, never a link. Null when there is none. */
24
+ product_id: string | null;
23
25
  name: string;
24
26
  price: string | null;
25
27
  /** True when the shopper holds this product's store page, which `sendLink` delivered beside the reply. */
@@ -43,6 +45,11 @@ export interface BuyToolModelView {
43
45
  link_sent: boolean;
44
46
  /** How many store pages of the products above the shopper holds, 0 when none went out. */
45
47
  product_links_sent: number;
48
+ /** The product this call's `product_id` picked, and whether the cart now holds it; null when the call picked none. */
49
+ pick: {
50
+ product_id: string | null;
51
+ in_cart: boolean;
52
+ } | null;
46
53
  }
47
54
  /** The text with every link taken out: a Markdown link keeps its label, and a bare address goes entirely. */
48
55
  export declare function stripLinks(text: string): string;
@@ -58,9 +65,14 @@ export declare function money(minor: number | null | undefined, currency: string
58
65
  * never holds a product link the model cannot name.
59
66
  */
60
67
  export declare function shownProducts(products: readonly BuyToolProduct[] | null | undefined): BuyToolProduct[];
68
+ /**
69
+ * The id the model passes back for a product: Agentcard's link-free ref, else the product's own id when
70
+ * it holds no link, else null. A retail product's id is its store page, and a link never reaches the model.
71
+ */
72
+ export declare function pickId(product: Pick<BuyToolProduct, 'id' | 'ref'>): string | null;
61
73
  /**
62
74
  * The model's view of one answer: the status, Agentcard's reply, the newest
63
- * cart and every cart on the table, the products by name and price, and
75
+ * cart and every cart on the table, the products by id, name and price, and
64
76
  * whether the shopper has each link. A status this version does not know
65
77
  * reads as `needs_input`.
66
78
  */
@@ -67,6 +67,15 @@ export function money(minor, currency) {
67
67
  export function shownProducts(products) {
68
68
  return (products ?? []).filter((product) => stripLinks(product.name) !== '').slice(0, MAX_PRODUCTS);
69
69
  }
70
+ /**
71
+ * The id the model passes back for a product: Agentcard's link-free ref, else the product's own id when
72
+ * it holds no link, else null. A retail product's id is its store page, and a link never reaches the model.
73
+ */
74
+ export function pickId(product) {
75
+ if (typeof product.ref === 'string' && product.ref !== '' && stripLinks(product.ref) === product.ref)
76
+ return product.ref;
77
+ return typeof product.id === 'string' && product.id !== '' && stripLinks(product.id) === product.id ? product.id : null;
78
+ }
70
79
  function cartView(cart) {
71
80
  return {
72
81
  store: stripLinks(cart.store?.name ?? cart.merchant_name) || stripLinks(cart.merchant),
@@ -78,7 +87,7 @@ function cartView(cart) {
78
87
  }
79
88
  /**
80
89
  * The model's view of one answer: the status, Agentcard's reply, the newest
81
- * cart and every cart on the table, the products by name and price, and
90
+ * cart and every cart on the table, the products by id, name and price, and
82
91
  * whether the shopper has each link. A status this version does not know
83
92
  * reads as `needs_input`.
84
93
  */
@@ -92,12 +101,14 @@ export function forModel(result) {
92
101
  reply: stripLinks(result.reply),
93
102
  cart: result.cart ? cartView(result.cart) : null,
94
103
  carts: (result.carts ?? []).map(cartView),
95
- products: products.map((product) => ({ name: stripLinks(product.name), price: money(product.priceCents, product.currency), link_sent: held(product) })),
104
+ products: products.map((product) => ({ product_id: pickId(product), name: stripLinks(product.name), price: money(product.priceCents, product.currency), link_sent: held(product) })),
96
105
  approval_expires_at: typeof result.approval_expires_at === 'string' ? stripLinks(result.approval_expires_at) || null : null,
97
106
  shopper_need_not_reply: result.shopper_need_not_reply === true,
98
107
  link_sent: result.link_sent === true,
99
108
  // Two products on one page share one link, so the count is of pages.
100
109
  product_links_sent: new Set(products.filter(held).map((product) => product.url)).size,
110
+ // The id the model sent, never the catalog id, which is a store page for a retail product.
111
+ pick: result.pick ? { product_id: pickId({ id: result.pick.product_id, ref: result.pick.ref }), in_cart: result.pick.in_cart === true } : null,
101
112
  };
102
113
  }
103
114
  /**