@agent-cards/commerce-sdk 0.2.0 → 0.8.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/dist/client.js CHANGED
@@ -9,6 +9,8 @@
9
9
  * token cache follows its client (packages/checkout/src/client.ts accessToken).
10
10
  */
11
11
  import { isRecord } from './verify.js';
12
+ import { listenToAgentcard } from './listen.js';
13
+ const PROGRESS_STAGES = new Set(['searching', 'building_cart', 'checking_out', 'working']);
12
14
  export const DEFAULT_BASE_URL = 'https://api.agentcard.sh';
13
15
  /** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
14
16
  export const BUY_TURN_TIMEOUT_MS = 130_000;
@@ -20,7 +22,7 @@ const NEXT_ACTIONS = new Set(['shipping_details', 'guest_details', 'add_card', '
20
22
  /**
21
23
  * A refusal, or a failure to reach Agentcard. `code` is the API's own
22
24
  * (`user_not_connected`, `external_user_id_ambiguous`, `turn_in_progress`,
23
- * `user_id_mismatch`), one read from the shape of the answer
25
+ * `user_id_mismatch`, `pick_not_found`), one read from the shape of the answer
24
26
  * (`cart_changed`, `nothing_to_confirm`, `conversation_closed`,
25
27
  * `conversation_not_found`), or the client's (`unreachable`, `timeout`,
26
28
  * `oauth_failed`, `unauthorized`, `http_<status>`). `status` is 0 when
@@ -118,6 +120,7 @@ function catalogOf(v) {
118
120
  const flight = flightOf(i.flight);
119
121
  return {
120
122
  id: str(i.id) ?? '',
123
+ ref: str(i.ref),
121
124
  name: str(i.name),
122
125
  priceCents: num(i.priceCents),
123
126
  currency: str(i.currency),
@@ -127,7 +130,7 @@ function catalogOf(v) {
127
130
  url: pageUrl(i.url),
128
131
  };
129
132
  }) : [];
130
- return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items };
133
+ return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items, presented: v.presented === true };
131
134
  }
132
135
  function paymentSourceOf(v) {
133
136
  return isRecord(v) && str(v.source) ? { source: v.source, brand: str(v.brand), last4: str(v.last4) } : null;
@@ -161,6 +164,7 @@ export function turnOf(body) {
161
164
  store_login_expires_at: str(body.store_login_expires_at),
162
165
  store_login_merchant_name: str(body.store_login_merchant_name),
163
166
  next_action: typeof body.next_action === 'string' && NEXT_ACTIONS.has(body.next_action) ? body.next_action : null,
167
+ pick: isRecord(body.pick) && str(body.pick.product_id) ? { product_id: body.pick.product_id, ref: str(body.pick.ref), in_cart: body.pick.in_cart === true } : null,
164
168
  raw: body,
165
169
  };
166
170
  }
@@ -253,6 +257,106 @@ function parseJson(text) {
253
257
  return null;
254
258
  }
255
259
  }
260
+ /** Each event of a server-sent stream, in order, read as it arrives, or from the whole text when the fetch hands no stream. */
261
+ async function eachEvent(res, onEvent) {
262
+ let buffer = '';
263
+ // True once onEvent says the stream is over.
264
+ let over = false;
265
+ const drain = (final) => {
266
+ buffer = buffer.replace(/\r\n?/g, '\n');
267
+ for (let end = buffer.indexOf('\n\n'); end >= 0 || (final && buffer.trim() !== ''); end = buffer.indexOf('\n\n')) {
268
+ const block = end >= 0 ? buffer.slice(0, end) : buffer;
269
+ buffer = end >= 0 ? buffer.slice(end + 2) : '';
270
+ let event = 'message';
271
+ const data = [];
272
+ for (const line of block.split('\n')) {
273
+ if (line.startsWith('event:'))
274
+ event = line.slice(6).trim();
275
+ else if (line.startsWith('data:'))
276
+ data.push(line.slice(5).replace(/^ /, ''));
277
+ }
278
+ if (data.length > 0 && onEvent(event, data.join('\n')) === true) {
279
+ over = true;
280
+ return;
281
+ }
282
+ }
283
+ };
284
+ const reader = res.body?.getReader?.();
285
+ if (!reader) {
286
+ buffer = await res.text();
287
+ drain(true);
288
+ return;
289
+ }
290
+ const decoder = new TextDecoder();
291
+ try {
292
+ while (!over) {
293
+ const chunk = await reader.read();
294
+ if (chunk.done)
295
+ break;
296
+ buffer += decoder.decode(chunk.value, { stream: true });
297
+ drain(false);
298
+ }
299
+ if (!over)
300
+ drain(true);
301
+ }
302
+ finally {
303
+ if (over)
304
+ reader.cancel?.().catch(() => { });
305
+ }
306
+ }
307
+ /**
308
+ * A streamed purchase turn, read to its `envelope`: the same answer a JSON turn returns, with its
309
+ * status in `http_status`. Each `progress` and `catalog` goes to its handler on the way. A stream that
310
+ * breaks or ends before its envelope, once it named the conversation, reads as a turn past its budget
311
+ * (`turn_timeout`), so the tool reads the conversation for what the turn left instead of losing it.
312
+ */
313
+ async function readStreamedTurn(res, stream, timeoutMs) {
314
+ let envelope = null;
315
+ let conversationId = null;
316
+ const delivering = [];
317
+ let failure = null;
318
+ try {
319
+ await eachEvent(res, (event, text) => {
320
+ const data = parseJson(text);
321
+ if (!data)
322
+ return;
323
+ conversationId = str(data.conversation_id) ?? conversationId;
324
+ if (event === 'envelope') {
325
+ envelope = data;
326
+ return true;
327
+ }
328
+ if (event === 'progress' && typeof data.stage === 'string' && PROGRESS_STAGES.has(data.stage) && stream.onProgress) {
329
+ try {
330
+ stream.onProgress({ stage: data.stage, conversationId: str(data.conversation_id) });
331
+ }
332
+ catch {
333
+ // The app's display failing never fails the turn.
334
+ }
335
+ }
336
+ else if (event === 'catalog' && stream.onCatalog && str(data.conversation_id)) {
337
+ const catalog = catalogOf(data.catalog);
338
+ if (!catalog || catalog.items.length === 0)
339
+ return;
340
+ const asOf = isRecord(data.catalog) ? str(data.catalog.as_of) : null;
341
+ delivering.push(Promise.resolve().then(() => stream.onCatalog({ conversationId: data.conversation_id, catalog, asOf })).catch(() => { }));
342
+ }
343
+ });
344
+ }
345
+ catch (error) {
346
+ failure = error;
347
+ }
348
+ await Promise.all(delivering);
349
+ const answered = envelope;
350
+ if (answered) {
351
+ const { http_status: httpStatus, ...body } = answered;
352
+ const status = typeof httpStatus === 'number' ? httpStatus : res.status;
353
+ return { status, ok: status >= 200 && status < 300, readable: true, body };
354
+ }
355
+ if (conversationId) {
356
+ return { status: 200, ok: true, readable: true, body: { conversation_id: conversationId, status: 'needs_input', reply: '', error_code: 'turn_timeout' } };
357
+ }
358
+ throw failure ? failureOf(failure, timeoutMs) : new AgentcardApiError(0, 'unreachable', 'Agentcard closed the purchase answer before it ended.');
359
+ }
256
360
  /** The client every shopper's buy tool shares. Build one per set of credentials and keep it. */
257
361
  export class AgentcardClient {
258
362
  baseUrl;
@@ -289,10 +393,15 @@ export class AgentcardClient {
289
393
  get sandbox() {
290
394
  return this.learnedSandbox;
291
395
  }
292
- /** The access token the client sends: the shopper's own, or the company's, exchanged once and renewed a minute before it expires. */
293
- async bearer() {
396
+ /**
397
+ * The access token the client sends: the shopper's own, or the company's, exchanged once and
398
+ * renewed a minute before it expires. Pass a company token Agentcard refused to get a new one.
399
+ */
400
+ async bearer(refused) {
294
401
  if (this.shopperToken !== null)
295
402
  return this.shopperToken;
403
+ if (refused !== undefined)
404
+ return this.renewAfter(refused);
296
405
  if (this.token && Date.now() < this.token.expiresAt)
297
406
  return this.token.value;
298
407
  if (!this.inflight)
@@ -305,11 +414,14 @@ export class AgentcardClient {
305
414
  * conversation instead. Only a 401 is sent again, once, with a fresh token,
306
415
  * because Agentcard refused it before anything ran.
307
416
  */
308
- async buy(body) {
417
+ async buy(body, stream) {
309
418
  if (!isRecord(body))
310
419
  throw new TypeError('Pass the purchase request as an object, such as { ask: "two AA battery packs" }.');
311
420
  this.checkShopper({ externalUserId: body.external_user_id, userId: body.user_id });
312
- return turnOf(await this.call('POST', '/buy', body, BUY_TURN_TIMEOUT_MS));
421
+ const streamed = !!stream && (typeof stream.onProgress === 'function' || typeof stream.onCatalog === 'function');
422
+ // A streamed turn keeps the soft budget a JSON turn has, so it answers within BUY_TURN_TIMEOUT_MS too.
423
+ const path = streamed ? '/buy?stream=1&soft_timeout=1' : '/buy';
424
+ return turnOf(await this.call('POST', path, body, BUY_TURN_TIMEOUT_MS, streamed ? stream : undefined));
313
425
  }
314
426
  /** One read of a conversation: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
315
427
  async conversation(conversationId, who = {}) {
@@ -337,6 +449,13 @@ export class AgentcardClient {
337
449
  throw new AgentcardApiError(200, 'invalid_answer', 'Agentcard answered the Vault link request without a link.', answer);
338
450
  return { id: str(answer.id) ?? '', url, expiresAt: str(answer.expires_at), reused: answer.reused === true };
339
451
  }
452
+ /**
453
+ * Hear every event Agentcard sends your company over one connection your server opens, with no
454
+ * webhook endpoint: `listenToAgentcard(this, opts)`. Takes your company credentials.
455
+ */
456
+ listen(opts) {
457
+ return listenToAgentcard(this, opts);
458
+ }
340
459
  /** A company names the shopper exactly once; a shopper's own token already names them. */
341
460
  checkShopper(who) {
342
461
  const named = [who.externalUserId, who.userId].filter((v) => v !== undefined);
@@ -349,10 +468,10 @@ export class AgentcardClient {
349
468
  throw new TypeError('Name the shopper once: externalUserId, your own id for them, or userId, the Agentcard user id.');
350
469
  }
351
470
  }
352
- async call(method, path, body, timeoutMs) {
471
+ async call(method, path, body, timeoutMs, stream) {
353
472
  let token = await this.bearer();
354
473
  for (let attempt = 0;; attempt++) {
355
- const answer = await this.send(method, path, token, body, timeoutMs);
474
+ const answer = await this.send(method, path, token, body, timeoutMs, stream);
356
475
  if (answer.status === 401 && attempt === 0 && this.credentials) {
357
476
  token = await this.renewAfter(token);
358
477
  continue;
@@ -370,24 +489,39 @@ export class AgentcardClient {
370
489
  throw errorOf(answer.status, answer.body);
371
490
  }
372
491
  }
373
- async send(method, path, token, body, timeoutMs) {
492
+ async send(method, path, token, body, timeoutMs, stream) {
493
+ let res;
374
494
  try {
375
- const res = await this.fetchFn(`${this.baseUrl}${path}`, {
495
+ res = await this.fetchFn(`${this.baseUrl}${path}`, {
376
496
  method,
377
497
  headers: {
378
498
  authorization: `Bearer ${token}`,
379
- accept: 'application/json',
499
+ accept: stream ? 'text/event-stream, application/json' : 'application/json',
380
500
  ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
381
501
  },
382
502
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
383
503
  signal: AbortSignal.timeout(timeoutMs),
384
504
  });
385
- const parsed = parseJson(await res.text());
386
- return { status: res.status, ok: res.ok, readable: parsed !== null, body: parsed ?? {} };
387
505
  }
388
506
  catch (error) {
389
507
  throw failureOf(error, timeoutMs);
390
508
  }
509
+ // Agentcard streams only once the turn starts: a refusal before it is plain JSON.
510
+ if (stream && /text\/event-stream/i.test(res.headers?.get?.('content-type') ?? ''))
511
+ return readStreamedTurn(res, stream, timeoutMs);
512
+ let text;
513
+ try {
514
+ text = await res.text();
515
+ }
516
+ catch (error) {
517
+ throw failureOf(error, timeoutMs);
518
+ }
519
+ const parsed = parseJson(text);
520
+ // A fetch that hands no headers still carries a streamed answer in its text: read it whole.
521
+ if (stream && parsed === null && /^(?:event|data):/m.test(text)) {
522
+ return readStreamedTurn({ status: res.status, ok: res.ok, text: async () => text }, stream, timeoutMs);
523
+ }
524
+ return { status: res.status, ok: res.ok, readable: parsed !== null, body: parsed ?? {} };
391
525
  }
392
526
  /** After a 401: the token another call already renewed, or one new exchange, however many calls were refused at once. */
393
527
  async renewAfter(refused) {
@@ -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
  */
@@ -10,6 +11,7 @@ export const BUY_TOOL_DESCRIPTION = [
10
11
  'Buy what this shopper asks for at real stores.',
11
12
  "Put the shopper's own words in `ask`.",
12
13
  'Tell the shopper what `reply` says, in your own voice.',
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.",
13
15
  'Show the shopper the cart before you confirm it.',
14
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.',
15
17
  'Set `confirm` to 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.',
@@ -22,6 +24,8 @@ export const BUY_TOOL_DESCRIPTION = [
22
24
  'After `needs_card` with a cart, Agentcard keeps the cart, so set `confirm` to true once the shopper says they added the card.',
23
25
  "After `needs_card` with no cart, call this tool again with the shopper's request once they say they added a card.",
24
26
  'Set `new_order` to true only when the shopper drops the cart.',
27
+ "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.",
28
+ '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
29
  ].join(' ');
26
30
  /** A fresh copy of the tool's definition, so a caller that edits one never changes another. */
27
31
  export function buyToolDefinition() {
@@ -37,17 +41,22 @@ export function buyToolDefinition() {
37
41
  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
42
  },
39
43
  new_order: { type: 'boolean', description: 'True only when the shopper drops the cart to start a new order. False otherwise.' },
44
+ product_id: {
45
+ type: ['string', 'null'],
46
+ description: "The `product_id` of the product the shopper picked from the `products` in your last result. Null when they picked none.",
47
+ },
40
48
  },
41
- required: ['ask', 'confirm', 'new_order'],
49
+ required: ['ask', 'confirm', 'new_order', 'product_id'],
42
50
  additionalProperties: false,
43
51
  },
44
52
  };
45
53
  }
46
- const FIELDS = new Set(['ask', 'confirm', 'new_order']);
54
+ const FIELDS = new Set(['ask', 'confirm', 'new_order', 'product_id']);
47
55
  /**
48
56
  * 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.
57
+ * with `ask` a string, `confirm` and `new_order` booleans, `product_id` a
58
+ * string, null or left out, and nothing else. Throws a TypeError whose message
59
+ * tells the model what to send.
51
60
  */
52
61
  export function parseBuyToolInput(value) {
53
62
  let input = value;
@@ -65,14 +74,18 @@ export function parseBuyToolInput(value) {
65
74
  const fields = input;
66
75
  const extra = Object.keys(fields).find((key) => !FIELDS.has(key));
67
76
  if (extra !== undefined)
68
- throw new TypeError(`The buy tool takes only ask, confirm and new_order, and ${extra} is not one of them.`);
77
+ throw new TypeError(`The buy tool takes only ask, confirm, new_order and product_id, and ${extra} is not one of them.`);
69
78
  if (typeof fields.ask !== 'string')
70
79
  throw new TypeError("Set ask to the shopper's words, as a string.");
71
80
  if (typeof fields.confirm !== 'boolean')
72
81
  throw new TypeError('Set confirm to true or false.');
73
82
  if (typeof fields.new_order !== 'boolean')
74
83
  throw new TypeError('Set new_order to true or false.');
75
- return { ask: fields.ask, confirm: fields.confirm, new_order: fields.new_order };
84
+ const productId = fields.product_id;
85
+ if (productId !== undefined && productId !== null && typeof productId !== 'string') {
86
+ throw new TypeError("Set product_id to the picked product's product_id, as a string, or to null.");
87
+ }
88
+ return { ask: fields.ask, confirm: fields.confirm, new_order: fields.new_order, product_id: typeof productId === 'string' && productId.trim() !== '' ? productId.trim() : null };
76
89
  }
77
90
  /** 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
91
  export function openAIBuyTool() {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Every event type Agentcard sends a company, the same list a webhook endpoint subscribes to by
3
+ * name. The stream (`agentcard.listen`) and a webhook endpoint carry the same types, with the same
4
+ * envelope and the same ids. A test holds this list to the backend's catalog.
5
+ */
6
+ export declare const AGENTCARD_EVENT_TYPES: readonly ["card.created", "card.updated", "card.closed", "cardholder.created", "cardholder.updated", "transaction.authorized", "transaction.declined", "transaction.cleared", "transaction.voided", "balance.low", "cardholder_onboarding_session.completed", "platform_connect_session.completed", "merchant.connected", "order.placed", "order.confirmed", "order.failed", "order.updated", "purchase.message", "connection.created", "connected_card.updated", "vault.card_stored", "vault.session_linked", "vault.session_card_picked", "vault.payment_permission.updated", "action.confirmed", "action.done", "checkout_authorization.approved", "checkout_authorization.submitted", "checkout_authorization.declined", "checkout_authorization.cancelled", "checkout_authorization.expired", "checkout_authorization.duplicate_charge", "checkout_authorization.refused", "checkout_authorization.watched", "checkout_authorization.amount_mismatch", "checkout_authorization.settled", "checkout_authorization.settlement_unconfirmed", "checkout_authorization.outcome_reported", "card_flow.started", "card_flow.failed", "transfer.approved", "transfer.initiated", "transfer.completed", "transfer.failed", "transfer.released", "wallet.funded", "user_wallet.funding_detected", "user_wallet.funded", "wallet.balance.low", "recovery.requested", "recovery.completed", "recovery.rejected", "wallet.withdrawal.initiated", "wallet.withdrawal.completed", "wallet.withdrawal.failed", "identity.verification.updated", "approval.requested", "reward.earned", "reward.reversed", "wallet_link.opened"];
7
+ /** One event type Agentcard sends. A type Agentcard adds later still reads as its own name. */
8
+ export type AgentcardEventType = (typeof AGENTCARD_EVENT_TYPES)[number];
9
+ /** Whether a filter entry names something Agentcard sends: an event type, a family such as `order.*`, or `*`. */
10
+ export declare function isEventPattern(pattern: string): boolean;
11
+ /** Whether an event type matches a filter entry, as a webhook subscription matches it. */
12
+ export declare function matchesEventPattern(type: string, pattern: string): boolean;
package/dist/events.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Every event type Agentcard sends a company, the same list a webhook endpoint subscribes to by
3
+ * name. The stream (`agentcard.listen`) and a webhook endpoint carry the same types, with the same
4
+ * envelope and the same ids. A test holds this list to the backend's catalog.
5
+ */
6
+ export const AGENTCARD_EVENT_TYPES = [
7
+ 'card.created',
8
+ 'card.updated',
9
+ 'card.closed',
10
+ 'cardholder.created',
11
+ 'cardholder.updated',
12
+ 'transaction.authorized',
13
+ 'transaction.declined',
14
+ 'transaction.cleared',
15
+ 'transaction.voided',
16
+ 'balance.low',
17
+ 'cardholder_onboarding_session.completed',
18
+ 'platform_connect_session.completed',
19
+ 'merchant.connected',
20
+ 'order.placed',
21
+ 'order.confirmed',
22
+ 'order.failed',
23
+ 'order.updated',
24
+ 'purchase.message',
25
+ 'connection.created',
26
+ 'connected_card.updated',
27
+ 'vault.card_stored',
28
+ 'vault.session_linked',
29
+ 'vault.session_card_picked',
30
+ 'vault.payment_permission.updated',
31
+ 'action.confirmed',
32
+ 'action.done',
33
+ 'checkout_authorization.approved',
34
+ 'checkout_authorization.submitted',
35
+ 'checkout_authorization.declined',
36
+ 'checkout_authorization.cancelled',
37
+ 'checkout_authorization.expired',
38
+ 'checkout_authorization.duplicate_charge',
39
+ 'checkout_authorization.refused',
40
+ 'checkout_authorization.watched',
41
+ 'checkout_authorization.amount_mismatch',
42
+ 'checkout_authorization.settled',
43
+ 'checkout_authorization.settlement_unconfirmed',
44
+ 'checkout_authorization.outcome_reported',
45
+ 'card_flow.started',
46
+ 'card_flow.failed',
47
+ 'transfer.approved',
48
+ 'transfer.initiated',
49
+ 'transfer.completed',
50
+ 'transfer.failed',
51
+ 'transfer.released',
52
+ 'wallet.funded',
53
+ 'user_wallet.funding_detected',
54
+ 'user_wallet.funded',
55
+ 'wallet.balance.low',
56
+ 'recovery.requested',
57
+ 'recovery.completed',
58
+ 'recovery.rejected',
59
+ 'wallet.withdrawal.initiated',
60
+ 'wallet.withdrawal.completed',
61
+ 'wallet.withdrawal.failed',
62
+ 'identity.verification.updated',
63
+ 'approval.requested',
64
+ 'reward.earned',
65
+ 'reward.reversed',
66
+ 'wallet_link.opened',
67
+ ];
68
+ const KNOWN = new Set(AGENTCARD_EVENT_TYPES);
69
+ const FAMILIES = new Set(AGENTCARD_EVENT_TYPES.map((t) => `${t.split('.')[0]}.*`));
70
+ /** Whether a filter entry names something Agentcard sends: an event type, a family such as `order.*`, or `*`. */
71
+ export function isEventPattern(pattern) {
72
+ return pattern === '*' || KNOWN.has(pattern) || FAMILIES.has(pattern);
73
+ }
74
+ /** Whether an event type matches a filter entry, as a webhook subscription matches it. */
75
+ export function matchesEventPattern(type, pattern) {
76
+ if (pattern === '*' || pattern === type)
77
+ return true;
78
+ return pattern.endsWith('.*') && type.startsWith(pattern.slice(0, -1));
79
+ }
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;