Customer assistant
Access via client.assistant. Requires the storefront:assistant scope on the storefront key.
The shopper can be anonymous or signed in. When a customer token is set on the client it is sent along, and the assistant can then look up that customer's orders without asking for an order number.
A conversation is carried by a secret token returned when it is created. Keep it next to the conversation id (session storage is a good home) and pass both to every later call. Without the token the conversation cannot be read or continued, and a wrong token answers 404 CONVERSATION_NOT_FOUND.
On Nuxt, <AvvyrAssistant /> from the layer does all of this for you. See Customer assistant.
Config
const config = await client.assistant.getConfig({ locale: 'sv' })
// → {
// enabled, name, greeting,
// capabilities: { products, orderStatus, returns, claims, handover },
// access: 'everyone' | 'signedIn',
// suggestedQuestions: string[], // for the locale, plus those set for every language
// attachments: { enabled },
// }
Show the chat only when enabled is true. greeting (may be empty) is meant as the first message in an empty chat. suggestedQuestions are one-tap questions for an empty chat, in the language passed as locale.
With access: 'signedIn' only signed-in customers may chat: creating a conversation, sending and uploading answer 401 SIGN_IN_REQUIRED without a customer token on the client. Show a sign-in prompt instead of the composer.
Start a conversation
const { conversationId, token, conversation } = await client.assistant.createConversation({
locale: 'sv', // optional
pageUrl: window.location.href, // optional, absolute http(s) URL
context: { productSku: 'CHAIR-01', pageType: 'product' }, // optional
})
Create it when the shopper sends the first message, not when the chat opens, so that opening and closing creates nothing. Answers 403 ASSISTANT_DISABLED when the shop has the assistant off and 401 SIGN_IN_REQUIRED as above.
Context
context tells the assistant where the shopper is, so "what are the measurements of this one?" works on a product page. pageType is lowercase letters and dashes (product, category, cart, checkout); unknown or malformed values are dropped. Pass the current context with every message too (below): it follows the shopper when they move to another page.
Send a message and stream the answer
const controller = new AbortController()
const result = await client.assistant.sendMessage(conversationId, token, 'Where is my order?', {
signal: controller.signal,
context: { productSku: 'CHAIR-01', pageType: 'product' }, // optional
onEvent(event) {
switch (event.type) {
case 'delta': append(event.content); break // a piece of the answer
case 'activity': showStatus(event.kind); break // a tool is running
case 'card': showCard(event.card); break // products, an order, tracking
case 'proposal': showProposal(event.proposal); break // a return to confirm
case 'handover': /* a person takes over */ break
case 'queued': /* a person answers; the message was saved */ break
case 'done': /* the turn is over: event.status */ break
case 'error': /* event.errorCode, event.message */ break
}
},
})
// → { status, error, aborted }
The answer is streamed as Server-Sent Events over a fetch POST, so it works in browsers and Node 18+. The client's request timeout covers only the wait for the response to start; once the answer streams it runs until the server ends it or you abort.
- Messages are at most 1000 characters (
400 MESSAGE_TOO_LONG). - Errors before the stream starts (validation, a wrong token, rate limits) reject with
StorefrontApiError. A failure during the turn arrives as anerrorevent and inresult.error. controller.abort()stops the answer. The promise resolves withaborted: true; the text received so far is yours to keep.activity.kindis one ofknowledge,products,verify,order,returns,handover,contact,working. Map it to a short line in your own language, such as "Fetching your order".- Text after an
activitybelongs to a new message: the server stores the text before and after a tool call as separate messages. Acardbelongs to the message of the same round (the text just before the tool, or a message of its own when there was none), and text after a card is a new message. - Streamed text has no message ids. Read the conversation afterwards when you need them.
Read the conversation
const conversation = await client.assistant.getConversation(conversationId, token)
// → { id, status, messages, proposals, verifiedOrderNumber }
// Only messages newer than a time (the newest message's createdAt)
const fresh = await client.assistant.getConversation(conversationId, token, { after: lastCreatedAt })
Messages are { id, role: 'user' | 'assistant' | 'staff', content, author, cards?, attachments?, createdAt }. author is the staff member's name on staff messages. content can be empty: an assistant message may carry only cards, and a shopper's message only photos.
status says who answers:
| Status | Meaning |
|---|---|
bot | The assistant answers |
waiting | Handed over, waiting for a person |
human | A person from the shop answers |
closed | Ended by the shop, no new messages |
While the status is waiting or human, poll with after (every 4 seconds while the chat is open) to receive the staff's replies.
Cards
The assistant shows products, orders and deliveries as cards instead of writing prices and tracking numbers in its text. The server builds them from the shop's own data (with the same price visibility rules as the storefront); the model only picks what to show. They arrive as card events while streaming and in message.cards when the conversation is read. Switch on type and skip types you do not know.
type | Fields |
|---|---|
products | items[{ sku, name, imageUrl, price, listPrice, currency, url, rating }]. price is null when the shop shows prices only to signed-in customers; listPrice is set only when discounted; url comes from the shop's product URL template, or null. At most 6. |
order | orderNumber, orderDate, paymentStatus, shipmentStatus, total, currency, lines[{ name, sku, quantity, returned, imageUrl }] |
tracking | orderNumber, shipmentStatus, shipments[{ carrier, trackingNumber, trackingUrl, shippedDate, status }], deliveries[{ status, deliveryDate, carrier }]. trackingUrl is always http(s) when set. |
return | returnNumber, returnType: 'return' | 'claim', lines[{ name, quantity }]. Added to the assistant's reply after a confirmed proposal. |
import type { AssistantCard } from '@avvyr/storefront-sdk'
function render(card: AssistantCard) {
switch (card.type) {
case 'products': return card.items.map((p) => p.name)
case 'tracking': return card.shipments.map((s) => s.trackingUrl)
// ...
}
}
Photos
When config.attachments.enabled is true the shopper may attach photos, for example of a damaged item. Upload them first, then send the message with their ids:
const input = document.querySelector<HTMLInputElement>('input[type=file]')!
const { attachments } = await client.assistant.uploadAttachments(conversationId, token, [...input.files!])
// → [{ id, url, thumbnailUrl }]
await client.assistant.sendMessage(conversationId, token, '', {
attachmentIds: attachments.map((a) => a.id),
onEvent,
})
- jpeg, png or webp, at most 5 MB each, 3 per upload and per message, 6 per conversation (
422 UNSUPPORTED_FILE_TYPE,FILE_TOO_LARGE,TOO_MANY_ATTACHMENTS;403 ATTACHMENTS_DISABLEDwhen the shop has photos off). Check type and size before uploading to give the shopper a quicker answer. filesareFiles or plainBlobs (Node 18+ too). A blob without a name is sent asphoto-1.jpgand so on.- With photos the message text may be empty. Without
attachmentIds, the message takes every photo uploaded since the last message. - The server re-encodes the photos to webp without metadata. They show on the shopper's message as
attachments[{ id, url, thumbnailUrl }]. If the shopper approves a claim, the conversation's photos move to the return when the shop's return settings take attachments. - The upload is a normal request and has the client's timeout (10 seconds by default). Raise
timeoutwhen creating the client if your shoppers upload large photos on slow connections.
Talk to a human
const conversation = await client.assistant.requestHandover(conversationId, token, {
name: 'Anna', // optional
email: 'anna@example.com', // optional, lets the shop answer by e-mail
message: 'I need help with my delivery', // optional
})
Available when config.capabilities.handover is true (otherwise 403 HANDOVER_DISABLED). Calling it again while the conversation is already waiting saves the contact details and message without a second handover.
Return proposals
The assistant never creates a return on its own. It prepares a proposal and the shopper confirms it:
// proposal.summary → { orderNumber, returnType: 'return' | 'claim', lines[{ name, quantity, reason }], claimTerms, customerMessage }
await client.assistant.confirmProposal(conversationId, token, proposal.id, {
termsAccepted: true, // required when summary.claimTerms is set and the shopper ticked them
})
// → { proposal, reply, conversation }
await client.assistant.rejectProposal(conversationId, token, proposal.id)
// → the proposal, status 'rejected'
status is pending, done, failed, rejected or expired. A pending proposal expires at expiresAt (30 minutes), and a newer proposal replaces older pending ones. A confirmed proposal's result.returnNumber is the return's number. Confirming twice, or after expiry, answers 409 PROPOSAL_NOT_PENDING / PROPOSAL_EXPIRED.
Reading other streams
The SSE reader is exported for any streamed endpoint:
import { readSseStream, createSseReader } from '@avvyr/storefront-sdk'
await readSseStream(response.body, (data) => console.log(JSON.parse(data)))