Proposal: Bundle support in UCP (catalog + checkout) #448
richardsdavies
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Summary
UCP's Catalog capability models
Product → Variantand nothing else. There is no kit, composite, grouped, or bundle entity. Bundle pricing therefore survives only as checkout-time cart math: the merchant recalculatestotalsserver-side and the discount appears as adiscount/items_discountline. The price is correct, but invisible until checkout, and the bundle as a unit of merchandising does not exist in the protocol. This post argues bundles need first-class representation, and proposes a concrete extension, modelled directly on UCP's owndev.ucp.shopping.discount, as a starting point for discussion.The gap
An agent has no way to discover a bundle. It sees individual variants at individual prices. Nothing tells it "these items belong together, and together they cost less." The agent can only stumble into a bundle discount by coincidentally assembling the qualifying cart. Catalog-level price adjustments today exist only as eligibility benefits (member/loyalty pricing via
context.eligibility), which do not cover "buy these together and save."This breaks the single most common merchandising primitive in commerce. Every UCP backer - Shopify, Etsy, Walmart, Target, Wayfair - sells bundles today.
Why this matters for agentic commerce specifically
Proposed extension
The extension composes onto three places using JSON Schema
allOf, exactly asdev.ucp.shopping.discountcomposes onto cart and checkout:Productbundleobject:bundle_type,pricing_strategy,component_groups(with per-groupselect_min/select_maxand eligible products), and a non-authoritativesavings_preview. Lets an agent discover and present a bundle.Cart/Checkoutbundles.selectionsarray grouping line items into configured bundle instances, so the Business applies bundle pricing server-side and reports which selections qualified.It covers
fixed,mix_and_match,build_a_box, andvolumebundles via an open string vocabulary, so new bundle types don't require a version bump. Crucially, it does not restate pricing. Final pricing stays where UCP already puts it, in the authoritative checkouttotals. The extension carries only the structure and grouping that totals can't express on their own.Design principles followed
dev.ucp.shopping.discount- vendor-friendly,allOfcomposition,ucp_requestannotations, output-only fields viaucp_request: omit.savings_previewis explicitly indicative; checkouttotalsremain authoritative.Draft schema
This schema is drafted as
com.kitenzo.*, but a vendor extension only activates when agents also declare it. Bundles are universal enough that they likely belong indev.ucp.shopping.bundles(core) or a community namespace. We are, of course, happy for this to land as a core extension. We're proposing the shape, not claiming the namespace.com.kitenzo.shopping.bundles.json — draft extension schema
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://kitenzo.com/ucp/schemas/shopping/bundles.json", "name": "com.kitenzo.shopping.bundles", "version": "2026-05-18", "title": "Bundles Extension", "description": "Extends Catalog, Cart, and Checkout with first-class bundle support. Lets an agent discover a bundle as a single merchandising unit, present its component groups, selection rules and indicative savings, then add a configured bundle to a cart so the merchant's bundle pricing is applied server-side. Covers fixed bundles, mix-and-match, build-a-box, and volume (quantity-break) bundles. DRAFT - authored by Kitenzo as a proposal to the UCP working group.", "$defs": { "bundle_type": { "type": "string", "description": "The kind of bundle. Well-known values: 'fixed' (a fixed set of products sold together), 'mix_and_match' (choose from eligible products under per-group rules), 'build_a_box' (choose N items from a pool), 'volume' (quantity-break pricing on a single product). Open vocabulary - consumers MUST tolerate unknown values.", "examples": ["fixed", "mix_and_match", "build_a_box", "volume"] }, "pricing_strategy": { "type": "string", "description": "How the bundle total is derived. Well-known values: 'fixed_total' (the whole bundle costs a set amount), 'percentage_discount' (% off the sum of components), 'amount_discount' (a fixed amount off the sum), 'component_priced' (each component keeps its own price; the discount is applied by merchant rules at checkout). Open vocabulary.", "examples": ["fixed_total", "percentage_discount", "amount_discount", "component_priced"] }, "savings_preview": { "type": "object", "description": "Indicative saving used to present a bundle offer before a cart exists. NOT authoritative — final pricing is always computed by the Business at checkout and surfaces in the standard checkout 'totals' array.", "properties": { "list_total": { "$ref": "https://ucp.dev/schemas/common/types/amount.json", "description": "Sum of component list prices at the default selection." }, "bundle_total": { "$ref": "https://ucp.dev/schemas/common/types/amount.json", "description": "Indicative bundle price at the default selection." }, "amount_saved": { "$ref": "https://ucp.dev/schemas/common/types/amount.json", "description": "list_total minus bundle_total." }, "percentage_saved": { "type": "number", "minimum": 0, "maximum": 100, "description": "Indicative percentage saving at the default selection." } } }, "eligible_items": { "type": "object", "description": "What a buyer may choose for a component group. At least one selector MUST be present. Selectors combine with OR logic.", "properties": { "product_ids": { "type": "array", "items": { "type": "string" }, "description": "Catalog product IDs eligible for this group." }, "variant_ids": { "type": "array", "items": { "type": "string" }, "description": "Catalog variant IDs eligible for this group." }, "collection_handles": { "type": "array", "items": { "type": "string" }, "description": "Handles of catalog collections whose products are eligible for this group." } } }, "component_group": { "type": "object", "description": "One slot or set of slots in a bundle. A 'fixed' bundle has groups with fixed contents (select_min == select_max == count of default_variant_ids). A 'mix_and_match' or 'build_a_box' group lets the buyer choose within select_min/select_max.", "required": ["id", "title", "select_min", "select_max", "eligible"], "properties": { "id": { "type": "string", "description": "Stable identifier for this group within the bundle." }, "title": { "type": "string", "description": "Human-readable group label (e.g. 'Choose your base', 'Add-ons')." }, "select_min": { "type": "integer", "minimum": 0, "description": "Minimum number of items the buyer must select from this group." }, "select_max": { "type": "integer", "minimum": 1, "description": "Maximum number of items selectable from this group." }, "eligible": { "$ref": "#/$defs/eligible_items", "description": "The pool of products/variants a buyer may choose from for this group." }, "default_variant_ids": { "type": "array", "items": { "type": "string" }, "description": "Variant IDs pre-selected for this group. Used to compute savings_preview and to offer a one-click default configuration." } } }, "bundle": { "type": "object", "description": "Bundle metadata attached to a catalog Product. Present only on products that represent a bundle. Output-only.", "required": ["bundle_type", "pricing_strategy", "component_groups"], "properties": { "bundle_type": { "$ref": "#/$defs/bundle_type" }, "pricing_strategy": { "$ref": "#/$defs/pricing_strategy" }, "component_groups": { "type": "array", "items": { "$ref": "#/$defs/component_group" }, "minItems": 1, "description": "Ordered groups the buyer configures to build the bundle." }, "select_min": { "type": "integer", "minimum": 0, "description": "For 'build_a_box': minimum total items across all groups. Omitted when per-group rules are sufficient." }, "select_max": { "type": "integer", "minimum": 1, "description": "For 'build_a_box': maximum total items across all groups. Omitted when per-group rules are sufficient." }, "savings_preview": { "$ref": "#/$defs/savings_preview" }, "provider": { "$ref": "https://ucp.dev/schemas/common/types/reverse_domain_name.json", "description": "Identifier of the app/service that defines and prices this bundle (e.g. 'com.kitenzo'). Lets agents attribute bundles and lets a Business host bundles from multiple providers." } } }, "bundle_selection": { "type": "object", "description": "Declares that a set of cart/checkout line items together form one configured instance of a bundle. The Business uses this grouping to apply bundle pricing server-side (e.g. via a Shopify Cart Transform function).", "required": ["bundle_id", "product_id", "line_item_refs"], "properties": { "bundle_id": { "type": "string", "description": "Client-assigned identifier correlating this selection across create/update calls." }, "product_id": { "type": "string", "description": "Catalog product ID of the bundle — the product whose response carried the 'bundle' object." }, "line_item_refs": { "type": "array", "minItems": 1, "items": { "type": "object", "required": ["line_item_id", "group_id"], "properties": { "line_item_id": { "type": "string", "description": "ID of a line item in this cart/checkout that is part of the bundle." }, "group_id": { "type": "string", "description": "The component_group id this line item satisfies." } } }, "description": "The line items composing this bundle instance and which group each fills." } } }, "bundles_object": { "type": "object", "description": "Bundle selections input on a cart or checkout, and applied bundles output.", "properties": { "selections": { "type": "array", "items": { "$ref": "#/$defs/bundle_selection" }, "description": "Configured bundles in this cart/checkout. Replaces previously submitted selections. Send an empty array to clear." }, "applied": { "type": "array", "items": { "$ref": "#/$defs/bundle_selection" }, "description": "Bundle selections the Business recognised and priced. A selection present in 'selections' but absent here did not qualify; see checkout 'messages'.", "ucp_request": "omit" } } }, "dev.ucp.shopping.cart": { "title": "Cart with Bundles", "description": "Cart extended with bundle selections.", "allOf": [ { "$ref": "https://ucp.dev/schemas/shopping/cart.json" }, { "type": "object", "properties": { "bundles": { "$ref": "#/$defs/bundles_object", "ucp_request": { "create": "optional", "update": "optional" } } } } ] }, "dev.ucp.shopping.checkout": { "title": "Checkout with Bundles", "description": "Checkout extended with bundle selections. Bundle discounts surface in the standard checkout 'totals' array (type 'discount' / 'items_discount'); this extension only conveys the grouping that produced them, so an agent can show the buyer WHY the price dropped.", "allOf": [ { "$ref": "https://ucp.dev/schemas/shopping/checkout.json" }, { "type": "object", "properties": { "bundles": { "$ref": "#/$defs/bundles_object", "ucp_request": { "create": "optional", "update": "optional", "complete": "omit" } } } } ] }, "dev.ucp.shopping.catalog.product": { "title": "Product with Bundle", "description": "Catalog Product extended with a 'bundle' object so an agent can discover and present a bundle as a single merchandising unit. NOTE: a catalog Product is a shared type (schemas/shopping/types/product.json) rather than a top-level capability entity; the exact composition point for catalog-side extensions should be confirmed with the UCP working group.", "allOf": [ { "$ref": "https://ucp.dev/schemas/shopping/types/product.json" }, { "type": "object", "properties": { "bundle": { "$ref": "#/$defs/bundle", "ucp_request": "omit" } } } ] } } }Open questions for the working group
Productis a shared type (schemas/shopping/types/product.json), not a top-level capability entity likecartorcheckout. Where should catalog-side extensions compose - the type itself, thelookup/searchresponses, or a new seam?catalog_searchgain a filter for "bundles only" / "products available within a bundle"?Closing
We've drafted this against the real
dev.ucp.shopping.discountextension so it slots into existing conventions rather than inventing new ones. Happy to turn it into a PR against the spec if the working group thinks the direction is sound. Feedback on the model would be great :)All reactions