Storefront modules

Features once, themes as skins: the storefront module contract, the read API, the agent surface and how the next module is added.

Written 2026-09-13.

What a module is

A module owns a storefront feature end to end: its settings, its data, what it renders, how it behaves in the browser, and the command an agent uses to change it. A theme owns look: a stylesheet, page shells, and a small set of primitives (button, input, chip, product card, grid …) drawn in that theme's own markup.

A module renders through the primitives of whichever theme you have on. So a feature works on all six themes the day it ships, changing theme never takes one away, and no module edits a theme.

Where a module appears

A module claims one or more slots, which every theme has:

SlotWhere it is on the page
header.topabove the navigation — the announcement bar
header.actionsbeside the cart link — the search box
catalog.toolbarabove a listing — collection chips, sort, result count
catalog.belowunder a listing
home.belowafter the home page's own sections — the collection grid
product.optionsthe variant picker
product.belowunder the product
cart.belowunder the cart summary — trust badges
checkout.belowunder the payment page summary
footer.topabove the footer

cart.below and checkout.below belong to no theme at all, so a module can sit beside a shopper's total without the cart markup knowing about it. Everywhere else a theme may place the slot itself, in its own upstream markup; where it does not, the slot is placed for it. Either way you change nothing, and a theme you switch to tomorrow shows the same modules.

Every theme implements the same primitives — Button, Link, Chip, Input, Select, Field, Heading, Text, Bar, Toolbar, Section, Grid, ProductCard, EmptyState. Where one is missing, the module falls back to a neutral primitive drawn in currentColor, inherit and your own accent colour, so nothing ever renders broken.

Settings and commands

Module settings live in your theme draft, keyed by module id, and every module's settings include enabled. A module that activates on a condition ignores enabled: false while that condition holds: the variant picker cannot be removed from a product with options, because a variant product bought without a variantId is refused at checkout.

set-module-settings { moduleId, settings } is the only way a merchant or an agent changes a module. The settings are validated against that module's own schema — unknown module ids and unknown keys are rejected — and nothing uploads code.

The modules

ModuleSlotsDataDefault
product-variantsproduct.optionsthe product's own optionson when the product has options
announcement-barheader.topsettings only (text, link)off
catalog-searchheader.actions/search?q= over name, description, SKU, tagson
catalog-filterscatalog.toolbarcollections as chips, sort (newest, price, name), result counton when collections exist
collection-gridhome.belowyour collectionson when collections exist
trust-badgescart.below, checkout.belowsettings only (three headings and three lines)off
custom-blockanywhere in a page's layout, and the header.top / footer.top slotssettings only (html, css, width)off until the first block

Collections are commerce data, edited in Products → Collections (/api/collections) rather than in the theme draft. A collection is manual (products you choose) or automated (products with any or all of some tags). Every active collection has a page at /collections/<handle> and appears in the sitemap. One catalog page renders all three listings — all products, one collection, and search results.

Trust badges. trust-badges is a reassurance row under the cart and payment summaries: secure checkout, returns and dispatch time, with a curated three-icon set and editable text. The icons are ours on purpose — you can reword a line, but you cannot put a padlock beside a claim your store does not honour. The row reads --fh-accent, --fh-muted and --fh-border from your brand settings, so it wears your own palette on every theme.

Custom blocks

Added 2026-09-16, widened 2026-09-23. Custom code is the reference for the allowlists, the limits and the security model; this is the module they arrive through.

custom-block (category content, activation manual) is what lets you put your own markup on the storefront without a theme change and without uploading code. Its settings are `{ enabled, blocks: [ { id, html, css, placement, sectionId, pageSlug?, width, visible } ] }` — up to 40 blocks per store, 32 KB of HTML and 8 KB of CSS each, inside one 256 KB custom-code budget. A block with a pageSlug is anchored above or below that content page's body instead of a home section.

Where a block renders is the page layout (document.layout, see storefront themes): every page is one ordered list of sections and blocks, and the header and footer slots are lists of blocks. placement, sectionId and pageSlug are kept on each block as a description of where it sits, for readers that still speak anchors; a draft written before layouts is read with the layout those anchors describe, so it renders exactly as before. A hidden section draws nothing and the blocks beside it stay. width is content (the theme's content column) or full.

Commands: add-custom-block, update-custom-block, move-custom-block (all three take position { placement?, target, page?, slot? }), remove-custom-block and set-layout { page | slot, order }, listed by list_modules with everything else. In the builder a page's sections and blocks sit side by side and are dragged with the same handle; the header and footer take blocks too. A block's fields are an HTML textarea, a CSS textarea, a width choice and a "what is allowed" note, and it is previewed in a sealed frame.

Sanitization runs twice: once when the block is saved, so what is stored is already safe, and again when it is rendered, so a block that reached your store's data another way, or was saved by an older version of the editor, still cannot bring markup of its own.

HTML allowlist (the full table is in custom code §1):

KeptNotes
h2–h6, p, lists, blockquote, figure, details, section, article, inline text elements, br, hrh1 is not on the list: one <h1> per page
table, thead, tbody, tfoot, tr, th, td, captioncolspan, rowspan, scope
div, spanclass, a namespaced id, title, lang, dir and up to four data-*
a[href]a site path, https:, mailto: or tel:; gains rel="noopener noreferrer nofollow"
img[src]HTTPS only; gains loading="lazy" and an alt
iframe[src]HTTPS www.youtube.com/embed/…, youtube.com/embed/…, www.youtube-nocookie.com/embed/… or player.vimeo.com/video/… only; gains loading, referrerpolicy and a sandbox

Everything else goes: script, style, form and its controls, svg, object, embed, video, audio and friends are dropped **with their contents**; any other unknown element loses its tags but keeps its text; comments, doctypes, on* handlers and style attributes are removed, an id is namespaced to fhc-… (and an in-block #anchor follows it); remaining text is escaped. Sanitizing is idempotent, so the save-time and render-time passes agree.

CSS: @import, @charset, @namespace, expression(, position: fixed, javascript:, behavior:, -moz-binding and any < are refused, and any url() that is neither HTTPS nor your store's own /media/ path takes its declaration with it. Only @media and @supports survive as at-rules. Every remaining selector is then prefixed with .fh-custom-<id> at render time, so a block's CSS can only reach inside that block — an attribute-selector exfiltration trick can match nothing outside it, and it has no non-HTTPS address to send to.

Blocks are data in the theme draft like every other module setting: nothing goes live until publish_storefront. The builder shows a block in a sealed sandbox="" frame, never in the dashboard document ([custom code](custom-code.md) §6).

Storefront read API

Every store serves a public, read-only JSON API on its own hostname, backed by the same loaders as the pages:

GET /api/storefront/products?collection=<handle>&q=<text>&sort=<newest|price-asc|price-desc|name>
GET /api/storefront/products/<slug>
GET /api/storefront/collections
GET /api/storefront/collections/<handle>

Responses carry Access-Control-Allow-Origin: * and a 60 s public cache. This is the headless surface: your own front end or an agent reads the catalog without a token. Writes never go through it.

To sell from such a front end, list its origin (Online Store → Domains → Custom front ends) and call POST /api/checkout with JSON; see agents → "Headless: your own front end".

Agent surface

  • Scoped tokens (Settings → Agents & API). Authorization: Bearer fh_… is accepted by the same API the dashboard uses. Scopes: storefront:read, storefront:write, storefront:publish, products:read, products:write, orders:read, orders:write, settings:read, settings:write. Tokens are hashed at rest and shown once.
  • MCP endpoint at https://formahand.com/mcp (Streamable HTTP, JSON-RPC). Tools mirror the typed API: get_store, list_modules, get_storefront_draft, apply_storefront_commands, publish_storefront, list_products, get_product, upsert_product, list_collections, upsert_collection, list_orders, get_order, list_content_pages, set_navigation. Publication is always an explicit tool call.
  • Events and subscriptions (events): every write is an event (<resource>.<verb> plus aliases such as order.paid, order.delivered); subscriptions match topic patterns and a predicate and deliver CloudEvents signed per Standard Webhooks plus X-Formahand-Signature, with backoff retries. The older fixed event list survives only as the /api/webhooks compatibility route.

Shipping providers

shipping-rates is the "Shipping & tracking" capability. A provider supplies rates, and optionally tracking (track(number, carrier)). The choices are flat (built in), karrio, shippo and easypost. You activate exactly one per store in Integrations; Checkout → Shipping shows which one is live. An order with a tracking number gets "Refresh tracking" through the active provider, which records tracking_status and tracking_checked_at. See shipping for the three shipping modes, labels and the buyer tracking page.