@brinkcommerce/agentic-shopping-sdk 0.1.0-alpha.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 +21 -0
- package/README.md +360 -0
- package/dist/chunk-2MMTZSYZ.js +26 -0
- package/dist/errors-CYbkUYHr.d.cts +126 -0
- package/dist/errors-CYbkUYHr.d.ts +126 -0
- package/dist/index.cjs +168 -0
- package/dist/index.d.cts +102 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.js +127 -0
- package/dist/server.cjs +244 -0
- package/dist/server.d.cts +103 -0
- package/dist/server.d.ts +103 -0
- package/dist/server.js +204 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brink Commerce AB
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
# @brinkcommerce/agentic-shopping-sdk
|
|
2
|
+
|
|
3
|
+
TypeScript SDK for the Brink Commerce agentic shopping agent. It has two entry points: the stream and card helpers a browser runs, and the authenticated client a server runs.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @brinkcommerce/agentic-shopping-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Node 20 or later. The SDK uses the global `fetch` and `crypto`, and older versions do not have them.
|
|
12
|
+
|
|
13
|
+
## Why there are two halves
|
|
14
|
+
|
|
15
|
+
A browser cannot call the agent. There are two reasons, and each one is enough on its own. The client secret that mints the bearer token must never reach a page. The agent endpoint also sends no CORS headers, so the browser would block the request even if the secret were public.
|
|
16
|
+
|
|
17
|
+
Every deployment therefore looks like this:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
browser ──POST /api/chat──▶ your server ──POST /invocations──▶ the agent
|
|
21
|
+
▲ (mints the token) │
|
|
22
|
+
└────────────── NDJSON, passed straight through ◀──────────────────┘
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`@brinkcommerce/agentic-shopping-sdk/server` is the middle box. `@brinkcommerce/agentic-shopping-sdk` is what the browser imports. It holds no credentials and never calls the agent.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Four things that break an integration
|
|
30
|
+
|
|
31
|
+
Each one fails quietly. None of them throws.
|
|
32
|
+
|
|
33
|
+
- **`products` is cumulative.** Every event carries all cards found so far. Replace your
|
|
34
|
+
list, do not append — appending duplicates every earlier card. [Reading the stream](#reading-the-stream)
|
|
35
|
+
- **A failed turn still returns HTTP 200.** The protocol has no error channel. `response.ok`
|
|
36
|
+
is true and the shopper gets one apologetic sentence. [Error handling](#error-handling)
|
|
37
|
+
- **`productVariantId` is the only id the cart accepts.** The other two ids are not cart ids,
|
|
38
|
+
and a wrong one adds nothing. [Adding to cart](#adding-to-cart)
|
|
39
|
+
- **Build the client at module scope.** One per request mints a token per request, and
|
|
40
|
+
Cognito bills each one. [Setup](#setup)
|
|
41
|
+
|
|
42
|
+
## Setup
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { AgentShoppingClient } from '@brinkcommerce/agentic-shopping-sdk/server'
|
|
46
|
+
|
|
47
|
+
export const client = new AgentShoppingClient({
|
|
48
|
+
runtimeArn: process.env.AGENT_RUNTIME_ARN!,
|
|
49
|
+
tokenUrl: process.env.AGENT_TOKEN_URL!,
|
|
50
|
+
clientId: process.env.AGENT_CLIENT_ID!,
|
|
51
|
+
clientSecret: process.env.AGENT_CLIENT_SECRET!,
|
|
52
|
+
})
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Brink provides all four values. `scope` is optional and defaults to `agentic-shopping/invoke`.
|
|
56
|
+
|
|
57
|
+
A runtime ARN looks like this:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
arn:aws:bedrock-agentcore:eu-west-1:134468645640:runtime/agenticShoppingAgent-QxabmK5UGG
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The client builds the invocation URL from it. The region is the fourth field of the ARN, so there is no region to set. `qualifier` is optional and defaults to `DEFAULT`, the runtime's default endpoint.
|
|
64
|
+
|
|
65
|
+
Pass `agentUrl` instead of `runtimeArn` when you do not call the runtime directly — your own gateway in front of the agent, or the container running locally on `http://localhost:8080/invocations`. Pass one or the other, never both.
|
|
66
|
+
|
|
67
|
+
An ARN the client cannot parse throws a `TypeError` naming `runtimeArn`. The client is built at module scope, so that throws at startup, not on the first shopper's turn.
|
|
68
|
+
|
|
69
|
+
The client mints a token on the first turn. It caches it in the process until a minute before it expires, so one mint serves every conversation that process handles.
|
|
70
|
+
|
|
71
|
+
**Build the client at module scope, as above. Never inside the request handler.** The cache lives in the process, so on a serverless host there is one cache per warm instance. A reused instance mints about once an hour, however many turns it serves. A client built per request mints on every request and never uses the cache. Cognito bills each M2M token request, so that difference shows up on your bill.
|
|
72
|
+
|
|
73
|
+
## Next.js route handler
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// app/api/chat/route.ts
|
|
77
|
+
import { createChatRoute } from '@brinkcommerce/agentic-shopping-sdk/server'
|
|
78
|
+
import { client } from '@/lib/agent'
|
|
79
|
+
|
|
80
|
+
export const POST = createChatRoute(client)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`createChatRoute` takes a standard `Request` and returns a `Response`. It has no Next.js dependency, so it works in any framework with the same shape.
|
|
84
|
+
|
|
85
|
+
Four things to get right in a Next.js handler:
|
|
86
|
+
|
|
87
|
+
- **Never read the body in the handler.** `await response.text()` buffers the whole reply, and the shopper watches a spinner until the agent finishes. `createChatRoute` passes `response.body` through untouched. Do the same in any handler you write yourself.
|
|
88
|
+
- **Nothing in front may buffer.** Run the route on Node (`export const runtime = 'nodejs'`). If a proxy or CDN sits in front of it, check that it does not buffer `application/x-ndjson`.
|
|
89
|
+
- **Raise the route's time limit.** A serverless host cuts the route off at its own limit, and that limit covers the whole stream — the route stays open until the agent writes `done`. It is not a time-to-first-byte budget. On Vercel the field is `export const maxDuration = <seconds>`, and the default of 10 to 15 seconds is shorter than any turn that runs a search: the reply stops mid-sentence and no `done` arrives. Set it above your slowest complete turn. 60 is the ceiling on the Hobby plan, and higher plans allow more. A high ceiling costs nothing when it goes unused: it decides when a turn is cut, not how long one takes. The shopper's own patience is a shorter clock, and it belongs in the browser — see [Reading the stream](#reading-the-stream). This SDK sets no timeout of its own, so the host's is the only one.
|
|
90
|
+
- **The route is open.** `createChatRoute` validates the payload and nothing else. Anyone who can reach the URL can spend your agent budget. Put your own session check and rate limit in front of it.
|
|
91
|
+
|
|
92
|
+
A host that cuts a turn cuts the stream. The reading loop in the browser then either throws or simply ends, and neither one carries a `done` event. So wrap the loop in a `try`, and treat a loop that finished without `done` as a failed turn. That is the only signal a cut reply gives you.
|
|
93
|
+
|
|
94
|
+
## Reading the stream
|
|
95
|
+
|
|
96
|
+
The body is the turn. `createChatRoute` reads five fields and ignores anything else:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import type { CartContext } from '@brinkcommerce/agentic-shopping-sdk'
|
|
100
|
+
|
|
101
|
+
interface ChatRequest {
|
|
102
|
+
prompt: string
|
|
103
|
+
sessionId: string
|
|
104
|
+
storeGroupId: string
|
|
105
|
+
market: string
|
|
106
|
+
cart?: CartContext
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Declare that shape in your own code. `AskInput` is the same shape, but the only entry point that exports it is the server one, and that entry point holds the client that reads your client secret. Keep it out of browser files. `CartContext` and `CartLine` are on both, so the cart itself is typed for you.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { parseWireEvents, createSessionId, isWireEvent } from '@brinkcommerce/agentic-shopping-sdk'
|
|
114
|
+
|
|
115
|
+
const sessionId = createSessionId()
|
|
116
|
+
|
|
117
|
+
const response = await fetch('/api/chat', {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: { 'Content-Type': 'application/json' },
|
|
120
|
+
body: JSON.stringify({ prompt, sessionId, storeGroupId, market }),
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// A turn that never reached the agent comes back as JSON, not as a stream.
|
|
124
|
+
if (!response.ok || response.body === null) {
|
|
125
|
+
const { error } = await response.json().catch(() => ({ error: 'the request failed' }))
|
|
126
|
+
showError(error)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for await (const event of parseWireEvents(response.body)) {
|
|
131
|
+
if (isWireEvent(event, 'tool_start')) setStatus('Searching…')
|
|
132
|
+
if (isWireEvent(event, 'text')) appendText(event.content)
|
|
133
|
+
if (isWireEvent(event, 'products')) setProducts(event.products)
|
|
134
|
+
if (isWireEvent(event, 'suggestions')) setSuggestions(event.suggestions)
|
|
135
|
+
if (isWireEvent(event, 'done')) setStatus(null)
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Check `response.ok` before you parse. The route answers JSON on a failure, and `parseWireEvents` reads a JSON body as one line it cannot parse: no events, no error, an empty reply on screen. [What the browser gets](#what-the-browser-gets) lists the four answers.
|
|
140
|
+
|
|
141
|
+
Use `isWireEvent` rather than `switch (event.type)`. A plain switch does not narrow the type. `UnknownWireEvent` carries an index signature, so it survives every branch and `event.content` comes out as `unknown`. The guard is what lets a handler read the fields without a cast.
|
|
142
|
+
|
|
143
|
+
There are five event types:
|
|
144
|
+
|
|
145
|
+
- `tool_start` — the agent started a search. Show a status.
|
|
146
|
+
- `text` — one delta of the reply. Append it.
|
|
147
|
+
- `products` — every card found so far, not only the new ones. Replace your list. Appending duplicates every earlier card.
|
|
148
|
+
- `suggestions` — follow-up prompts to offer the shopper.
|
|
149
|
+
- `done` — the turn is over. It always arrives, including when the turn failed.
|
|
150
|
+
|
|
151
|
+
An event type this SDK version does not know is passed through rather than dropped, so a new event type reaches your handler the day it ships. `isWireEvent` returns false for it. That is why the checks above are a chain rather than an exhaustive switch.
|
|
152
|
+
|
|
153
|
+
`stream()` and `ask()` both take an `AbortSignal`, and `createChatRoute` wires the shopper's own request to it. A shopper who closes the tab mid-answer cancels the turn, instead of leaving the agent generating into nothing.
|
|
154
|
+
|
|
155
|
+
Use that signal for the shopper's patience too, and time the gap between events rather than the whole turn. A turn still writing at twenty seconds is fine to watch. A turn that has written nothing for ten is broken:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const STALL_MS = 10_000
|
|
159
|
+
const controller = new AbortController()
|
|
160
|
+
let stall = setTimeout(() => controller.abort(), STALL_MS)
|
|
161
|
+
|
|
162
|
+
// pass controller.signal to the fetch above, then:
|
|
163
|
+
for await (const event of parseWireEvents(response.body)) {
|
|
164
|
+
clearTimeout(stall)
|
|
165
|
+
stall = setTimeout(() => controller.abort(), STALL_MS)
|
|
166
|
+
// handle the event
|
|
167
|
+
}
|
|
168
|
+
clearTimeout(stall)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Aborting cancels the turn at the agent, and the loop throws, so the `try` around it is where you offer the shopper another go. A whole-turn timeout does the opposite: it cuts a reply that was three words from finishing. Streaming is what covers the wait — the first words land in a second or two, and `tool_start` gives you a status to show before them.
|
|
172
|
+
|
|
173
|
+
`collectReply` aggregates a whole stream, for when there is no progressive UI to feed:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
const { text, products, suggestions } = await collectReply(parseWireEvents(response.body!))
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Product cards
|
|
182
|
+
|
|
183
|
+
Amounts are in minor units, and each card carries its own `currencyCode`:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { formatPrice } from '@brinkcommerce/agentic-shopping-sdk'
|
|
187
|
+
|
|
188
|
+
formatPrice(card.salePriceAmount, card.currencyCode, 'sv-SE') // 59900 SEK → "599,00 kr"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The third argument is the locale. Leaving it off does not mean "the shopper's". It means the runtime's, which on a server is whatever the container was built with. The same card then renders as `599,00 kr` for one shopper and `SEK 599.00` for the next. Pass the locale of the market being shopped.
|
|
192
|
+
|
|
193
|
+
The agent writes `[product:<productId>]` after each product name. That is where the card belongs:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { interleaveProducts } from '@brinkcommerce/agentic-shopping-sdk'
|
|
197
|
+
|
|
198
|
+
const { segments, unmatched } = interleaveProducts(text, products)
|
|
199
|
+
|
|
200
|
+
segments.map((segment) =>
|
|
201
|
+
segment.type === 'text' ? <Markdown>{segment.content}</Markdown> : <Card product={segment.product} />
|
|
202
|
+
)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Every marker has a card in the same turn's `products`.** That holds on a turn that runs no search. The agent recalls a jacket it showed three turns ago, writes the marker, and the card arrives with the reply. So pass the current turn's `products` and nothing else. You do not need to keep your own registry of cards from earlier turns, and a card from an earlier turn does not show up in `unmatched`.
|
|
206
|
+
|
|
207
|
+
**Store each turn's cards on the message they arrived with.** A transcript shows earlier replies as well as the current one, and `interleaveProducts` needs the `products` that came with the text you hand it:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
type Message =
|
|
211
|
+
| { role: 'shopper'; text: string }
|
|
212
|
+
| { role: 'agent'; text: string; products: ProductCard[] }
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
One `products` field for the whole conversation is the bug to avoid. The next turn overwrites it, and every earlier reply in the transcript loses its cards.
|
|
216
|
+
|
|
217
|
+
**Use `PRODUCT_MARKER` rather than writing your own regex.** A product id is a merchant-chosen string over `[A-Za-z0-9_-]`, and hyphens are common in one. A regex of `\[product:(\w+)\]` misses every id that carries a hyphen. In a catalog where they all do, it drops every card in the reply, and it does so silently.
|
|
218
|
+
|
|
219
|
+
A card carries three ids, and only two of them are Brink's:
|
|
220
|
+
|
|
221
|
+
- `productId` is the card itself — one colourway. It is the only id a marker names. Key your cards on it.
|
|
222
|
+
- `productParentId` is Brink's product parent. Several cards share one.
|
|
223
|
+
- `productVariantId` on each variant is Brink's variant.
|
|
224
|
+
|
|
225
|
+
**Never build one of those ids out of another.** They often look related. A variant id commonly begins with the parent and a hyphen: parent `2232602`, variant `2232602-120L`. That prefix is part of the id, not a namespace you can strip to reach "the real" variant id. An id you trimmed or assembled is one Brink has never heard of. It prices nothing and adds nothing to a cart. A card whose ids all miss simply looks out of stock, so you get no error to debug. Read each id from its own field and pass it whole.
|
|
226
|
+
|
|
227
|
+
**A card sits under the paragraph that names it.** Each text segment is one whole paragraph, trimmed. The cards it names follow it, in the order the markers appear. A paragraph that holds nothing but a marker gives you no text segment.
|
|
228
|
+
|
|
229
|
+
`interleaveProducts` also closes the gap a stripped marker leaves, so `Active Tee [product:…] in navy` reads as `Active Tee in navy` with no double space. `stripMarkers(text)` does not. It removes the markers and leaves the rest as the agent wrote it.
|
|
230
|
+
|
|
231
|
+
A marker whose id has no card is stripped. `stripMarkers(text)` is the escape hatch for a UI that renders prose and cards separately.
|
|
232
|
+
|
|
233
|
+
The other two return values split the same cards two ways. `matched` is every card a marker named, in the order the markers appear. `unmatched` is every card no marker named. Together they are the turn's `products`, and each card is in exactly one of them.
|
|
234
|
+
|
|
235
|
+
**Handle `matched.length === 0`.** The agent wrote a reply that names no card, so every card is in `unmatched`. Render the prose, then every card in a shelf beneath it. That is the whole fallback.
|
|
236
|
+
|
|
237
|
+
**Do not match cards to prose by name.** A guess degrades silently instead of failing, and any word list it needs is tenant- and language-specific. A fuzzy matcher passes review, ships, and puts the wrong card under the wrong paragraph in the first language nobody on the team reads.
|
|
238
|
+
|
|
239
|
+
Both functions hide a marker the stream has only half delivered. Text arrives in deltas, so the tail of a reply spends a few frames holding `[pro` or `[product:sr-off`. Rendered as prose, that flashes a raw marker at the shopper. A tail that cannot become a marker, such as prose that simply ends in a bracket, is left alone.
|
|
240
|
+
|
|
241
|
+
One thing follows from that while a reply streams: a card stays in `unmatched` until its closing bracket lands. Render the shelf after the `done` event, not before.
|
|
242
|
+
|
|
243
|
+
**`variants[]` is what Brink sells. It is not what is in stock.** A size Brink prices nothing for never reaches the array, so `variants.map(v => v.size)` is a straight answer to "what sizes does this come in?". But a variant on that list can still be sold out. `isAvailable` and `availableSizes` are the live stock signals. Gate the buy button on those, and describe sizes from `variants[]`.
|
|
244
|
+
|
|
245
|
+
## The reply is markdown
|
|
246
|
+
|
|
247
|
+
The agent writes markdown into `text`: `**bold**` around product names, and `##` headings on longer answers. That is all it writes today, and what it writes follows its own prompt rather than this SDK's version. It writes no tables, no bullet lists and no strikethrough, so a renderer plugin for those is provisioning for output that never comes.
|
|
248
|
+
|
|
249
|
+
`interleaveProducts` returns data, not markup, so rendering is yours to do. Use a real markdown renderer rather than a regex.
|
|
250
|
+
|
|
251
|
+
**A segment never carries half a markdown construct**, so you can send each one to a renderer on its own. A segment boundary is always a blank line, and the agent opens and closes `**bold**` on one line:
|
|
252
|
+
|
|
253
|
+
```text
|
|
254
|
+
**W SPRAY GORE TEX JACKET** [product:2212103-120] — 2,800 SEK
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
The exception is a markdown block that spans a blank line itself: a loose list, or a fenced block with an empty line in it. The agent writes neither today. If it starts, that block renders as two blocks.
|
|
258
|
+
|
|
259
|
+
Handle one more thing while a reply streams: a `**` whose closing pair has not arrived yet. Render the unclosed span as plain words, so only the weight arrives late. Hiding it instead makes words appear and disappear as the shopper reads. A marker is the opposite case. Keep it hidden until it closes, because a half-written marker is not text the shopper should ever see.
|
|
260
|
+
|
|
261
|
+
## Cart context
|
|
262
|
+
|
|
263
|
+
Send the shopper's cart and the agent can answer "what goes with what I already have?". It reads the cart. It cannot change it.
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
await client.stream({
|
|
267
|
+
prompt,
|
|
268
|
+
sessionId,
|
|
269
|
+
storeGroupId,
|
|
270
|
+
market,
|
|
271
|
+
cart: {
|
|
272
|
+
items: [{ name: 'W SPRAY GORE TEX JACKET', size: 'M', quantity: 1 }],
|
|
273
|
+
totalAmount: 280000,
|
|
274
|
+
currencyCode: 'SEK',
|
|
275
|
+
},
|
|
276
|
+
})
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`createChatRoute` reads the same `cart` from the browser's request body and forwards it. A malformed cart is a 400 naming the field, not a forwarded turn.
|
|
280
|
+
|
|
281
|
+
Four things to get right:
|
|
282
|
+
|
|
283
|
+
- **`totalAmount` is minor units.** Send `3500` for a 3,500 SEK cart and the agent tells the shopper their cart is 35 kr.
|
|
284
|
+
- **`items: []` and no `cart` are different.** An empty list says the cart is empty. Omitting `cart` says the cart is invisible, and the agent answers as though the shopper owned nothing.
|
|
285
|
+
- **The agent cannot change the cart.** It will not offer to add, remove or resize anything, and it will not name your buy button. Asked to fix a wrong size, it looks the product up and shows the card.
|
|
286
|
+
- **A cart line carries no product id.** The agent searches by `name` to talk about a cart item, so send the name as it appears on the storefront. A name from somewhere else finds something else.
|
|
287
|
+
|
|
288
|
+
`CartContext` and `CartLine` are exported from both entry points, so the browser-side cart can be typed against the shape it will post.
|
|
289
|
+
|
|
290
|
+
## Adding to cart
|
|
291
|
+
|
|
292
|
+
The cart is Brink's, not the agent's, so it is out of scope here. Use [`@brinkcommerce/shopper-sdk`](https://github.com/brinkcommerce/shopper-sdk) for it.
|
|
293
|
+
|
|
294
|
+
Get one thing right at the handoff: **`productVariantId` is the only id `POST /sessions/items` accepts.** `productParentId` is for the PDP and `productId` is for variant lookups. Neither is ever a cart id. Falling back to one when no variant is selected is a guaranteed rejected add. Gate the add on a chosen variant instead.
|
|
295
|
+
|
|
296
|
+
Wire the cart up properly and a shopper can go from question to purchase without leaving the conversation.
|
|
297
|
+
|
|
298
|
+
## Store context
|
|
299
|
+
|
|
300
|
+
`storeGroupId` and `market` are the store group and market your storefront is already rendering. Send both on every turn. There is no default and no fallback, because a storefront that spans several store groups knows which one it is on before the chat opens.
|
|
301
|
+
|
|
302
|
+
- **`storeGroupId`** is an opaque Brink id. The merchant picks it, and the agent matches it exactly, case included. Pass it whole and do not normalise it. It looks like `sail-racing-se`.
|
|
303
|
+
- **`market`** is the store market's two-letter country code, which the agent upper-cases. `SE`, not `sv-SE` and not `Sweden`.
|
|
304
|
+
|
|
305
|
+
Two things go wrong here. They are different, and both are quiet.
|
|
306
|
+
|
|
307
|
+
A store group the agent does not serve, or no store context at all, comes back as one apologetic sentence at HTTP 200. The agent runs no search. See [Error handling](#error-handling).
|
|
308
|
+
|
|
309
|
+
A store group that is real and served, but has no prices configured at Brink, is the one to watch for. Every price lookup 404s, the search comes back with nothing, and the agent says the catalog does not carry what the shopper asked for. That reads exactly like an empty catalog, so check the store context before you debug your own code.
|
|
310
|
+
|
|
311
|
+
## Sessions
|
|
312
|
+
|
|
313
|
+
`createSessionId()` makes one id per conversation. It meets the agent's 33-character minimum.
|
|
314
|
+
|
|
315
|
+
It calls `crypto.randomUUID()`, which a browser only provides in a secure context: `https://…` or `http://localhost`. Open the same dev server on a LAN address to test on a phone — `http://192.168.1.20:3000` — and `crypto.randomUUID` is undefined, so the call throws before the first turn. Make the id on the server and send it to the page, or test on localhost.
|
|
316
|
+
|
|
317
|
+
The first turn **pins the session to its store group and market**. A later turn on the same id naming a different store is refused, and the refusal arrives as ordinary reply text. So start a new session id when the shopper switches store or market. Do not reuse the old one.
|
|
318
|
+
|
|
319
|
+
**The id is yours to keep.** Calling `createSessionId()` on mount starts a new conversation on every mount. A shopper who opens a product page and comes back has lost the thread: the agent still holds the history, the shopper's screen does not.
|
|
320
|
+
|
|
321
|
+
Persist the id, and the transcript with it, under the key your app already uses for per-tab state. `sessionStorage` is the natural fit: one tab, one conversation, gone when the tab is.
|
|
322
|
+
|
|
323
|
+
Drop both when the shopper switches store group or market. The pinning rule above says why: the first turn pinned that id, and the refusal that follows is ordinary reply text. A persisted id makes that failure survive a refresh, which is what makes it hard to see.
|
|
324
|
+
|
|
325
|
+
## Error handling
|
|
326
|
+
|
|
327
|
+
`AgentError` covers transport failures: a non-2xx from the agent, or a failed token mint. It carries `statusCode` and `body`. A failed mint carries the status only, because an authorization error's description can echo the client secret back.
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
import { AgentError } from '@brinkcommerce/agentic-shopping-sdk'
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
await client.ask({ prompt, sessionId, storeGroupId, market })
|
|
334
|
+
} catch (e) {
|
|
335
|
+
if (e instanceof AgentError) console.error(e.statusCode, e.body)
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
A cached token refused with a 401 is re-minted and the turn is retried once. A token revoked or rotated mid-life then costs one round trip, rather than failing every turn until it expires.
|
|
340
|
+
|
|
341
|
+
`createChatRoute` catches `AgentError` and answers `502` with `{ "error": "the agent answered <status>" }`. It does not forward the upstream body, because a failed mint's `error_description` can quote the client secret back. A browser reading `await response.text()` on a failure therefore finds that JSON, rather than whatever error page the framework would otherwise have rendered.
|
|
342
|
+
|
|
343
|
+
### What the browser gets
|
|
344
|
+
|
|
345
|
+
`createChatRoute` answers one of four ways, and only the first carries a stream:
|
|
346
|
+
|
|
347
|
+
| Status | Body | When |
|
|
348
|
+
| --- | --- | --- |
|
|
349
|
+
| `200` | NDJSON | The agent answered. A failed turn arrives this way too. |
|
|
350
|
+
| `400` | `{ "error": "market is required" }` | A field is missing, or the cart is malformed. The message names the field. This one is your bug, not the shopper's. |
|
|
351
|
+
| `502` | `{ "error": "the agent answered 503" }` | The agent or the token mint failed. Worth retrying. |
|
|
352
|
+
| `499` | empty | The shopper cancelled the request. Render nothing. |
|
|
353
|
+
|
|
354
|
+
So a browser has two failures to handle, and they look nothing alike. A 400 or a 502 is a JSON body with an `error` string, and there is no stream to read. A failed turn is an ordinary 200 stream carrying one apologetic sentence.
|
|
355
|
+
|
|
356
|
+
**A failed turn is not an HTTP failure.** This is the one that surprises people. Four cases arrive as a `text` + `done` pair at **HTTP 200**: a turn that broke mid-answer, a request naming no store, a request naming a store the agent does not serve, and a store conflict. The protocol has no error channel. `response.ok` sees nothing but success, and the shopper gets one apologetic sentence where a reply should be.
|
|
357
|
+
|
|
358
|
+
The store cases are your bug rather than the shopper's, and this SDK makes them unrepresentable. `storeGroupId` and `market` are both required in `AskInput`, and `createChatRoute` rejects a payload missing either with a 400 rather than forwarding a request that would come back as one sentence.
|
|
359
|
+
|
|
360
|
+
`ask()` is the non-streaming branch, and it **never carries product cards**. The agent writes cards onto the stream only. Use `stream()` if you want them.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
var __typeError = (msg) => {
|
|
2
|
+
throw TypeError(msg);
|
|
3
|
+
};
|
|
4
|
+
var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
|
|
5
|
+
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
|
|
6
|
+
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
|
|
7
|
+
var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
|
|
8
|
+
var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
|
|
9
|
+
|
|
10
|
+
// src/errors.ts
|
|
11
|
+
var AgentError = class extends Error {
|
|
12
|
+
constructor(statusCode, body) {
|
|
13
|
+
super(`Agent error: ${statusCode}`);
|
|
14
|
+
this.statusCode = statusCode;
|
|
15
|
+
this.body = body;
|
|
16
|
+
this.name = "AgentError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
__privateGet,
|
|
22
|
+
__privateAdd,
|
|
23
|
+
__privateSet,
|
|
24
|
+
__privateMethod,
|
|
25
|
+
AgentError
|
|
26
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent's wire contract, hand-written. There is no OpenAPI spec to generate from, so a change to these
|
|
3
|
+
* shapes on the agent side will not fail a build here.
|
|
4
|
+
*/
|
|
5
|
+
/** One variant of a product — one size of one colourway. See `ProductCard.variants` for what its presence means. */
|
|
6
|
+
interface ProductCardVariant {
|
|
7
|
+
/**
|
|
8
|
+
* Brink's variant id, verbatim, and the only id `POST /sessions/items` accepts. A merchant-chosen string
|
|
9
|
+
* over `[A-Za-z0-9_-]`.
|
|
10
|
+
*
|
|
11
|
+
* It often begins with `productParentId` and a hyphen — parent `2232602`, variant `2232602-120L`. That
|
|
12
|
+
* prefix is **part of the id**, not a namespace in front of it: strip it and you have an id Brink has
|
|
13
|
+
* never heard of, which adds nothing to a cart and prices nothing. Pass it whole, always.
|
|
14
|
+
*/
|
|
15
|
+
productVariantId: string;
|
|
16
|
+
color: string;
|
|
17
|
+
size: string;
|
|
18
|
+
imageUrl?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* One product card, as carried by a `products` event. Three ids, and only two of them are Brink's:
|
|
22
|
+
* `productParentId` is the product parent, `productVariantId` on each variant is the only id that adds to
|
|
23
|
+
* cart, and `productId` is the card itself — one colourway of that parent. Several cards share one
|
|
24
|
+
* `productParentId`.
|
|
25
|
+
*
|
|
26
|
+
* Never compute one of the three from another. They can look related — a card id and a variant id may both
|
|
27
|
+
* begin with the parent — but that is one catalog's naming, not a rule the contract makes, and an id you
|
|
28
|
+
* assembled yourself is one Brink cannot answer for. Read each from its own field.
|
|
29
|
+
*/
|
|
30
|
+
interface ProductCard {
|
|
31
|
+
/**
|
|
32
|
+
* The card's own id, and what a `[product:…]` marker names. A merchant-chosen string over `[A-Za-z0-9_-]`.
|
|
33
|
+
* Use it to key a card, to dedupe, and to ask the agent a follow-up about this product.
|
|
34
|
+
*/
|
|
35
|
+
productId: string;
|
|
36
|
+
/** Brink's product parent, over the same alphabet. For the PDP; never a cart id, and never a marker id. */
|
|
37
|
+
productParentId: string;
|
|
38
|
+
name: string;
|
|
39
|
+
description: string;
|
|
40
|
+
brand: string;
|
|
41
|
+
category: string;
|
|
42
|
+
/** Lowercased by the agent, so it matches a lowercase facet list without a second normalisation. */
|
|
43
|
+
gender: string;
|
|
44
|
+
imageUrl: string;
|
|
45
|
+
/**
|
|
46
|
+
* Minor units, and the lowest across this card's variants. Divide by 100 to display, in this product's own
|
|
47
|
+
* `currencyCode`. Where a card's sizes are priced differently this is a "from" price, so a PDP that has a
|
|
48
|
+
* size selected should show that variant's own price rather than this one.
|
|
49
|
+
*/
|
|
50
|
+
basePriceAmount: number;
|
|
51
|
+
/** Minor units, the same way. Equals `basePriceAmount` when there is no discount. */
|
|
52
|
+
salePriceAmount: number;
|
|
53
|
+
currencyCode: string;
|
|
54
|
+
isAvailable?: boolean;
|
|
55
|
+
availableSizes?: string[];
|
|
56
|
+
/**
|
|
57
|
+
* The variants of this card that Brink sells, and only those — a size Brink prices nothing for is left
|
|
58
|
+
* off, so `variants.map((variant) => variant.size)` is a straight answer to "what sizes does it come in?".
|
|
59
|
+
*
|
|
60
|
+
* Sellable is not in stock. A variant here can be sold out. `isAvailable` and `availableSizes` are the
|
|
61
|
+
* live stock signals, and both cover this card's variants rather than every variant of the parent.
|
|
62
|
+
*/
|
|
63
|
+
variants: ProductCardVariant[];
|
|
64
|
+
}
|
|
65
|
+
/** The five event types the agent streams. */
|
|
66
|
+
type WireEvent = {
|
|
67
|
+
type: 'tool_start';
|
|
68
|
+
tool: string;
|
|
69
|
+
} | {
|
|
70
|
+
type: 'text';
|
|
71
|
+
content: string;
|
|
72
|
+
} | {
|
|
73
|
+
type: 'products';
|
|
74
|
+
products: ProductCard[];
|
|
75
|
+
} | {
|
|
76
|
+
type: 'suggestions';
|
|
77
|
+
suggestions: string[];
|
|
78
|
+
} | {
|
|
79
|
+
type: 'done';
|
|
80
|
+
};
|
|
81
|
+
/** An event type this SDK version does not know. The backend adds types without a major bump. */
|
|
82
|
+
interface UnknownWireEvent {
|
|
83
|
+
type: string;
|
|
84
|
+
[key: string]: unknown;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* One line of the shopper's cart. It carries no product id, so the agent searches by `name` to talk about a
|
|
88
|
+
* cart item — send the name as it appears on the storefront, or the search comes back with something else.
|
|
89
|
+
*/
|
|
90
|
+
interface CartLine {
|
|
91
|
+
name: string;
|
|
92
|
+
size?: string;
|
|
93
|
+
/** At least 1. Two of one size is one line with `quantity: 2`. */
|
|
94
|
+
quantity: number;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The cart the shopper already owns, as the agent sees it. The agent reads it and cannot change it: it will
|
|
98
|
+
* not offer to add, remove or resize anything, because every cart change is the shopper's to make in the shop.
|
|
99
|
+
*/
|
|
100
|
+
interface CartContext {
|
|
101
|
+
/** An empty list says the cart is empty. Omitting the whole `cart` says it is invisible. */
|
|
102
|
+
items: CartLine[];
|
|
103
|
+
/** Minor units, like every amount on the wire. `350000` is a 3,500 SEK cart. */
|
|
104
|
+
totalAmount: number;
|
|
105
|
+
currencyCode: string;
|
|
106
|
+
}
|
|
107
|
+
/** The store a turn's prices are scoped to. There is no default anywhere — both fields are required. */
|
|
108
|
+
interface StoreContext {
|
|
109
|
+
storeGroupId: string;
|
|
110
|
+
/** Two-letter country code. */
|
|
111
|
+
market: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A transport failure: a non-2xx from the agent, or from the token mint.
|
|
116
|
+
*
|
|
117
|
+
* It is not how a failed *turn* is reported. A turn that failed, a request naming no store and a store
|
|
118
|
+
* conflict all arrive as a `text` + `done` pair at HTTP 200 — the protocol has no error channel.
|
|
119
|
+
*/
|
|
120
|
+
declare class AgentError extends Error {
|
|
121
|
+
readonly statusCode: number;
|
|
122
|
+
readonly body: unknown;
|
|
123
|
+
constructor(statusCode: number, body: unknown);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export { AgentError as A, type CartContext as C, type ProductCard as P, type StoreContext as S, type UnknownWireEvent as U, type WireEvent as W, type CartLine as a, type ProductCardVariant as b };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent's wire contract, hand-written. There is no OpenAPI spec to generate from, so a change to these
|
|
3
|
+
* shapes on the agent side will not fail a build here.
|
|
4
|
+
*/
|
|
5
|
+
/** One variant of a product — one size of one colourway. See `ProductCard.variants` for what its presence means. */
|
|
6
|
+
interface ProductCardVariant {
|
|
7
|
+
/**
|
|
8
|
+
* Brink's variant id, verbatim, and the only id `POST /sessions/items` accepts. A merchant-chosen string
|
|
9
|
+
* over `[A-Za-z0-9_-]`.
|
|
10
|
+
*
|
|
11
|
+
* It often begins with `productParentId` and a hyphen — parent `2232602`, variant `2232602-120L`. That
|
|
12
|
+
* prefix is **part of the id**, not a namespace in front of it: strip it and you have an id Brink has
|
|
13
|
+
* never heard of, which adds nothing to a cart and prices nothing. Pass it whole, always.
|
|
14
|
+
*/
|
|
15
|
+
productVariantId: string;
|
|
16
|
+
color: string;
|
|
17
|
+
size: string;
|
|
18
|
+
imageUrl?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* One product card, as carried by a `products` event. Three ids, and only two of them are Brink's:
|
|
22
|
+
* `productParentId` is the product parent, `productVariantId` on each variant is the only id that adds to
|
|
23
|
+
* cart, and `productId` is the card itself — one colourway of that parent. Several cards share one
|
|
24
|
+
* `productParentId`.
|
|
25
|
+
*
|
|
26
|
+
* Never compute one of the three from another. They can look related — a card id and a variant id may both
|
|
27
|
+
* begin with the parent — but that is one catalog's naming, not a rule the contract makes, and an id you
|
|
28
|
+
* assembled yourself is one Brink cannot answer for. Read each from its own field.
|
|
29
|
+
*/
|
|
30
|
+
interface ProductCard {
|
|
31
|
+
/**
|
|
32
|
+
* The card's own id, and what a `[product:…]` marker names. A merchant-chosen string over `[A-Za-z0-9_-]`.
|
|
33
|
+
* Use it to key a card, to dedupe, and to ask the agent a follow-up about this product.
|
|
34
|
+
*/
|
|
35
|
+
productId: string;
|
|
36
|
+
/** Brink's product parent, over the same alphabet. For the PDP; never a cart id, and never a marker id. */
|
|
37
|
+
productParentId: string;
|
|
38
|
+
name: string;
|
|
39
|
+
description: string;
|
|
40
|
+
brand: string;
|
|
41
|
+
category: string;
|
|
42
|
+
/** Lowercased by the agent, so it matches a lowercase facet list without a second normalisation. */
|
|
43
|
+
gender: string;
|
|
44
|
+
imageUrl: string;
|
|
45
|
+
/**
|
|
46
|
+
* Minor units, and the lowest across this card's variants. Divide by 100 to display, in this product's own
|
|
47
|
+
* `currencyCode`. Where a card's sizes are priced differently this is a "from" price, so a PDP that has a
|
|
48
|
+
* size selected should show that variant's own price rather than this one.
|
|
49
|
+
*/
|
|
50
|
+
basePriceAmount: number;
|
|
51
|
+
/** Minor units, the same way. Equals `basePriceAmount` when there is no discount. */
|
|
52
|
+
salePriceAmount: number;
|
|
53
|
+
currencyCode: string;
|
|
54
|
+
isAvailable?: boolean;
|
|
55
|
+
availableSizes?: string[];
|
|
56
|
+
/**
|
|
57
|
+
* The variants of this card that Brink sells, and only those — a size Brink prices nothing for is left
|
|
58
|
+
* off, so `variants.map((variant) => variant.size)` is a straight answer to "what sizes does it come in?".
|
|
59
|
+
*
|
|
60
|
+
* Sellable is not in stock. A variant here can be sold out. `isAvailable` and `availableSizes` are the
|
|
61
|
+
* live stock signals, and both cover this card's variants rather than every variant of the parent.
|
|
62
|
+
*/
|
|
63
|
+
variants: ProductCardVariant[];
|
|
64
|
+
}
|
|
65
|
+
/** The five event types the agent streams. */
|
|
66
|
+
type WireEvent = {
|
|
67
|
+
type: 'tool_start';
|
|
68
|
+
tool: string;
|
|
69
|
+
} | {
|
|
70
|
+
type: 'text';
|
|
71
|
+
content: string;
|
|
72
|
+
} | {
|
|
73
|
+
type: 'products';
|
|
74
|
+
products: ProductCard[];
|
|
75
|
+
} | {
|
|
76
|
+
type: 'suggestions';
|
|
77
|
+
suggestions: string[];
|
|
78
|
+
} | {
|
|
79
|
+
type: 'done';
|
|
80
|
+
};
|
|
81
|
+
/** An event type this SDK version does not know. The backend adds types without a major bump. */
|
|
82
|
+
interface UnknownWireEvent {
|
|
83
|
+
type: string;
|
|
84
|
+
[key: string]: unknown;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* One line of the shopper's cart. It carries no product id, so the agent searches by `name` to talk about a
|
|
88
|
+
* cart item — send the name as it appears on the storefront, or the search comes back with something else.
|
|
89
|
+
*/
|
|
90
|
+
interface CartLine {
|
|
91
|
+
name: string;
|
|
92
|
+
size?: string;
|
|
93
|
+
/** At least 1. Two of one size is one line with `quantity: 2`. */
|
|
94
|
+
quantity: number;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The cart the shopper already owns, as the agent sees it. The agent reads it and cannot change it: it will
|
|
98
|
+
* not offer to add, remove or resize anything, because every cart change is the shopper's to make in the shop.
|
|
99
|
+
*/
|
|
100
|
+
interface CartContext {
|
|
101
|
+
/** An empty list says the cart is empty. Omitting the whole `cart` says it is invisible. */
|
|
102
|
+
items: CartLine[];
|
|
103
|
+
/** Minor units, like every amount on the wire. `350000` is a 3,500 SEK cart. */
|
|
104
|
+
totalAmount: number;
|
|
105
|
+
currencyCode: string;
|
|
106
|
+
}
|
|
107
|
+
/** The store a turn's prices are scoped to. There is no default anywhere — both fields are required. */
|
|
108
|
+
interface StoreContext {
|
|
109
|
+
storeGroupId: string;
|
|
110
|
+
/** Two-letter country code. */
|
|
111
|
+
market: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A transport failure: a non-2xx from the agent, or from the token mint.
|
|
116
|
+
*
|
|
117
|
+
* It is not how a failed *turn* is reported. A turn that failed, a request naming no store and a store
|
|
118
|
+
* conflict all arrive as a `text` + `done` pair at HTTP 200 — the protocol has no error channel.
|
|
119
|
+
*/
|
|
120
|
+
declare class AgentError extends Error {
|
|
121
|
+
readonly statusCode: number;
|
|
122
|
+
readonly body: unknown;
|
|
123
|
+
constructor(statusCode: number, body: unknown);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export { AgentError as A, type CartContext as C, type ProductCard as P, type StoreContext as S, type UnknownWireEvent as U, type WireEvent as W, type CartLine as a, type ProductCardVariant as b };
|