Kettle & Loom (a demo shop)

Kettle & Loom concierge API

The JSON endpoints behind a demo shop and its AI product-finder. Version 0.1.0, OpenAPI 3.1.0.

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

FieldTypeRequiredNotes
messagestringyes
conversationIdstring (uuid)no
Example
{
  "message": "A dutch oven under £120 that works on induction"
}
Stream parts
PartMeaning
startThe stream opens. Carries the message id.
data-activityTransient. What the concierge is doing right now, such as "Searching the catalogue". Never stored.
data-productsProduct 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-specsA size or spec table for one product, from the catalogue.
data-cartThe outcome of an add to cart: added, needs_confirmation (quantities above 3), or refused, with the cart afterwards.
data-orderAn order card: status and timeline.
text-startThe answer text begins.
text-deltaA piece of the answer text. Concatenate the deltas.
text-endThe answer text is complete.
data-spec-sourceThe footnote "from the product spec", built by the server from the spec values the answer used. Only when the answer stated a spec.
data-metaConversation id, message id, provider, model, token counts, latency, outcome and prompt version. Send the conversation id back to continue the conversation.
errorThe answer failed after the stream began. The text is safe to show.
finishThe stream is complete. The connection then sends [DONE] and closes.
Responses

200 The answer, streamed. Parts are listed in x-stream-parts.

  • Set-Cookie kl_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-After Seconds 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-After Seconds 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
NameInRequiredNotes
numberpathyesAn 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-After Seconds 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

FieldTypeRequiredNotes
passwordstringyesThe shared demo password.
Example
{
  "password": "the demo password"
}

application/x-www-form-urlencoded AdminLogin

FieldTypeRequiredNotes
passwordstringyesThe shared demo password.
Responses

200 Signed in (JSON request). The session cookie is set.

  • Set-Cookie kl_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.

  • Location Where 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-After Seconds 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.

  • Location Where to go next.
  • Set-Cookie kl_admin, expired.

403 The request came from another site (forbidden).

application/json Error

Schemas

Error

The error envelope every JSON endpoint uses.

FieldTypeRequiredNotes
errorobjectyes
error.code"invalid_request" | "rate_limited" | "budget_exhausted" | "conversation_full" | "upstream_unavailable" | "unauthorized" | "forbidden" | "not_found" | "internal_error"yesStable, machine-readable. Branch on this, not on message.
error.messagestringyesPlain English, safe to show to a person.
error.requestIdstringyesQuote this when reporting a problem; it is in the server logs.
error.retryAfterSecondsintegernoPresent on 429: seconds until a retry can succeed. Also sent as Retry-After.
error.resetsAtstring (date-time)noPresent on budget_exhausted: when the daily token budget resets (UTC).

Cart

A cart priced against live stock. Amounts are pence (GBP).

FieldTypeRequiredNotes
idstring or nullyesNull until the first item is added.
linesobject[]yes
lines[].variantIdstringyes
lines[].productSlugstringyes
lines[].productNamestringyes
lines[].variantLabelstringyesFor example "24 cm · Forest".
lines[].skustringyes
lines[].unitPenceintegeryes
lines[].qtyintegeryesWhat will be bought: the requested quantity, trimmed to stock.
lines[].requestedQtyintegeryesWhat was asked for before trimming to stock.
lines[].stockintegeryes
lines[].maxQtyintegeryesThe most the quantity stepper offers: 10, or what is in stock.
lines[].linePenceintegeryes
lines[].artobjectyesIllustration parameters for the storefront. Not a stable part of the API.
lines[].bodystringyesIllustration body colour for the storefront. Not a stable part of the API.
lines[].issue"sold_out" | "reduced" or nullyesWhy the line cannot be bought as it stands, if it cannot.
itemCountintegeryes
subtotalPenceintegeryes
shippingPenceintegeryes
totalPenceintegeryes
freeShippingGapPenceintegeryesPence 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.

FieldTypeRequiredNotes
numberstringyesFor example KL-2041.
status"placed" | "packed" | "shipped" | "out_for_delivery" | "delivered" | "cancelled"yes
placedAtstring (date-time)yes
carrierstring or nullyes
trackingRefstring or nullyes
etaDatestring (date) or nullyesExpected delivery day; null once delivered or cancelled.
demobooleanyesTrue for orders placed through the demo checkout.
itemsobject[]yes
items[].productSlugstringyes
items[].namestringyes
items[].variantLabelstringyes
items[].skustringyes
items[].unitPenceintegeryes
items[].qtyintegeryes
currency"GBP"yes
subtotalPenceintegeryes
shippingPenceintegeryes
totalPenceintegeryes
timelineobject[]yesWhat happened, then what comes next.
timeline[].keystringyes
timeline[].titlestringyesFor example "Out for delivery".
timeline[].detailstring or nullyes
timeline[].locationstring or nullyes
timeline[].atstring (date-time) or nullyesNull for steps that have not happened yet.
timeline[].state"done" | "current" | "upcoming" | "cancelled"yes

Health

FieldTypeRequiredNotes
status"ok"yes
catalogueobjectyes
catalogue.sourcestringyesWhere the catalogue is read from, e.g. "postgres".
catalogue.productsintegeryes
catalogue.variantsintegeryes
latencyMsnumberyesHow long the catalogue check took.

Degraded

FieldTypeRequiredNotes
status"degraded"yes
cataloguenullyes

AdminLogin

FieldTypeRequiredNotes
passwordstringyesThe shared demo password.

AdminLoginResult

FieldTypeRequiredNotes
oktrueyes
expiresInSecondsintegeryes