# Vendor MCP — drive your store from your own AI agent

`POST /api/v1/vendor/mcp`

BTAB already tells an AI what a store **is** — `/agents.md` and `/.well-known/ucp` on every
storefront. This is the other half: what the store's **operator** can ask. It speaks
[Model Context Protocol](https://modelcontextprotocol.io) over streamable HTTP, so it works from
Claude Code, Claude Desktop, Cursor, Codex — anything that speaks MCP.

**Your key's scope is the authorisation.** A `read` key can only look. A `read_write` key may
also change things — through write tools that land lane by lane — and is refused nothing it is
entitled to, asked no extra confirmation for a plain write, and audited on every call. See
[Capabilities](#capabilities).

> **Status (L3, 2026-09-07):** the capability gate, audit trail, kill switch (ON since L2's
> prod smoke) and prompt-injection fencing are live; catalogue, site, content, enquiry-status,
> order-fulfilment and the two-step destructive tools are all registered.
>
> **Public copy of this document:** https://btab.app/docs/vendor-mcp.md (the repository is private).

## One key, one store — the server tells you which

A vendor key is bound to **exactly one store**, and the server says so on every handshake so an
agent can never mistake the store for the platform:

- **`initialize` → `serverInfo`** is named after the store: `{ name: "<slug>", title: "<Store> (vendor MCP)",
  version, store: { slug, name, url, blog_url, post_url_pattern, published } }`. The public catalogue
  MCP keeps its own `btab-catalog` name — it is not store-bound.
- **`initialize` → `instructions`** (the MCP field clients inject into the model's system prompt) is
  generated per key: *"You are operating the vendor MCP for one store only: {name} ({url}) …
  create_store_blog_post publishes at {blog_url}/{slug}. That is not the Btab company blog
  (https://btab.app/blog) and not content.btab.app. Before any write, call store_overview. If the user
  says 'this site' or 'the blog', they mean this store."*
- **`GET` the endpoint** and the same `store` block + `instructions` come back as plain JSON.
- **`store_overview`** carries `url`, `blog_url`, `post_count`, and says in a field what the blog is *not*.
- The dashboard snippet names the server after the store slug (`"mcpServers": { "<slug>": … }`), not `btab`.

**A vendor key cannot publish to https://btab.app/blog or to content.btab.app.** Those are the Btab
company surfaces; nothing on this endpoint reaches them.

---

## Connect it

### From the vendor dashboard (the short version)

**Settings → API Keys → Connect your AI agent.**

1. Pick **Read only** or **Read & write**. The card shows what each can do — the list is rendered
   from the live tool registry, so it is never out of date — and, for read & write, what an agent
   can *never* touch.
2. Mint the key (shown once). For **Cursor** and **VS Code** the card then shows an
   **Add to Cursor** / **Add to VS Code** button that installs the server in one click, key
   already in it — no file to find and no JSON to paste. For **Claude Code** it offers the
   equivalent one-line `claude mcp add` command. Everything else pastes the generated snippet —
   Claude Desktop (Settings → Connectors), Codex, or any client that takes a URL and a header.
   The install buttons appear only once a real key exists: installing a placeholder would
   register a broken server you would then have to find and repair.
3. Ask it something. Reads are immediate. Writes show up under **Agent activity** on the same page
   — tool, what changed, when, which key — with an **Undo** button on rows that have a recorded
   inverse (the page says plainly when a row cannot be undone and why).
4. **To stop it:** revoke the key (Settings → API Keys) — every call fails instantly. Platform-wide,
   an admin can switch all agent writes off from Admin → Settings (`vendor_mcp_writes_enabled`).

Undo replays the row's stored inverse through exactly the same gate, budget and audit sink as the
original call, and writes its own audit row — it is a dashboard action only; an API key cannot call
the activity or undo endpoints.

### By hand

1. In the vendor dashboard, mint an API key (Settings → API Keys):
   - **`read`** — the agent can look but never change anything. Pick this unless you need writes.
   - **`read_write`** — the agent may also call write tools. Everything it changes
     is recorded under Agent activity, and revoking the key stops it instantly.
2. Point your client at the endpoint with the key as a bearer token.

**Claude Code** — `.mcp.json` (the server key is your store's slug — the dashboard snippet fills it in):

```json
{
  "mcpServers": {
    "<your-store-slug>": {
      "type": "http",
      "url": "https://api.btab.app/api/v1/vendor/mcp",
      "headers": { "Authorization": "Bearer YOUR_KEY" }
    }
  }
}
```

**VS Code** — `.vscode/mcp.json`. ⚠️ The top-level key is **`servers`**, not `mcpServers`; a
config under the wrong key is ignored without an error.

```json
{
  "servers": {
    "<your-store-slug>": {
      "type": "http",
      "url": "https://api.btab.app/api/v1/vendor/mcp",
      "headers": { "Authorization": "Bearer YOUR_KEY" }
    }
  }
}
```

**Claude Code (one line)** —

```bash
claude mcp add --transport http <your-store-slug> https://api.btab.app/api/v1/vendor/mcp \
  --header "Authorization: Bearer YOUR_KEY"
```

**Cursor / Codex** — same URL and header; syntax varies by client. Cursor's `mcp.json` entry takes
`url` + `headers` and infers the transport, so it carries no `type` field.

### What an API key does not reach

The browser-hosted assistants — **ChatGPT connectors, claude.ai custom connectors, and Grok** —
accept **a URL and nothing else**. They cannot be given a bearer header, so an API key cannot
connect them. They connect through OAuth instead — the next section — which is live on this
endpoint.

### Connecting a browser assistant (OAuth)

When `VENDOR_MCP_OAUTH_ENABLED` is on, the whole flow is: **paste the MCP URL, press Allow.**

1. The host POSTs the URL unauthenticated and gets `401` with
   `WWW-Authenticate: Bearer resource_metadata="…"`.
2. It fetches that document, then `/.well-known/oauth-authorization-server`.
3. It identifies itself — preferably with a **client-id metadata document** (its `client_id` is an
   https URL serving its own metadata; nothing is registered with us), or by `POST /oauth/register`
   if it does not speak CIMD yet.
4. It opens `/oauth/authorize`, which sends the vendor to a consent screen on the dashboard:
   *"Allow **chatgpt.com** to use your store?"*, with a read / read-and-change choice.
5. On Allow, the code is exchanged at `/oauth/token` with PKCE for an access token and a rotating
   refresh token.

**No key is ever shown to a human**, there is no expiry to choose, and revocation is
**Settings → AI agent → Connected apps → Disconnect**, which kills the grant, its credential and every live
token at once.

What the consent screen shows is deliberate: it leads with the **host** the client's metadata
document was served from — or, for a client that registered dynamically and therefore has no such
host, the **redirect host** access will be sent to. `client_name` is self-asserted on both paths and
is shown only as a label, never as the thing you are asked to judge.

An OAuth grant resolves to the same `read` / `read_write` scope an API key does, so the dispatch
gate, the write budget, the kill switch and the audit trail treat it identically — and
`vendor_agent_writes` still records **which** connected agent made each change.

**The API-key path is not going away.** It is the right credential for a script or a cron job, and
it is what the `curl` check above uses. OAuth is the human door; the key is the machine door.

What the app sees, in order:

- A 401 here carries `WWW-Authenticate: Bearer resource_metadata="…", scope="read read_write"`.
  (With `VENDOR_MCP_OAUTH_ENABLED` off the challenge is deliberately absent: a challenge naming
  an authorization server that 404s is worse than none, because the client reports OAuth as
  broken instead of falling back to the credential that works.)
- `GET /.well-known/oauth-protected-resource/api/v1/vendor/mcp` returns the RFC 9728
  protected-resource metadata (also served at the bare `/.well-known/oauth-protected-resource`,
  which some clients request instead). The endpoint answers browser preflight with its own
  CORS, including `Access-Control-Expose-Headers: WWW-Authenticate`.
- `GET /.well-known/oauth-authorization-server` (RFC 8414): PKCE `S256` only, public clients
  (`token_endpoint_auth_methods_supported: ["none"]`), client-ID metadata documents supported
  (claude.ai, Claude Code and ChatGPT use theirs and never register), dynamic registration for
  the rest (Cursor, VS Code, Gemini CLI; `https://` or loopback `http://` redirect URIs, any
  port), and `authorization_response_iss_parameter_supported: true` — every authorization
  response carries `iss` (RFC 9207).
- `scope` on the authorization request is a space-separated list (RFC 6749 §3.3). The grant is
  the widest of `read` / `read_write` present; anything else (`offline_access`, OIDC scopes) is
  ignored rather than refused. No scope means `read`.
- The access token is good on **this endpoint only** (its RFC 8707 `resource`). Every other
  vendor route refuses it with `OAUTH_TOKEN_WRONG_AUDIENCE`, so a connected app can never create
  an API key or change account settings, whatever it was granted.

`scopes_supported` is `read` / `read_write` — the **same** vocabulary as an API key, deliberately.
An OAuth grant resolves to the same scope the dispatch gate already reads, so there is one
authorisation vocabulary in the system rather than two to keep in step.

A dashboard session cookie also authenticates, which is what makes the endpoint testable from a
browser, but an API key is the right credential for an agent.

## Tools

| Tool | Answers |
|---|---|
| `store_overview` | **Which store this key drives**: name, slug, `url`, `blog_url` (where posts publish — this store's blog, never btab.app/blog), `post_count`, published, **discoverable** (the admin search switch), **indexable** (whether search engines may list it now: the switch on AND the store's own domain serving; a btab.app address is never indexed), custom-domain status, SEO title/description. Start here, and before any write. |
| `list_store_blog_posts` | This store's blog posts — drafts and scheduled included, newest activity first — each with its public `url`. Optional `status` (`draft` · `published` · `live` · `scheduled`), `page`, `limit` (1–100, default 25). |
| `search_help` | Search the published BTAB help articles for sellers (the same ones as **Help** in the dashboard). `query` (plain words), optional `limit` (1–5, default 3). Returns each match's `title`, `url` (`/help/<slug>`), `summary` and an `excerpt`; an empty list means no article covers it. |
| `search_visibility` | Sitemap submitted? Home page indexed? Clicks and impressions over 28 days, and the top 5 search queries that reached the store. |
| `store_traffic` | Visits, visitors and pageviews for this store, with an optional top-10 breakdown (daily / pages / referrers / countries / devices). Its OWN analytics, not Google's — `search_visibility` is search, this is traffic. Reports `tracking_since` so "no visits yet" and "never tracked" stay distinguishable. |
| `bank_matching_review` | Deposits from the store's imported bank statements that were not matched to an awaiting-payment invoice automatically, with the best candidate invoices and why (reference / amount / name), the recent matches, and the invoices still open. Read only — importing a statement and confirming a match happen in the dashboard. |
| `vendor_sales_overview` | Sales over a period (today / 7d / 30d / 90d / 12m, or custom Sydney dates): gross sales, discounts, refunds, net sales, orders, AOV, units, conversion rate, and **what the store earned** (btab-sold) or **btab's fees** (a store selling under its own business), with the change vs the previous period and a daily/weekly series. The same numbers as Analytics in the dashboard (one query service). Each response carries `definitions`: what every number means, in the same words as the dashboard tiles (e.g. net sales = after discounts and refunds), with the tax wording of the orders counted (`tax`: "GST included" for Australian orders). |
| `vendor_sales_breakdown` | One breakdown table for a period: by product, variant, category (the store's category names, as on its storefront; same-label categories merged, no category = Uncategorised), maker (the store's own makers by name, or one unnamed "Your makers" row for a staff login without Suppliers access; btab's catalogue and partners' makers as one "Other makers" row, never named), staff (the staff login that made each order, with average order; orders no staff login made as "Owner (counter and invoices)" and "Online and imported"; a staff login that is not a Manager sees only its own row), payment method (money taken by tender, before refunds: the order's payment ledger (split tenders, deposits, gift cards) plus bank deposits matched to it; an order with neither uses its own method, with any gift-card part as its own row; imported orders as collected by Shopify / Kogan; no method = not recorded), channel, discount code, btab campaign (only while campaigns run) or returns (units returned, return rate, reasons, remedies). Each row has the previous period's figure and % change; earned vs btab fees follows the same both-path rule as the overview. |
| `vendor_automation_health` | Every automation the store runs (Shopify order import, other order intake, maker feeds its account owns, product sheet uploads, supplier list uploads, customer and review imports) and its state now: `failed`, `missed` (should have run and did not), `waiting` (held orders, a feed run held for review, an upload never applied) or `ok`. Each row has the problem in plain words, `last_run_at`, `last_ok_at`, `count_failed` in the period (`range`, default 7d) and `link`, the dashboard page to fix it. Same rows as Analytics → Automations and the daily automations email. |
| `vendor_customer_report` | Customers over a period: how many bought, new vs returning, returning-customer rate, repeat rate, account sign-ups, average spend (with the change vs the previous period), how many are subscribed to marketing, and the top customers by net spend (email, name, subscribed, account). |
| `list_enquiries` | Enquiries with quote state — quoted, **sent**, **viewed by the buyer**, won, lost. Optional `status`, `limit` (1–100, default 25). |
| `get_attention` | **What needs doing right now**, in one call — paid orders nobody has touched, orders a supplier marked ready that still need a courier, purchase orders a supplier has not confirmed, new enquiries, reviews awaiting moderation. Returns `counts` per kind, `kinds` (each with the newest item's id, time and one-line title) and `latest` (the newest across all kinds). One kind is informational, not owed: `orders_sent_elsewhere` — parallel-run orders imported from the store's other system while that system still sends their purchase orders (`imported_order_po_send` off); they are counted and reported but never in `total`, never `latest`, and not in the orders `attention` filter. No arguments. This is the SAME feed the dashboard's sidebar badges and arrival toast render, so an agent and the store owner always see the same list. |
| `order_summary` | Order counts by status. Optional `status`, `start_date`, `end_date`. |
| `list_orders` | **Find orders** — the read the rest of the orders group depends on, since every other order tool takes an `order_id` and nothing else produces one. Per order: `order_number`, `id`, `status`, `payment_status`, `order_type`, `po_number`, the money (`total_cents`, `shipping_cents`, `tax_amount_cents`, `charge_total_cents`), the payment position (`payment_state` — `unpaid` · `part_paid` · `paid` — with `amount_paid_cents` and `balance_due_cents`: a counter deposit or a part payment leaves an order `part_paid` with the rest owed), `item_count` and the dates; plus `status_counts` for the whole filtered set rather than the current page. **Buyer details are withheld here** — `customer_name` and nothing else, no email, no phone, no address: browsing twenty orders is not a reason to pull twenty buyers' contact details into a conversation. Nothing is hidden from the vendor; `get_order` opens one in full. Search still *matches* on email even though email is not returned. Filters: `status` (an unrecognised one is refused rather than quietly matching nothing), `attention` (the "action needed" list behind the Orders badge), `search` (order number, buyer name, buyer email), `start_date`, `end_date`. **`end_date` is a timestamp, not a day**: `"2026-09-30"` means `created_at <= 2026-09-30 00:00:00` and so excludes the 30th — pass the next day or a full timestamp to cover a whole day. `sort_by` is an enum (`created_at` · `updated_at` · `id`) and an unknown value falls back to newest-first instead of failing; `order` is `asc`/`desc`. Paged: `page`, `limit` 1–100 (default 20, larger clamped). |
| `get_order` | **One order in full** (`order_id`), for this store only — another store's id returns the same "not found" as an id that does not exist, so it can never be used to probe. **Unlike `list_orders` this returns the buyer's contact details**: `customer` (name, email, phone as entered at checkout) and `shipping_address`. That is what opening a single order is *for* — shipping it, or answering a question about it — so open orders one at a time with a reason, and browse with `list_orders`. Also returns `items` (`product_id`, name, `quantity`, `price_cents`), `item_count`, the same money and date fields as the list, the vendor's own private `notes`, and `fulfilment` — **this** store's part of the order with its `status`, `tracking_number` and `tracking_carrier`, or `null` when no part exists yet (normal for an order nobody has accepted). |
| `list_supplier_pos` | **Every supplier purchase order of the store**, across all orders: `view` = `to_send` (drafts waiting for review) · `overdue` (no answer within the chase window, or the ready date passed) · `sent` · `confirmed` · `ready` (not yet picked up) · `received` · `all`; rows with `po_number`, `order_number`, `status`, `expected_ready_date`, `overdue`, `pickup_warehouse`, `supplier_note`, `parallel_run` (an imported order whose PO the store's old system sends during a parallel run — btab will not send it), `cannot_supply_at` (set when the maker said it can't supply: cancel the PO or send the goods to another maker), plus the count per view and the store's `send_mode`. Sending the drafts is **not** agent-callable (it emails suppliers). |
| `order_purchase_orders` | **Where each supplier is up to on one order** (`order_id`): one purchase order per supplier, with `status` — `draft` (raised, not sent) · `sent` · `confirmed` (the supplier acknowledged, with an `expected_ready_date` where they gave one) · `ready` (the goods are ready for pickup) — plus `po_number`, `item_count`, the `pickup_warehouse` the goods come from, the supplier's own `supplier_note` (what they wrote when answering), the `notes` printed on the PO, `chase_count` (reminders btab sent a silent supplier), `supplier_replies` (the supplier's email replies still waiting, each with a proposed status / ready date / warehouse and whether it came from the supplier's usual address), and an `all_ready` flag for the order. **`ready` is not `shipped`**: it is the cue to book a courier, and it is the one thing that tells you the goods physically exist before you call `ship_order`. Procurement view only — no buyer details and no selling prices. Sending or re-sending a purchase order is **not** agent-callable: that email carries one-click links which record a commitment on the store's behalf and cannot be unsent (see "What an agent can never be given" below). Neither is **arranging the delivery** — booking a courier and marking a supplier's goods shipped is a physical commitment and an irreversible buyer notification, so it stays a human step in the dashboard. `unset_suppliers` lists the store's own suppliers on the order that have no email address (or only the store's own): no purchase order is prepared for them until the supplier's email is added (`save_my_supplier`).  Where the store can edit its purchase order emails each PO also shows `email_draft` (saved for its next send), `last_email` (to, cc, subject, attached file names, when) and `revision`. |
| `search_catalogue` | **The shared BTAB catalogue** — every product this store *could* list, which is a different corpus from `list_products` (the store's own shelf). The `product_id` it returns is what `add_product_listing` and `bulk_add_listings` take. Per product: name, `sku`, `product_type`, `supplier_id`/`supplier_business_name`, retail price, `vendor_earn_cents` (what this store is paid for one sale at that retail — say "you earn $X") with `vendor_earn_basis` (`ex_gst` on a GST-registered store that holds its own sales — say "ex GST"), `wholesale_price_cents` only when the platform discloses it (else `null`), `price_floor_cents`, `pricing_type`, `commission_percent` and `supplier_retail_cents` on commission products, `enquiry_only`, stock, `has_variants`, `image_url`, `approval_status`. **Nothing upstream of wholesale** — factory price, BTAB's share and the supplier's full commission rate are stripped before the product leaves the API. **What is in the catalogue at all**: only products a store can actually list — admin-approved, priced on both sides, lifecycle-active, and carrying at least one image; a product that does not come back may be excluded by one of those rather than absent. **Search declares itself**: full-text first, substring fallback when full-text finds nothing, and the result carries `search.mode` (`fts`/`ilike`) and `search.fallback` so looser matches are not presented as exact hits. Filters (all vendor-chosen, none a gate): `in_stock`, `pricing_type`, `supplier_id`, `brand`, `category`, `store_type`, `exclude_in_store` (hides what is already listed), `purchase_mode` (`buy_now`/`enquiry`), `price_min_cents`/`price_max_cents` (retail band, max **exclusive**), `ready_to_list`, `attributes` (the supplier's **verified** product facts, e.g. `{"color": ["color__black"], "backrest_type": ["backrest_type__mesh"]}` — OR within an attribute, AND across; colour filters on the colour family, not the swatch name). `complete_for` (a channel — `storefront`, `google`, `meta` or `freight` — only products the nightly completeness pass found **100% complete** for it: Google = brand, a photo and a check-digit-valid barcode or an MPN; storefront = enough photos, a full description and the category's required facts; freight = weight and carton size for every variant). Search also reads the verified facts' words, so "mesh back" finds a chair whose backrest fact says so. `sort_by` is an enum (`recommended` · `name` · `created_at` · `updated_at` · `stock_quantity` · `retail_price_cents` · `wholesale_price_cents` · `sku` · `margin` · `readiness`) and falls back rather than failing; omitted, the order is relevance when searching else the platform default, and the result reports which in `sort.applied`. Paged: `page`, `limit` 1–100 (default 20, larger clamped). **Scope** (store types): `scope` `store` searches only this store's departments (its store type), plus its own makers and products not yet sorted into a category; `all` searches everything; omitted follows the platform default (everything, until the platform switches stores to their departments). The result carries `scope` (`applied`, `requested`, `default`, `departments`, `outside_count`): when a scoped search leaves matches outside the store, `outside_count` says how many, so say "N more outside your store" and offer `scope: "all"` rather than implying the catalogue has nothing else. |
| `list_ranges` | **Ranges**: btab-curated sets of catalogue products (for example "Dining starter") that a store adds in one go with `take_range`. Per range: `slug` (what `take_range` takes), `name`, `description`, `department`/`department_label`, `count` (how many products in it **this store** can list right now: membership is live, computed through the same catalogue rules as `search_catalogue`, so it follows the catalogue), `in_store` (how many the store already lists), `to_add`, and `covers` (up to 4 product photo URLs). A range with fewer products than the platform minimum (`min_products`, Admin → Settings) for this store is not listed. Optional `scope`: `store` lists only ranges in the store's departments, `all` every live range, omitted follows the platform default; the result says which in `scope.applied`. A range is a curated set, not a ranking: never describe one as best-selling, popular or trending. Ranges are switched on per platform and per store; when off this answers `RANGES_NOT_ENABLED`. |
| `quote_earnings` | **"At $Y you earn $X"** (`product_id`, optional `retail_cents` — omitted = the catalogue retail). What this store is paid for **one** sale at that price, from the same split the order pays: the Platform Fee is taken on the actual selling price at this store's rate, and BTAB's share is fixed at product setup. **GST:** `vendor_earn_basis` is `ex_gst` when the store sells under its own business and is GST-registered (or its registration is unknown): the figure is the sale less what it owes BTAB, ex GST, less the maker cost ex GST — the margin report's number — so say "you earn $X ex GST"; `as_charged` otherwise (BTAB collects, or the store is not registered, where a registered maker's GST counts as a cost). Also returns `price_floor_cents` (the lowest custom price `set_product_price` accepts; below it the sale loses money after the fee) and `below_floor`. On a store that sells under its **own** business the floor is **advisory** (`price_floor_enforcement: "advisory"`): a lower price is accepted and the response carries `price_floor_warning` — below it the store earns $0 and BTAB invoices the difference. Read-only. Never estimate earnings from wholesale — ask this. |
| `get_product` | **One catalogue product in full** (`product_id`). The catalogue is shared by every store, so this works for any catalogue product whether or not this store lists it — it is not a read of the store's own listing (`list_products` carries the custom title, custom price and enquiry override). Returns everything a `search_catalogue` row carries plus the full `description` and `description_format`, `seo_title`/`seo_description`, `slug`, **all** `images` (id, `alt_text`, `position`, url — not just the primary), `options`, and `variants` with their own sku, price and stock. Nothing upstream of wholesale. A `product_id` outside the sellable catalogue returns not-found, which cannot distinguish "does not exist" from "unapproved, unpriced, inactive or imageless". |
| `catalogue_health` | Listed vs inactive, enquiry-only vs add-to-cart, and how many products are missing the weight or dimensions needed to quote delivery (`missing_weight`, `missing_dimensions`, and `missing_either` — the union, since the first two overlap and must never be added). To **name** the products behind any of those counts, call `list_products` with `missing`; it shares the predicate, so the count and the list can never disagree. |
| `list_plugins` | **The store's apps** (plugins) — the same list as Website → Apps: per app `id`, `name`, `description`, `enabled`, `tier` (the plan it belongs to), `category`, `requires` (apps that must be on first), `needs_attributes` and `available`/`unavailable_reason`. An app the store's plan does not include is **not listed** — it is not part of this store, rather than a locked row. Tools that belong to an app (every rooms tool) answer `PLUGIN_NOT_ENABLED` until it is on — `enable_app` turns it on. |
| `list_media` | **The store's media library** — every video and image uploaded to BTAB storage, newest first, each with the `url` to put in a block and the `id` to delete it by, plus how much of the storage quota is used. Optional `limit` (1–500, default 100). |
| `list_rooms` | **Shop by Room** (plugin `rooms`): this store's shoppable rooms — drafts and scheduled included, in display order — each with its public `url` (`{rooms_url}/{slug}`), `live` flag and `product_count`. A room with an AI generation nobody has dealt with also carries `ai_photos_waiting`: `{status: 'generating'｜'ready', candidates, render_id}` — `ready` means scenes passed the fidelity judge and none has been chosen, so the room is still showing a placeholder; follow it with `list_room_renders` and `choose_room_render`. `null` (or absent) means nothing is owed. Optional `status` (`draft` · `published`). Returns `PLUGIN_NOT_ENABLED` until the Rooms app is enabled for the store (Website → Apps). |
| `get_room` | One room by `room_id`: title, intro, hero, SEO fields, and its items in rail order — `product_id`, `name`, `position`, `enquiry_only`, `has_hotspot`, `visible` (a delisted product stays in the room but is not shown to shoppers; nothing is ever substituted in its place). Also returns `hero_verdict` — our cached answer to "is this hero photo actually a room?" (`{ is_room_scene, reason }`, or `null` when the photo has never been judged). `is_room_scene: false` means a product cut-out or a swatch chart, so hotspot pins on it are meaningless: change the hero before placing any. Same `PLUGIN_NOT_ENABLED` rule. |
| `suggest_rooms` | **Read-only room proposals** drawn from the store's own data — no photo analysis, no cost. Two signals: a product **range** (products whose names share the first two words are one collection) and a **shared supplier photo** (one image file attached to several products is its own list of the pieces in that photo). Each proposal carries title, slug, `room_type`, `hero_image_key`, `product_ids` and `why`; ones an earlier run already drafted are flagged `already_drafted`. **Creates nothing** — `create_room` + `add_room_products` do that. Optional `source` (`range` · `shared-image`), `limit` (default 24, max 200). |
| `suggest_room_products` | **Read-only**: looks at ONE room's own photo and says which of a short list of the store's products physically appear in it, each with a `confidence` and a one-line `reason`; ones it cannot tell about come back in `unsure`, never as matches. The candidate list is built from the room itself (products sharing its hero photo file, then its range) — that narrowness **is** the method. It cannot find products in an arbitrary photo. One room per call, no bulk mode, and it **writes nothing**. If the room's hero turns out to be a single-product photo rather than a room, it says so in `note`, returns no suggestions and spends no model call. |
| `generate_room_image` | **AI room scene (write, counts against the store's monthly quota):** renders THIS room as a photoreal scene from up to `rooms_ai_image_max_products` of its own products (`product_ids` optional, in reference order; `style` optional ≤200 chars; `strategy` optional — `"reference"` (default) imagines a new room, `"stage"` keeps the room's own uploaded photo exactly and places the products into it; staged candidates carry `room_unchanged_1to5` and a redecorated room is `rejected_reason: "room_changed"`; a room without a real photo is refused; a `"stage"` render whose room photo fails the content-safety check below is refused `unsafe_image` **before** the render is spent). Returns a *queued* render — poll `list_room_renders` until `status: "ready"`. Every candidate is judged for product fidelity (score 0–100 = the lowest per-product confidence that the rendered piece IS the reference product; under the floor → `rejected_reason: "fidelity"`, not choosable). **Publishes nothing** — but note the room's *photo* may still be set for you: if the room had no real photo yet, the finished render applies its best-judged candidate as the hero automatically (`rooms_ai_auto_apply_hero`), which `list_room_renders` reports as `applied_hero.auto`. The room stays a draft either way. Errors: `FEATURE_DISABLED` (platform flag off), `QUOTA_EXCEEDED` (with `resets_at`), `UNAVAILABLE` (provider not configured), `RATE_LIMITED`. |
| `list_room_renders` | Read-only: a room's AI generations newest first — status, candidates (`url`, `thumb_url`, `score`, per-product `products[]` verdicts with `defects`, `invented_products`, `scale_plausible`, `photoreal_1to5`, `rejected_reason`), the `chosen` candidate, the store's monthly `quota` (`used`/`quota`/`remaining`/`resets_at`) and the current `settings`. Optional `limit` (default 20, max 100). **Check `applied_hero` before asking the vendor to pick anything:** when the room had no real photo yet, a finished render applies its own best candidate as the hero (platform setting `rooms_ai_auto_apply_hero`, on by default) — that render returns `chosen_auto: true` and the response carries `applied_hero` `{key, url, source, render_id, auto}`, meaning *this render's scene IS the room's photo right now*. `auto: true` ⇒ report what was applied, don't ask for a pick; offer `choose_room_render` only to CHANGE it. A `ready` render with candidates and no `chosen_key` is still owed a pick. `applied_hero` is `null` once the vendor replaces the hero with something else. |
| `choose_room_render` | Makes one candidate (`key`) of a `ready` generation the room's hero. Snapshots the previous hero into the room's versions first (`undo` carries the `version_id` for `restore_room_version`); records `hero_source: "ai_generated"` (the storefront shows the scene with no AI caption — that caption was removed 2026-09-21). Rejected candidates cannot be chosen. Let the vendor pick — do not choose for them unless asked. Also the way to CHANGE an auto-applied hero (`applied_hero.auto`): that photo is a default, not a decision, and the placeholder it replaced is restorable from the room's versions. |

### Write tools — catalogue (`write` capability)

Every write tool calls **the same controller the dashboard calls** — the fixed-retail lock, the
price rule, the minimum price, the protected-vendor guard and the storefront cache refresh
all apply to an agent exactly as to a human, and the error an agent reads is the API's own
(`RETAIL_PRICE_LOCKED`, `CUSTOM_PRICING_TIER_LOCKED`, `PROTECTED_VENDOR`, …). Every one accepts
`dry_run` and `idempotency_key` (below). Ids come from `list_products`.

**On a store btab sells for, btab sets the price on btab products** (2026-10-11). A price on them
is refused (`CUSTOM_PRICING_TIER_LOCKED`, "btab sets the price on btab products."); clearing an old
price stays open, and the store's own goods keep their price (`price_editable` in `list_products`
says which). Such a store makes no discount codes either (`DISCOUNT_CODES_TIER_LOCKED`); codes
already running keep working until they end and can be disabled. An admin can open either for one
store. `tools/list` gives such a store the matching tool descriptions.

| Tool | Does | Undo recorded |
|---|---|---|
| `get_product_images` (read) | The supplier's whole image pool for one listing (with `is_selected`) and the selected set in display order, with the `image_id` values every image tool takes — **the only source of image ids**, including a room's `hero_product_image_id`. Position 0 is the primary photo. The pool is the supplier's: ids can disappear and positions shift, so re-read rather than reusing ids. Also `store_pictures[]`: this store's own pictures on the listing, each with `check_status` (`checking`, `passed` = shown to shoppers, `refused` = never shown) and `check_reason`, the sentence to tell the owner. | — |
| `get_reference_photos` (read) | The maker's real photos of one listed product (downloadable, primary first), its name, colour / finish where known, and its size: **the reference to give your image model before making any picture of the product.** The picture must show this exact product, same colour and finish, nothing added; one made from words alone is refused. No photos → it says so, and no picture can be added. | — |
| `list_product_drafts` (read) | AI content drafts staged but not live, newest first — **the only way to learn a draft exists**, so the three draft tools below can be called at all. Carries `has_live_custom`: true means approving OVERWRITES a description the store already has. | — |
| `list_products` (read) | The store's listings with `product_id`, retail price, `vendor_earn_cents` (what the store is paid for one sale at its own price) with `vendor_earn_basis` (`ex_gst` / `as_charged`, as `quote_earnings`), `wholesale_price_cents` only when disclosed (else `null`), `price_floor_cents` (the lowest custom price the write gate accepts — propose below it and `set_product_price` refuses, except on an advisory floor, which accepts it with `price_floor_warning`), `price_floor_enforcement` (`block` or `advisory`), custom-pricing flag, selling mode, vendor-fulfilled, stock. Newest first (by when each product was added to the store, ties by insertion order). Paged. Optional `search`: partial, case-insensitive, over the name **as this store shows it** (the custom title where one is set, else the catalogue name) and the sku — a product renamed here must be findable by the name its owner gave it. The search filters the total too, so `total` and `total_pages` describe the matches rather than the whole shelf. **This is the store's own shelf**, so a search for something never added returns nothing, correctly — that is not evidence the product does not exist; use `search_catalogue` for that. Optional `missing` (`weight` · `dimensions` · `either`): name the products behind `catalogue_health`'s data-quality counts, using the **same predicate** as those counts so the number and the rows always agree. `weight` and `dimensions` **overlap** — a product missing both is in each, so they do not add up; `either` is the union, i.e. what cannot be quoted for delivery at all. No filtering by stock, price or selling mode yet. | — |
| `add_product_listing` | List an approved catalogue product, optional `custom_retail_price_cents`. | remove |
| `remove_product_listing` | Unlist (soft). | re-add with the previous price |
| `set_product_price` | Set or clear (`null`) the custom retail price, in cents. On a store btab sells for, only the store's own goods (`price_editable: true`) take a price; btab products can only be cleared. **There is no bulk price tool, on purpose.** | previous price |
| `set_enquiry_mode` | `enquiry_only_override`: `true` / `false` / `null` (inherit). | previous value |
| `set_low_stock_alert` | `low_stock_threshold`: a whole number = email the store once when the product's stock falls to or below it (again only after it goes back above and falls again); `null` = off. Refused (`HTTP_409`) for stock that is not counted. | previous value |
| `set_vendor_fulfilled` | `vendor_fulfilled`: `true` / `false`. | previous value |
| `set_channel_visibility` | Keep a product out of (`visible: false`) or back in (`true`) one sales channel's feed: `channel` `google` (Merchant Center, `/feed/google.xml`) or `meta` (`/feed/meta.xml`). Only narrows a feed; the storefront still sells it. | previous value |
| `set_channel_visibility_many` | The bulk form: up to 500 `product_ids` shown on (`visible: true`) or hidden from (`false`) one channel's feed (`google` or `meta`) in one call. Ids that aren't your listings come back in `not_found`. | the listings it flipped, flipped back |
| `set_google_listing` | "List my products on Google (free)": `opt_in` `true` puts the store into btab's own Merchant Center feed, `false` takes it out. Only for stores btab sells for, on a btab.app address, that pass the Google readiness check; otherwise refused (`HTTP_409`) with `reasons`. | previous value |
| `list_discounts` (read) | The store's discount codes + automatic discount with `times_used` (paid orders only), `discount_given_cents`, `revenue_cents`; whether the store may make discounts (`entitled`: a store that sells under its own business, or one an admin opened it for) and the platform's `max_discount_bps` / `max_active`. The `discount_id` source. | — |
| `create_discount` | A percent (`value_bps`) or fixed (`value_cents`) discount: `method` `code` (with `code`) or `automatic` (one active per store), optional min subtotal/quantity, product scope, customer emails, dates, usage limits. Every item is still clamped at its break-even floor at checkout. `HTTP_403 DISCOUNT_CODES_TIER_LOCKED` on a store btab sells for (btab sets the price on btab products). | disable it |
| `update_discount` | Edit any field of one discount (send only what changes), or `status` `disabled` / `active`. Codes are never deleted. On a store btab sells for, only `status: disabled` is accepted. | previous values |
| `list_platform_campaigns` (read) | btab-run sales (e.g. "Spring sale: 10% off office chairs") that are live or scheduled, with `opted_in` and `phase`. btab pays the discount from its Platform Fee (the store's margin is unchanged); btab catalogue products only. `eligible: false` + `reason` for a store that sells under its own business. The `campaign_id` source. | — |
| `join_platform_campaign` | Opt the store in (one click); the campaign price shows on its eligible products while live. 403 `CAMPAIGN_STORE_SELLER` for a store that sells under its own business. | `leave_platform_campaign` |
| `leave_platform_campaign` | Opt the store out; orders already placed keep their price. | `join_platform_campaign` |
| `get_customer_account_settings` (read) | Whether shoppers can sign in to the store with an emailed 6-digit code (`customer_accounts`: `off` default / `optional`) and how many have (`signed_up`). Guest checkout is unchanged either way. | — |
| `set_customer_account_settings` | Turn shopper sign-in `optional` or `off` (existing accounts are kept). | previous value |
| `get_abandoned_checkout_settings` (read) | Whether the store emails shoppers who left checkout without paying: `emails` (`two` / `one` / `off`), `send_after_minutes`, and `available` (the platform has reminders on for this store). Reminders only ever go to shoppers who ticked the marketing box. | — |
| `list_abandoned_carts` (read) | The store's abandoned carts in the reminder window: when, email (in full only for shoppers who agreed to marketing emails, else masked `j***@…`), items and value, reason, consent, reminders sent, and the paid order a reminder brought back; plus `recovered_sales` (last 30 days). Sending a reminder is the owner's click in Orders → Abandoned checkouts, not a tool. Also `drafts`: checkouts left before "Place order" (A4, email + cart only; email masked unless consented), with consent and reminders sent. | — |
| `list_returns` (read) | B2: return requests on the store's orders (buyers ask from their order link), newest first: order, item, quantity, `reason` (damaged / faulty / not_as_described / wrong_item / changed_mind), the buyer's note and preferred `remedy` (refund / replacement / repair), `status` (open = requested / approved = authorised, the buyer may send it back / resolved / cancelled), photos, `handled_by` (`btab`, or `store` when it sells under its own business). Plus `settings`: `returns_change_of_mind` (the change-of-mind window that applies), `source` (`platform` when btab sells on the store, so btab's returns policy sets the window; `store` when the store sells under its own business and sets its own), `editable`, and `returns_address` / `returns_instructions` (where an approved return is sent). Faults and damage are always accepted (Australian Consumer Law); change of mind only within that window. | — |
| `update_return` | B2: answer a return the STORE handles (`handled_by: "store"`): `status` approved (return authorised: the buyer is emailed the store's returns address and instructions; `no_return_address` 409 until one is set) / resolved / cancelled / open, optional `note` the buyer sees. approved, resolved and cancelled each email the buyer from the store. btab-handled returns are refused. Moves no money. | the previous status |
| `list_my_suppliers` (read) | The business's **private supplier book**: the makers it buys from itself (name, contacts, PO email and CC, address, pickup hours, prepay discount %, PO notes), each with this store's `stats`: products listed, open purchase orders, last ordered, spend this year at cost. Private to the vendor account (every store of one owner shares it); no other store ever sees it. `available: false` = not switched on for this account yet. | — |
| `get_supplier_overview` (read) | One supplier (`id`) as the store's supplier page shows it: the record, pickup warehouses, lead time; products from this maker (listed on this store, in the catalogue, last stock update, never updated); open purchase orders (order and stock POs, status, total at cost); spend this month and this year (Sydney calendar, cancelled excluded); 12-month performance (average hours from sent to confirmed and to ready, sent POs that needed chasing); and `identity`, the maker's business identity: `abn` / `abn_display`, `state` (`confirmed` on the Australian Business Register, `unconfirmed` = register not reached yet, `not_found`, `cancelled`, `no_abn` with `no_abn_reason`, `none`), the register's `entity_name` and `entity_type`, `gst_registered` and `gst_registered_from`, `checked_at`, `register_url`. | — |
| `save_my_supplier` | Add a supplier (no `id`; `business_name` required) or edit one (`id`; send only what changes, `""`/`null` clears). Emails are checked; `prepay_discount_pct` 0–100 is recorded only. The sheet sync's keys have their own fields — `sync_code` (the maker's SKU prefix), `sync_shopify_vendor`, `sync_stores` (`OB`, `TJ` or `OB+TJ`), `sync_website_brand` — and a `po_notes` that still carries them is split into those fields, so `po_notes` (printed on purchase orders) holds only what the maker should read. | `save_my_supplier` with the old values, or `remove_my_supplier` for a new one |
| `set_supplier_own_stock` | **"We stock these products ourselves."** `id` (from `list_my_suppliers`) + `own_stock` true/false. True = this supplier in the private book **is the store** (a house brand, e.g. the store's own name listed as the Shopify vendor): on every **new** order its products come to the store to pack and send (`accept_order`, `ship_order`) and **no purchase order is prepared**. False = purchase orders are prepared for it again. Orders already placed are not changed. For one product, use `set_vendor_fulfilled`. `dry_run` previews; returns `undo`. |
| `set_product_supplier` | **"This supplier makes it."** Moves products of the supplier `supplier_id` to `to_supplier_id`, another supplier in the store's private book: the listed `product_ids`, or **all** of that supplier's products when `product_ids` is omitted. Use it when a product sits under the wrong supplier (e.g. the store's own brand name from its Shopify vendor) and someone else really makes it; purchase orders for it then go to the right supplier. Only the product's supplier changes: its SKU, prices, cost and any purchase order already raised are not touched, and the store's sheet does not undo it. A product that cannot move (the target already has that SKU; a supplier shared with other stores) is listed under `refused` and skipped. `dry_run` reports what would move; returns `undo`. If the store holds the stock itself, use `set_supplier_own_stock`. |
| `remove_my_supplier` | Archive a supplier from the book (existing purchase orders keep their details). | — |
| `set_supplier_abn` | One supplier's **ABN** (`id`): EITHER `abn` (11 digits, spaces allowed): checksum, then looked up on the Australian Business Register and stored with the register's entity name and GST status (a bad checksum is refused, nothing changes); OR `no_abn_reason` (`hobby` / `not_in_business` / `under_75_supplier` / `not_australian` / `other`, optional `note`) to record that the maker has none. Returns `identity` and a `result.message`. `dry_run` shows what is on file. | `set_supplier_abn` with the previous ABN or reason (nothing on file: no undo) |
| `list_supplier_warehouses` (read) | One supplier's **pickup warehouses** (`id`): name, address, state, postcode, hours. A new purchase order starts with the one nearest the buyer (same state, then closest postcode); the ids are what `update_purchase_order` takes. | — |
| `preview_purchase_order` (read) | One purchase order of the store's **own** supplier (`order_id`, `po_id`) exactly as the supplier would receive it: `to`, `cc`, the email `subject`, the document (family / branded / plain), whether a PDF is attached, `deliver_to`, `notes`, and the lines with this PO's buy prices ex GST (`cost_source` catalogue / store, `cost_to_confirm`). Draft or sent; `editable` = still a draft. Not available on a btab-sold order (that purchase order is btab's). Where the store can edit its purchase order emails it also returns `message` (the opening text), `email_editable`, `email_saved_for_next_send`, `revision` and `doc` (what the store wrote over btab's values), and accepts `cc` / `subject` / `message` to **try** values without saving them. | —  |
| `set_supplier_warehouses` | Replace one supplier's warehouse list (`id`, `warehouses: [{label, address_line1, city, state, postcode, hours}]`, max 20). Purchase orders already raised keep the warehouse they carry. | `set_supplier_warehouses` with the previous list |
| `import_suppliers` | `kind: "shopify"` (a Shopify products export: one supplier per distinct Vendor, existing names matched, products without a Vendor listed) or `kind: "csv"` (the template columns, including `abn` and the four `sync_*` columns; an old-style `po_notes` carrying the sync keys is split into them; a name already in the book is updated; an `abn` cell is checked and looked up as `set_supplier_abn` does, blank leaves it, a bad ABN skips the row). **Dry run by default**: returns the plan with line-numbered problems and a `plan_hash`; `dry_run: false` with that `plan_hash` applies exactly that plan (refused if the book changed since: dry-run again) and returns `run_id`. | `undo_supplier_import(run_id)` |
| `get_network_sharing` (read) | **Network sharing** (independent stores only): every share (what, with which partner stores, what btab pays: `pct_off` bps off her price or a `fixed` price, advisory `rrp_cents`, active/paused), partner store names, the supplier book's on-selling confirmations, the Network terms, and **`owner_actions`**: what only the owner can do herself in Network → Sharing (accept the terms, confirm a supplier allows on-selling), with the `/network` link. An agent never does those. `available: false` = not an independent store. | — |
| `find_partner_stores` (read) | Up to 10 stores of OTHER businesses by store name or web address (≥ 2 characters); their ids are what `save_network_share` takes. Stores that sell under their own business are left out (they cannot sell shared goods). | — |
| `save_network_share` | Share goods with partner stores: `scope_type` supplier / brand / product / account + `scope_ref`, `share_mode` `partners` (only `partner_vendor_ids`; a store that sells under its own business is refused), `network` (every store btab sells for except `excluded_vendor_ids`) or `private`, `list_price_rule` (`{type:"pct_off",bps}` or `{type:"fixed",cents}` for one product), optional advisory `rrp_cents`, `status` active / paused. Needs the owner to have accepted the terms and, for a supplier or product, confirmed on-selling. `dry_run` previews. | `stop_network_share` |
| `stop_network_share` | Remove one share; partner stores see those goods out of stock at once. | — |
| `list_network_orders` (read) | Partner stores' orders of the business's own goods, to ship: order, status (pending → accepted → shipped → delivered), lines, ship-to, **`ship_as_store`** (ship under THAT store's name, no invoice or price in the parcel) and the draft purchase order to the maker. `open: true` = still to ship. | — |
| `list_network_problems` (read) | Problems buyers reported on the business's own goods sold by partner stores (read only): order, item and option, quantity, reason, the remedy the buyer wants, the buyer's own words, photo URLs, status, when reported, the store it sold on, `fix_by` (the date the maker has to fix a fault by; null when there is no clock), `overdue` and `escalated_at` (the buyer asked btab to decide). No buyer contact details. Default: still in progress; `open: false` = every status but cancelled. | — |
| `fulfil_network_order` | `accept`, then `ship` (`tracking_number` required, `tracking_carrier` optional; emails the buyer), then `deliver`. | — |
| `get_supply_statement` (read) | What btab owes the business for partner sales of its goods, by Sydney month: in the refund hold, due at the next supplier payout, paid, refunded (`hold_days`). With `month` (YYYY-MM): the lines (order, selling store, amount, status). Never the buyer. | — |
| `set_returns_change_of_mind` | B2: `off` (default), `14` or `30` — whether buyers may return an item they changed their mind about, within that many days of delivery, for a refund. Only a store that sells under its own business sets its own window: when btab sells on the store, btab's returns policy applies and this fails with `btab_sells` (HTTP 409, `effective` = btab's window), changing nothing. | the previous value |
| `list_gift_cards` (read) | D4: the store's gift cards, newest first: `last4` (the code is never shown again after issue), initial and current balance, expiry, status (active / expired / void), `promotional`, and `issuer` (`btab` on a btab-sold store, `store` when it sells under its own business). | — |
| `issue_gift_card` | D4: give away a PROMOTIONAL gift card (`amount_cents`, optional `expires_in_months` 1–120, `recipient_email` gets the code). Returns the code ONCE. Only a store that sells under its own business can (it funds the goods); on a btab-sold store btab issues them. | none (a card, once given, is the recipient's) |
| `get_gift_card_settings` (read) | D4: whether the store SELLS gift cards (`sell`), the amounts it offers in cents (`denominations`; empty = the defaults), `effective_denominations` (what buyers see, within the platform maximum), `expiry_months` (a sold card lasts at least 36) and `available` (false while gift cards are off platform-wide). | — |
| `set_gift_card_settings` | D4: `sell` (true / false) puts a gift card on the storefront; `denominations` sets the amounts (1–10 values in cents, each ≥ 100 and ≤ the platform maximum; `[]` = $25 / $50 / $100). A sold card is paid by card only, carries no GST (charged when it is spent), lasts ≥ 36 months and is emailed once paid. | previous values |
| `set_abandoned_checkout_settings` | Set `emails` and/or `send_after_minutes` (60 / 360 / 600 / 1440). A5: `discount_step` (1 / 2 / null) puts a discount in that reminder, for opted-in shoppers only — `discount_mode` `code` (one of the store's active codes, `discount_code_id`) or `generate` (a single-use code per shopper from `discount_template`: `kind`, `value_bps` / `value_cents`, optional `min_subtotal_cents`). Store-funded and floor-protected; the code is shown in the email and applied from its link. | previous values |
| `get_product_spec_settings` (read) | The product page spec table (Dimensions, Materials & finish, Features, Warranty) shows the supplier's **verified** facts. `hidden` = the attribute codes this store leaves out of its product pages, its product markup (JSON-LD) and its own Google / Meta feeds; `available` = the spec attributes with a verified value on the store's listings (`code`, `label`, `group`, `hidden`); `max_rows` = the platform's rows-per-page limit. | — |
| `set_product_spec_settings` | Replace the hidden list: `hidden` = codes from `available` (`[]` shows every row). Presentation only — a store never changes a product's facts. | previous list |
| `get_title_code_settings` (read) | Many shops start product names with a maker's code ("VI Florida Tallboy"). `hidden` = the codes this store hides from buyers: its product pages, cards, rooms and its own Google / Meta feeds then show "Florida Tallboy". `suggested` = short upper-case first words that start at least 3 of the store's listings (`code`, `products`, `example`, `hidden`). | — |
| `set_title_code_settings` | Replace the hidden list: `hidden` = codes such as `["VI","MD"]` (`[]` shows names as they are). Presentation only: the product's name never changes, and purchase orders and order lines keep the full name. | previous list |
| `get_product_filter_settings` (read) | The attribute filters (Colour, Material, Backrest type…) the store's product listing offers shoppers beside its categories; counts and matches use only the supplier's **verified** facts. `filter_attributes` = the store's own list of attribute codes in display order (`[]` = none), or `null` when it uses the default (`uses_default`: every filterable attribute of the families its products belong to); `effective` = what the listing offers now; `available` = the filterable attributes with a verified value on the store's listings (`code`, `label`, `selected`, and `store_label` when the store named the group itself); `labels` = the store's own names for filter groups, by attribute code (`{}` = none); `colour_words` = how colours are shown: `mode` `base` (btab's base colours, the default) or `own` (the store's own colour words), `words` = the store's list of `word`, `label`, `hex` in its order, `in_use` = the colour words on the store's listings with product counts, `unmapped` = words in use with no hex yet. | — |
| `set_product_filter_settings` | Set the store's attribute filters: `filter_attributes` = codes from `available`, in display order (`[]` = no attribute filters, `null` = back to the default). Optional `labels` = the store's own name for a filter group, e.g. `{"features": "Feature"}` (left out = unchanged, `null` = none). Optional `colour_words` = `{"mode": "own", "words": [{"word": "ash", "hex": "#A8ACA5"}]}`: the store's own colour words and the hex of each, in display order, written as in the shop's sheet (left out = unchanged, `null` = none). A call changes only the fields it names. Presentation only — a store never changes a product's facts. | previous values of the fields the call named |
| `get_store_menu` (read) | T-6: the store's own header menu as a tree of at most 3 levels (Living Room > Table > Coffee Table). Each item: `label`, `link` (`category` + `category_slug`, `page` + `page_slug`, or `url`), `href`, an optional picture (`image_media_id`, `image`), `highlight` (painted in the header's highlight colour, e.g. a red SALE), `cta` (a "Shop Now" link back into its group, drawn under the item with the same address, never a category; no children) and `children`. `items` = `[]` means no menu (the header shows the flat category list). `categories` = what a category link can point at today; `limits` = `max_depth`, `max_items`. | — |
| `set_store_menu` | T-6: replace the WHOLE menu with `items` (same shape, at most 3 levels). A picture must be the store's own media (`list_media`). `items: []` removes the menu. Category and product page breadcrumbs follow the first item that links to that category. | the previous menu |
| `list_collections` (read) | The store's own **collections by rule** (Shopify-style smart collections), each a page at `/collections/<slug>`: `slug`, `heading` (its name and page H1), `match` (`all` / `any`), `rules`, `position`, `count` (published products matching now), `listed` (holds a product: only then is the page live and in the sitemap), `hidden`, `path`. Also `fields` (each field's relations) and the platform `limits`. | — |
| `preview_collection` (read) | Try a rule before saving: `match` + `rules` → `count` and the first 6 products (`id`, `name`). Changes nothing. | — |
| `save_collection` | Create or replace the collection at `/collections/<slug>`: `heading` (its name and page H1), `match` (default `all`), `rules` (`{ field, relation, value }`: `type` equals / not_equals; `tag` equals / contains / not_equals; `title` contains / starts_with / not_contains; `brand` equals / not_equals; `attribute:<code>` equals a verified value code), optional `position`. The slug cannot be a category the store already sells in or an old category address; it wins over a product type of the same name. Collections only read product data. Page copy (SEO title, description, intro, hidden) is set like any category's. | the previous definition (or `delete_collection` for a new one) |
| `get_product_page_media_settings` (read) | Whether the store's product pages show a product's live 3D model (`pdp_3d_enabled`, default true) and offer "View in your room" AR with it (`pdp_ar_button_enabled`, default true). `platform_3d_enabled` = btab's master switch; while false no store shows 3D. | — |
| `set_product_page_media_settings` | Set `pdp_3d_enabled` and/or `pdp_ar_button_enabled` (booleans) for every product page of the store. | previous values |
| `get_payment_method_settings` (read) | What shoppers can pay with besides card: `bnpl` (Klarna, Zip, Afterpay) and `bank_transfer`, each with `value` (what checkout does now), `stored` (the store's own choice; `null` = platform default), `editable` and `reason`. `store_sells` = the store sells under its own business: its methods are managed in its own Stripe (`bnpl.manage_url`) and bank transfer is not offered. **Read only** — changing payment methods is the money rail; the owner does it in the dashboard (Settings → Payouts → Payment methods). | — |
| `sales_by_channel` (read) | Sales over the last `days` (default 30) by where the buyer came from — `revenue_cents` is GOODS (`total_cents`: no shipping, no duty, no gift cards sold) over the counted statuses (not cancelled, not a test order, paid or owed), the one rule the Home Revenue tile and `sales_overview` also use (D8, 2026-10-17); before it was the charge of paid orders — — `ai_assistant` (a chat assistant or agent link), `facebook_instagram`, `google`, `vendor_link` (the store's own share links), `email`, `other_referral`, `direct`, `unknown` (before tracking) — with `orders` and `revenue_cents`. `list_orders` also takes a `channel` filter and returns each order's `channel`. | — |
| `list_reviews` (read) | Shoppers' reviews of the store's products (`status` pending / approved / rejected = hidden, with `hidden_reason`), the allowed `hide_reasons`, the public moderation policy text and `autopublish_days`. The `review_id` source. | — |
| `moderate_review` | `action` `publish`, or `hide` with a required `reason_code` (offensive / spam / personal_info / not_about_product / conflict_of_interest) and optional `note`. A low rating is never a reason to hide a review. Stores cannot delete reviews. | previous state |
| `moderate_review_photo` | Reviews R6: `action` `hide` takes one photo of a review off the product page (required `reason_code`, same list as reviews, e.g. `personal_info` for a face or an address), `show` puts it back. Photo ids come from `list_reviews` (`review.photos[]`). Hiding the whole review already hides its photos. | previous state |
| `list_shared_reviews` (read) | Reviews R7: verified reviews from OTHER stores that show on this store's catalogue products (the shared pool, never naming the store), with `hidden_here` / `hidden_reason` when this store hid one. | — |
| `hide_shared_review` | Reviews R7: `action` `hide` takes a shared review off THIS store only (required `reason_code`, same list as reviews); it stays on its own store and every other store. `show` puts it back. Only btab can remove a review from the pool. | previous state |
| `import_reviews` | Reviews R10: bring the store's reviews from its previous shop. `csv` = the whole export as text (Judge.me, Shopify Product Reviews, or any CSV with rating, date, text and a SKU / handle / product-name column; `mapping` `{ field: column }` for odd names). Matched to THIS store's products only (handle → SKU; two products at one step are reported, never guessed); a match by product NAME alone is a suggestion, listed by line in `suggestions` and imported only if passed in `accept_suggestions`; unmatched / invalid rows come back by line in `report`. Published with the original dates, marked Imported (`imported_from` on `list_reviews`), never Verified, never shared with other stores; a reply becomes the store's reply; photo URLs are fetched and stripped. Re-importing the same file adds nothing. `dry_run` first: it returns the counts, the suggestions and a `plan_hash`; the import needs that `plan_hash` and is refused (`refused: 'plan_changed'`, with the new preview) if the store's products changed in between. | `undo_review_import(run_id)` |
| `update_product_description` | Store's own title / description (`text` or sanitised `html`) / SEO keywords; say `custom_content_source`. **Send only the fields to change** — anything omitted is left as it is; send an explicit `null` to clear one. | previous content |
| `save_product_draft` | Edit a pending AI content draft. | previous draft |
| `discard_product_draft` | Discard a pending draft. | none (one-way) |
| `revert_product_draft` | Restore pre-approval content (one-shot). | none |
| `select_product_images` | Set which supplier images the listing shows, by id (max 10). **REPLACES the whole selection** — any image not in your list is dropped. To add one without losing the rest, read `get_product_images` and send the current set back with the new id included. | previous selection and order (exact) |
| `reorder_product_images` | Set display order. | previous order |
| `remove_product_image` | Drop one selected image. | re-select it |
| `set_product_image_alt` | SEO: this store's description (alt text) of one photo on its product page: what screen readers read, what image search indexes, and the caption in the page's product markup. Plain words about what the photo shows, ideally under 125 characters (max 250). `""` goes back to the supplier's. Only this store changes; `image_id` from `get_product_images` (`selected[]`, which now also returns `store_alt_text`). | the previous text |
| `get_product_media` (read) | L3: the supplier's 3D models, videos and documents for one listing, each with THIS store's choice (`hidden`, `store_position`) and the `asset_id` the tools below take; plus the photos and videos this store attached itself (`store[]`, by `vendor_media_id`). | — |
| `set_product_media_hidden` | Hide (`hidden: true`) or show again one supplier 3D model, video or document on THIS store's product page and feeds only. Other stores are untouched. | the previous `hidden` |
| `order_product_media` | This store's order for one kind (`video`, `model3d`, `document`): `asset_ids` first, the rest in the supplier's order; `[]` = the supplier's order again. | the previous order |
| `attach_store_media` | Attach a media-library item (`vendor_media_id`, from `list_media` / `upload_media`) to one listing on this store only; `remove: true` takes it off (the file stays in the library). A store video shows on its product page and in its own feeds. Max 20 per product. **Store pictures are checked** against the product's own photos before shoppers see them: the answer carries `check_status` (`checking`); read `get_product_images` for `passed` or the reason it was refused. A refused picture is never shown and cannot be overridden. Refused outright when the store is not switched on for pictures, the listing's photos come from its sheet, or the product has no supplier photo. | the inverse attach / remove |
| `set_product_search_listing` | SEO: how one listing appears in search, on this store only. Send just the fields to change: `seo_title` (≤70), `seo_description` (≤320; `""` = default), `seo_hidden` (noindex + out of the sitemap and feeds, page stays reachable), `handle` (the end of the product URL `/products/<handle>`; lowercase letters, digits, hyphens; typed text is tidied; unique in the store; `""` = the default `<name>-<id>`). A changed handle keeps the old address working (301). `dry_run` returns the current listing. | the previous values |
| `set_colour_chart` | Colour charts: the chart of one option value (a fabric, finish or top) on the store's OWN products — `option_name`, `option_value`, `colours` (`[{ name, code? }]`, at most 200, the code is the supplier's colour code for the purchase order), `vendor_media_id` (the chart picture from the media library; `null` = names only); `remove: true` deletes it. A colour never changes the price. Shown to shoppers only while the store's Colour Charts app is on. `dry_run` returns the options and current charts. | the previous chart (or remove) |

### Write tools — site, content, enquiries, orders (`write`)

| Tool | Does | Undo recorded |
|---|---|---|
| `update_store_seo` | SEO title/description/keywords, hero headline/subheadline, Google site verification. Only the fields sent change. | previous values of the fields sent |
| `vendor_generate_policy` | BRIEF-PT: write the store's `returns`, `delivery`, `terms` or `privacy` page (`type`) from btab's template, filled from what btab knows — the seller of record's legal name and ABN ("sold by btab on behalf of the store" on a btab-sold store), the change-of-mind window, the real delivery prices, whether Meta/Google ad tracking is on, the contact email. Returns always state the Australian Consumer Law guarantees; delivery never promises delivery times. `dry_run: true` returns `current` and `generated` side by side with `edited` and `stale` (generated from facts that have since changed). Text the store wrote or edited is never replaced without `confirm_overwrite: true` (else `WOULD_OVERWRITE`). | `vendor_set_policy_text` with the previous text |
| `vendor_set_policy_text` | Policy editor twin: replace ONE policy page's text (`type`, `text`) with the store's own words. Light markdown renders on the storefront (`#` headings, `-`/`1.` lists, `**bold**`, links). `base` = the text you read; if the page changed since, nothing is written (`POLICY_CHANGED`, with `current`). Max 60,000 characters; the other pages are never touched; empty text hides the page. `dry_run: true` reports `current`, `proposed` and `would_conflict`. | the same tool with `text` = the previous text |
| `create_store_blog_post` / `update_post` | Posts on **this store's own blog** (`{blog_url}/{slug}` — the result carries the `url`). The route's own validation (slug format, lengths, block schema) runs unchanged. `create_post` is a deprecated alias of `create_store_blog_post` — same handler, kept for one release. | unpublish / previous fields |
| `set_enquiry_status` | `new` · `won` · `lost` · `closed`. **Status only** — never prices, sends or converts a quote; `quoted` / `sent` / `accepted` are refused (`STATUS_NOT_AGENT_SETTABLE`) because they imply a quote event. Note that moving an enquiry with a live quote to `won`/`lost`/`closed` takes it out of the convertible set, and this tool cannot move it back. | previous status (not when the enquiry was `accepted`) |
| `enable_app` / `disable_app` | Turn ONE app (`app_id` from `list_plugins`) on or off — exactly the Website → Apps switch, through the same controller. **On** adds the app's blocks to the pages it declares (and, the first time only, its page to the menu) and unlocks its tools. **Never starts a charge**: an app outside the store's plan is **refused** with that reason (`REFUSED`), as are an app whose required app is off, one needing catalogue data the store lacks, and a store with no storefront (`NO_STORE`); the dry run reports the reason as `problem`. **Off** keeps the app's settings and data (Rooms keeps its rooms); refused while another enabled app needs it. Idempotent by desired state: on→on and off→off change nothing, and a configured app is never reset. | the opposite switch (`disable_app` ⇄ `enable_app`); re-enabling restores the kept settings |
| `accept_order` | pending → accepted for this store's part of an order. Silent (no customer notification). | none (one-way) |
| `set_purchase_order_send_mode` | `send_mode` `auto` (each supplier purchase order is emailed the moment its order is paid) or `review_first` (they wait as drafts on Orders → Supplier POs until the store sends them); `null` = the store's default (review_first for a store selling under its own business). Only POs raised from now on; emails nobody. | the previous mode |
| `set_purchase_order_email_settings` | The store's **usual purchase order email**, used for every purchase order it sends in its own name (automatic sends too): `cc` (up to 5 addresses that get a copy), `wording` (the opening text) and `subject` (the subject pattern). `wording` and `subject` may use `{supplier_name}` `{store_name}` `{order_number}` `{po_number}` `{invoice_number}`; `null` = btab's wording / subject again. Only the keys given change; a dry run with no keys reads the current settings and btab's defaults. Emails nobody. Refused where editing purchase order emails is not switched on for the store (flag `po_email_compose_enabled` + rollout). | the previous settings |
| `receive_purchase_order` | The goods on one supplier purchase order arrived (`order_id`, `po_id`, optional `note`). With `bill` = `{supplier_invoice_number, subtotal_cents` (ex GST)`, gst_cents` (default 10%)`, issue_date, due_date, source_document_url}` it also records the supplier's bill, approved, on the Supplier Bills page — **only for the store's own suppliers** — and pushes it to the connected accounting system unless the store's accounting sync is paused. **Destructive (two-step)**: preview + `confirm_token`. No stock moves; emails nobody. | none (void the bill in the dashboard) |
| `handle_supplier_reply` | A supplier answered a purchase order **by email** (it lands on the PO: `order_purchase_orders` → `supplier_replies`, with what it proposes). `action` `apply` (optionally override `status` confirmed/ready, `expected_ready_date`, `pickup_warehouse_id`) moves the PO one step forward; `dismiss` when it says nothing to apply. Check `verified_sender` first. Emails nobody. | none (one-way) |
| `update_purchase_order` | On one supplier purchase order (`order_id`, `po_id`): `pickup_warehouse_id` — one of **that supplier's** warehouses (`list_supplier_warehouses`; `null` clears) — and/or `notes`, the text printed on the PO. Emails nobody; the supplier sees it the next time they open the PO. | `update_purchase_order` with the old values |
| `edit_purchase_order` | On one **draft** purchase order of the store's own supplier (`order_id`, `po_id`): `notes` (order comments), `deliver_to` (the instruction printed in the Deliver to box, e.g. "Pick up from VIC or SA / Boxed"; `null` = the store default) and `lines` = `[{ id, quantity (1–9999), unit_cost_cents }]`. The buy price (ex GST) is **this purchase's only** — the product's cost never changes. A sent purchase order cannot be changed. Every change is recorded; the preview shows before/after. Emails nobody. **Where the store can edit its purchase order emails** (otherwise refused): `email` = `{ cc, subject, message }` saves the words of this PO's email for the next send by a person (`null` clears; draft or sent PO; its own call); `supplier_contact` = `{ name, email }` (who this PO is addressed and sent to), `supplier_address`, `deliver` = `{ mode: deliver|pickup, name, line1, line2, suburb, state, postcode, phone }`, `customer_invoice_ref`, `required_by` and `po_date` (`YYYY-MM-DD`); a line may also carry `description`, `sku` or `remove: true`; `add_lines` = `[{ description, quantity, unit_cost_cents, sku?, product_id? (one of this supplier's products), kind?: "freight" }]`. `revise: true` makes the change on a purchase order that was **already sent** (nothing received or billed yet): it is marked as a revision (`revision`, `revision_unsent`) and waits for a person to re-send it from the send screen, which tells the supplier what changed. Files are attached by a person on the send screen, never by an agent; sending stays not agent-callable. | `edit_purchase_order` with `restore` = the returned snapshot (puts the draft back exactly, including where each price came from)  |
| `edit_dispatch_details` | What one order's **dispatch documents** print (delivery slip, box labels, run sheet), for this order only: `recipient`, `company`, `line1`, `line2`, `city`, `state`, `postcode`, `phone`, `note` (to the driver or receiver), `window` (delivery window or date, printed on the slip and the run sheet) and `boxes` = `[{ line_index, boxes }]` (default quantity × the product's package count; the labels' "of N" is the sum). `null` = back to the order's value; `reset: true` removes every change. The order's own address never changes. Every change is recorded. Read first with `get_dispatch_details` (read-only). Prints and sends nothing. | `edit_dispatch_details` with `restore` = the returned saved changes |
| `change_purchase_order_supplier` | On one **draft** purchase order of the store's own supplier (`order_id`, `po_id`): address it to another supplier of the private book (`supplier_id`) when the wrong one was picked, e.g. the draft was raised to the store's own brand name. If that supplier already has a draft on the order, the lines are added to it and this draft is cancelled (its number is not reused). `also_product` (default true) also uses that supplier for these products from now on. Lines and buy prices stay; the order's money is not touched. Refused for a sent purchase order, a supplier with no email, and a supplier that is the store's own stock. Emails nobody. `undo: true` puts the last change on that purchase order back while it is still an unsent draft. |
| `use_own_stock_for_order` | For an order placed **before** the store said it stocks a supplier's products itself (`set_supplier_own_stock`): on that order (`order_id`) the items of that supplier (`supplier_id`) become the store's own to pack and send (`accept_order`, `ship_order`), and the unsent draft purchase order for them is cancelled. Only while the supplier has not started on them: refused when its purchase order was already sent or the items are under way (the own-stock setting then applies from the next order). The order's money is not touched. Emails nobody. `undo: true` puts it back while the store has not started sending them. |
| `prepare_purchase_orders` | Raises one **draft** purchase order per supplier on a paid order that has none yet (`order_id`). Emails nobody, whatever the store's send mode — review with `preview_purchase_order`, change with `edit_purchase_order`; the store sends from the order page (sending is not agent-callable). | `discard_draft_purchase_orders` A supplier of the store with no email address gets no draft; the result counts them as `supplier_not_set`. |
| `discard_draft_purchase_orders` | Removes the listed purchase orders (`order_id`, `po_ids`) only while each is still an **unsent draft** — the undo of `prepare_purchase_orders`. A sent purchase order is never removed. Emails nobody. | `prepare_purchase_orders` |
| `record_supplier_answer` | The supplier answered by phone or chat: `status` `confirmed` (optionally `expected_ready_date`, YYYY-MM-DD) or `ready`, with an optional `pickup_warehouse_id` and a `note` of what they said. One step forward only, and `ready` accepts that supplier's part so delivery can be arranged. Emails nobody. | none (one-way) |

### Destructive tools — two-step confirm (`destructive`)

The first call runs a **preview** and returns `{ requires_confirmation: true, confirm_token, expires_in: 300, preview }`;
nothing changes. The second call with the **same arguments plus `confirm_token`** executes. The token
is bound to the exact arguments and expires in five minutes — a token for one id set cannot confirm
another. Both calls are audited (the first as a dry run).

| Tool | Does | Why two-step |
|---|---|---|
| `set_store_published` | `is_published: true / false` — the store goes live or offline for every visitor. | store-wide, visible to the public |
| `ship_order` | Mark this store's part of an order shipped with `tracking_number` (required) + `tracking_carrier`. **The preview states in plain words that confirming emails the buyer**: an "on its way" email with the parcel's tracking (every store, every tier), and the tax invoice once the order's last part ships — neither can be unsent. | cannot be un-shipped or un-emailed |
| `bulk_add_listings` / `bulk_remove_listings` | Up to `vendor_mcp_bulk_max` (default 200) product ids per call, **ids-mode only** — `mode:"filter"` / `filters` are refused outright. The preview lists exactly which ids would be added (or skipped, with the reason) / removed. Undo: the inverse bulk call over exactly the affected ids. | many rows a model chose |
| `delete_collection` | Deletes the collection at `/collections/<slug>` with its page copy (SEO title, description, intro). The page stops existing; menu or block links to it go to "not found" until changed. Products are never touched. Undo: `save_collection` with the previous definition (the page copy must be set again). | a public page disappears, with its copy |
| `take_range` | Adds the products of one range (`slug`, from `list_ranges`) that the store does not list yet, at default pricing, through the same ids-mode bulk add as `bulk_add_listings`. The preview names exactly which products would be added; products already listed are left alone (`already_listed`). At most `vendor_mcp_bulk_max` (default 200) per confirmed call: `remaining` says how many of the range are still not listed after the call, and calling again (preview + confirm) continues. Refused products come back in `skipped` with the reason. Undo: `bulk_remove_listings` over exactly the added ids (`added_ids`). `RANGES_NOT_ENABLED` when ranges are off for the store, `RANGE_NOT_FOUND` for an unknown slug. | many rows in one call |
| `undo_supplier_import` | Puts the private supplier book back as it was before one `import_suppliers` run (`run_id`): archives the suppliers it created and puts back the fields and ABNs it changed. **Refused, changing nothing,** when a supplier it created already has products, purchase orders or other records (the answer names them). A field or ABN edited since the import is left as it is and listed. `dry_run`: what the run did and whether the undo would be refused. | archives suppliers |
| `undo_review_import` | Reviews R10: deletes the reviews one `import_reviews` run created (`run_id`), with their photos, in one step; the star ratings refresh. A review the store has edited, answered or moderated since is KEPT and listed (`kept`); a run is undone once; `dry_run` shows what would go and stay. Never touches a review a shopper wrote on this store. | removes published reviews |

### Step 0 — get your file INTO BTAB (media library, `write` / `destructive`)

**Before any design work that involves a file you have: put the file here first.** A `video-band`
block takes a `videoUrl` and a `posterUrl`; a hero slider takes an `imageUrl`. Those settings accept
any string, which is how, on 2026-09-14, an agent that had just generated a store video linked it
from a **72-hour temp file host** — the store's video was set to break in three days and nothing
anywhere said so.

So the design tools now **refuse** a known temporary host (catbox, tmpfiles, transfer.sh, file.io,
0x0.st, gofile, wetransfer, a Discord CDN link…) in any URL-typed block setting, and **warn** when a
media setting points at any other non-BTAB host. Uploading is one extra call, and the `url` you get
back never expires.

**Three doors in, whichever one matches what you are holding:**

| You have | Call | Limit |
|---|---|---|
| **Bytes you just generated** | `upload_media { data_base64, content_type, filename }` | `vendor_media_inline_max_mb` — **10 MB** by default, 12 MB at most (base64 rides in the tool call, so this door is deliberately small) |
| **A public https URL** (including a temp host you had to use — importing FROM one is exactly what this is for) | `upload_media { source_url }` | `vendor_media_max_mb` — **50 MB** by default |
| **A file bigger than the inline limit** | `create_upload` → PUT the bytes → `finalize_upload` | `vendor_media_max_mb`; nothing passes through the model's context |

| Tool | What it does | Undo |
|---|---|---|
| `upload_media` | Puts a file in this store's media library. Give **exactly one** of `data_base64` (the file's bytes, base64; `content_type` and `filename` optional — the bytes are sniffed either way) or `source_url` (a public `https://` URL BTAB fetches server-side). Accepts **MP4 · WebM** video and **JPEG · PNG · WebP · AVIF** images. Returns `{ id, key, url, content_type, bytes, usage }`. For a **product picture an AI made**, pass `generated: true` (checked with the stricter AI floor, keeps a hidden AI tag) and make it from `get_reference_photos`. | `delete_media {media_id}` |
| `create_upload` | For a file over the inline limit: reserves a slot and returns `{ media_id, upload_url, upload_expires_at, curl }`. The url is **BTAB's**, lives **15 minutes**, needs **no auth header** (the url *is* the credential) and works **once**. Nothing is stored and nothing counts against the quota until the bytes land; an unused reservation expires and is swept. | `delete_media {media_id}` |
| `finalize_upload` | After the PUT: confirms the bytes actually landed (it asks object storage, not just the record) and returns the permanent `url`. `UPLOAD_NOT_RECEIVED` = PUT them and call again; `UPLOAD_EXPIRED` = start over with `create_upload`. | `delete_media {media_id}` |
| `list_media` (read) | What is already stored, with each `url` and `id`, and the quota usage. Call it before uploading — the file may already be here. Reserved-but-unfilled uploads are never listed. | — |
| `delete_media` (destructive) | Permanently removes one file and frees its quota. Two-step confirm. | **none** — the object is gone, and a block still pointing at its URL renders nothing. Re-uploading gives a *new* URL. |

The big-file flow end to end:

```bash
# 1. reserve (MCP)          → { media_id: 42, upload_url: "https://api.btab.app/api/v1/media-upload/<token>" }
# 2. send the bytes (any HTTP client — no Authorization header)
curl -X PUT --data-binary @home-background.mp4 \
  -H "Content-Type: video/mp4" "https://api.btab.app/api/v1/media-upload/<token>"
# 3. finalize (MCP)         → { url: "https://images.btab.app/.../raw:1/..." }  ← this goes in the block
```

**Why `url` is safe to hard-code in a block.** It is a signed imgproxy `raw:1` link that streams the
stored object through untouched with byte-range support, so a `<video>` can seek, and it does not
expire.

**What is checked, on every door.** The bytes are **sniffed** — a caller that declares `video/mp4`
and sends something else is refused, never guessed at — and the mime allowlist, the per-file size cap
(`vendor_media_max_mb`) and the per-store quota (`vendor_media_quota_mb`, default **500 MB**) are the
same for an agent and for a dashboard upload, because all of it runs through one gate. Over quota is
a refusal naming what is stored and what the file needs; the fix is `list_media` → `delete_media` →
retry. `source_url` additionally runs behind an SSRF guard: **public addresses only** (private,
loopback, link-local, CGNAT and cloud-metadata ranges refused, validated on the very DNS lookup the
socket connects to, so a rebind cannot slip past), **https only**, **no redirects** (give the final
URL), and a **30-second timeout**.

Then: take `url` from the result and put it in the block —
`update_page_blocks` with a `set-settings` op on the `video-band` block's `videoUrl` (and the poster
in `posterUrl` the same way).

### Design tools — pages, blocks, theme settings, themes

**Design changes are live immediately — there is no publish step for design.** A store's design is
one `layout_recipe` + `theme`; a write persists and the storefront serves it on the next request.
**The one exception — a draft theme:** a live store the platform allows (`get_store_design` → `design_draft.mode`
true) edits a *draft theme*. Every change to its pages, blocks or theme settings then goes to the draft, shoppers keep
the published theme, `preview_design` and the store's preview link show the draft, and `publish_design` puts the draft
theme live (`discard_design` throws it away). Every write answer says which with `live_immediately` and `design_draft`.
(The tool names keep `design`; the words a store reads say *theme*.)
`set_store_published` controls whether the store is *reachable at all*, not whether a design is
live — never call it to "publish" a design change. Design is **blocks with typed settings over
theme tokens** (colours, fonts, spacing): **no raw HTML, no arbitrary CSS** — if something cannot
be said as a block setting or a theme token, say so rather than inventing markup.

Every design write is **schema-validated** (invalid settings come back as field-level `issues` to
fix, nothing half-applied), **snapshotted before it lands** (a row in the store's design history —
a *version* — holding the state before the write; the dashboard editor shares the same history),
and returns an **`undo`** the agent replays itself (`{tool, args}`): the reversed list of per-op
inverses, or `restore_design {version_id}` when an op has no cheap inverse. The dashboard's own
`POST /agent-activity/:id/undo` is a session-only button and refuses an API key — an agent undoes
by calling the tool its `undo` names.

Read side (`read` — a read key sees all of these):

| Tool | Answers |
|---|---|
| `get_store_design` | Everything at once: `has_recipe` (false = legacy renderer, no design writes possible), `theme_in_use` and `colour_set_in_use` (also returned under their old names `look_in_use` / `palette_in_use`), the theme settings (`theme`: colours, fonts, tokens), every page with its kind (`home` · `index` · `detail` · `content`), block count, title, nav and published flag, the store `url`, and `live_immediately: true`. While the setting `assistant_theme_notes` is on, also `theme_notes: { theme, notes }`: the theme designer's advice for the store's theme (what to keep, what may change freely), to follow when planning changes. **Call this first.** |
| `list_pages` | Just the pages array. |
| `get_page_blocks` | One page's blocks top to bottom — id, type, source, version, current settings and a one-line schema hint. Use the ids from here in ops. |
| `get_block_schemas` | **Call with no arguments first** — a ~4 KB summary of every block type (type, name, category, allowed page kinds, singleton). Then `block_type` for ONE block's full settings schema, or `kind` for every block allowed on a page kind. Do not pull every full schema at once (~7k tokens). |
| `list_themes` | The hand-built themes (complete designs: a layout plus colours and fonts). Each entry of `looks` (the key keeps its old name) is one theme: id (the `theme_id`), name, description, colours, fonts, home sections, `version` (a store on an older one may be offered the newer one: `check_theme_update`), `palettes` (its colour sets: the same layout in other colours, the theme's own first; their ids are `colour_set_id` values; a colour set is never a separate theme), and `entitled` (switching theme is free for every store today). The list is this store's own: the same themes its dashboard Themes page shows. A theme made by an outside designer and approved by btab also carries `author` and `thumbnailUrl`; btab's own themes have neither key. `preview_theme` and `apply_theme` accept exactly the themes this list returns. |
| `preview_theme` | `theme_id` (+ optional `colour_set_id`, `adapt_colors`) → a private `preview_url` of **this store in that theme**, over its real products — the same preview the shop sees on its Themes page. **Persists nothing**; the link expires (`expires_at`, `expires_in` seconds). If the preview cache is unavailable → `preview_available:false`. Switch for real with `apply_theme` and the same arguments. Also takes `content` (`"sample"` = start fresh) and `own_page` (see `apply_theme`), and returns the same `content_mode` / `content_kept` / `content_not_shown` as `apply_theme`: the preview is the composition the switch writes. |
| `check_theme_update` | Is there a **newer version of the theme this store is on**? `available:true` with `follows` (the settings that would change: label, from, to), `kept` (settings the new version also changes but this store changed first, so they stay), `from_version` / `to_version` and a `note` on what is new; or `available:false` with a `reason` (`not_offered`, `not_entitled`, `no_look`, `up_to_date`, `nothing_changes`). `with_preview: true` adds a private `preview_url` of the store after the update (nothing is saved). Updates are offered, never automatic. |
| `theme_status` | The themes **this store has sent to Btab** (as an outside creator): each with `theme` (its id, for a new version), `version`, `status` (`submitted` = waiting for review, `changes_requested`, `rejected`, `published` = on the Themes page, `suspended`, `retired`), Btab's `note` and its `latest_check` (status per check). Also `open` (whether Btab takes themes from this store now; when false, `message` says so) and `creator` (the "by <name>" credit). `check_id` returns one check in full with its `todo` list. |
| `list_design_versions` | The store's design history, newest first — id, source (`dashboard` · `agent` · `admin` · `look` · `restore`), who, label, when. No recipe payloads. |
| `preview_design` | `ops` (the same vocabulary as `update_page_blocks`, plus the preview-only `set-plugin {pluginId, enabled?, settings?}` to show a store feature such as the header or share rail switched or re-tuned, e.g. for a whole design) → a private, signed `preview_url` that renders the store **as it would be** after the ops, over the store's real products. **Persists nothing**; the link expires (`expires_at`, 30 minutes by default). Invalid ops → `valid:false` + `issues`. If the preview cache is unavailable → `preview_available:false` (the ops are still valid). |

Write side — every write runs the **same controller the dashboard editor uses**, so the
protected-vendor guard, validation, page-rename redirects and cache refresh apply to an agent
exactly as to a human. `dry_run: true` previews any of them.

| Tool | Does | Undo recorded |
|---|---|---|
| `update_page_blocks` | `ops[]` applied in order, validated as a whole: `set-settings {page, blockId, settings}` (send the full settings object) · `add-block {page, position:{at:"start"|"end"|index}, blockSpec}` · `remove-block` · `move-up` / `move-down` · `set-theme {themeOp}` · `add-page {slug, kind?, title?}` · `remove-page` · `set-page-meta {slug, meta}` (meta `layoutFor: "<category slug>"` makes a catalog page that category's own layout at `/collections/<slug>`, instead of the products page's; the page is then not a page of its own; `null` takes it off) · `rename-page {from, to}` (writes a 301; while the store edits a draft, at Publish) · `duplicate-page {from, slug}`. **`set-recipe` (wholesale replacement) is refused.** Two slugs are **nav carriers** — `blog` (this store's posts) and `rooms` (Shop by Room). Their routes are served by their own features, not by a recipe page, so they can never be created with `add-page`; `set-page-meta {slug, meta:{nav, navOrder, title}}` is how they go into the header or the footer, and `nav: null` takes them out. Only those three fields are settable on a carrier. | the reversed per-op inverses, else `restore_design {version_id}` |
| `set_theme_settings` | `colors` (`#RRGGBB`) · `fonts` (allowed names) · `tokens` (radius/density/motion) — sugar over one `set-theme` op. Changes the theme's **settings only, never the theme itself** (that is `apply_theme`). Invalid keys are **ignored, not errors** — read `ignored_keys` in the result before telling the vendor it changed. | previous values of the applied keys |
| `create_page` | A custom page at `/<slug>`, kind `content` (default) or `catalog` (fixed after creation). Reserved slugs refused. **Requires the store's custom-pages feature** — otherwise refused with "ask BTAB". | `remove-page` |
| `delete_page` (destructive) | Removes a custom page, its blocks and its settings; the preview names how many blocks go. Core pages cannot be deleted. | rebuilds the page: `add-page` + its blocks + its meta |
| `apply_theme` (destructive) | Switches the store to a theme from `list_themes`: replaces **every page and the theme settings**, in the theme's own colours or one of its colour sets (`colour_set_id`; a colour set id such as `heirloom` also works as the `theme_id` and means Atelier in the Heirloom colours). Products, orders and posts are untouched; plugins the theme declares are re-tuned, others left alone. `adapt_colors: true` keeps the store's brand hue. The old argument names `look_id` / `palette_id` are accepted too. **The store's own content (keep my content):** `content_mode` in the preview and the result says what happens. `"keep"` = the store's words, pictures, chosen products, custom pages and page settings move into the theme's layout; `content_not_shown` lists, by kind with a count, what the theme has no place for (kept, not deleted; it comes back on a theme with a place); `content_fields_not_shown` lists, per block, the item fields a list loses in a block that shows the list but knows fewer fields (image-cards' `text`, `tag`, `imageUrl2` in category-showcase: `[{page, fromBlockType, toBlockType, slot, fields, items}]`). The cards show without those fields; the whole list is kept and comes back on a theme with image-cards. `content_not_shown` has a matching line, e.g. `Categories: text, tag, photo 2`; `own_page: [kind]` puts a kind whose `own_page_offer` is true (a FAQ, a long text) on its own page linked in the footer; `content_kept` lists what was kept. `"sample"` = the theme's sample pages; `custom_pages_removed` / `nav_entries_removed` name what the store loses. Which one applies is the store's setting (`theme_switch_content`); `content:"sample"` asks for a fresh start on purpose; an agent can never force `keep`. Gate: the same entitlement the dashboard's Themes page checks. | `restore_design {version_id}` of the pre-switch snapshot |
| `apply_theme_update` | Takes the update `check_theme_update` offered (`to_version` must be the one it returned; a theme that has moved on since is refused). **Only theme settings the store never changed take the new value; every setting it changed stays.** Pages, blocks, apps, products and copy are untouched. If an update would ever remove content the store wrote, it is refused (`content_lost`) and nothing changes. | `restore_design {version_id}` of the pre-update snapshot |
| `send_theme` | Sends **this store's current design** to Btab as a free theme other stores can use. `check_only: true` runs the same three checks as the send (structure and safety, its own layout, a render on a phone and a computer) and sends nothing: it returns the `check`, with problems as a `todo` list in words (the render step may be `queued`; read it later with `theme_status`). Otherwise `name` is required; `theme` = an id from `theme_status` sends a new version of that theme; `changelog` says what changed. A refused send returns the to-do list and sends nothing. Btab reviews every theme and emails its decision. Only while Btab takes themes from this store (`not_open` otherwise). | none (a sent theme is withdrawn by Btab's reviewer) |
| `update_store_copy` | `tagline` (≤255) and/or `about_text` (≤5000) through the same `PUT /vendor/site` chain the dashboard's Website form uses. **A hero block's headline is the store name, the tagline, or its own text** (`headlineSource`): set the tagline here for a tagline hero, or give the hero free text with `headlineSource: "custom"` + `headline` (≤120 chars) via `update_page_blocks` set-settings (gradient, banner and overlay variants). | previous values of the fields sent |
| `update_store_identity` | The store's **name** (1–255, trimmed; never the slug/URL) and/or **logo** — `logo_url` must be the `url` that `upload_media` / `list_media` returned (other hosts refused; `""` clears). It sets a logo from a file the store already has; it cannot draw or generate one. `favicon_url`: the browser-tab icon (also the phone home-screen icon), the same kind of `upload_media` url, a square image (512 × 512 works best); `""` goes back to the generated icon (a square logo, else the store's initials). The dashboard also takes a PNG or SVG upload (`POST /vendor/site/favicon`). `show_business_identity` (boolean, default off): whether the footer names the store's business (legal name + business number, as on its tax invoice) when it sells under its own business. `phone`: the store's phone number as shoppers dial it (6–15 digits; `""` clears), one store fact read by the Call Bar app. `address` (one line), `contact_email` and `hours` (`{ mon: [{ open, close }], …, sun: [] }`, 24-hour, `[]` = closed, `null` clears): store facts the storefront footer reads. Same `PUT /vendor/site` chain. | previous values of the fields sent |
| `restore_design` (destructive) | Puts the whole design back to a version (`version_id`, or omit for the most recent). Snapshots the current state first, so a restore is itself undoable. Another store's version id is not found. **Apps:** apps a theme switch turned on or off go back to how that version had them, so undoing a switch leaves the store exactly as it was. Apps the store switched itself (`enable_app` / the Apps page) stay as the store set them. An app the store's plan or btab no longer allows stays off. | `restore_design` of the pre-restore snapshot |
| `get_design_draft` | Whether this store edits a draft theme (`design_draft.mode`) and whether a draft exists now (`exists`, `started_at`, `updated_at`), plus `live_immediately` the other way round. | — |
| `publish_design` (destructive) | Puts the draft theme live for a store that edits one. Previews (published pages + theme settings → the draft's), then `confirm_token`. The published theme is saved as a version first. Apps the draft never touched keep their live settings (`apps_kept_live`). | `restore_design {version_id}` brings the previous theme back as a draft; `publish_design` again puts it live |
| `discard_design` (destructive) | Throws the draft theme away; the published theme is untouched. Previews, then `confirm_token`. The draft is saved as a version first. | `restore_design {version_id}` brings the draft back |

**Words in braces are filled in by the storefront.** A block or app setting may hold a token:
`{store}` (the store's name), `{tagline}`, or one of a theme's trade words such as `{shop all}`,
`{search hint}`, `{all products}`, `{back to all}`, `{about this}`. A theme writes these instead of
naming a trade, and the storefront fills them in for the store's type (a furniture store reads
"Shop all furniture", any other store reads "Shop all"). Leave a token as it is to keep that
behaviour; write plain text over it to set the shop's own words, which are then shown exactly as
written on every store type.

The workflow an agent is expected to follow: `get_store_design` → `get_block_schemas` (no
arguments) → `preview_design` → `update_page_blocks` / `set_theme_settings` → read the
`undo` it returned. To switch theme: `list_themes` → `preview_theme` → `apply_theme` (two-step). The handshake's `instructions` say the same in plain words. **If the change
involves a file you are holding — a video you generated, an image you made — Step 0 comes first:**
a design write that points a block at a temporary file host is refused, naming `upload_media`.

#### Old names (still work until 2027-01-02)

The theme tools were renamed when the dashboard started calling a Look a **theme**. The old names
are the **same tools with the same arguments**: they are still listed (their descriptions start
"Deprecated: use …") and still work until **2027-01-02**. Both argument spellings work with both
names (`theme_id` / `look_id`, `colour_set_id` / `palette_id`). A `confirm_token` belongs to the
name that minted it: confirm with the same name and the same arguments. The audit row of a call
records the name it was made with.

| Old name | New name | Note |
|---|---|---|
| `list_looks` | `list_themes` | |
| `apply_look` | `apply_theme` | |
| `check_look_update` | `check_theme_update` | |
| `apply_look_update` | `apply_theme_update` | |
| `set_theme` | `set_theme_settings` | Colours, fonts and tokens only. `set_theme` **never** switches a whole theme, and when it is retired it is removed, never given a new meaning. |

They are retired with a switch (BTAB Admin → Settings → Agents & API → Old tool names), and then
removed. While the switch is off the old names are missing from `tools/list` and a call to one is
refused with `TOOL_RENAMED`, naming the new tool; the new names are never affected. The REST routes were renamed the same way, and the old paths still work:
`GET /vendor/site/recipe/themes` (`/looks`), `POST /preview-theme` (`/preview-look`),
`POST /apply-theme` (`/apply-look`), `GET /theme-update`, `POST /theme-update/preview` and
`POST /theme-update/apply` (`/look-update…`).

### Rooms — Shop by Room (plugin `rooms`)

A **room** is a vendor-owned content object: a hero photo, a short intro and an ordered rail of the
store's own products, with a public page at `{rooms_url}/{slug}` (the store's `rooms_url` is on the
`store` block — `https://<store>/rooms`). Shoppers act on the **whole room in one click** — every
piece to the cart, or one quote request covering every enquiry-only piece (the storefront decides
from the products, never the agent). Rooms live in their own tables, so a room never depends on
page layout; products are re-joined at read time through the storefront's visibility rule, and a
product that leaves the store simply drops out of the room — **nothing is ever substituted in its
place**.

The two **read** tools above (`list_rooms`, `get_room`) and the five **write** tools below are
registered under the plugin (`src/plugins/rooms/mcpTools.js`) — the tool list has one shape for
every key; a call against a store that has not enabled the Rooms app returns `PLUGIN_NOT_ENABLED`,
never a different tool list.

#### Write tools — rooms (`write` capability)

**These exist only because room history does.** Every room write snapshots the room WHOLE — its
details *and* its pieces — into `vendor_room_versions` before it lands, attributed to the API key
that made it, and the vendor can put any version back from **Website → Rooms → Earlier versions**
(a restore is itself undoable). That was the precondition for letting an agent touch a room at all:
a room write with nothing to undo it does not ship. Ids come from `list_rooms`.

| Tool | Does | Undo recorded |
|---|---|---|
| `list_room_starters` | **Starter rooms (read):** the platform's empty styled rooms (living, dining, bedroom, terrace — two angles each) a store can start from when it has no room photo. Free, instant, no model call. Prefer a supplier's real room photo (`suggest_rooms`, source `shared-image`) when one exists. |
| `start_room_from_starter` | **Start a room from a starter (write, draft):** `starter_id` (from `list_room_starters`), optional `title`. The server records the photo as ours (`hero_source: "starter"`), sets `room_type` from the starter, publishes nothing. Then `add_room_products` → `generate_room_image` with `strategy: "stage"` to place them into the room → `choose_room_render`. |
| `create_room` | Create a room: `slug`, `title` and a photo (`hero_image_key` preferred, else `hero_image_url`) are required — a room without a photo is refused, because a room IS a photo and the pieces in it. The photo's **shape** and its **content** are both checked (see below), and a *placeholder* photo cannot be published (see below). Defaults to `draft`. | unpublish (when created published) |
| `update_room` | Change any of a room's fields by `room_id`. Does NOT touch the pieces. `status: "draft"` takes it off the storefront. Setting a **new** photo runs the shape check *and* the content-safety check below; publishing one that is still a placeholder is refused. | the previous values |
| `add_room_products` | Append products to the rail (or insert at `positions`). Ids already in the room are ignored, not duplicated; existing pieces keep their pins and notes. Capped by the platform's max-products-per-room setting. | remove exactly what was added |
| `remove_room_products` | Take products out of the rail. Ids not in the room are reported, not an error. Never delists or deletes the product itself. | add them back **at their original positions** |
| `publish_room` | `published: true` puts the room on the storefront; `false` takes it off. Refused while the room's photo is a placeholder (see below). | the status it had |
| `list_room_versions` | The room's saved versions, newest first, with the `version_id` every room write's `undo` refers to — **the only way to discover one**. Read it before `restore_room_version`. Bounded by the `vendor_site_recipe_version_retention` knob, so an old version can age out. | — (read) |
| `restore_room_version` | Write a previous snapshot's room fields **and** product rail back, by `version_id`. Restores the snapshot's publish state too, so restoring a version taken while the room was live puts it back on the storefront (and vice versa). Pieces the store no longer lists are dropped, never substituted. | the version it replaced (snapshotted first) |

##### The room photo is checked for its SHAPE, not just its presence

A room hero is the one image a shopper **pans across**: it fills the height of a
sliding window and overflows sideways. So when `create_room` or `update_room`
sets a **new** photo — a different `hero_image_key` or `hero_image_url` than the
room already had — that photo is measured first.

The one to know about is **360°/equirectangular**. A photo sphere is a wide JPEG
like any other, so nothing downstream can tell it apart; rendered flat it comes
out visibly bent, with straight walls bowing. Uploading one through
`upload_media` and then pointing a room at it used to work and produce exactly
that. It is now caught on the file's own GPano metadata.

```json
{
  "error": "Unprocessable Entity",
  "code": "equirectangular",
  "message": "This is a 360° photo sphere. Room photos are shown in a flat sliding window…",
  "panorama": { "ok": false, "code": "equirectangular", "aspect": null, "warnings": [] }
}
```

`code` is `equirectangular` (a photo sphere), `too_small` (it would be shown
shorter than the window and stretched), or `unreadable`. Aim for roughly **2.5:1
at 2400px across**; a phone's Panorama mode produces this naturally. A photo that
is merely narrow is **accepted** with a warning in `panorama.warnings` rather
than refused — it just will not slide.

Three things this deliberately does **not** do, so an agent can rely on them:

- **It only ever looks at a NEW photo.** Changing a title, re-sending the same
  hero, or restoring an earlier version never re-judges a photo already in place,
  so a room that works today cannot start failing.
- **A photo hosted somewhere else is never fetched, and never refused.** Only a
  `hero_image_key` (or one of our own image URLs) can be measured. An off-site
  `hero_image_url` is accepted unexamined.
- **Refusal is a platform setting** (`rooms_photo_check_mode`). It ships in
  `shadow`, where nothing is refused and the verdict comes back on a *successful*
  write as an optional `panorama` field — worth reading and passing on to the
  vendor, since in shadow it is the only warning they get.

##### The room photo is also checked for what is IN it

Separately from the shape check above, a room photo we hold is looked at by a
vision model and refused if it contains **nudity, sexual content, weapons,
drugs, gore or hate symbols**. Room photos are public — they are shown to
shoppers on the store's own site — so this is the one room-photo check that
ships **enforcing** rather than in shadow.

It runs at three points, and an agent can hit all three:

- setting a new photo with `create_room` / `update_room`;
- `generate_room_image` with `strategy: "stage"`, which sends the room's own
  photo to an image model — refused **before** the render is spent, so it costs
  nothing against the quota;
- the store's own dashboard upload.

```json
{
  "error": "Unprocessable Entity",
  "code": "unsafe_image",
  "message": "We can't use that photo — it looks like it contains nudity. …",
  "categories": ["nudity"]
}
```

`categories` is any of `nudity`, `sexual_content`, `weapons`, `drugs`, `gore`,
`hate_symbols`. **Do not retry the same photo** — the verdict is cached per
image and will not change. Choose a different one, or
`start_room_from_starter`.

The same three guarantees as the shape check apply, for the same reasons:

- **Only a NEW photo is ever judged**, so a room that works today cannot start
  failing.
- **A photo hosted somewhere else is never fetched, and never refused.** Only a
  `hero_image_key` (or one of our own image URLs) can be examined.
- **A photo the checker cannot judge is never refused** — a model timeout or
  error abstains, in every mode.

It is deliberately blind to what is ordinary in interior photography: wine
glasses on a table, an ashtray, a framed figure study, a decorative sabre on a
wall, kitchen knives, medicines, people sitting in the room. Refusal is a
platform setting (`rooms_photo_safety_mode`: `enforce` by default, or
`shadow` / `off`), and in `shadow` the verdict comes back on a *successful*
upload as an optional `safety` field instead.

##### `style` is the INSTRUCTIONS field, and it goes last on purpose

`style` reads like a tag list and is not one. It is the box a shopkeeper types
into, in her own words — *"move the table to the far end and make 4 chairs"* —
and it works: that exact sentence produced that exact scene on 2026-09-21. Treat
it as an instruction, not a mood board.

- **≤300 characters, plain text.** Longer is refused with a sentence that says
  how long yours is. **Links are refused** (`INVALID_ARGS`) rather than stripped:
  a URL in a prompt is either a fetch attempt or a paste accident, and
  generating anyway would spend one of the store's twenty monthly images on
  something nobody asked for. Control characters are refused for the same reason.
- **It is fenced and placed AFTER every hard rule.** It may ARRANGE the scene —
  move products, turn them, ask for several **copies of a product already in the
  room** ("four chairs" = four of the listed chair). It may **not** introduce a
  product that is not on the rail, change what a listed product looks like, or
  (with `strategy: "stage"`) alter the vendor's own photograph. An instruction
  that asks for any of those is simply not honoured — and if the model obeys it
  anyway, the fidelity judge rejects the candidate, so asking costs a generation
  and returns nothing usable.
- It is stored on the render row (`style.extra`) and comes back on the render,
  so you can show what was asked for without keeping your own copy.

##### `retry_of` — "try again, but…"

A near miss is not a failure. `retry_of: <render id>` says *this generation is
another attempt at that one*: same room, same pieces, amended `style`.

```json
{ "room_id": 357, "style": "four chairs, closer to the table", "retry_of": 412 }
```

The id must be a render **of this room** (a borrowed or guessed one is
`NOT_FOUND`), the attempts chain on the row so a surface can show them as one
conversation, and the earlier attempt stays exactly where it is — nothing is
deleted and its candidates remain choosable until the vendor picks one.

**It is not cheaper.** An attempt is a generation: the provider bills it and it
counts one against the monthly quota. Say so before you spend it.

##### `provider` never gets you the development placeholder

`mock` is a real name in the registry — it is the free, keyless renderer this
platform runs on locally and in CI, and it produces a flat colour, not a room.
It is not something a store can render with: `list_room_renders → settings.providers`
has never listed it, and on production **naming it is refused**:

```json
{ "error": "INVALID_ARGS", "message": "\"mock\" is a development placeholder, not a renderer this store can use." }
```

A render that names nothing is refused the same way if the store's own
configuration would land on it — `UNAVAILABLE`, *"AI scenes are not available
for this store."* — rather than spending one of the store's twenty monthly
generations on an image nobody can sell from. **Read `settings.providers` and
pass one of those, or pass nothing at all.**

##### A placeholder photo cannot be published

The dashboard writes an interim `placehold.co` "Room photo coming" hero so a room
can be saved before its photo exists. That is a real row, so an agent reading a
room can find one — and until 2026-09-19 nothing stopped it going live, which put
a grey box on a real storefront as the first card of `/rooms`.

A write that **asks to publish** — `status: "published"` on `create_room` /
`update_room`, a `publish_at`, or `publish_room {published: true}` — while the
photo the room would be left with is a placeholder is refused:

```json
{ "error": "Bad Request", "message": "Add a real photo before publishing — the placeholder can't go live." }
```

Two things this deliberately does **not** do:

- **An edit that is not a publish is never refused.** A room that is already live
  on a placeholder stays fully editable — fix the title, change the photo,
  unpublish it. Refusing every write would trap the vendor in the broken room.
- **Nothing is unpublished behind your back.** A room already live on a
  placeholder keeps its row exactly as it is; the **storefront** simply stops
  showing it (absent from `/rooms`, 404 on its page) until it has a real photo.
  `list_rooms` still reports it as published, because it is.

Give the room a real `hero_image_key` (upload one with `upload_media`) and it is
a page again immediately.

#### Suggest tools — rooms (`read` capability)

`suggest_rooms` and `suggest_room_products` **propose and write nothing**. They carry no `target`, no
annotations, no version row and no undo — because nothing happened. The agent turns a proposal into a
room with `create_room` / `add_room_products`, and *that* call is what the vendor can see in the
room's history and put back.

- **`suggest_rooms`** is free and instant: pure SQL over the store's own listings, no model. A shared
  supplier photo attached to more products than the platform's group-size setting (default 12) is a
  catalogue screenshot, not a room, and is refused rather than proposed — the refusals come back in
  `not_proposed` with a reason, because the number is worth seeing. A shared supplier photo must also
  BE a room: each one is looked at once, ever ("is this a photographed room, or a swatch chart or a
  cut-out on a white backdrop?"), and the answer is kept for good. Photos not yet judged come back as
  `unclassified` and are picked up by the next call rather than proposed on a guess. A room proposed
  from a shared photo is named after its **pieces** (the range they share plus the room type,
  e.g. *"VI Veronica bedroom"*), never after the image filename.
- **`suggest_room_products`** is the one AI call on this surface, rate-limited per store (the same
  bucket the dashboard button uses, so two surfaces are one budget). It **verifies** a dozen known
  candidates against the room's photo rather than detecting objects in an open catalogue — which is
  precisely why it is cheap and accurate here and why *"find my products in any photo I upload"* is a
  different, much harder feature. Ask for that and the honest answer is that this tool cannot do it.
- Anything the model is unsure about is returned in `unsure` and **never** presented as a match. A
  wrong pin is worse than a missing one. A hero already known not to be a room short-circuits: the
  tool returns `hero_is_room: false`, an empty `suggestions` list and a `note` saying to swap the
  photo — because a cut-out gives the model nothing to verify against, and what comes back then is a
  confident guess, not a match.

There is deliberately **no `delete_room`**. Deletion stays on the dashboard; an agent unpublishes.

⚠️ **A published room is live the instant the call returns.** Rooms are vendor *content*, not a
design recipe: there is no separate publish step and no draft preview link. To schedule instead, set
`publish_at` through `update_room` — a published room stays hidden until that time passes (and an
unparseable timestamp is refused rather than hiding the room forever).

⚠️ **Never substitute a different product for one that has left the store.** A room re-joins its
pieces to the catalogue at read time, so a delisted piece simply drops out and the room still
renders. If a vendor asks why a piece is gone, say it has gone — do not swap in something similar.
Houzz auto-tagged designers' own photography with "visually similar" marketplace products, 1,920
designers petitioned, and the policy was reversed on 2020-03-03. Our route is safe by construction
(the supplier's own photo, the supplier's own SKUs, vendor-confirmed) and stays safe only while
nothing swaps a pin silently.

### Things worth asking

- *"What should I deal with first?"* — `get_attention`; `latest.title` is the one-line answer and
  `counts` says how much else is queued behind it.
- *"Which quotes were viewed but never accepted?"* — `list_enquiries`, then read `quote.viewed_at`
  against `quote.converted_order_id`.
- *"Which store am I connected to, and where does my blog live?"* — `store_overview` (`url`, `blog_url`); the handshake's `instructions` already said it.
- *"Can Google see my store?"* — `store_overview` for `indexable` (false until the store's own domain serves and the switch is on), then `search_visibility`.
- *"How were sales this month / what did I earn?"* — `vendor_sales_overview`.
- *"What sold best / which category / which channel / how did my code do / what gets returned?"* — `vendor_sales_breakdown`.
- *"How many customers came back / who are my best customers?"* — `vendor_customer_report`.
- *"Did my Shopify orders come in / did the Vivin feed run / is anything broken?"* — `vendor_automation_health`.
- *"How many visits did I have?"* — `store_traffic`. Not `search_visibility`: Google clicks are not visits.
- *"What is stopping me quoting delivery?"* — `catalogue_health` → `missing_weight` /
  `missing_dimensions`.
- *"Is my range too broad?"* — `catalogue_health` → `listed`. A store carrying essentially the whole
  catalogue is harder to rank than a focused one; the tool says so when it sees that shape.
- *"Put the three slowest sellers on enquiry-only."* — `list_products`, then `set_enquiry_mode`
  with `dry_run: true` per product, then the real call with an `idempotency_key`.
- *"Take the store offline for the weekend."* — `set_store_published {is_published:false}` → read the
  preview → call again with the `confirm_token`.
- *"What does my store look like?"* — `get_store_design`; then `get_page_blocks {page:"home"}` for the detail.
- *"Show me a preview with the hero headline changed."* — the headline is the store name or the
  tagline: `update_store_copy {tagline}` sets the text, then `get_page_blocks` for the hero's id →
  `preview_design {ops:[{op:"set-settings", … headlineSource:"tagline"}]}` → open `preview_url`. The
  preview itself saves nothing.
- *"Make it real."* — the same ops through `update_page_blocks`; it is live at once. Keep the
  returned `undo`.
- *"Show me my store in the Aurora theme."* — `preview_theme {theme_id:"aurora"}` → open `preview_url`.
  Nothing is saved.
- *"Switch to Aurora, then put it back."* — `apply_theme {theme_id:"aurora"}` (two-step) →
  `restore_design` with the `version_id` the result returned (two-step).

## Capabilities

Every tool declares a **capability** — `read`, `write` or `destructive` — and the registry refuses
to register one that does not (`src/lib/mcp/registry.js`). What a caller may do is a **set of
capabilities derived once per request** from the credential and the platform kill switch:

| Credential | Kill switch off | Kill switch on (today) |
|---|---|---|
| `read` API key | `read` | `read` |
| `read_write` API key | `read` | `read`, `write`, `destructive` |
| Dashboard session | `read` | `read`, `write`, `destructive` |

That set is enforced **at dispatch**: `tools/list` only shows tools in the caller's set, and a
`tools/call` outside it is refused *before the handler runs* with `isError: true` naming the fix
(`[SCOPE_DENIED] … mint a read_write key`). This is the guarantee that replaced the first cut's
"no write tool can be registered" — it stays true as write tools arrive.

MCP **annotations** (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) are
emitted per tool so a client can label it and decide whether to prompt. They are hints for the
client's UI and, as the spec says, untrusted. **They are never the gate.** The dispatch check is.

Every tool also declares a **group** — `products` · `store` · `orders` · `insights` · `account` —
the vendor-facing bucket the dashboard's capability drawer files it under (`GET
/api/v1/vendor/agent-activity/capabilities` returns it as `group`). It is declared on the tool,
never inferred from its target, so it cannot drift silently when a controller is renamed; an
unknown value is refused at registration. It carries no permission meaning — the capability does.

### The kill switch and the budget

- `vendor_mcp_writes_enabled` (Admin → Settings; **on** since 2026-09-07 after the L2 production
  smoke test) — off means every credential is read-only, and a write call returns `[SCOPE_DENIED]`
  naming the switch. Flipping it off is the no-deploy kill switch for every write tool at once.
- `vendor_mcp_writes_per_hour` (Admin → Settings, default **120**) — write/destructive attempts one
  credential may make per hour, counting dry runs and refusals. Over it → `[RATE_LIMITED]` with
  `retryAfterSeconds`. `0` is a second kill switch.
- `vendor_mcp_bulk_max` (Admin → Settings, default **200**, never above the API's own 500) — ids per
  `bulk_*` call. Over it → `[BULK_CAP_EXCEEDED]`.

### Every write is audited

Every write/destructive call — executed, previewed, or refused — appends **one row** to
`vendor_agent_writes`: tool, capability, a hash of the arguments, the arguments with secrets masked,
`dry_run`, outcome, and where the tool can say so, `before` / `after` / `undo`. A write tool
**cannot run without the audit sink** — that is enforced in the registry, not by convention. The
dashboard's Agent activity panel reads this table (`GET /api/v1/vendor/agent-activity`, session only).

| `outcome` | Meaning |
|---|---|
| `ok` | executed (or, with `dry_run: true`, previewed — the row says which) |
| `error` | the controller refused it — the row carries the API's own message and code (`RETAIL_PRICE_LOCKED`, `HTTP_404`, …) |
| `refused_scope` | a `read` key called a write/destructive tool; nothing ran |
| `refused_disabled` | the platform kill switch is off; nothing ran |
| `refused_rate` | over `vendor_mcp_writes_per_hour` for this credential |
| `refused_idempotency` | same `idempotency_key`, different arguments |
| `refused_confirm` | a `confirm_token` for other arguments, another vendor/tool, or past expiry |

A destructive tool's first (preview) call is an `ok` + `dry_run` row; its confirmed second call is
a separate `ok` row. Undo from the dashboard writes its own row too.

### Arguments every write tool accepts

- `dry_run: true` — preview. The tool reports what *would* change and writes nothing; the audit row
  is marked `dry_run`. It is a preview from the live row, **not a guarantee** — the real call can
  still fail on a rule the preview did not exercise.
- `idempotency_key` — a string of your choosing. A repeat with the same key and the same arguments
  returns the stored result and **executes nothing**; the same key with *different* arguments is
  refused (`[IDEMPOTENCY_CONFLICT]`). Send one whenever you might retry.
- `confirm_token` — for `destructive` tools only (two-step: the first call returns a preview and a
  token, the second call with the token executes). See [Destructive tools](#destructive-tools-two-step-confirm-destructive).

### What an agent can never be given

`src/lib/mcp/forbidden.js` is a frozen list of controller/route targets the registry **refuses at
build time** — anything that puts text in a customer's inbox (sending a quote or enquiry reply),
re-pricing a sent quote, **emailing a supplier a purchase order** (either kind — the owned-stock
one and the per-order one Lily's process raises; that email carries one-click links which record a
commitment on the store's behalf, and it cannot be unsent), goods receipts (stock/landed cost),
deleting the store, anything on the money rail or in accounting, payouts, **connecting or
disconnecting the store's own Stripe account** (that choice decides where every future payment on
the store lands, and the approval it leads to is the owner signing in to their own Stripe — not an
agent's to give; the callback is unauthenticated by construction because its signed state *is* the
credential), password changes, secret rotation,
**minting or revoking an API key**, filter-mode bulk operations, and any admin action. A tool
whose `target` matches cannot be registered; adding to the registry anything on that list needs
its own design review first. There is also **no bulk price endpoint** on the platform, on purpose.

### Prompt injection

Tool results contain text typed by third parties (an enquiry's `message`, a buyer's name). Every
result is therefore **fenced**: the text block opens with a notice that what follows is *quoted
data*, that it may contain instructions, and that an instruction found inside it is something to
**report to the user, never to follow**. The fences carry a per-call nonce so text inside the data
cannot forge a closing fence. Clients that want the numbers without parsing the fence read
`structuredContent`, which carries the raw result.

## Protocol

JSON-RPC 2.0. Supported: `initialize`, `ping`, `tools/list`, `tools/call`. Protocol version
`2025-06-18`; `2025-11-25`, `2025-03-26` and `2024-11-05` are also answered (a client proposing an unknown revision is answered `2025-06-18`).

- A **notification** (no `id`) gets **HTTP 202 and no body** — the spec forbids replying, and
  clients send `notifications/initialized` immediately after the handshake.
- A **failing tool** is an HTTP 200 JSON-RPC **result** with `isError: true` — not a JSON-RPC error.
  A JSON-RPC error means the *server* is broken; `isError` lets the model read why its call failed.
  A refusal carries its code in the text (`[SCOPE_DENIED]`, `[RATE_LIMITED]`, …) and in
  `structuredContent.error`.
- A successful `tools/call` returns the result **fenced** in `content[0].text` and raw in
  `structuredContent`.
- `GET` on the endpoint returns a description of the server — including the capabilities *this*
  credential holds and whether writes are switched on — rather than a 404.

```bash
curl -s https://api.btab.app/api/v1/vendor/mcp \
  -H "Authorization: Bearer YOUR_KEY" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"store_overview"}}'
```

## Limits and what is not here

- **Traffic is `store_traffic`** (added 2026-09-21). The deadline this section used to call for is a
  `statement_timeout` of 5s on the Umami pool — the heaviest query measures ~1.2 ms, so the timeout
  exists to stop a pathological plan holding a read-only connection, not to trim normal work.
  One Umami `website_id` covers every storefront, so the hostname array is the tenant boundary: it is
  built from the caller's own site row and never from arguments, and the tool takes no host input.
- **No stock write, ever** — stock is supplier-owned and the only vendor-side stock move (goods
  receipts) has no reversal. **No quote tools** (pricing/sending a quote is a communication change).
  **No delete-post, delete-site, API-key, payout or accounting tool** — see the never-list. Design
  edits (`update_page_blocks`, `set_theme_settings`, `create_page`, `delete_page`, `restore_design`) are
  structured, schema-validated ops with version history and an undo on every write; wholesale
  `set-recipe` replacement is refused by the tool.
- **No transcoding, and no poster extracted from a video.** BTAB stores the file you send and serves
  it back; it does not re-encode, resize or compress a video, and it will not pull a still frame out
  of one for `posterUrl` — upload the poster yourself. Send web-ready files (H.264 MP4 or WebM).
- **No media tool takes a file from the DASHBOARD's disk.** The three agent doors are `data_base64`,
  `source_url` and `create_upload` (see Step 0); a vendor uploading by hand uses the dashboard's own
  multipart route.
- Tool **names are an API** — a saved prompt refers to them. Renaming one is a breaking change; add
  a new tool instead.

## Keeping the MCP in step

The MCP's *behaviour* stays fresh by construction — every tool delegates to the same controller the
dashboard and the REST API use, block schemas come from the generated schema file (which has its own
staleness gate in CI), and `update_page_blocks` ops come straight from the differ's `ALLOWED_OPS`.
The *contract* — tool names, arguments, descriptions, the handshake `instructions`, this page — is
hand-written, so CI holds it to the code:

- **`MCP coverage` (CI, every PR).** A PR that changes a vendor-facing surface (vendor site, recipe,
  posts, enquiries, products, orders, customers, the MCP routes, store creation) must **also** change
  the MCP contract in the same PR — a tool under `src/lib/mcp/` or this document — **or** carry the
  label **`mcp-no-change`** with a PR-body line `MCP: no change — <reason>`. The reason is printed
  into the job summary so a reviewer sees why an agent needs nothing new. Otherwise the job fails and
  names the files.
- **Plugin-owned tools.** A plugin can ship its own tools: its manifest declares `mcpTools: 'mcpTools.js'`
  (a module path relative to the plugin directory) and that module exports `register(tools, deps)` in
  the same shape as the core tool modules; `src/lib/mcp/tools.js` registers it at boot after the core
  set, so a plugin's tools live beside the plugin and count as the MCP contract for the coverage gate
  above (`src/plugins/<id>/mcpTools.js`). **Registration is global, enablement is per store:** every key
  sees the same tool list, so an agent can learn one surface; whether *this* store may call a plugin's
  tool is decided inside the tool, by whether the plugin is enabled for the store (Website → Apps) —
  a call against a store without the plugin returns a clean `PLUGIN_NOT_ENABLED` error, never a
  different tool list.
- **Registry ↔ API drift test.** Every write/destructive design tool's declared target must resolve
  to a real exported controller function (the one the tool actually calls), the advertised op
  vocabulary must equal the differ's, and this page must name every design tool.
- **Generated schemas gate.** `recipeData.generated.json` must match the TypeScript block schemas it
  is generated from, so `get_design_system` can never describe a block the storefront no longer
  renders.
- This page and its public copy at `https://btab.app/docs/vendor-mcp.md` are byte-identical (tested).

## See also

- `docs/api/VENDOR_INTEGRATION_GUIDE.md` — the REST API and API keys
- Each storefront's `/agents.md` and `/.well-known/ucp` — the inbound half
