Developer guide

Vendor MCP — drive your store from your own AI agent

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 over streamable HTTP, so it w…

Written with the code it describes, in the same pull request. Plain Markdown: /developers/vendor-mcp.md

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 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.

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):

{
  "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.

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

Claude Code (one line) —

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

ToolAnswers
store_overviewWhich 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_postsThis 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_helpSearch 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_visibilitySitemap submitted? Home page indexed? Clicks and impressions over 28 days, and the top 5 search queries that reached the store.
store_trafficVisits, 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_reviewDeposits 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_overviewSales 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_breakdownOne 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_healthEvery 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_reportCustomers 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_enquiriesEnquiries with quote state — quoted, sent, viewed by the buyer, won, lost. Optional status, limit (1–100, default 25).
get_attentionWhat 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_summaryOrder counts by status. Optional status, start_date, end_date.
list_ordersFind 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_orderOne 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_posEvery 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_ordersWhere 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_catalogueThe 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_rangesRanges: 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_productOne 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_healthListed 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_pluginsThe 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_mediaThe 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_roomsShop 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_roomOne 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_roomsRead-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_productsRead-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_imageAI 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_rendersRead-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_renderMakes 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.

ToolDoesUndo 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_listingList an approved catalogue product, optional custom_retail_price_cents.remove
remove_product_listingUnlist (soft).re-add with the previous price
set_product_priceSet 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_modeenquiry_only_override: true / false / null (inherit).previous value
set_low_stock_alertlow_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_fulfilledvendor_fulfilled: true / false.previous value
set_channel_visibilityKeep 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_manyThe 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_discountA 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_discountEdit 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_campaignOpt 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_campaignOpt 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_settingsTurn 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_returnB2: 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_supplierAdd 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_supplierArchive a supplier from the book (existing purchase orders keep their details).—
set_supplier_abnOne 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_warehousesReplace 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_supplierskind: "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_shareShare 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_shareRemove 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_orderaccept, 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_mindB2: 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_cardD4: 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_settingsD4: 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_settingsSet 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_settingsReplace 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_settingsReplace 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_settingsSet 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_menuT-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_collectionCreate 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_settingsSet 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_reviewaction 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_photoReviews 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_reviewReviews 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_reviewsReviews 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_descriptionStore'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_draftEdit a pending AI content draft.previous draft
discard_product_draftDiscard a pending draft.none (one-way)
revert_product_draftRestore pre-approval content (one-shot).none
select_product_imagesSet 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_imagesSet display order.previous order
remove_product_imageDrop one selected image.re-select it
set_product_image_altSEO: 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_hiddenHide (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_mediaThis 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_mediaAttach 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_listingSEO: 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_chartColour 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)

ToolDoesUndo recorded
update_store_seoSEO title/description/keywords, hero headline/subheadline, Google site verification. Only the fields sent change.previous values of the fields sent
vendor_generate_policyBRIEF-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_textPolicy 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_postPosts 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_statusnew · 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_appTurn 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_orderpending → accepted for this store's part of an order. Silent (no customer notification).none (one-way)
set_purchase_order_send_modesend_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_settingsThe 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_orderThe 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_replyA 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_orderOn 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_orderOn 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: deliverpickup, name, line1, line2, suburb, state, postcode, phone }, customer_invoice_ref, required_byandpo_date (YYYY-MM-DD); a line may also carry description, skuorremove: 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_dispatch_detailsWhat 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_supplierOn 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_orderFor 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_ordersRaises 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_ordersRemoves 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_answerThe 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).

ToolDoesWhy two-step
set_store_publishedis_published: true / false — the store goes live or offline for every visitor.store-wide, visible to the public
ship_orderMark 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_listingsUp 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_collectionDeletes 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_rangeAdds 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_importPuts 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_importReviews 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 haveCallLimit
Bytes you just generatedupload_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 limitcreate_upload → PUT the bytes → finalize_uploadvendor_media_max_mb; nothing passes through the model's context
ToolWhat it doesUndo
upload_mediaPuts 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_uploadFor 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_uploadAfter 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:

# 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):

ToolAnswers
get_store_designEverything 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_pagesJust the pages array.
get_page_blocksOne 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_schemasCall 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_themesThe 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_themetheme_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_updateIs 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_statusThe 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 " credit). check_id returns one check in full with its todo list.
list_design_versionsThe store's design history, newest first — id, source (dashboard · agent · admin · look · restore), who, label, when. No recipe payloads.
preview_designops (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.

ToolDoesUndo recorded
update_page_blocksops[] applied in order, validated as a whole: set-settings {page, blockId, settings} (send the full settings object) · `add-block {page, position:{at:"start""end"
set_theme_settingscolors (#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_pageA 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); 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_updateTakes 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_themeSends 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_copytagline (≤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_identityThe 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_draftWhether 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 nameNew nameNote
list_lookslist_themes
apply_lookapply_theme
check_look_updatecheck_theme_update
apply_look_updateapply_theme_update
set_themeset_theme_settingsColours, 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.

ToolDoesUndo recorded
list_room_startersStarter 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_starterStart 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_roomCreate 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_roomChange 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_productsAppend 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_productsTake 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_roompublished: 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_versionsThe 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_versionWrite 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.

{
  "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.
{
  "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.

{ "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:

{ "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:

{ "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:

CredentialKill switch offKill switch on (today)
read API keyreadread
read_write API keyreadread, write, destructive
Dashboard sessionreadread, 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).

outcomeMeaning
okexecuted (or, with dry_run: true, previewed — the row says which)
errorthe controller refused it — the row carries the API's own message and code (RETAIL_PRICE_LOCKED, HTTP_404, …)
refused_scopea read key called a write/destructive tool; nothing ran
refused_disabledthe platform kill switch is off; nothing ran
refused_rateover vendor_mcp_writes_per_hour for this credential
refused_idempotencysame idempotency_key, different arguments
refused_confirma 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.

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.
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