@agent-cards/commerce-sdk 0.0.0-stage → 0.1.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 +38 -0
- package/README.md +295 -2
- package/dist/adapters.d.ts +35 -0
- package/dist/adapters.js +23 -0
- package/dist/buy-tool.d.ts +175 -0
- package/dist/buy-tool.js +562 -0
- package/dist/client.d.ts +250 -0
- package/dist/client.js +412 -0
- package/dist/definition.d.ts +100 -0
- package/dist/definition.js +103 -0
- package/dist/handler.d.ts +48 -0
- package/dist/handler.js +190 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/message.d.ts +78 -0
- package/dist/message.js +76 -0
- package/dist/model-view.d.ts +62 -0
- package/dist/model-view.js +110 -0
- package/dist/verify.d.ts +41 -0
- package/dist/verify.js +137 -0
- package/examples/anthropic.mjs +55 -0
- package/examples/openrouter.mjs +53 -0
- package/examples/walkthrough.mjs +97 -0
- package/package.json +41 -4
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { BuyToolProduct, BuyToolResult, BuyToolStatus } from './buy-tool.js';
|
|
2
|
+
export interface BuyToolModelCart {
|
|
3
|
+
store: string;
|
|
4
|
+
items: Array<{
|
|
5
|
+
name: string;
|
|
6
|
+
qty: number;
|
|
7
|
+
price: string | null;
|
|
8
|
+
}>;
|
|
9
|
+
fees: string | null;
|
|
10
|
+
tip: string | null;
|
|
11
|
+
total: string;
|
|
12
|
+
}
|
|
13
|
+
export interface BuyToolModelProduct {
|
|
14
|
+
name: string;
|
|
15
|
+
price: string | null;
|
|
16
|
+
/** True when the shopper holds this product's store page, which `sendLink` delivered beside the reply. */
|
|
17
|
+
link_sent: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** The buy tool's answer as the model reads it. */
|
|
20
|
+
export interface BuyToolModelView {
|
|
21
|
+
status: BuyToolStatus;
|
|
22
|
+
/** Agentcard's answer for the shopper, with every link taken out. */
|
|
23
|
+
reply: string;
|
|
24
|
+
/** The newest cart on the table. */
|
|
25
|
+
cart: BuyToolModelCart | null;
|
|
26
|
+
/** Every cart on the table, the newest last, when the shopper is buying from more than one store. */
|
|
27
|
+
carts: BuyToolModelCart[];
|
|
28
|
+
products: BuyToolModelProduct[];
|
|
29
|
+
approval_expires_at: string | null;
|
|
30
|
+
shopper_need_not_reply: boolean;
|
|
31
|
+
/** True when the shopper already has the link this answer needed. */
|
|
32
|
+
link_sent: boolean;
|
|
33
|
+
/** How many store pages of the products above the shopper holds, 0 when none went out. */
|
|
34
|
+
product_links_sent: number;
|
|
35
|
+
}
|
|
36
|
+
/** The text with every link taken out: a Markdown link keeps its label, and a bare address goes entirely. */
|
|
37
|
+
export declare function stripLinks(text: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* An amount in its currency's minor unit, as the model reads it: the currency's own minor units place the
|
|
40
|
+
* decimal point, so 1331 is $13.31 in USD, CA$13.31 in CAD and ¥1,331 in JPY. An amount whose currency
|
|
41
|
+
* Agentcard did not name shows with no symbol, because a dollar sign on it could be wrong.
|
|
42
|
+
*/
|
|
43
|
+
export declare function money(minor: number | null | undefined, currency: string | null | undefined): string | null;
|
|
44
|
+
/**
|
|
45
|
+
* The products the model reads: the first twenty that keep a name once links
|
|
46
|
+
* are taken out. The tool sends store pages for these same twenty, so the shopper
|
|
47
|
+
* never holds a product link the model cannot name.
|
|
48
|
+
*/
|
|
49
|
+
export declare function shownProducts(products: readonly BuyToolProduct[] | null | undefined): BuyToolProduct[];
|
|
50
|
+
/**
|
|
51
|
+
* The model's view of one answer: the status, Agentcard's reply, the newest
|
|
52
|
+
* cart and every cart on the table, the products by name and price, and
|
|
53
|
+
* whether the shopper has each link. A status this version does not know
|
|
54
|
+
* reads as `needs_input`.
|
|
55
|
+
*/
|
|
56
|
+
export declare function forModel(result: BuyToolResult): BuyToolModelView;
|
|
57
|
+
/**
|
|
58
|
+
* A thrown error as the model may read it: the same message with every link
|
|
59
|
+
* taken out, and the original error on `cause` for your logs. A TypeError
|
|
60
|
+
* stays a TypeError, so input the model got wrong still reads as such.
|
|
61
|
+
*/
|
|
62
|
+
export declare function forModelError(error: unknown): Error;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const MODEL_STATUSES = new Set(['needs_input', 'needs_card', 'approval_sent', 'placing', 'order_placed', 'declined']);
|
|
2
|
+
/** The model sees the first twenty products the search found, the cap the backend's catalog keeps for a turn. */
|
|
3
|
+
const MAX_PRODUCTS = 20;
|
|
4
|
+
const MARKDOWN_LINK = /\[([^\]]*)\]\([^)]*\)/g;
|
|
5
|
+
const SCHEME_URL = /\b[a-z][a-z0-9+.-]*:\/\/[^\s<>"'`)\]]*/gi;
|
|
6
|
+
const WWW_URL = /\bwww\.[^\s<>"'`)\]]*/gi;
|
|
7
|
+
/** A host followed by a path, a query or a fragment: `vault.agentcard.sh/a/1`, `vault.agentcard.sh?token=abc`. */
|
|
8
|
+
const HOST_PATH = /\b(?:[a-z0-9-]+\.)+[a-z]{2,}[/?#][^\s<>"'`)\]]*/gi;
|
|
9
|
+
/**
|
|
10
|
+
* A bare host on a common top-level domain, such as `agentcard.sh` or `amazon.com`, which a chat app
|
|
11
|
+
* turns into a link. The list keeps words with a dot, such as `Node.js`, and an email address's host.
|
|
12
|
+
*/
|
|
13
|
+
const BARE_HOST = /(?<![@\w.-])(?:[a-z0-9-]+\.)+(?:com|net|org|io|co|sh|ai|app|dev|me|us|uk|ca|de|fr|es|it|nl|eu|au|in|jp|br|mx|info|biz|shop|store|site|online|link|page|xyz|ly|gg|to|tv|fm|so)(?![\w-])/gi;
|
|
14
|
+
/** The text with every link taken out: a Markdown link keeps its label, and a bare address goes entirely. */
|
|
15
|
+
export function stripLinks(text) {
|
|
16
|
+
return String(text ?? '')
|
|
17
|
+
.replace(MARKDOWN_LINK, '$1')
|
|
18
|
+
.replace(SCHEME_URL, '')
|
|
19
|
+
.replace(WWW_URL, '')
|
|
20
|
+
.replace(HOST_PATH, '')
|
|
21
|
+
.replace(BARE_HOST, '')
|
|
22
|
+
.replace(/\(\s*\)|\[\s*\]|<\s*>/g, '')
|
|
23
|
+
.replace(/[ \t]+([.,;:!?])/g, '$1')
|
|
24
|
+
.replace(/[ \t]{2,}/g, ' ')
|
|
25
|
+
.replace(/[ \t]+$/gm, '')
|
|
26
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
/** A currency's minor units as Intl knows them: 2 for USD and CAD, 0 for JPY. */
|
|
30
|
+
function minorUnits(format) {
|
|
31
|
+
return format.resolvedOptions().maximumFractionDigits ?? 0;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* An amount whose currency Agentcard did not name comes from an older backend, whose amount fields count
|
|
35
|
+
* cents as their names say: it scales by the US dollar's minor units and prints with no symbol.
|
|
36
|
+
*/
|
|
37
|
+
const UNNAMED_DIGITS = minorUnits(new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }));
|
|
38
|
+
const UNNAMED = new Intl.NumberFormat('en-US', { minimumFractionDigits: UNNAMED_DIGITS, maximumFractionDigits: UNNAMED_DIGITS });
|
|
39
|
+
/**
|
|
40
|
+
* The ISO 4217 codes this runtime knows, read once. `Intl.NumberFormat` takes any three letters, so a code
|
|
41
|
+
* outside this set, such as `ZZZ`, names no currency here. A runtime without the list knows none.
|
|
42
|
+
*/
|
|
43
|
+
const KNOWN_CURRENCIES = new Set(typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('currency') : []);
|
|
44
|
+
/** The format for an ISO 4217 code, or null for a missing or unknown one. */
|
|
45
|
+
function currencyFormat(currency) {
|
|
46
|
+
const code = typeof currency === 'string' ? currency.trim().toUpperCase() : '';
|
|
47
|
+
return KNOWN_CURRENCIES.has(code) ? new Intl.NumberFormat('en-US', { style: 'currency', currency: code }) : null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* An amount in its currency's minor unit, as the model reads it: the currency's own minor units place the
|
|
51
|
+
* decimal point, so 1331 is $13.31 in USD, CA$13.31 in CAD and ¥1,331 in JPY. An amount whose currency
|
|
52
|
+
* Agentcard did not name shows with no symbol, because a dollar sign on it could be wrong.
|
|
53
|
+
*/
|
|
54
|
+
export function money(minor, currency) {
|
|
55
|
+
if (typeof minor !== 'number' || !Number.isFinite(minor))
|
|
56
|
+
return null;
|
|
57
|
+
const format = currencyFormat(currency);
|
|
58
|
+
if (!format)
|
|
59
|
+
return UNNAMED.format(minor / 10 ** UNNAMED_DIGITS);
|
|
60
|
+
return format.format(minor / 10 ** minorUnits(format));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The products the model reads: the first twenty that keep a name once links
|
|
64
|
+
* are taken out. The tool sends store pages for these same twenty, so the shopper
|
|
65
|
+
* never holds a product link the model cannot name.
|
|
66
|
+
*/
|
|
67
|
+
export function shownProducts(products) {
|
|
68
|
+
return (products ?? []).filter((product) => stripLinks(product.name) !== '').slice(0, MAX_PRODUCTS);
|
|
69
|
+
}
|
|
70
|
+
function cartView(cart) {
|
|
71
|
+
return {
|
|
72
|
+
store: stripLinks(cart.store?.name ?? cart.merchant_name) || stripLinks(cart.merchant),
|
|
73
|
+
items: cart.items.map((line) => ({ name: stripLinks(line.name), qty: line.qty, price: money(line.priceCents, cart.currency) })),
|
|
74
|
+
fees: cart.serviceFeesCents > 0 ? money(cart.serviceFeesCents, cart.currency) : null,
|
|
75
|
+
tip: cart.tipCents > 0 ? money(cart.tipCents, cart.currency) : null,
|
|
76
|
+
total: money(cart.totalCents, cart.currency) ?? money(0, cart.currency),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 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
|
|
82
|
+
* whether the shopper has each link. A status this version does not know
|
|
83
|
+
* reads as `needs_input`.
|
|
84
|
+
*/
|
|
85
|
+
export function forModel(result) {
|
|
86
|
+
const status = typeof result.status === 'string' && MODEL_STATUSES.has(result.status) ? result.status : 'needs_input';
|
|
87
|
+
const products = shownProducts(result.products);
|
|
88
|
+
const held = (product) => product.linkSent === true && typeof product.url === 'string' && product.url !== '';
|
|
89
|
+
return {
|
|
90
|
+
status,
|
|
91
|
+
reply: stripLinks(result.reply),
|
|
92
|
+
cart: result.cart ? cartView(result.cart) : null,
|
|
93
|
+
carts: (result.carts ?? []).map(cartView),
|
|
94
|
+
products: products.map((product) => ({ name: stripLinks(product.name), price: money(product.priceCents, product.currency), link_sent: held(product) })),
|
|
95
|
+
approval_expires_at: typeof result.approval_expires_at === 'string' ? stripLinks(result.approval_expires_at) || null : null,
|
|
96
|
+
shopper_need_not_reply: result.shopper_need_not_reply === true,
|
|
97
|
+
link_sent: result.link_sent === true,
|
|
98
|
+
// Two products on one page share one link, so the count is of pages.
|
|
99
|
+
product_links_sent: new Set(products.filter(held).map((product) => product.url)).size,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A thrown error as the model may read it: the same message with every link
|
|
104
|
+
* taken out, and the original error on `cause` for your logs. A TypeError
|
|
105
|
+
* stays a TypeError, so input the model got wrong still reads as such.
|
|
106
|
+
*/
|
|
107
|
+
export function forModelError(error) {
|
|
108
|
+
const message = stripLinks(error instanceof Error ? error.message : String(error)) || 'The buy tool failed.';
|
|
109
|
+
return error instanceof TypeError ? new TypeError(message, { cause: error }) : new Error(message, { cause: error });
|
|
110
|
+
}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Why a delivery was refused. */
|
|
2
|
+
export type AgentcardSignatureReason = 'missing_header' | 'malformed_header' | 'stale_timestamp' | 'wrong_signature';
|
|
3
|
+
/** Thrown when a delivery's `AgentCard-Signature` does not prove Agentcard sent it; `reason` says why. */
|
|
4
|
+
export declare class AgentcardSignatureError extends Error {
|
|
5
|
+
readonly reason: AgentcardSignatureReason;
|
|
6
|
+
constructor(reason: AgentcardSignatureReason, message: string);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Thrown when a correctly signed body is not an Agentcard event. A TypeError to callers; the
|
|
10
|
+
* webhook handler tells it apart from a bug so it answers 400 rather than 500.
|
|
11
|
+
*/
|
|
12
|
+
export declare class InvalidEventBodyError extends TypeError {
|
|
13
|
+
}
|
|
14
|
+
/** The envelope every event arrives in. Delivery is at least once: deduplicate on `id`. */
|
|
15
|
+
export interface AgentcardEvent<T extends string = string, D = Record<string, unknown>> {
|
|
16
|
+
/** Stable event id (`evt_…`), the same on every redelivery. */
|
|
17
|
+
id: string;
|
|
18
|
+
type: T;
|
|
19
|
+
/** Unix seconds. */
|
|
20
|
+
created: number;
|
|
21
|
+
/** False for a sandbox event. */
|
|
22
|
+
livemode: boolean;
|
|
23
|
+
data: D;
|
|
24
|
+
}
|
|
25
|
+
export interface VerifyOptions {
|
|
26
|
+
/** How far the signed timestamp may sit from now, in seconds. Default 300. */
|
|
27
|
+
toleranceSeconds?: number;
|
|
28
|
+
/** The current time in unix seconds. Default: the system clock. */
|
|
29
|
+
now?: number;
|
|
30
|
+
}
|
|
31
|
+
/** The tolerance in seconds. A value that is not a number of seconds would turn the replay check off, so it throws. */
|
|
32
|
+
export declare function toleranceOf(opts: Pick<VerifyOptions, 'toleranceSeconds'>): number;
|
|
33
|
+
/**
|
|
34
|
+
* Checks one delivery and returns its event. Pass the raw request body, the
|
|
35
|
+
* `AgentCard-Signature` header and the endpoint's secret. Nothing in the body
|
|
36
|
+
* is read before the signature holds. Throws `AgentcardSignatureError` for a
|
|
37
|
+
* missing, malformed, stale or wrong signature, and a TypeError when a
|
|
38
|
+
* correctly signed body is not an event.
|
|
39
|
+
*/
|
|
40
|
+
export declare function verifyAgentcardWebhook(rawBody: string | Uint8Array, signatureHeader: string | undefined, secret: string, opts?: VerifyOptions): AgentcardEvent;
|
|
41
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook verification. Agentcard signs every delivery with
|
|
3
|
+
* `AgentCard-Signature: t=<unix seconds>,v1=<hex>`: HMAC-SHA256 under the
|
|
4
|
+
* endpoint's secret over `${t}.${rawBody}`. The check runs on the raw bytes,
|
|
5
|
+
* compares in constant time, and refuses a timestamp outside the tolerance,
|
|
6
|
+
* so a captured delivery cannot be replayed later.
|
|
7
|
+
*/
|
|
8
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
9
|
+
/** Thrown when a delivery's `AgentCard-Signature` does not prove Agentcard sent it; `reason` says why. */
|
|
10
|
+
export class AgentcardSignatureError extends Error {
|
|
11
|
+
reason;
|
|
12
|
+
constructor(reason, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'AgentcardSignatureError';
|
|
15
|
+
this.reason = reason;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Thrown when a correctly signed body is not an Agentcard event. A TypeError to callers; the
|
|
20
|
+
* webhook handler tells it apart from a bug so it answers 400 rather than 500.
|
|
21
|
+
*/
|
|
22
|
+
export class InvalidEventBodyError extends TypeError {
|
|
23
|
+
}
|
|
24
|
+
const DEFAULT_TOLERANCE_SECONDS = 300;
|
|
25
|
+
/** The tolerance in seconds. A value that is not a number of seconds would turn the replay check off, so it throws. */
|
|
26
|
+
export function toleranceOf(opts) {
|
|
27
|
+
const tolerance = opts.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
|
|
28
|
+
if (typeof tolerance !== 'number' || !Number.isFinite(tolerance) || tolerance < 0) {
|
|
29
|
+
throw new TypeError('Pass toleranceSeconds as a number of seconds, 0 or more, or leave it out for 300.');
|
|
30
|
+
}
|
|
31
|
+
return tolerance;
|
|
32
|
+
}
|
|
33
|
+
const SIGNATURE_HEX = /^[0-9a-fA-F]{64}$/;
|
|
34
|
+
const TIMESTAMP = /^\d{1,12}$/;
|
|
35
|
+
/**
|
|
36
|
+
* Checks one delivery and returns its event. Pass the raw request body, the
|
|
37
|
+
* `AgentCard-Signature` header and the endpoint's secret. Nothing in the body
|
|
38
|
+
* is read before the signature holds. Throws `AgentcardSignatureError` for a
|
|
39
|
+
* missing, malformed, stale or wrong signature, and a TypeError when a
|
|
40
|
+
* correctly signed body is not an event.
|
|
41
|
+
*/
|
|
42
|
+
export function verifyAgentcardWebhook(rawBody, signatureHeader, secret, opts = {}) {
|
|
43
|
+
if (typeof secret !== 'string' || secret === '') {
|
|
44
|
+
throw new TypeError('Pass the webhook endpoint secret Agentcard returned when you created the endpoint.');
|
|
45
|
+
}
|
|
46
|
+
if (typeof rawBody !== 'string' && !(rawBody instanceof Uint8Array)) {
|
|
47
|
+
throw new TypeError('Pass the raw request body as a string or bytes. The signature covers the bytes Agentcard sent, never a parsed object.');
|
|
48
|
+
}
|
|
49
|
+
if (!signatureHeader) {
|
|
50
|
+
throw new AgentcardSignatureError('missing_header', 'The request has no AgentCard-Signature header.');
|
|
51
|
+
}
|
|
52
|
+
const parsed = parseSignatureHeader(signatureHeader);
|
|
53
|
+
if (!parsed) {
|
|
54
|
+
throw new AgentcardSignatureError('malformed_header', 'The AgentCard-Signature header does not read t=<unix seconds>,v1=<64 hex characters>.');
|
|
55
|
+
}
|
|
56
|
+
// The timestamp is signed as the header spells it.
|
|
57
|
+
const expected = createHmac('sha256', secret).update(`${parsed.stamp}.`).update(rawBody).digest();
|
|
58
|
+
// Every candidate is compared, so the time taken never says which one matched.
|
|
59
|
+
let matched = false;
|
|
60
|
+
for (const given of parsed.signatures) {
|
|
61
|
+
if (timingSafeEqual(expected, given))
|
|
62
|
+
matched = true;
|
|
63
|
+
}
|
|
64
|
+
if (!matched) {
|
|
65
|
+
throw new AgentcardSignatureError('wrong_signature', 'The signature does not match this body under the secret you passed. Pass the secret of the endpoint that received the delivery, and the raw request bytes, never re-serialized JSON.');
|
|
66
|
+
}
|
|
67
|
+
const now = opts.now ?? Math.floor(Date.now() / 1000);
|
|
68
|
+
const tolerance = toleranceOf(opts);
|
|
69
|
+
if (!Number.isFinite(now))
|
|
70
|
+
throw new TypeError('Pass now as unix seconds, or leave it out to read the system clock.');
|
|
71
|
+
const drift = now - parsed.t;
|
|
72
|
+
if (Math.abs(drift) > tolerance) {
|
|
73
|
+
const when = drift > 0 ? `${drift} seconds ago` : `${-drift} seconds from now`;
|
|
74
|
+
throw new AgentcardSignatureError('stale_timestamp', `Agentcard signed this delivery ${when}, and the limit is ${tolerance} seconds. Check this server's clock.`);
|
|
75
|
+
}
|
|
76
|
+
return parseEnvelope(rawBody);
|
|
77
|
+
}
|
|
78
|
+
/** The header's timestamp and every `v1` signature on it, or null when the header is malformed. */
|
|
79
|
+
function parseSignatureHeader(header) {
|
|
80
|
+
let stamp;
|
|
81
|
+
const signatures = [];
|
|
82
|
+
for (const part of header.split(',')) {
|
|
83
|
+
const item = part.trim();
|
|
84
|
+
if (item === '')
|
|
85
|
+
continue;
|
|
86
|
+
const eq = item.indexOf('=');
|
|
87
|
+
if (eq <= 0)
|
|
88
|
+
return null;
|
|
89
|
+
const key = item.slice(0, eq).trim();
|
|
90
|
+
const value = item.slice(eq + 1).trim();
|
|
91
|
+
if (key === 't') {
|
|
92
|
+
if (stamp !== undefined || !TIMESTAMP.test(value))
|
|
93
|
+
return null;
|
|
94
|
+
stamp = value;
|
|
95
|
+
}
|
|
96
|
+
else if (key === 'v1') {
|
|
97
|
+
if (!SIGNATURE_HEX.test(value))
|
|
98
|
+
return null;
|
|
99
|
+
signatures.push(hexToBytes(value));
|
|
100
|
+
}
|
|
101
|
+
// Any other scheme is skipped, so a scheme Agentcard adds later never breaks this check.
|
|
102
|
+
}
|
|
103
|
+
return stamp === undefined || signatures.length === 0 ? null : { t: Number(stamp), stamp, signatures };
|
|
104
|
+
}
|
|
105
|
+
function hexToBytes(hex) {
|
|
106
|
+
const out = new Uint8Array(hex.length / 2);
|
|
107
|
+
for (let i = 0; i < out.length; i++)
|
|
108
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
export function isRecord(value) {
|
|
112
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
113
|
+
}
|
|
114
|
+
function nonEmpty(value) {
|
|
115
|
+
return typeof value === 'string' && value !== '';
|
|
116
|
+
}
|
|
117
|
+
function parseEnvelope(rawBody) {
|
|
118
|
+
const text = typeof rawBody === 'string' ? rawBody : new TextDecoder().decode(rawBody);
|
|
119
|
+
let value;
|
|
120
|
+
try {
|
|
121
|
+
value = JSON.parse(text);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new InvalidEventBodyError('The webhook body is not JSON.');
|
|
125
|
+
}
|
|
126
|
+
// A consumer deduplicates on the id, so an event without one is refused.
|
|
127
|
+
if (!isRecord(value) || !nonEmpty(value.id) || !nonEmpty(value.type) || !isRecord(value.data)) {
|
|
128
|
+
throw new InvalidEventBodyError('The webhook body is not an Agentcard event: an event carries id, type and data.');
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
id: value.id,
|
|
132
|
+
type: value.type,
|
|
133
|
+
created: typeof value.created === 'number' ? value.created : 0,
|
|
134
|
+
livemode: value.livemode === true,
|
|
135
|
+
data: value.data,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// A shopping agent in your terminal: Claude Opus 5.5 with the buy tool, through the Anthropic SDK's tool runner.
|
|
2
|
+
//
|
|
3
|
+
// npm install @anthropic-ai/sdk @agent-cards/commerce-sdk
|
|
4
|
+
// AGENTCARD_CLIENT_ID=... AGENTCARD_CLIENT_SECRET=... ANTHROPIC_API_KEY=... node anthropic.mjs
|
|
5
|
+
//
|
|
6
|
+
// Sandbox credentials place no real order. SHOPPER_ID is your own id for the shopper you play.
|
|
7
|
+
import readline from 'node:readline';
|
|
8
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
9
|
+
import { AgentcardClient, buyTool } from '@agent-cards/commerce-sdk';
|
|
10
|
+
|
|
11
|
+
const agentcard = new AgentcardClient({
|
|
12
|
+
clientId: process.env.AGENTCARD_CLIENT_ID ?? '',
|
|
13
|
+
clientSecret: process.env.AGENTCARD_CLIENT_SECRET ?? '',
|
|
14
|
+
baseUrl: process.env.AGENTCARD_BASE_URL,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const buy = buyTool({
|
|
18
|
+
agentcard,
|
|
19
|
+
externalUserId: process.env.SHOPPER_ID ?? 'shopper-1',
|
|
20
|
+
// Your app texts or emails each link to the shopper. This script prints it, where the model never reads it.
|
|
21
|
+
sendLink: (link) => console.log(`\n ${link.kind} link for the shopper: ${link.url}\n`),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const anthropic = new Anthropic();
|
|
25
|
+
// Lines that arrive while a turn runs wait their turn, so piped input works as well as typing.
|
|
26
|
+
const terminal = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: 'you> ' });
|
|
27
|
+
let messages = [];
|
|
28
|
+
|
|
29
|
+
terminal.prompt();
|
|
30
|
+
for await (const line of terminal) {
|
|
31
|
+
const said = line.trim();
|
|
32
|
+
if (!said) break;
|
|
33
|
+
const runner = anthropic.beta.messages.toolRunner({
|
|
34
|
+
model: 'claude-opus-5-5',
|
|
35
|
+
max_tokens: 16000,
|
|
36
|
+
system: 'You are a shopping assistant. Buy what the shopper asks for with the buy tool.',
|
|
37
|
+
tools: [buy],
|
|
38
|
+
messages: [...messages, { role: 'user', content: said }],
|
|
39
|
+
// When a safety classifier declines a request, Anthropic answers it with a fallback model instead.
|
|
40
|
+
betas: ['server-side-fallback-2026-07-01'],
|
|
41
|
+
fallbacks: 'default',
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
const final = await runner;
|
|
45
|
+
// The runner keeps every turn, the tool calls and results included, so the next message continues from all of it.
|
|
46
|
+
messages = [...runner.params.messages];
|
|
47
|
+
const text = final.content.filter((block) => block.type === 'text').map((block) => block.text).join('\n');
|
|
48
|
+
console.log(`agent> ${final.stop_reason === 'refusal' ? '(the model declined to answer)' : text}`);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// A failed turn leaves the history as it was, so the shopper can say it again.
|
|
51
|
+
console.log(`agent> (the turn failed: ${error.message})`);
|
|
52
|
+
}
|
|
53
|
+
terminal.prompt();
|
|
54
|
+
}
|
|
55
|
+
terminal.close();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The same shopping agent through OpenRouter's agent SDK, on any model OpenRouter routes to.
|
|
2
|
+
//
|
|
3
|
+
// npm install @openrouter/agent zod @agent-cards/commerce-sdk
|
|
4
|
+
// AGENTCARD_CLIENT_ID=... AGENTCARD_CLIENT_SECRET=... OPENROUTER_API_KEY=... node openrouter.mjs
|
|
5
|
+
//
|
|
6
|
+
// Sandbox credentials place no real order. OPENROUTER_MODEL picks another model.
|
|
7
|
+
import readline from 'node:readline';
|
|
8
|
+
import { OpenRouter, tool } from '@openrouter/agent';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { AgentcardClient, buyTool, toOpenRouterTool } from '@agent-cards/commerce-sdk';
|
|
11
|
+
|
|
12
|
+
const agentcard = new AgentcardClient({
|
|
13
|
+
clientId: process.env.AGENTCARD_CLIENT_ID ?? '',
|
|
14
|
+
clientSecret: process.env.AGENTCARD_CLIENT_SECRET ?? '',
|
|
15
|
+
baseUrl: process.env.AGENTCARD_BASE_URL,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const buy = buyTool({
|
|
19
|
+
agentcard,
|
|
20
|
+
externalUserId: process.env.SHOPPER_ID ?? 'shopper-1',
|
|
21
|
+
// Your app texts or emails each link to the shopper. This script prints it, where the model never reads it.
|
|
22
|
+
sendLink: (link) => console.log(`\n ${link.kind} link for the shopper: ${link.url}\n`),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const openrouter = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY });
|
|
26
|
+
// OpenRouter's agent SDK takes only tools its own tool() builds from a zod schema, so this one SDK keeps the adapter.
|
|
27
|
+
const tools = [toOpenRouterTool(buy, { tool, z })];
|
|
28
|
+
// Lines that arrive while a turn runs wait their turn, so piped input works as well as typing.
|
|
29
|
+
const terminal = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: 'you> ' });
|
|
30
|
+
// Agentcard keeps the purchase itself, so the chat history only needs what each side said.
|
|
31
|
+
const history = [];
|
|
32
|
+
|
|
33
|
+
terminal.prompt();
|
|
34
|
+
for await (const line of terminal) {
|
|
35
|
+
const said = line.trim();
|
|
36
|
+
if (!said) break;
|
|
37
|
+
try {
|
|
38
|
+
const result = openrouter.callModel({
|
|
39
|
+
model: process.env.OPENROUTER_MODEL ?? 'anthropic/claude-opus-5.5',
|
|
40
|
+
instructions: 'You are a shopping assistant. Buy what the shopper asks for with the buy tool.',
|
|
41
|
+
input: [...history, { role: 'user', content: said }],
|
|
42
|
+
tools,
|
|
43
|
+
});
|
|
44
|
+
const text = await result.getText();
|
|
45
|
+
history.push({ role: 'user', content: said }, { role: 'assistant', content: text });
|
|
46
|
+
console.log(`agent> ${text}`);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
// A failed turn leaves the history as it was, so the shopper can say it again.
|
|
49
|
+
console.log(`agent> (the turn failed: ${error.message})`);
|
|
50
|
+
}
|
|
51
|
+
terminal.prompt();
|
|
52
|
+
}
|
|
53
|
+
terminal.close();
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// A scripted sandbox run of the buy tool, from a new shopper to a confirmed cart, with no model in the loop.
|
|
2
|
+
//
|
|
3
|
+
// AGENTCARD_CLIENT_ID=... AGENTCARD_CLIENT_SECRET=... node walkthrough.mjs
|
|
4
|
+
//
|
|
5
|
+
// It runs on sandbox credentials only, and it books a restaurant table: a table costs nothing, and the
|
|
6
|
+
// sandbox refuses the booking itself, so nothing is booked or charged. AGENTCARD_BASE_URL points it at
|
|
7
|
+
// another Agentcard, such as one on your machine, which needs the reservations key for table search.
|
|
8
|
+
import { realpathSync } from 'node:fs';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import { AgentcardClient, buyTool } from '@agent-cards/commerce-sdk';
|
|
11
|
+
|
|
12
|
+
/** What the shopper says, in order, and what each answer must carry. */
|
|
13
|
+
const SCRIPT = [
|
|
14
|
+
{ input: { ask: 'a table for 2 at an italian place in san francisco tomorrow at 7pm', confirm: false, new_order: false }, expect: 'products' },
|
|
15
|
+
{ input: { ask: 'the first one, please', confirm: false, new_order: false }, expect: 'cart' },
|
|
16
|
+
{ input: { ask: 'yes, book it', confirm: true, new_order: false }, expect: 'sandbox_unavailable' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function check(expect, result) {
|
|
20
|
+
const ok = expect === 'products' ? result.products.length > 0
|
|
21
|
+
: expect === 'cart' ? result.cart !== null
|
|
22
|
+
: expect === 'sandbox_unavailable' ? result.status === 'declined' && result.decline_code === 'sandbox_unavailable'
|
|
23
|
+
: expect === 'needs_card' ? result.status === 'needs_card' && result.links.length === 1
|
|
24
|
+
: false;
|
|
25
|
+
if (!ok) throw new Error(`expected ${expect}, got status ${result.status} (decline_code ${result.decline_code}, reply "${result.reply}")`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Connects a test shopper, shops through the tool, then asks as a shopper nobody connected. Throws at the first answer that is off. */
|
|
29
|
+
export async function walkthrough({ clientId, clientSecret, baseUrl, fetch = globalThis.fetch, log = console.log, phone = '+14155550100', runId = Date.now().toString(36) }) {
|
|
30
|
+
const agentcard = new AgentcardClient({ clientId, clientSecret, baseUrl, fetch });
|
|
31
|
+
await agentcard.bearer();
|
|
32
|
+
if (agentcard.sandbox !== true) throw new Error('These are not sandbox credentials. The walkthrough runs on sandbox credentials only.');
|
|
33
|
+
|
|
34
|
+
const post = async (path, body) => {
|
|
35
|
+
const res = await fetch(`${agentcard.baseUrl}${path}`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { authorization: `Bearer ${await agentcard.bearer()}`, 'content-type': 'application/json', accept: 'application/json' },
|
|
38
|
+
body: JSON.stringify(body),
|
|
39
|
+
});
|
|
40
|
+
const text = await res.text();
|
|
41
|
+
if (!res.ok) throw new Error(`${path} answered ${res.status}: ${text}`);
|
|
42
|
+
return text ? JSON.parse(text) : {};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// 1. Connect a shopper under your own id for them. In the sandbox, the code is always 111111.
|
|
46
|
+
const externalUserId = `walkthrough-${runId}`;
|
|
47
|
+
const attempt = await post('/api/v2/connect/start', { phone, external_user_id: externalUserId });
|
|
48
|
+
const connection = await post('/api/v2/connect/verify', { connect_id: attempt.id, code: '111111' });
|
|
49
|
+
await post('/api/v2/connect/consent', { user_id: connection.user.id });
|
|
50
|
+
log(`connected ${externalUserId} as ${connection.user.id}`);
|
|
51
|
+
|
|
52
|
+
// 2. Shop through the tool, the way a model would call it.
|
|
53
|
+
const show = (label, result) => log(`${label}\n${JSON.stringify(result, ['status', 'reply', 'decline_code', 'cart', 'items', 'name', 'qty', 'priceCents', 'totalCents', 'products', 'links', 'kind', 'url', 'link_sent'], 2)}`);
|
|
54
|
+
const buy = buyTool({ agentcard, externalUserId, sendLink: (link) => log(`${link.kind} link: ${link.url}`) });
|
|
55
|
+
const results = [];
|
|
56
|
+
for (const step of SCRIPT) {
|
|
57
|
+
const result = await buy.answer(step.input);
|
|
58
|
+
show(`> ${step.input.ask}`, result);
|
|
59
|
+
check(step.expect, result);
|
|
60
|
+
results.push(result);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 3. A shopper nobody connected gets a Vault link, and the same link when they ask again.
|
|
64
|
+
const stranger = buyTool({ agentcard, externalUserId: `walkthrough-stranger-${runId}`, sendLink: (link) => log(`${link.kind} link: ${link.url}`) });
|
|
65
|
+
const first = await stranger.answer(SCRIPT[0].input);
|
|
66
|
+
const second = await stranger.answer(SCRIPT[0].input);
|
|
67
|
+
show('> a shopper nobody connected, twice', second);
|
|
68
|
+
check('needs_card', first);
|
|
69
|
+
check('needs_card', second);
|
|
70
|
+
if (first.links[0].url !== second.links[0].url) throw new Error('expected the same Vault link both times, got two different links');
|
|
71
|
+
return { results, stranger: [first, second] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True when node runs this file itself. Node names a module by its real path, so a symlinked start, as from an installed package, still counts. */
|
|
75
|
+
function runDirectly() {
|
|
76
|
+
if (!process.argv[1]) return false;
|
|
77
|
+
try {
|
|
78
|
+
return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (runDirectly()) {
|
|
85
|
+
const { AGENTCARD_CLIENT_ID: clientId, AGENTCARD_CLIENT_SECRET: clientSecret, AGENTCARD_BASE_URL: baseUrl } = process.env;
|
|
86
|
+
if (!clientId || !clientSecret) {
|
|
87
|
+
console.error('Set AGENTCARD_CLIENT_ID and AGENTCARD_CLIENT_SECRET to your sandbox credentials.');
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
walkthrough({ clientId, clientSecret, baseUrl }).then(
|
|
91
|
+
() => console.log('walkthrough passed'),
|
|
92
|
+
(error) => {
|
|
93
|
+
console.error(`walkthrough failed: ${error.message}`);
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-cards/commerce-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Give your agent one tool that buys for your shoppers at real stores, and hear what happened to each purchase from a signed webhook.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"examples",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"_comment_build": "Zero runtime dependencies: node:crypto is the one import, and the buy tool calls the global fetch. TypeScript is pinned, not installed, as in @agent-cards/sdk. types/node.d.ts declares the two node:crypto functions the source calls, so the build needs no @types package and the published declarations name no Node type; FetchLike names AbortSignal, which every runtime with fetch declares.",
|
|
25
|
+
"build": "npx -y -p typescript@5.9.3 tsc",
|
|
26
|
+
"prepublishOnly": "pnpm build",
|
|
27
|
+
"test": "pnpm build && node --test test/*.test.mjs",
|
|
28
|
+
"verify:pack": "node scripts/verify-pack.mjs",
|
|
29
|
+
"_comment_pack": "verify:pack lists what npm would publish (npm pack --dry-run) and fails on a missing manifest target, README or example, or on a test, source, shim or build-cache file; then it loads the built root and checks the names the README imports. The release workflow runs it after the build."
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"agentcard",
|
|
33
|
+
"agents",
|
|
34
|
+
"buy",
|
|
35
|
+
"tools",
|
|
36
|
+
"webhooks",
|
|
37
|
+
"purchases"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
},
|
|
42
|
+
"license": "SEE LICENSE IN LICENSE"
|
|
43
|
+
}
|