Sources
The GatewaySources contract: how the gateway reads your source of truth, field by field, and the guarantees that govern what it renders.
Last updated
The contract
A source is a lookup from a request URL to your source-of-truth data. Return the data when the URL is yours; return null (or undefined) when it isn’t. Sync or async both work, but resolvers run on the edge hot path (p95 < 50ms compute budget), so back them with your in-memory catalog, a KV cache, or a fast local store, not an origin round-trip.
interface GatewaySources { // all optional; missing source => pass through
// Commerce shaped.
product?: SourceResolver<ProductSource> // a product detail page
policies?: SourceResolver<PolicySource[]> // shipping, returns, warranty
catalog?: SourceResolver<CatalogItemSource[]> // a priced listing
// Universal: any page on any site, and any index of them.
document?: SourceResolver<DocumentSource> // a guide, article, service, location, FAQ
collection?: SourceResolver<CollectionSource> // an index of documents
// Optional router. Pure and synchronous. When it names a kind, that resolver
// is the only one called. Return null to use the fixed order below.
match?: (url: URL) => SourceKind | null
}
type SourceResolver<T> = (url: URL) => T | null | undefined | Promise<T | null | undefined>
type SourceKind = 'product' | 'policies' | 'catalog' | 'document' | 'collection'Resolution order & error containment
- The order is fixed:
product→policies→catalog→document→collection. The first source returning data wins, so product wins when several would match a URL. The two universal sources sit below the three commerce ones, which is what makes them safe to add: a store that wires site-wide documents keeps its PDPs rendering as PDPs. Narrow the higher-precedence resolver when two would match. There is no separate setting for it. - A source that throws is treated as "no match" and resolution continues with the next source. It never breaks your site.
- If the render itself fails, the request passes through to HTML; the gateway never substitutes a lower-precedence document for the one that matched.
- An empty
policies/catalogarray is no match. So is a missing source: configure only the resolvers you have.
ProductSource
One product detail page. Rendering order is fixed and front-loaded: title (linked to the canonical URL) → brand → price (compare-at struck through when present: ~~$120.00~~ $89.00) → availability → shipping → returns → variants table → description → attributes → images as markdown links. Absent optionals render nothing.
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | yes | Canonical product URL; the linked title points here. |
title | string | yes | Product title. |
brand | string | no | Rendered as a Brand fact line. |
description | string | no | Rendered verbatim under a Description heading. |
price | Money | yes | Current price. Minor units (see Money below). |
compareAtPrice | Money | no | Struck through next to the price when present. |
availability | Availability | yes | 'in_stock' | 'out_of_stock' | 'preorder' | 'backorder', rendered with deterministic labels (In stock, …). |
variants | ProductVariantSource[] | no | Rendered as a table: id / title / options / price / availability. |
shipping | ShippingSource | no | Front-loaded shipping facts. |
returns | ReturnsSource | no | Front-loaded returns facts. |
images | { url: string; alt?: string }[] | no | Rendered as markdown links at the bottom. |
attributes | Record<string, string> | no | Material, size-chart facts, etc., rendered verbatim under Details. |
ProductVariantSource, ShippingSource, ReturnsSource
| Field | Type | Required | Notes |
|---|---|---|---|
ProductVariantSource.id | string | yes | Variant identifier (SKU-like id shown in the table). |
ProductVariantSource.title | string | yes | Variant title. |
ProductVariantSource.price | Money | yes | Per-variant price. |
ProductVariantSource.availability | Availability | yes | Per-variant stock state. |
ProductVariantSource.sku | string | no | Explicit SKU when distinct from id. |
ProductVariantSource.options | Record<string, string> | no | e.g. { Color: "Juniper Green" }. |
ShippingSource.summary | string | yes | Merchant-authored shipping summary. Rendered verbatim. |
ShippingSource.freeThreshold | Money | no | Free-shipping threshold. |
ShippingSource.regions | string[] | no | Ship-to regions. |
ShippingSource.etaDays | [number, number] | no | [min, max] delivery estimate in days, from the merchant. |
ReturnsSource.summary | string | yes | Merchant-authored returns summary. Rendered verbatim. |
ReturnsSource.windowDays | number | no | Return window in days. |
ReturnsSource.url | string | no | Link to the full returns policy. |
PolicySource
Policy documents (shipping, returns, warranty, …), rendered as linked headings + verbatim bodies.
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | yes | Policy title, rendered as a linked heading. |
url | string | yes | Canonical policy URL. |
body | string | yes | Policy text. Rendered verbatim, never summarized or reworded. |
CatalogItemSource
Collection/catalog listings, rendered as a markdown table (linked title, price, availability).
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | yes | Item URL; the table’s linked title points here. |
title | string | yes | Item title. |
price | Money | yes | Item price. |
availability | Availability | yes | Same enum as products. |
DocumentSource
Any page that is not a product, a policy set, or a priced listing. A guide, a service, a location, an article, an FAQ, a plan, a profile, a job posting. Most of a site is usually documents, and a site with no catalog wires this and collection and nothing else.
The shape is the same promise as the commerce types: every field is authoritative data you supply, and the renderer only transforms format. It never invents, estimates, or rewords a value.
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | yes | Canonical URL of this page. |
title | string | yes | Page title. |
kind | DocumentKind | no | Advisory routing label, never rendered: page, service, location, plan, article, faq, profile, event, listing, job, course, or your own string. |
summary | string | no | Verbatim, rendered as a blockquote at the top. |
updated | string | no | ISO 8601, verbatim. |
access | 'free' | 'registered' | 'metered' | 'subscriber' | no | Default free. sections is emitted only when this is free; anything else renders summary, facts, actions and an access notice. Mechanical, so no renderer bug can leak a paywalled body. |
facts | Fact[] | no | The front-loaded block, and the reason this type exists. Your order is authoritative. Capped at 60. |
actions | ActionSource[] | no | { label, url, kind?, note? }. Capped at 20, never truncated mid-list. |
contact | ContactSource | no | { phone?, email?, url?, address?: string[] }, address in your display order. |
sections | { heading?, body }[] | no | Prose. Emitted only when access is free. Truncatable under the byte cap. |
related | LinkSource[] | no | Cross-links. Last in the tail, and the first thing dropped under the byte cap. |
A Fact is { label, value, note? }, and value is a closed union so every rendering is deterministic and locale-free: text, list, number (with an optional unit), boolean (rendered as a fixed Yes/No), money (with an optional maxValue for a range, and period or per), date (ISO 8601, verbatim), url (scheme allowlisted), and hours.
hours renders a markdown table from { weekly, exceptions?, timeZone, note? }. timeZone is required, because hours without a zone are an ambiguous fact. A weekday with intervals: [] is Closed; a weekday absent from weekly renders Not stated, which is a different fact. The renderer never computes "open now": that is the agent’s job, and doing it here would be inventing a value.
CollectionSource
An index of documents. A guides hub, a services list, a locations directory, a blog archive. Where catalog is the priced listing, this is the unpriced one: it renders as a link list, or as a table when the items carry facts.
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | yes | Canonical URL of the listing itself, so an agent can resolve which page it is reading. |
title | string | no | Defaults to the fixed Contents label. |
items | CollectionItemSource[] | yes | { url, title, summary?, facts? }. Capped at 500 rendered rows, and the union of item facts becomes the table columns, capped at 12. |
updated | string | no | ISO 8601, verbatim. Emitted as dateModified in JSON-LD; the markdown renderer does not render it. |
language | string | no | BCP 47, verbatim, emitted as inLanguage. Malformed values are dropped. |
Items carrying no facts render as a link list. Give the items facts and the same listing renders as a table, which is what makes a 3,000-page guide index answerable in one fetch instead of three thousand.
Money
| Field | Type | Notes |
|---|---|---|
amount | number | Integer minor units (cents for USD): 8900 → $89.00. Zero-decimal currencies are handled (¥4,900). |
currency | string | ISO currency code, e.g. USD. |
A non-integer amount throws rather than rounds, because silently altering a price is never acceptable.
What rendering guarantees
Size budget: output is capped at maxBytes (default 5120 bytes, UTF-8). Buying facts are front-loaded, and truncation only ever removes content from the bottom (description, attributes, images) on whole lines, appending a fixed truncation note; the front-loaded facts and the variants table are never removed. If the facts alone exceed the budget, they are emitted anyway: facts are never sacrificed to the byte budget.