Public Goods
Public Goods — Every Shopify storefront, reached by its own name.
Public Goods — Every Shopify storefront, reached by its own name. One engine over the platform's two published surfaces — the store's own UCP MCP server at /api/ucp/mcp for search, and the keyless Ajax storefront JSON for products and the cart.
Domain: publicgoods.com
Also known as: publicgoods, Public Goods
Call it directly
bowmark.providers.publicgoods.search(query: string, opts?: { productType?: string; inStockOnly?: boolean; limit?: number }): Promise<ShopifyProduct[]>
bowmark.providers.publicgoods.getProduct(handle: string): Promise<ShopifyProduct>
bowmark.providers.publicgoods.resolveProductUrl(url: string): Promise<ShopifyProductFromUrl>
bowmark.providers.publicgoods.getProducts(handles: string[]): Promise<ShopifyProductBatch>
bowmark.providers.publicgoods.listProducts(opts?: { limit?: number; cursor?: string | null }): Promise<ShopifyProductPage>
bowmark.providers.publicgoods.listCollections(opts?: { limit?: number }): Promise<ShopifyCollection[]>
bowmark.providers.publicgoods.getCollection(handle: string, opts?: { limit?: number; cursor?: string | null }): Promise<ShopifyCollectionProducts>
bowmark.providers.publicgoods.getSetEvidence(handle: string, opts?: { includeAlgorithmic?: boolean }): Promise<ShopifySetEvidence>
bowmark.providers.publicgoods.addToCart(items: Array<{ variantId: string; quantity?: number }>): Promise<ShopifyCart>
bowmark.providers.publicgoods.getCart(): Promise<ShopifyCart>Functions
| Function | What it does |
|---|---|
search | Searches Public Goods's live catalogue and returns matching products with their real variants, current prices and per-variant stock. |
getProduct | Reads one Public Goods product by its handle — every variant, its exact price, its SKU and whether that specific size or colour is purchasable right now. |
resolveProductUrl | Turns a Public Goods product URL into the product, which is what you have when a link arrives from a search result, a page you read, or a person. |
getProducts | Reads FULL detail for many Public Goods products in one call — the shape to use when ranking a candidate set, because a search row carries neither the description copy nor the per-variant… |
listProducts | Walks Public Goods's WHOLE catalogue a page at a time, in the store's own merchandised order — the shape for assembling a realistic candidate set, which search cannot do because it ranks… |
listCollections | Lists Public Goods's own merchandised collections — handle, title, blurb and product count. |
getCollection | Reads one Public Goods collection's products, in the RETAILER's own merchandised order, as full ShopifyProduct rows. |
getSetEvidence | Two answers about what goes with this Public Goods product, in one call. evidence is ONLY what a MERCHANDISER pinned by hand, in the order they typed it — an explicit retailer statement,… |
addToCart | Puts variants into THIS run's own cart on publicgoods.com and returns the cart as the store reports it. |
getCart | Reads THIS run's cart back from publicgoods.com — lines, quantities, per-line and order totals, and the real cart and checkout URLs. |
Types
interface ShopifyVariant {
/** Shopify's numeric variant id as a string. What addToCart takes. */
id: string;
/** The variant's own label, e.g. "Black / S". */
title: string;
/** Decimal string exactly as the store publishes it, e.g. "45.00". */
price: string;
compareAtPrice: string | null;
sku: string | null;
/** The store's own per-variant stock flag. */
available: boolean;
options: string[];
/** The same values keyed by the option's own NAME — { Color: "Black", Size: "S" }.
* Read THIS to filter by size; options[1] is only the size on a store that
* happens to order it second. {} when the two lists cannot be reconciled. */
selectedOptions: Record<string, string>;
/** The image the STORE linked to this variant. Usually NULL — most storefronts
* publish no image-to-variant link at all, and null says so rather than
* handing back the first product picture. */
image: ProductImage | null;
/** Why this price is, or is not, a markdown. */
sale: SaleEvidence;
}
interface ProductImage {
/** Absolute HTTPS url, at the largest rendition the CDN serves. */
url: string;
/** The retailer's own alt text, or null. */
altText: string | null;
/** The retailer's colour name for this picture. Set when the store links it,
* or when the whole product is ONE colourway (which is how both yoga
* retailers publish: one colour per product). Null otherwise. */
color: string | null;
colorId: string | null;
/** Variant ids the STORE linked. [] means it published no link — NOT that the
* image belongs to every variant. */
variantIds: string[];
}
interface SaleEvidence {
/** True only with retailer evidence. NEVER set from a low price or from the
* word "sale" in a title. */
onSale: boolean;
currentPrice: string | null;
/** The published "was" price. Strictly greater than currentPrice when
* evidenceType is "compare_at_price". Null when nothing published one. */
originalPrice: string | null;
/** ISO 4217, from the storefront's own /meta.json. Null only when that read
* failed — never defaulted to "USD", which is wrong on every non-US store. */
currency: string | null;
promotionMessage: string | null;
/** How it was decided. "compare_at_price" is a struck-through price;
* "retailer_sale_badge" is a published sale tag; "none" is not on sale. */
evidenceType: "compare_at_price" | "sale_price" | "retailer_sale_badge" | "published_promotion" | "none";
/** The retailer text or field the classification rests on, verbatim. */
evidenceText: string | null;
}
/** The retailer's own labels, read off its published tags, category and options.
* NEVER inferred from the title or from an image. Null or [] when unpublished,
* which is common and is an honest answer. */
interface PublishedProductAttributes {
audience: string | null;
garmentType: "sports_bra" | "tank" | "crop_top" | "leggings" | "shorts" | "other" | null;
categories: string[];
collections: string[];
fabrics: string[];
materials: string[];
color: string | null;
colorFamily: string | null;
pattern: string | null;
styleTags: string[];
neckline: string | null;
strapWidth: string | null;
backDesign: string | null;
sleeveLength: string | null;
rise: string | null;
waistband: string | null;
/** How long the GARMENT is — "Cropped", "Midi". What every store's own
* `length::` tag fills, on a top exactly as on a bottom. */
garmentLength: string | null;
/** The inside leg seam and only that. NULL on a top — a tank has no inseam,
* and its length is `garmentLength`. */
inseam: string | null;
legShape: string | null;
fit: string | null;
coverage: string | null;
}
/** Shared colour, fabric, collection and style family — what makes two garments
* PLAUSIBLY coordinate. None of it establishes a set. */
interface CoordinationMetadata {
collectionNames: string[];
fabricNames: string[];
colorName: string | null;
colorId: string | null;
colorFamily: string | null;
/** The store's own grouping key for one style across its colourways. Two
* products sharing it are the same garment in two colours. */
productFamily: string | null;
}
/** An EXPLICIT retailer-published relationship. Shared colour, fabric,
* collection or family is NOT this — that is CoordinationMetadata. Usually [].
*
* Two doors fill it. On a PRODUCT row it comes from the store's tags, and no
* store measured publishes a set tag, so it is [] there. getSetEvidence() reads
* the other one: the complementary products a MERCHANDISER pinned by hand,
* admitted only for rows the store marks pr_prod_strat=pinned. The algorithmic
* "related products" feed is never read into this — that is a recommendation
* engine's output, not the retailer stating a pairing. */
interface RetailerSetEvidence {
evidenceType: "official_set" | "shop_the_set" | "complete_the_look" | "matching_piece";
evidenceText: string | null;
sourceUrl: string | null;
setId: string | null;
relatedProducts: Array<{ productId: string | null; handle: string | null; title: string | null; url: string | null }>;
}
/** What getSetEvidence returns. An OBJECT rather than a bare array so the
* healthy EMPTY answer is still probeable: productId and sourceUrl exist only
* when BOTH hops answered, where an empty evidence list is the ordinary case. */
interface ShopifySetEvidence {
handle: string;
/** Shopify's numeric PRODUCT id — the only key the recommendations door takes,
* and published by no other function here. */
productId: string;
/** The product page the pairing is published on. */
sourceUrl: string;
/** ONLY the rows a merchandiser pinned by hand. [] when they pinned nothing,
* which is the common case and is the STORE's answer. At most one entry — one
* statement, N related products. Read this for "what did the retailer SAY". */
evidence: RetailerSetEvidence[];
/** EVERY row the store recommends, pinned AND algorithmic, deduplicated, each
* labelled. Read source.by before treating one as the retailer's decision —
* the algorithmic rows are usually SUBSTITUTES rather than companions, since
* similarity returns the nearest product and the nearest thing to a baby
* monitor is another baby monitor. [] when includeAlgorithmic was false. */
recommendations: RecommendedProduct[];
/** Non-empty when the store returned a FULL page for an intent, which cannot be
* told apart from a longer list cut off at the cap. A merchandiser's pairing is
* a statement, so a partial one must not read as the whole. */
warnings: string[];
}
/** One recommended product, with the store's own label for who chose it. */
interface RecommendedProduct {
productId: string | null;
handle: string | null;
title: string | null;
url: string | null;
source: RecommendationSource;
}
interface RecommendationSource {
/** "retailer" when a merchandiser pinned it; "algorithm" otherwise, INCLUDING
* a row the store labelled with nothing — an unknown provenance must never
* read as a person's decision. */
by: "retailer" | "algorithm";
/** The store's own token, verbatim and unmapped — "pinned", "jac" (Jaccard),
* "e" (embedding), "collection_fallback". Null when the row carried none. */
strategy: string | null;
}
interface ShopifyProduct {
handle: string;
title: string;
productType: string;
vendor: string;
url: string;
optionNames: string[];
variants: ShopifyVariant[];
priceRange: { min: string; max: string } | null;
inStock: boolean;
tags: string[];
descriptionHtml: string | null;
/** The same copy with its markup removed. */
descriptionText: string | null;
/** ISO 4217, read once per store from its own /meta.json. */
currency: string | null;
/** When the store's door ANSWERED this row, ISO 8601 UTC. Every field here is
* a live fact with a shelf life — the price, the markdown, the per-variant
* stock flag — so read it before treating a cached row as current. Stamped per
* REQUEST: a getProducts batch carries one stamp per handle, not one per call. */
fetchedAt: string;
/** Every image the door published, deduplicated, in the store's own order. */
images: ProductImage[];
attributes: PublishedProductAttributes;
/** Per-FIELD origin for `attributes` — keyed by the same field names. Read it
* before ranking on a null: "absent" is the store publishing nothing, and is
* the store's own answer; "unreachable" is a door we could not read, and means
* UNKNOWN. On this store both doors are one request that either answered or
* threw, so nothing here is ever "unreachable" — the status exists because the
* same vocabulary is used by providers whose page can refuse mid-answer. */
attributeProvenance: Record<string, FieldProvenance>;
attributeCompleteness: Completeness;
coordination: CoordinationMetadata;
retailerSetEvidence: RetailerSetEvidence[];
}
/** Where one attribute value came from, and the retailer text behind it. */
interface FieldProvenance {
/** "published" — the retailer stated it. "absent" — every door was silent.
* "unreachable" — a door that would carry it was refused, so it is UNKNOWN. */
status: "published" | "absent" | "unreachable";
/** Which door: "shopify_tags", "shopify_product_type", "shopify_color_option"
* or "shopify_product_copy". Null when nothing filled it. */
source: string | null;
/** The retailer's own words the value rests on, verbatim — the tag, or the
* sentence out of the description. Null when nothing filled it. */
evidence: string | null;
/** On "unreachable" only: what was refused. */
detail?: string;
}
/** The roll-up over one product's attributeProvenance. */
interface Completeness {
fields: number;
published: number;
absent: number;
unreachable: number;
/** published / fields, to 3dp. */
ratio: number;
/** The fields whose value is UNKNOWN rather than known-empty. Read this before
* comparing two rows: a row with entries here was not fully looked at. */
unreachableFields: string[];
sourcesUsed: string[];
}
/** What getProducts returns. PARTIAL by design: one handle the store will not
* serve costs that row and nothing else, where getProduct throws. */
interface ShopifyProductBatch {
/** In the order the handles were passed, not the order they finished. */
products: ShopifyProduct[];
/** Every handle the store did not serve, with what it said. */
missing: Array<{ handle: string; detail: string }>;
requested: number;
warnings: string[];
}
interface ShopifyCartLine {
/** Shopify's own line key, which its cart-change endpoints address a line by. */
lineKey: string;
variantId: string;
productTitle: string;
/** Null on a single-variant product. */
variantTitle: string | null;
quantity: number;
price: string;
lineTotal: string;
url: string;
}
/** One collection the store publishes — its own merchandised grouping, and the
* ONE place either yoga retailer states a set. Membership in a collection the
* retailer NAMED "Matching Sets" is a published fact and is admissible as set
* evidence; two products being adjacent inside it is NOT, because a position in
* a list is not a statement. */
interface ShopifyCollection {
handle: string;
title: string;
url: string;
descriptionHtml: string | null;
/** True when the retailer's OWN title or handle names this a set, a matching
* piece or a look. Never inferred from what is inside it. */
setLike: boolean;
productCount: number | null;
updatedAt: string | null;
}
interface ShopifyCollectionProducts {
handle: string;
url: string;
/** In the RETAILER's own order. Curated collections are merchandised
* top-then-bottom; reading a pairing out of that order is the caller's
* inference, never this provider's claim. [] is an ordinary answer. */
products: ShopifyProduct[];
/** Pass back as opts.cursor for the next page, or NULL when this is the last
* one. A collection bigger than one page is reachable only through this. */
cursor: string | null;
/** Empty on an ordinary page. One entry when the walk hit the store platform's
* 25,000-row ceiling with the collection unfinished — a TRUNCATION, which a
* null cursor on its own would read as the end of the list. */
warnings: string[];
}
/** One page of a whole-catalogue walk. Advance it with the cursor; there is
* deliberately no "fetch everything" call, because every row is a request
* against the store and only the caller knows how many candidates it needs. */
interface ShopifyProductPage {
/** In the store's own MERCHANDISED order — not id, not date. The store may
* re-merchandise mid-walk, so key on handle rather than assuming pages are
* disjoint. */
products: ShopifyProduct[];
/** Pass back as opts.cursor for the next page. NULL when the store answered a
* short page, which is what the end of the catalogue looks like. */
cursor: string | null;
/** How many rows this page asked the store for. */
limit: number;
/** Empty on an ordinary page. One entry when the walk stopped at the 25,000-row
* ceiling with the catalogue unfinished. */
warnings: string[];
}
/** What resolveProductUrl hands back — the product a url names, and the variant
* its own ?variant= selected. */
interface ShopifyProductFromUrl {
product: ShopifyProduct;
/** The variant ?variant= named, or NULL when the url named none — the ordinary
* case for a link off a collection page. Also null when it named one the store
* no longer publishes, which warnings says. NEVER the first variant instead:
* that answers "is my size in stock" about a different size. */
variant: ShopifyVariant | null;
/** The ?variant= value exactly as the url carried it, kept even when it
* matched nothing — a stale link is a fact about the link. */
variantIdInUrl: string | null;
/** Empty on a clean resolve. One entry when the url named a variant the store
* no longer publishes. */
warnings: string[];
}
interface ShopifyCart {
/** The store's own cart token — a bearer credential, so treat it like one. */
token: string;
lines: ShopifyCartLine[];
/** Units, NOT lines: two of one variant is one line and two items. */
itemCount: number;
subtotal: string;
currency: string;
cartUrl: string;
checkoutUrl: string;
}Examples
// Everything published about one product: the size a shopper can actually
// buy, the picture for that colour, and WHY a price is a markdown.
const [hit] = await bowmark.providers.publicgoods.search("leggings", { inStockOnly: true, limit: 1 });
if (!hit) return { note: "nothing matched" };
const p = await bowmark.providers.publicgoods.getProduct(hit.handle);
const small = p.variants.find((v) => v.selectedOptions.Size === "S" && v.available);
return {
title: p.title,
// The retailer's OWN labels. null/[] means it published none — never inferred.
garment: p.attributes.garmentType, // "leggings" | "sports_bra" | … | null
fabric: p.attributes.fabrics, // ["Spacedye"] on Beyond Yoga, [] elsewhere
colour: p.attributes.color,
small: small && {
variantId: small.id, // the ONLY thing addToCart takes
price: small.price,
onSale: small.sale.onSale,
why: small.sale.evidenceType, // "compare_at_price" | "retailer_sale_badge" | "none"
was: small.sale.originalPrice, // null unless the store published one
proof: small.sale.evidenceText,
},
// Absolute https. `color` is set when the store links it OR when the whole
// product is one colourway; `variantIds: []` means NO published link — it does
// NOT mean the picture is every variant's.
photos: p.images.map((i) => ({ url: i.url, color: i.color, variantIds: i.variantIds })),
};// Coordinating two pieces, WITHOUT pretending the store sold them as a set.
const tops = await bowmark.providers.publicgoods.search("tank", { inStockOnly: true, limit: 5 });
const bottoms = await bowmark.providers.publicgoods.search("leggings", { inStockOnly: true, limit: 5 });
// THE STRONGEST claim, and the only one that is the retailer SPEAKING: what a
// merchandiser pinned to this product by hand. Its own call, and [] on most
// products, which is the store's answer rather than a failure.
const set = tops[0] && (await bowmark.providers.publicgoods.getSetEvidence(tops[0].handle));
const pinned = set ? set.evidence : []; // [] means the merchandiser pinned nothing
// Everything the store recommends, labelled. NEVER treat a row as the retailer's
// decision without reading source.by — the "algorithm" rows are usually
// SUBSTITUTES (a baby monitor's nearest neighbour is another baby monitor).
const alternatives = (set ? set.recommendations : []).filter((r) => r.source.by === "algorithm");
// The product rows carry the TAG-derived half of the same field, which is []
// on every store measured because no store publishes a set:: tag.
const fromTags = [...tops, ...bottoms].flatMap((p) => p.retailerSetEvidence);
// coordination is the weaker, honest signal: shared colour, fabric, collection,
// and the store's own style family. Weigh it; do not report it as a set.
const pairs = tops.flatMap((t) =>
bottoms
.filter((b) => b.coordination.colorFamily && b.coordination.colorFamily === t.coordination.colorFamily)
.map((b) => ({ top: t.title, bottom: b.title, sharedColorFamily: t.coordination.colorFamily })),
);
// pinned[0].relatedProducts is what the retailer SAID. pairs is what we matched.
return { pinned, alternatives, fromTags, coordinated: pairs };