@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
package/LICENSE
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Copyright (c) 2026 Agentcard Corporation. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software, including its source, compiled and minified code, models,
|
|
4
|
+
word lists, recognizers and data files (the "Software"), is proprietary to
|
|
5
|
+
Agentcard Corporation and is licensed, not sold.
|
|
6
|
+
|
|
7
|
+
You may install and run the Software only to use Agentcard's services, under
|
|
8
|
+
the terms you accepted when you signed up for them. If you integrate
|
|
9
|
+
Agentcard into an application, you may include the Software, unmodified and
|
|
10
|
+
as Agentcard publishes it, in that application and distribute the application
|
|
11
|
+
to your end users so they can use Agentcard's services through it; this
|
|
12
|
+
license binds every copy you distribute that way. Otherwise you may not, and
|
|
13
|
+
may not help or allow anyone else to:
|
|
14
|
+
|
|
15
|
+
1. copy, modify, translate or create derivative works of the Software, except
|
|
16
|
+
for copies made automatically when installing and running it or included
|
|
17
|
+
in an application as the paragraph above allows;
|
|
18
|
+
2. reverse engineer, decompile, disassemble, deobfuscate or otherwise try to
|
|
19
|
+
derive the source, models, thresholds, word lists, recognizers or data of
|
|
20
|
+
the Software, except to the extent applicable law expressly permits it
|
|
21
|
+
despite this restriction;
|
|
22
|
+
3. extract, scrape or reuse the Software's data, including its merchant,
|
|
23
|
+
processor and passphrase data, in any other product or service;
|
|
24
|
+
4. use the Software, or anything learned from studying it, to build, train,
|
|
25
|
+
tune or benchmark a product or service that competes with Agentcard, or to
|
|
26
|
+
defeat or imitate its security checks;
|
|
27
|
+
5. redistribute, sublicense, sell, rent or publish the Software apart from an
|
|
28
|
+
application as the paragraph above allows, or remove this notice from it.
|
|
29
|
+
|
|
30
|
+
Agentcard keeps dated fingerprints of the Software's data. A match in
|
|
31
|
+
another product or data set is evidence that it was copied from the
|
|
32
|
+
Software.
|
|
33
|
+
|
|
34
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
35
|
+
IMPLIED. IN NO EVENT SHALL AGENTCARD CORPORATION BE LIABLE FOR ANY CLAIM,
|
|
36
|
+
DAMAGES OR OTHER LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
|
|
37
|
+
|
|
38
|
+
Questions: legal@agentcard.sh
|
package/README.md
CHANGED
|
@@ -1,3 +1,296 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @agent-cards/commerce-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Give your agent one tool that buys for your shoppers at real stores, and tell each shopper what happened to their purchase in your agent's voice. The buy tool sends what the shopper says to Agentcard, and Agentcard finds the products, builds the cart and places the order once the shopper approves it. Agentcard then sends your webhook endpoint a signed `purchase.message` event at each moment a shopper should hear about, such as an order going through or an approval link about to expire. The package checks the signature and hands you the message.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @agent-cards/commerce-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The package runs on Node 18 or later and has no dependencies.
|
|
12
|
+
|
|
13
|
+
## Give your agent the buy tool
|
|
14
|
+
|
|
15
|
+
Build one client with your company's credentials and one buy tool per shopper, then pass the tool to your agent as it is:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
19
|
+
import { AgentcardClient, buyTool } from '@agent-cards/commerce-sdk';
|
|
20
|
+
|
|
21
|
+
const agentcard = new AgentcardClient({
|
|
22
|
+
clientId: process.env.AGENTCARD_CLIENT_ID ?? '',
|
|
23
|
+
clientSecret: process.env.AGENTCARD_CLIENT_SECRET ?? '',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const buy = buyTool({
|
|
27
|
+
agentcard,
|
|
28
|
+
externalUserId: shopper.id, // your own id for the shopper
|
|
29
|
+
sendLink: (link) => textShopper(shopper.id, link.url),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const answer = await new Anthropic().beta.messages.toolRunner({
|
|
33
|
+
model: 'claude-opus-5-5',
|
|
34
|
+
max_tokens: 16000,
|
|
35
|
+
tools: [buy],
|
|
36
|
+
messages: [{ role: 'user', content: 'two packs of AA batteries from Amazon' }],
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`shopper` and `textShopper` stand for your own code. The model calls `buy` with the shopper's words, shows the shopper the cart, and confirms the cart only after the shopper says yes.
|
|
41
|
+
|
|
42
|
+
Build the client once and keep it. Every shopper's tool shares the client's access token, and the client renews the token a minute before it expires. Keep each shopper's tool as well. Agentcard keeps each purchase between calls, so a tool built for every message buys the same way, but it sends the same product links again each time Agentcard shows the same products.
|
|
43
|
+
|
|
44
|
+
The model sends `ask`, `confirm` and `new_order`, and nothing else. Pass `deliveryAddress` when you already hold the shopper's address: the order goes there, and Agentcard never asks the shopper for it. The model can change neither the address nor the shopper.
|
|
45
|
+
|
|
46
|
+
## Use the tool in any framework
|
|
47
|
+
|
|
48
|
+
Pass `buy` as it is. Each framework reads its own fields from the same object, so you pick no adapter:
|
|
49
|
+
|
|
50
|
+
| Framework | Pass the tool | Who runs each call |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| Anthropic SDK, `client.beta.messages.toolRunner` | `tools: [buy]` | The tool runner. |
|
|
53
|
+
| Vercel AI SDK 7 | `tools: { buy }` | The SDK. |
|
|
54
|
+
| OpenAI SDK, OpenRouter's chat API, and any API that takes OpenAI's format | `tools: [buy.openai]` | Your loop, through `buy.handle`. |
|
|
55
|
+
| OpenRouter's agent SDK, `callModel` | `tools: [toOpenRouterTool(buy, { tool, z })]` | The SDK. |
|
|
56
|
+
|
|
57
|
+
In your own loop, hand each tool call to `buy.handle` and give the model what it returns. Here the OpenAI SDK talks to OpenRouter's chat API:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import OpenAI from 'openai';
|
|
61
|
+
|
|
62
|
+
const openrouter = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey: process.env.OPENROUTER_API_KEY });
|
|
63
|
+
const messages: OpenAI.ChatCompletionMessageParam[] = [{ role: 'user', content: 'two packs of AA batteries from Amazon' }];
|
|
64
|
+
const send = async () => (await openrouter.chat.completions.create({ model: 'anthropic/claude-opus-5.5', messages, tools: [buy.openai] })).choices[0].message;
|
|
65
|
+
|
|
66
|
+
let reply = await send();
|
|
67
|
+
while (reply.tool_calls?.length) {
|
|
68
|
+
messages.push(reply);
|
|
69
|
+
for (const call of reply.tool_calls) {
|
|
70
|
+
if (call.type !== 'function') continue;
|
|
71
|
+
const content = await buy.handle(call.function.arguments).catch((error) => error.message);
|
|
72
|
+
messages.push({ role: 'tool', tool_call_id: call.id, content });
|
|
73
|
+
}
|
|
74
|
+
reply = await send();
|
|
75
|
+
}
|
|
76
|
+
console.log(reply.content); // the model's answer for the shopper
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`buy.handle` takes the model's arguments as an object or as their JSON text, and returns the JSON text the model reads, with every link taken out. A call with input outside the schema rejects before anything reaches Agentcard. A call that fails rejects with a message that names no link, so the model reads `error.message` as it is.
|
|
80
|
+
|
|
81
|
+
For a framework with its own tool format, declare `buy.definition`, which holds the name, the description and the input schema, and run the same three lines for each tool call.
|
|
82
|
+
|
|
83
|
+
OpenRouter's agent SDK accepts only tools that its own `tool` builds from a zod schema, so that SDK keeps an adapter. Pass your own `tool` and `z`, and the package still installs no dependencies:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { OpenRouter, tool } from '@openrouter/agent';
|
|
87
|
+
import { z } from 'zod';
|
|
88
|
+
import { toOpenRouterTool } from '@agent-cards/commerce-sdk';
|
|
89
|
+
|
|
90
|
+
const result = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }).callModel({
|
|
91
|
+
model: 'anthropic/claude-opus-5.5',
|
|
92
|
+
input: 'two packs of AA batteries from Amazon',
|
|
93
|
+
tools: [toOpenRouterTool(buy, { tool, z })],
|
|
94
|
+
});
|
|
95
|
+
console.log(await result.getText());
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`toAnthropicTool(buy)` is no longer needed, and returns `buy` itself, so code that still calls it keeps working.
|
|
99
|
+
|
|
100
|
+
## Send the shopper each link
|
|
101
|
+
|
|
102
|
+
A purchase needs a link at three moments: the shopper approves the purchase on an approval page, a new shopper adds a card on a Vault page, and a shopper signs in to a store's own account on its login page before Agentcard can buy there. Each link's `kind` says which: `approval`, `vault` or `store_login`, and a `store_login` link names the store in `store`. The tool hands each link to `sendLink` and waits for it, so the shopper holds the link before the model answers. The model reads `link_sent: true` and never the link itself, because a tool runner puts each answer straight into the model's context, and a model that holds a link writes it into the chat.
|
|
103
|
+
|
|
104
|
+
The tool also hands `sendLink` a `product` link for each product a search finds, up to twenty per answer. The link carries the product's page at its store in `url`, the product's name in `title`, its price in `price`, such as `$12.99`, and its id in `productId`. Every price the tool writes, in a link or in the model's view, uses the currency Agentcard names for the cart or the product, in that currency's own minor units: `1331` is `$13.31` in USD, `CA$13.31` in CAD and `¥1,331` in JPY. A price whose currency Agentcard does not name shows with no symbol. The link arrives beside the reply, and you decide how to show it: as a card, in a list, or as a text with the URL. The model reads `product_links_sent` and never a URL, so a shopper who asks for links hears that the links are there. When the next answer in the conversation shows the same products, the tool sends none of their links again. A product link your `sendLink` throws on counts as not sent, and the call goes on.
|
|
105
|
+
|
|
106
|
+
Without `sendLink`, `result.links` still carries each Vault, approval and store login link for your code, each product in `result.products` carries its `url`, and the model still never reads one.
|
|
107
|
+
|
|
108
|
+
## Sign up a new shopper
|
|
109
|
+
|
|
110
|
+
A shopper who has not connected to your company yet gets a Vault link on their first call. The tool asks Agentcard for the link and hands it to `sendLink`, and the model reads `needs_card`. The shopper connects and adds a card on the Vault page, and the next call buys. Agentcard returns the same link while it has time left, so a shopper who asks twice gets one link.
|
|
111
|
+
|
|
112
|
+
A shopper who is connected but has no card gets the add-card link Agentcard returned with its answer, and the model reads `needs_card` as well.
|
|
113
|
+
|
|
114
|
+
## Wait for a placement
|
|
115
|
+
|
|
116
|
+
An order takes a few seconds to go through after the shopper approves it. A call that lands while Agentcard is still placing the order pauses once for 12 seconds and reads the conversation, and the model reads `order_placed` with the order number, or `placing`. A call that finds the order already placed reads it at once, with no pause. An order the store cancelled, or one whose payment never confirmed, reads as `declined` with the reason.
|
|
117
|
+
|
|
118
|
+
A request that takes Agentcard more than 110 seconds comes back before its cart is ready. The tool then reads the conversation every 5 seconds for up to a minute, and returns the cart once Agentcard finishes the request, so the shopper's first yes confirms that cart. If Agentcard is still working when the minute ends, the tool returns the cart Agentcard has built so far. When the shopper has carts at more than one store, the tool returns every cart and no `cart`, so the model asks the shopper which one to buy before it confirms. A confirm that takes that long returns the order it placed, sends the approval link it paused on, or reads `placing` while the order is still going through. Pass `wait` to replace the timer between reads, as the tests do.
|
|
119
|
+
|
|
120
|
+
## Read what the model sees
|
|
121
|
+
|
|
122
|
+
The model reads one object per call:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"status": "needs_input",
|
|
127
|
+
"reply": "Here's an 8 pack of AA batteries at Amazon for $12.99, $13.31 with fees. Want me to order it?",
|
|
128
|
+
"cart": {
|
|
129
|
+
"store": "Amazon",
|
|
130
|
+
"items": [{ "name": "AA batteries, 8 pack", "qty": 1, "price": "$12.99" }],
|
|
131
|
+
"fees": "$0.32",
|
|
132
|
+
"tip": null,
|
|
133
|
+
"total": "$13.31"
|
|
134
|
+
},
|
|
135
|
+
"carts": [
|
|
136
|
+
{ "store": "Amazon", "items": [{ "name": "AA batteries, 8 pack", "qty": 1, "price": "$12.99" }], "fees": "$0.32", "tip": null, "total": "$13.31" }
|
|
137
|
+
],
|
|
138
|
+
"products": [],
|
|
139
|
+
"approval_expires_at": null,
|
|
140
|
+
"shopper_need_not_reply": false,
|
|
141
|
+
"link_sent": false,
|
|
142
|
+
"product_links_sent": 0
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`cart` is the newest cart, and `carts` lists every cart on the table, the newest last, so a shopper buying from more than one store hears each one.
|
|
147
|
+
|
|
148
|
+
`status` says where the purchase stands:
|
|
149
|
+
|
|
150
|
+
| `status` | What happened |
|
|
151
|
+
|---|---|
|
|
152
|
+
| `needs_input` | Agentcard asks the shopper something, or shows a cart. |
|
|
153
|
+
| `needs_card` | The shopper adds a card from a link before Agentcard can buy. |
|
|
154
|
+
| `approval_sent` | The shopper approves the purchase from a link. |
|
|
155
|
+
| `placing` | The shopper approved, and the order is going through. |
|
|
156
|
+
| `order_placed` | The order went through. |
|
|
157
|
+
| `declined` | Agentcard did not buy, and `reply` says why. |
|
|
158
|
+
|
|
159
|
+
A status Agentcard adds later reads as `needs_input`. `shopper_need_not_reply` is true when a `purchase.message` will tell the shopper how the order went.
|
|
160
|
+
|
|
161
|
+
## Run the tool yourself
|
|
162
|
+
|
|
163
|
+
`buy.answer(input)` sends the model's input and returns the whole answer for your code: the status, Agentcard's reply with its links, the cart and its hash, the products, the links, and the answer as it came in `envelope`. `buy.forModel(result)` returns what the model may read. Give the model the view, never the answer:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
const result = await buy.answer({ ask: 'two packs of AA batteries', confirm: false, new_order: false });
|
|
167
|
+
const view = buy.forModel(result); // the tool result your model reads
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`buy.run`, `buy.execute` and `buy.handle` each return the same text, `JSON.stringify(buy.forModel(result))`: Anthropic's tool runner calls `buy.run`, the Vercel AI SDK calls `buy.execute`, and your own loop calls `buy.handle`.
|
|
171
|
+
|
|
172
|
+
## Buy with a shopper's own token
|
|
173
|
+
|
|
174
|
+
A tool that buys for one signed-in shopper takes that shopper's own Agentcard access token instead of a client, and names nobody:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
const buy = buyTool({ accessToken: shopperAccessToken, sendLink });
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Try it in the sandbox
|
|
181
|
+
|
|
182
|
+
Run the walkthrough with your sandbox credentials to watch one purchase from start to finish:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
AGENTCARD_CLIENT_ID=... AGENTCARD_CLIENT_SECRET=... node node_modules/@agent-cards/commerce-sdk/examples/walkthrough.mjs
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The walkthrough connects a test shopper with the sandbox code `111111`, asks for a restaurant table, picks one and confirms it. The sandbox refuses the booking itself, so the last answer is `declined` with `decline_code: sandbox_unavailable`, and nothing is booked or charged. Then a shopper nobody connected asks twice and gets the same Vault link both times. The walkthrough refuses live credentials before it connects anybody.
|
|
189
|
+
|
|
190
|
+
`examples/anthropic.mjs` and `examples/openrouter.mjs` run the buy tool as a chat in your terminal, and each prints the links your app would send.
|
|
191
|
+
|
|
192
|
+
## Read a refusal
|
|
193
|
+
|
|
194
|
+
Five refusals still answer the shopper, so they come back as `needs_input` with the reason in `reply` and the code in `error_code`: a changed cart (`cart_changed`), a confirm with no cart (`nothing_to_confirm`), a confirm on a closed conversation (`conversation_closed`), a delivery address that changed since the cart was built (a code that starts with `delivery_address`), and a request Agentcard is still working on (`turn_in_progress`). Every other refusal throws an `AgentcardApiError`, and `error.code` says why:
|
|
195
|
+
|
|
196
|
+
| `code` | What it means | What to do |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| `external_user_id_ambiguous` | Two of your shoppers share the `externalUserId` you passed. | Give each shopper their own id. |
|
|
199
|
+
| `oauth_failed` | Agentcard refused your client credentials. | Check `clientId` and `clientSecret`. |
|
|
200
|
+
| `unauthorized` | Agentcard refused the access token. | Sign the shopper in again when the token was their own. |
|
|
201
|
+
| `timeout` | Agentcard did not answer in time, so whether the turn ran is unknown. A purchase turn waits 130 seconds. | Read the conversation with `agentcard.conversation(conversationId, { externalUserId })` when an earlier result gave you its `conversation_id`. Call the tool again only once that read shows the turn did not finish, because a second call sends a second turn. |
|
|
202
|
+
| `unreachable` | No answer came back. The request can still have reached Agentcard. | As for `timeout`. |
|
|
203
|
+
| `invalid_answer` | Agentcard answered with a body the client cannot read. | As for `timeout`. |
|
|
204
|
+
|
|
205
|
+
The tool sends each purchase request once, because a purchase turn runs against live stores. Only a request Agentcard refused with a `401` goes again, once, with a renewed token.
|
|
206
|
+
|
|
207
|
+
`buy.answer` throws the `AgentcardApiError` itself. `buy.run`, `buy.execute` and `buy.handle` throw the same message with every link taken out, and keep the original error on `error.cause` for your logs.
|
|
208
|
+
|
|
209
|
+
## Mount the handler
|
|
210
|
+
|
|
211
|
+
Subscribe your webhook endpoint to `purchase.message`, then mount the handler on its route with `express.raw`:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
import express from 'express';
|
|
215
|
+
import { purchaseAgentWebhook } from '@agent-cards/commerce-sdk';
|
|
216
|
+
|
|
217
|
+
const app = express();
|
|
218
|
+
const secret = process.env.AGENTCARD_WEBHOOK_SECRET ?? ''; // returned once, when you created the endpoint
|
|
219
|
+
|
|
220
|
+
app.post('/agentcard/webhooks', express.raw({ type: 'application/json' }), purchaseAgentWebhook(secret, async (message) => {
|
|
221
|
+
// Past validUntil, the link or window the message names has closed: drop it, and the delivery still answers 200.
|
|
222
|
+
const until = message.facts.validUntil ? Date.parse(message.facts.validUntil) : Infinity;
|
|
223
|
+
if (until <= Date.now()) return;
|
|
224
|
+
// A link must arrive exactly as written, and a message without a note has only its text.
|
|
225
|
+
const sendAsIs = message.kind === 'approval_expiring' || message.kind === 'card_link_unfinished' || !message.note;
|
|
226
|
+
if (sendAsIs) return textShopper(message.userId, message.text);
|
|
227
|
+
try {
|
|
228
|
+
await runAgentTurn(message.userId, message.note, until);
|
|
229
|
+
} catch {
|
|
230
|
+
// The agent could not say it, so the shopper still hears what happened while it still holds.
|
|
231
|
+
if (until > Date.now()) await textShopper(message.userId, message.text);
|
|
232
|
+
}
|
|
233
|
+
}));
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`textShopper` and `runAgentTurn` stand for your own code. `runAgentTurn` gets the time the message stops holding, so it sends nothing after it. An empty secret throws when the server starts.
|
|
237
|
+
|
|
238
|
+
Mount the route before any `app.use(express.json())`. The signature covers the exact bytes Agentcard sent, and a JSON parser that runs first throws those bytes away. If a JSON parser has to run first, keep the bytes for the handler:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
app.use(express.json({ verify: (req, _res, buf) => { Object.assign(req, { rawBody: buf }); } }));
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Without the raw bytes, the handler answers `500` and `onError` receives the fix.
|
|
245
|
+
|
|
246
|
+
## Handle each delivery
|
|
247
|
+
|
|
248
|
+
A delivery with a missing, malformed, stale or wrong signature gets a `400`, and your code never sees it. A verified delivery runs your callback, and the handler answers when the callback is done: `200` when it returns, `500` when it throws. After a `500`, Agentcard delivers the event again, so a shopper's update is not lost to a failed send.
|
|
249
|
+
|
|
250
|
+
Agentcard waits 10 seconds for the answer. Keep the callback short: send the text, or save the agent turn to a queue that retries a failed job, then return. Without a queue, await the send, so a send that fails answers `500` and Agentcard delivers the event again.
|
|
251
|
+
|
|
252
|
+
Agentcard delivers each event at least once, and again after any answer other than a `2xx`. Keep `message.eventId` and skip an id you already handled.
|
|
253
|
+
|
|
254
|
+
Pass `onEvent` to receive every other event type, and `onError` to receive an error your callback throws. Without `onError`, the error goes to `console.error`. A purchase message the package cannot read also reaches `onError`, and gets a `200`, because a second delivery would read the same.
|
|
255
|
+
|
|
256
|
+
## Read a message
|
|
257
|
+
|
|
258
|
+
| Field | What it is for |
|
|
259
|
+
|---|---|
|
|
260
|
+
| `text` | The text to send the shopper as is. Send it when you have no agent, and for every message that carries a link, so the link arrives exactly as Agentcard wrote it. |
|
|
261
|
+
| `note` | For your agent: what happened, marked as written by the system and not typed by the shopper. Give it to your agent as a turn, and the agent tells the shopper in its own voice. |
|
|
262
|
+
| `facts` | The same values one by one, for a company that writes its own copy. A fact that does not apply to the message is absent. |
|
|
263
|
+
|
|
264
|
+
When a message has no `note`, or your agent cannot run the turn, send `text` instead, and the shopper still hears what happened.
|
|
265
|
+
|
|
266
|
+
The facts are `merchantName`, `totalCents`, `currency`, `cardBrand`, `cardLast4`, `orderId`, `reason`, `approvalUrl`, `approvalExpiresAt`, `cardLinkUrl`, `retryUntil`, `nudgeNumber` and `validUntil`. `totalCents` is `2306` for `$23.06`.
|
|
267
|
+
|
|
268
|
+
`validUntil` is when the message stops holding: the expiry of any link or window its text names. A message that arrives after it, or that you would send after it, is stale, so drop it instead of sending it. A message that stays true carries no `validUntil`.
|
|
269
|
+
|
|
270
|
+
`userId` is the shopper's Agentcard user id, and `externalUserId` is your own id for them when you gave Agentcard one. `kind` says which moment the message is about:
|
|
271
|
+
|
|
272
|
+
| `kind` | When it arrives |
|
|
273
|
+
|---|---|
|
|
274
|
+
| `order_placed` | The shopper approved, and Agentcard placed the order. |
|
|
275
|
+
| `order_failed` | The order failed after the shopper approved. |
|
|
276
|
+
| `order_not_placed_yet` | The shopper approved, and the order is not placed yet. |
|
|
277
|
+
| `approval_expiring` | The approval link expires soon. `text` carries the link. |
|
|
278
|
+
| `approval_expired` | The approval expired before the shopper answered, and no charge went through. |
|
|
279
|
+
| `card_link_unfinished` | The shopper has not added a card from their card link. `text` carries the link again. |
|
|
280
|
+
| `card_link_expired` | The card link expired before the shopper added a card. |
|
|
281
|
+
| `cart_idle` | A cart sat, and nobody sent it for approval. |
|
|
282
|
+
|
|
283
|
+
Agentcard can add a kind later. A kind your code does not know still carries `text`, ready to send as is.
|
|
284
|
+
|
|
285
|
+
## Verify without the handler
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
import { verifyAgentcardWebhook, readPurchaseMessage } from '@agent-cards/commerce-sdk';
|
|
289
|
+
|
|
290
|
+
// Node types a header as a string or a list of strings; the check takes one string.
|
|
291
|
+
const signature = req.headers['agentcard-signature'];
|
|
292
|
+
const event = verifyAgentcardWebhook(rawBody, Array.isArray(signature) ? signature.join(',') : signature, secret);
|
|
293
|
+
const message = readPurchaseMessage(event); // null for every other event type
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
`verifyAgentcardWebhook` throws `AgentcardSignatureError` when the delivery did not come from Agentcard, and `error.reason` says why: `missing_header`, `malformed_header`, `stale_timestamp` or `wrong_signature`. A delivery signed more than 300 seconds away from now is stale, and `toleranceSeconds` changes the limit.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two adapters from before the buy tool fit every framework on its own.
|
|
3
|
+
* `toAnthropicTool` is no longer needed: pass the buy tool itself, and it
|
|
4
|
+
* returns that same object for one more release. `toOpenRouterTool` stays for
|
|
5
|
+
* OpenRouter's agent SDK, whose `callModel` takes only tools built with its
|
|
6
|
+
* own `tool` and a zod schema. The package imports no SDK: it calls the `tool`
|
|
7
|
+
* and `z` you pass, so it keeps no dependencies.
|
|
8
|
+
*
|
|
9
|
+
* Both hand the model the JSON text `buy.handle` returns, and a failed call
|
|
10
|
+
* as the error's message with every link taken out.
|
|
11
|
+
*/
|
|
12
|
+
import type { BuyTool } from './buy-tool.js';
|
|
13
|
+
/** @deprecated The buy tool is Anthropic's runnable tool already. Kept for one release. */
|
|
14
|
+
export type AnthropicBuyTool = BuyTool;
|
|
15
|
+
/** @deprecated Pass the buy tool itself: `toolRunner({ tools: [buy] })`. Returns the same object, for one release. */
|
|
16
|
+
export declare function toAnthropicTool(tool: BuyTool): BuyTool;
|
|
17
|
+
/** The three zod builders the OpenRouter adapter calls; `z` from zod 3 or 4 has them. */
|
|
18
|
+
export interface ZodLike {
|
|
19
|
+
object(shape: Record<string, unknown>): {
|
|
20
|
+
strict(): unknown;
|
|
21
|
+
};
|
|
22
|
+
string(): {
|
|
23
|
+
describe(description: string): unknown;
|
|
24
|
+
};
|
|
25
|
+
boolean(): {
|
|
26
|
+
describe(description: string): unknown;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** What the OpenRouter adapter needs from you: `tool` from `@openrouter/agent`, and `z` from zod. */
|
|
30
|
+
export interface OpenRouterToolkit<T> {
|
|
31
|
+
tool: (config: any) => T;
|
|
32
|
+
z: ZodLike;
|
|
33
|
+
}
|
|
34
|
+
/** The buy tool for OpenRouter's `callModel({ tools: [toOpenRouterTool(buy, { tool, z })] })`. */
|
|
35
|
+
export declare function toOpenRouterTool<T>(buy: BuyTool, toolkit: OpenRouterToolkit<T>): T;
|
package/dist/adapters.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** @deprecated Pass the buy tool itself: `toolRunner({ tools: [buy] })`. Returns the same object, for one release. */
|
|
2
|
+
export function toAnthropicTool(tool) {
|
|
3
|
+
return tool;
|
|
4
|
+
}
|
|
5
|
+
/** The buy tool for OpenRouter's `callModel({ tools: [toOpenRouterTool(buy, { tool, z })] })`. */
|
|
6
|
+
export function toOpenRouterTool(buy, toolkit) {
|
|
7
|
+
const { tool, z } = toolkit ?? {};
|
|
8
|
+
if (typeof tool !== 'function' || !z || typeof z.object !== 'function') {
|
|
9
|
+
throw new TypeError("Pass { tool, z }: tool from '@openrouter/agent' and z from 'zod'.");
|
|
10
|
+
}
|
|
11
|
+
const { properties } = buy.definition.input_schema;
|
|
12
|
+
return tool({
|
|
13
|
+
name: buy.definition.name,
|
|
14
|
+
description: buy.definition.description,
|
|
15
|
+
inputSchema: z.object({
|
|
16
|
+
ask: z.string().describe(properties.ask.description),
|
|
17
|
+
confirm: z.boolean().describe(properties.confirm.description),
|
|
18
|
+
new_order: z.boolean().describe(properties.new_order.description),
|
|
19
|
+
}).strict(),
|
|
20
|
+
// OpenRouter's SDK encodes what execute returns as JSON, so the view goes back as an object and reaches the model as the same text.
|
|
21
|
+
execute: async (input) => JSON.parse(await buy.execute(input)),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The buy tool: one call that buys for one shopper, in one object that every
|
|
3
|
+
* agent framework takes. `answer` sends the model's input to Agentcard and
|
|
4
|
+
* returns the whole answer for your code, with the status, Agentcard's
|
|
5
|
+
* reply, the cart and its hash, the products and the links. `forModel` turns
|
|
6
|
+
* that answer into what the model may read, with every link taken out, and
|
|
7
|
+
* `run`, `execute` and `handle` return that view as JSON text. A link
|
|
8
|
+
* reaches the shopper through `sendLink`, and each call waits for it before
|
|
9
|
+
* it returns: the Vault and approval links, then the store page of each
|
|
10
|
+
* product the model reads.
|
|
11
|
+
*
|
|
12
|
+
* Agentcard keeps the conversation: every call continues the shopper's open
|
|
13
|
+
* purchase (`resume: true`), and a confirm places the cart Agentcard last
|
|
14
|
+
* showed (`confirm: true`). The tool keeps one thing between calls: the store
|
|
15
|
+
* pages it sent last. Agentcard repeats the last search in each answer for a
|
|
16
|
+
* while, so a list it shows again in the same conversation does not reach
|
|
17
|
+
* the shopper twice. A tool built for each message buys the same way, and
|
|
18
|
+
* sends such a list again.
|
|
19
|
+
*
|
|
20
|
+
* Two answers make the tool wait before it returns. A placement Agentcard is
|
|
21
|
+
* still finishing gets one 12-second pause and one read of the conversation.
|
|
22
|
+
* A turn that outlived Agentcard's budget gets a read every 5 seconds for up
|
|
23
|
+
* to a minute, until the turn ends.
|
|
24
|
+
*/
|
|
25
|
+
import { AgentcardClient, type BuyCart, type BuyDeliveryAddress, type FetchLike } from './client.js';
|
|
26
|
+
import { type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolStandardSchema, type OpenAIBuyTool } from './definition.js';
|
|
27
|
+
import { type BuyToolModelView } from './model-view.js';
|
|
28
|
+
/**
|
|
29
|
+
* Where the purchase stands:
|
|
30
|
+
* - `needs_input`: Agentcard asks the shopper something, or shows a cart.
|
|
31
|
+
* - `needs_card`: the shopper connects and adds a card first, from a link.
|
|
32
|
+
* - `approval_sent`: the shopper approves the purchase from a link.
|
|
33
|
+
* - `placing`: the shopper approved, and the order is going through.
|
|
34
|
+
* - `order_placed`: the order went through.
|
|
35
|
+
* - `declined`: Agentcard did not buy, and `reply` says why.
|
|
36
|
+
*/
|
|
37
|
+
export type BuyToolStatus = 'needs_input' | 'needs_card' | 'approval_sent' | 'placing' | 'order_placed' | 'declined';
|
|
38
|
+
/**
|
|
39
|
+
* A link for the shopper. `vault`: the page where they connect and add a
|
|
40
|
+
* card. `approval`: the page where they approve this purchase. `store_login`:
|
|
41
|
+
* the page where they sign in to the store named in `store`, before Agentcard
|
|
42
|
+
* can buy there. `product`: a product's page at its store, with its `title`,
|
|
43
|
+
* `price` and `productId`.
|
|
44
|
+
*/
|
|
45
|
+
export interface BuyToolLink {
|
|
46
|
+
kind: 'vault' | 'approval' | 'store_login' | 'product';
|
|
47
|
+
url: string;
|
|
48
|
+
/** When the link stops working, as an ISO 8601 timestamp, or null when Agentcard did not say. Always null on a product link. */
|
|
49
|
+
expiresAt: string | null;
|
|
50
|
+
/** On a store login link: the store's name, or null when Agentcard named none. */
|
|
51
|
+
store?: string | null;
|
|
52
|
+
/** On a product link: the product's name. */
|
|
53
|
+
title?: string;
|
|
54
|
+
/** On a product link: the price as the shopper reads it, such as `$12.99`, or null when Agentcard named none. */
|
|
55
|
+
price?: string | null;
|
|
56
|
+
/** On a product link: Agentcard's id for the product. */
|
|
57
|
+
productId?: string;
|
|
58
|
+
}
|
|
59
|
+
/** A product the last search found. */
|
|
60
|
+
export interface BuyToolProduct {
|
|
61
|
+
id: string;
|
|
62
|
+
name: string;
|
|
63
|
+
/** The price in the minor unit of `currency`. */
|
|
64
|
+
priceCents: number | null;
|
|
65
|
+
/** The ISO 4217 code the store prices the product in, or null when Agentcard sent none. */
|
|
66
|
+
currency: string | null;
|
|
67
|
+
imageUrl: string | null;
|
|
68
|
+
store: string | null;
|
|
69
|
+
/** The product's page at its store, or null when Agentcard sent none. */
|
|
70
|
+
url: string | null;
|
|
71
|
+
/**
|
|
72
|
+
* True when the shopper holds this product's store page: `sendLink` took it
|
|
73
|
+
* in this call, or in the last call that sent store pages in the same
|
|
74
|
+
* conversation.
|
|
75
|
+
*/
|
|
76
|
+
linkSent: boolean;
|
|
77
|
+
}
|
|
78
|
+
/** One answer of the buy tool, whole, for your code. Hand it to `forModel` before a model reads it. */
|
|
79
|
+
export interface BuyToolResult {
|
|
80
|
+
status: BuyToolStatus;
|
|
81
|
+
/** Agentcard's answer for the shopper, links included. */
|
|
82
|
+
reply: string;
|
|
83
|
+
/** The cart on the table, or null. */
|
|
84
|
+
cart: BuyCart | null;
|
|
85
|
+
/** Every cart on the table, oldest first. */
|
|
86
|
+
carts: BuyCart[];
|
|
87
|
+
products: BuyToolProduct[];
|
|
88
|
+
/** The Vault or approval link this answer needs, each one handed to `sendLink` before the call returned. A product's store page is its `url`. */
|
|
89
|
+
links: BuyToolLink[];
|
|
90
|
+
/** True when `sendLink` took every link in `links`. */
|
|
91
|
+
link_sent: boolean;
|
|
92
|
+
/** When the approval link stops working, as an ISO 8601 timestamp. */
|
|
93
|
+
approval_expires_at: string | null;
|
|
94
|
+
/** True when Agentcard tells the shopper how the order went, so the shopper need not reply. */
|
|
95
|
+
shopper_need_not_reply: boolean;
|
|
96
|
+
conversation_id: string | null;
|
|
97
|
+
order_id: string | null;
|
|
98
|
+
decline_code: string | null;
|
|
99
|
+
error_code: string | null;
|
|
100
|
+
/** Every cart hash in this answer. */
|
|
101
|
+
hashes: string[];
|
|
102
|
+
/** Agentcard's answer as it came: the purchase answer, or the body of the refusal. */
|
|
103
|
+
envelope: Record<string, unknown>;
|
|
104
|
+
}
|
|
105
|
+
export interface BuyToolOptions {
|
|
106
|
+
/** A client built with your company's credentials. Build it once and share it across every shopper's tool. */
|
|
107
|
+
agentcard?: AgentcardClient;
|
|
108
|
+
/** Or one shopper's own Agentcard access token, for a tool that buys only for them. */
|
|
109
|
+
accessToken?: string;
|
|
110
|
+
/** Your own id for the shopper. Required with a company client, and left out with a shopper's own token. */
|
|
111
|
+
externalUserId?: string;
|
|
112
|
+
/**
|
|
113
|
+
* Delivers a link to the shopper. Each call waits for it, so the shopper
|
|
114
|
+
* holds the link before the model answers. A `sendLink` that throws on a
|
|
115
|
+
* Vault or approval link makes the call throw too, and the model never
|
|
116
|
+
* reads that a link went out. One that throws on a product link leaves
|
|
117
|
+
* that product's `linkSent` false, and the call goes on.
|
|
118
|
+
*/
|
|
119
|
+
sendLink?: (link: BuyToolLink) => void | Promise<void>;
|
|
120
|
+
/** Where the orders go, when you already hold the shopper's address. The model never sets it. */
|
|
121
|
+
deliveryAddress?: BuyDeliveryAddress;
|
|
122
|
+
/** With `accessToken`: where Agentcard answers. Default `https://api.agentcard.sh`. */
|
|
123
|
+
baseUrl?: string;
|
|
124
|
+
/** With `accessToken`: the fetch to call Agentcard with. Default: the global fetch. */
|
|
125
|
+
fetch?: FetchLike;
|
|
126
|
+
/** The pause between two reads of the conversation. Default: a timer. */
|
|
127
|
+
wait?: (ms: number) => Promise<void>;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The buy tool, in the shape every agent framework takes, because their field
|
|
131
|
+
* names do not collide:
|
|
132
|
+
* - Anthropic's tool runner sends `name`, `description`, `input_schema` and
|
|
133
|
+
* `strict` to the API, the only fields JSON carries, and calls `parse` and `run`.
|
|
134
|
+
* - The Vercel AI SDK reads `description`, `inputSchema` and `strict`, and calls `execute`.
|
|
135
|
+
* - The OpenAI SDK, and every API that takes OpenAI's format, takes `openai`,
|
|
136
|
+
* and your loop hands each call to `handle`.
|
|
137
|
+
* The tool carries no `type`: Anthropic's API takes `custom` or none, and the
|
|
138
|
+
* Vercel AI SDK throws on `custom`. `run`, `execute` and `handle` are one
|
|
139
|
+
* function, so every framework hands the model the same JSON, with every
|
|
140
|
+
* link taken out.
|
|
141
|
+
*/
|
|
142
|
+
export interface BuyTool {
|
|
143
|
+
/** The tool's name, `buy`. */
|
|
144
|
+
readonly name: string;
|
|
145
|
+
/** What the model reads before it calls the tool. */
|
|
146
|
+
readonly description: string;
|
|
147
|
+
/** The JSON Schema of the model's input, under Anthropic's field name. */
|
|
148
|
+
readonly input_schema: BuyToolInputSchema;
|
|
149
|
+
/** Asks the model API for input that always fits the schema. */
|
|
150
|
+
readonly strict: true;
|
|
151
|
+
/** The same schema, as the Vercel AI SDK reads it. */
|
|
152
|
+
readonly inputSchema: BuyToolStandardSchema;
|
|
153
|
+
/** The tool in OpenAI's function-calling format, for `tools: [buy.openai]`. */
|
|
154
|
+
readonly openai: OpenAIBuyTool;
|
|
155
|
+
/** The name, description and input schema to give the model. */
|
|
156
|
+
readonly definition: BuyToolDefinition;
|
|
157
|
+
/** Checks the model's input against the schema, and throws a message the model can act on. */
|
|
158
|
+
parse(input: unknown): BuyToolInput;
|
|
159
|
+
/** Runs one call and returns what the model reads, as JSON text. Anthropic's tool runner calls it. */
|
|
160
|
+
run(input: BuyToolInput): Promise<string>;
|
|
161
|
+
/** The same call, for the Vercel AI SDK. */
|
|
162
|
+
execute(input: BuyToolInput): Promise<string>;
|
|
163
|
+
/** The same call, for your own loop: takes the model's arguments as an object or as their JSON text. */
|
|
164
|
+
handle(args: unknown): Promise<string>;
|
|
165
|
+
/** Sends the model's input to Agentcard and returns the whole answer, links included, for your code. Never hand it to the model. */
|
|
166
|
+
answer(input: BuyToolInput): Promise<BuyToolResult>;
|
|
167
|
+
/** The answer as the model may read it, with every link taken out. */
|
|
168
|
+
forModel(result: BuyToolResult): BuyToolModelView;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* A buy tool for one shopper. With a company client, name the shopper with
|
|
172
|
+
* `externalUserId`; with a shopper's own `accessToken`, the token names them.
|
|
173
|
+
* Throws a TypeError for options that cannot work, before any call.
|
|
174
|
+
*/
|
|
175
|
+
export declare function buyTool(options: BuyToolOptions): BuyTool;
|