About this API
Kettle & Loom is a fictional homeware shop built to show a product-finding concierge at work. Nothing ships and no payment is taken. These are the endpoints the storefront itself uses: the streamed concierge, the cart, order status, a health check and the demo admin sign-in. They are public, rate limited per address, and every JSON error uses one envelope. Cart changes are made by the storefront (server actions) and by the concierge, not by a public write endpoint.
Errors from every JSON endpoint look the same: { "error": { "code", "message", "requestId" } }, plus retryAfterSeconds and a Retry-After header when waiting helps. Branch on code, never on message. See Error. Every response carries an X-Request-Id header, and the JSON endpoints send Cache-Control: no-store.
Reference
Concierge
Ask the shop a question and get a streamed, tool-grounded answer.
POST /api/concierge
Ask the concierge
Sends one shopper message and streams the answer as a Server-Sent Events response in the AI SDK UI message stream format. The concierge calls tools (search, product, size guide, add to cart, cart, order status) and the results arrive as typed data parts alongside the text. Messages are limited to 500 characters, conversations to 60 messages and each address to 20 messages per 10 minutes. A global daily token budget applies: when it is spent the endpoint answers 503 budget_exhausted with the time it resets. The first call sets the kl_cart cookie (httpOnly) that ties the conversation and the cart to one browser; start a conversation by omitting conversationId and continue it by sending the id from the data-meta part.
Request body
application/json
| Field | Type | Required | Notes |
|---|---|---|---|
message | string | yes | |
conversationId | string (uuid) | no |
Example
{
"message": "A dutch oven under £120 that works on induction"
}Stream parts
| Part | Meaning |
|---|---|
start | The stream opens. Carries the message id. |
data-activity | Transient. What the concierge is doing right now, such as "Searching the catalogue". Never stored. |
data-products | Product cards from a search: the products, which variants matched, and what was searched for. Includes a note when the shop does not sell the thing. |
data-specs | A size or spec table for one product, from the catalogue. |
data-cart | The outcome of an add to cart: added, needs_confirmation (quantities above 3), or refused, with the cart afterwards. |
data-order | An order card: status and timeline. |
text-start | The answer text begins. |
text-delta | A piece of the answer text. Concatenate the deltas. |
text-end | The answer text is complete. |
data-spec-source | The footnote "from the product spec", built by the server from the spec values the answer used. Only when the answer stated a spec. |
data-meta | Conversation id, message id, provider, model, token counts, latency, outcome and prompt version. Send the conversation id back to continue the conversation. |
error | The answer failed after the stream began. The text is safe to show. |
finish | The stream is complete. The connection then sends [DONE] and closes. |
Responses
200 The answer, streamed. Parts are listed in x-stream-parts.
Set-Cookiekl_cart: the cart cookie, set when the caller had none.
text/event-stream
Example
data: {"type":"start","messageId":"m1"}
data: {"type":"data-activity","data":{"tool":"search_products","label":"Searching the catalogue"},"transient":true}
data: {"type":"data-products","data":{"products":[{"id":"cw-ember-dutch-oven","name":"Ember Dutch Oven","price":"£99–£149"}],"query":"dutch oven, up to £120, induction-safe"}}
data: {"type":"text-start","id":"t1"}
data: {"type":"text-delta","id":"t1","delta":"Best match: the Ember Dutch Oven, "}
data: {"type":"text-delta","id":"t1","delta":"which works on induction."}
data: {"type":"text-end","id":"t1"}
data: {"type":"data-meta","data":{"conversationId":"6c1a0c1e-5f0e-4d9e-9a57-2f1d3c6d9b10","outcome":"answered"}}
data: {"type":"finish"}
data: [DONE]
400 The body is not JSON, or the message is empty, too long or malformed (invalid_request).
application/json Error
409 The conversation has reached its message limit (conversation_full). Start a new one.
application/json Error
413 The request body is too large (invalid_request).
application/json Error
429 Too many messages from this address (rate_limited).
Retry-AfterSeconds to wait before trying again.
application/json Error
503 The daily token budget is spent (budget_exhausted, with resetsAt) or no model could answer (upstream_unavailable).
Retry-AfterSeconds to wait before trying again.
application/json Error
Cart
Read the cookie-bound cart.
GET /api/cart
Read the cart
The cart for the kl_cart cookie, priced against live stock. A caller with no cookie gets an empty cart with a null id. Amounts are pence in GBP.
Responses
200 The cart.
application/json Cart
Example
{
"id": null,
"lines": [],
"itemCount": 0,
"subtotalPence": 0,
"shippingPence": 0,
"totalPence": 0,
"freeShippingGapPence": 0
}503 The cart could not be read (upstream_unavailable).
application/json Error
Orders
Look up an order by number.
GET /api/orders/{number}
Look up an order
Status, items and timeline for one order. The order number is the only credential, so lookups are limited to 30 per 10 minutes per address and the response never says who the order is for. Try KL-2041 (on its way).
Parameters
| Name | In | Required | Notes |
|---|---|---|---|
number | path | yes | An order number such as KL-2041. Case does not matter. |
Responses
200 The order.
application/json Order
Example
{
"number": "KL-2041",
"status": "shipped",
"placedAt": "2026-10-19T09:12:00.000Z",
"carrier": "Northline Couriers",
"trackingRef": "NL4417209",
"etaDate": "2026-10-23",
"demo": false,
"items": [
{
"productSlug": "ember-dutch-oven",
"name": "Ember Dutch Oven",
"variantLabel": "24 cm · Forest",
"sku": "K001-24-FOR",
"unitPence": 11900,
"qty": 1
}
],
"currency": "GBP",
"subtotalPence": 11900,
"shippingPence": 0,
"totalPence": 11900,
"timeline": [
{
"key": "placed",
"title": "Order placed",
"detail": null,
"location": null,
"at": "2026-10-19T09:12:00.000Z",
"state": "done"
},
{
"key": "shipped",
"title": "Collected by courier",
"detail": null,
"location": "Leeds depot",
"at": "2026-10-20T14:30:00.000Z",
"state": "current"
},
{
"key": "out_for_delivery",
"title": "Out for delivery",
"detail": null,
"location": null,
"at": null,
"state": "upcoming"
},
{
"key": "delivered",
"title": "Delivered",
"detail": null,
"location": null,
"at": null,
"state": "upcoming"
}
]
}400 Not an order number (invalid_request).
application/json Error
404 No such order (not_found).
application/json Error
429 Too many lookups from this address (rate_limited).
Retry-AfterSeconds to wait before trying again.
application/json Error
503 The order could not be read (upstream_unavailable).
application/json Error
Platform
Health.
GET /api/health
Health check
Liveness plus one catalogue query. 200 when the database answers, 503 when it does not.
Responses
200 Healthy.
application/json Health
Example
{
"status": "ok",
"catalogue": {
"source": "postgres",
"products": 80,
"variants": 332
},
"latencyMs": 41
}503 The database cannot be reached.
application/json Degraded
Example
{
"status": "degraded",
"catalogue": null
}Admin
The demo admin. One shared password; no accounts.
POST /api/admin/login
Sign in to the demo admin
Checks the shared demo password and sets the kl_admin session cookie (httpOnly, SameSite=Strict, scoped to /admin, 8 hours). Every attempt counts against 5 per 10 minutes per address, right or wrong. Cross-site posts are refused. The admin page posts a form here and follows the 303; send JSON to get JSON. While ADMIN_PASSWORD is unset on the deployment the endpoint answers 404.
Request body
application/json AdminLogin
| Field | Type | Required | Notes |
|---|---|---|---|
password | string | yes | The shared demo password. |
Example
{
"password": "the demo password"
}application/x-www-form-urlencoded AdminLogin
| Field | Type | Required | Notes |
|---|---|---|---|
password | string | yes | The shared demo password. |
Responses
200 Signed in (JSON request). The session cookie is set.
Set-Cookiekl_admin: signed, httpOnly, SameSite=Strict, Path=/admin.
application/json AdminLoginResult
Example
{
"ok": true,
"expiresInSeconds": 28800
}303 Form request: redirects to /admin/concierge, with the session cookie when the password was right, or with ?login=wrong, ?login=missing or ?login=limited when it was not.
LocationWhere to go next.
400 The body is missing the password (invalid_request).
application/json Error
401 Wrong password (unauthorized).
application/json Error
403 The request came from another site (forbidden).
application/json Error
404 The admin is switched off on this deployment (not_found).
application/json Error
413 The request body is too large (invalid_request).
application/json Error
429 Too many attempts from this address (rate_limited).
Retry-AfterSeconds to wait before trying again.
application/json Error
POST /api/admin/logout
Sign out of the demo admin
Expires the kl_admin cookie and redirects to the admin sign-in page. Cross-site posts are refused.
Responses
303 Signed out. Redirects to /admin/concierge.
LocationWhere to go next.Set-Cookiekl_admin, expired.
403 The request came from another site (forbidden).
application/json Error
Schemas
Error
The error envelope every JSON endpoint uses.
| Field | Type | Required | Notes |
|---|---|---|---|
error | object | yes | |
error.code | "invalid_request" | "rate_limited" | "budget_exhausted" | "conversation_full" | "upstream_unavailable" | "unauthorized" | "forbidden" | "not_found" | "internal_error" | yes | Stable, machine-readable. Branch on this, not on message. |
error.message | string | yes | Plain English, safe to show to a person. |
error.requestId | string | yes | Quote this when reporting a problem; it is in the server logs. |
error.retryAfterSeconds | integer | no | Present on 429: seconds until a retry can succeed. Also sent as Retry-After. |
error.resetsAt | string (date-time) | no | Present on budget_exhausted: when the daily token budget resets (UTC). |
Cart
A cart priced against live stock. Amounts are pence (GBP).
| Field | Type | Required | Notes |
|---|---|---|---|
id | string or null | yes | Null until the first item is added. |
lines | object[] | yes | |
lines[].variantId | string | yes | |
lines[].productSlug | string | yes | |
lines[].productName | string | yes | |
lines[].variantLabel | string | yes | For example "24 cm · Forest". |
lines[].sku | string | yes | |
lines[].unitPence | integer | yes | |
lines[].qty | integer | yes | What will be bought: the requested quantity, trimmed to stock. |
lines[].requestedQty | integer | yes | What was asked for before trimming to stock. |
lines[].stock | integer | yes | |
lines[].maxQty | integer | yes | The most the quantity stepper offers: 10, or what is in stock. |
lines[].linePence | integer | yes | |
lines[].art | object | yes | Illustration parameters for the storefront. Not a stable part of the API. |
lines[].body | string | yes | Illustration body colour for the storefront. Not a stable part of the API. |
lines[].issue | "sold_out" | "reduced" or null | yes | Why the line cannot be bought as it stands, if it cannot. |
itemCount | integer | yes | |
subtotalPence | integer | yes | |
shippingPence | integer | yes | |
totalPence | integer | yes | |
freeShippingGapPence | integer | yes | Pence to add for free delivery; 0 when already free or the cart is empty. |
Order
An order. It never includes who it is for: no name, town or postcode.
| Field | Type | Required | Notes |
|---|---|---|---|
number | string | yes | For example KL-2041. |
status | "placed" | "packed" | "shipped" | "out_for_delivery" | "delivered" | "cancelled" | yes | |
placedAt | string (date-time) | yes | |
carrier | string or null | yes | |
trackingRef | string or null | yes | |
etaDate | string (date) or null | yes | Expected delivery day; null once delivered or cancelled. |
demo | boolean | yes | True for orders placed through the demo checkout. |
items | object[] | yes | |
items[].productSlug | string | yes | |
items[].name | string | yes | |
items[].variantLabel | string | yes | |
items[].sku | string | yes | |
items[].unitPence | integer | yes | |
items[].qty | integer | yes | |
currency | "GBP" | yes | |
subtotalPence | integer | yes | |
shippingPence | integer | yes | |
totalPence | integer | yes | |
timeline | object[] | yes | What happened, then what comes next. |
timeline[].key | string | yes | |
timeline[].title | string | yes | For example "Out for delivery". |
timeline[].detail | string or null | yes | |
timeline[].location | string or null | yes | |
timeline[].at | string (date-time) or null | yes | Null for steps that have not happened yet. |
timeline[].state | "done" | "current" | "upcoming" | "cancelled" | yes |
Health
| Field | Type | Required | Notes |
|---|---|---|---|
status | "ok" | yes | |
catalogue | object | yes | |
catalogue.source | string | yes | Where the catalogue is read from, e.g. "postgres". |
catalogue.products | integer | yes | |
catalogue.variants | integer | yes | |
latencyMs | number | yes | How long the catalogue check took. |
Degraded
| Field | Type | Required | Notes |
|---|---|---|---|
status | "degraded" | yes | |
catalogue | null | yes |
AdminLogin
| Field | Type | Required | Notes |
|---|---|---|---|
password | string | yes | The shared demo password. |
AdminLoginResult
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
expiresInSeconds | integer | yes |