Simulators
Shopify Storefront simulator
shopify-storefront-twin is a local, stateful simulator of the Shopify Storefront API, version 2026-10, as a buyer who is not signed in uses it — the shop, its products, variants and collections, search, predictive search and recommendations, pages, blogs, articles, menus and metaobjects, and the cart with its fifteen mutations — built from the API’s published introspection, as Shopify’s Hydrogen package vendors it.
Quickstart
You need an API key from Account → API keys (how).
curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_… # your API key
rystic run shopify-storefront-twin -d --port 0 --scenario shopify-storefront_apparel_catalog # prints the URL it serves on
curl -s http://localhost:<port>/_rystic/version
Expect "service_hosts":["myshopify.com"]. The examples below use localhost:8080; use the port rystic run printed. On Enterprise, rystic login ./rystic-license.json takes the place of the key (license files).
List the products, then create a cart and read it back:
SF=(localhost:8080/api/2026-10/graphql.json -H 'X-Shopify-Storefront-Access-Token: placeholder' -H 'Content-Type: application/json')
curl -s "${SF[@]}" -d '{"query":"{ products(first: 5) { nodes { handle title } } }"}'
# -> {"data":{"products":{"nodes":[{"handle":"classic-tee","title":"Classic Tee"},…]}},…}
curl -s "${SF[@]}" -d '{"query":"mutation { cartCreate(input: {lines: [{merchandiseId: \"gid://shopify/ProductVariant/9002\", quantity: 2}]}) { cart { id totalQuantity } userErrors { code message } } }"}'
# -> {"data":{"cartCreate":{"cart":{"id":"gid://shopify/Cart/8ef910ca90bf4c9d8000000000000001?key=7c0a4019","totalQuantity":2},"userErrors":[]}},…}
curl -s "${SF[@]}" -d '{"query":"{ cart(id: \"gid://shopify/Cart/8ef910ca90bf4c9d8000000000000001?key=7c0a4019\") { totalQuantity } }"}'
# -> {"data":{"cart":{"totalQuantity":2}},…}
Point your client at it
Shopify’s clients build every URL on https://<your-store>.myshopify.com. A request left unpointed goes to your real store.
@shopify/storefront-api-client always builds an https URL from storeDomain, even when you pass http://localhost:8080, so hand it a fetch that rewrites the store’s origin to the twin’s and refuses anything else:
import { createStorefrontApiClient } from "@shopify/storefront-api-client";
const twin = "http://localhost:8080";
const client = createStorefrontApiClient({
storeDomain: "your-store.myshopify.com",
apiVersion: "2026-10",
publicAccessToken: "placeholder",
customFetchApi: (url, init) => {
const target = String(url).replace(/^https:\/\/your-store\.myshopify\.com/, twin);
if (!target.startsWith(twin + "/")) throw new Error(`refusing ${target}`);
return fetch(target, init);
},
});
const { data } = await client.request(`{ products(first: 5) { nodes { handle } } }`);
Any other GraphQL client works the same way: POST JSON to http://localhost:8080/api/2026-10/graphql.json with the token header.
Plans
shopify-storefront-twin is on Pro and Enterprise, not Free (plans); on a Free key, rystic pull shopify-storefront-twin and a start are refused with product_not_in_plan. Both plans serve the whole Storefront surface below, the whole control plane, custom latency and fault injection included, and every world. Without --scenario the twin boots an empty store — the shop Rystic Storefront, its localization, API versions and the discount code WELCOME10, with no products — and POST /_rystic/reset wipes it back to that.
Auth
Send the storefront’s public access token in the X-Shopify-Storefront-Access-Token header. The twin also accepts a delegate token in Shopify-Storefront-Private-Token in its place, as the measured store answered the shop and cart reads. It accepts any token value in either header and checks no token shape, scope or expiry.
The twin models a store whose password page is on, as the store it is measured against is. A request carrying neither header answers 400 {"errors":[{"message":"Online Store channel is locked.","extensions":{"code":"BAD_REQUEST"}}]} on every query and mutation, before any field resolves, as a password-protected Shopify store answers it. A live store with no password page serves some reads without a token; the twin does not model that store.
Coverage
1 route across 19 resources, counted from the sim’s literal registrations.
GraphQL fields answer at POST /api/2026-10/graphql.json.
| Resource | Operations |
|---|---|
| API version (1) | QueryRoot.publicApiVersions |
| Article (2) | QueryRoot.article; QueryRoot.articles |
| Blog (3) | QueryRoot.blog; QueryRoot.blogByHandle; QueryRoot.blogs |
| Cart (6) | Mutation.cartAttributesUpdate; Mutation.cartBuyerIdentityUpdate; Mutation.cartCreate; Mutation.cartDiscountCodesUpdate; Mutation.cartNoteUpdate; QueryRoot.cart |
| Cart delivery address (4) | Mutation.cartDeliveryAddressesAdd; Mutation.cartDeliveryAddressesRemove; Mutation.cartDeliveryAddressesReplace; Mutation.cartDeliveryAddressesUpdate |
| Cart delivery group (1) | Mutation.cartSelectedDeliveryOptionsUpdate |
| Cart line (3) | Mutation.cartLinesAdd; Mutation.cartLinesRemove; Mutation.cartLinesUpdate |
| Cart metafield (2) | Mutation.cartMetafieldDelete; Mutation.cartMetafieldsSet |
| Collection (4) | QueryRoot.collection; QueryRoot.collectionByHandle; QueryRoot.collections; QueryRoot.nodes |
| Localization (1) | QueryRoot.localization |
| Location (2) | Location.address; QueryRoot.locations |
| Menu (1) | QueryRoot.menu |
| Metaobject (2) | QueryRoot.metaobject; QueryRoot.metaobjects |
| Page (3) | QueryRoot.page; QueryRoot.pageByHandle; QueryRoot.pages |
| Product (15) | Product.compareAtPriceRange; Product.options; Product.priceRange; Product.selectedOrFirstAvailableVariant; Product.variantBySelectedOptions; QueryRoot.node; QueryRoot.predictiveSearch; QueryRoot.product; QueryRoot.productByHandle; QueryRoot.productRecommendations; QueryRoot.productTags; QueryRoot.productTypes; QueryRoot.products; QueryRoot.search; QueryRoot.sitemap |
| Product variant (1) | ProductVariant.storeAvailability |
| Shop (2) | QueryRoot.paymentSettings; QueryRoot.shop |
| URL redirect (1) | QueryRoot.urlRedirects |
The catalog: product by id or handle, productByHandle and products, each with its variants and its options with their values, and only product(id:|handle:) with its price ranges in every case, which other roots can answer null, nulling the nearest field above them that may be null, and with variantBySelectedOptions, selectedOrFirstAvailableVariant and each option’s optionValues, which every other root that answers a product answers null and []; collection, collectionByHandle and collections with their products in the store’s order; products(query:) by tag:; search, predictiveSearch, productRecommendations and productTags. Content: pages, blogs and articles, menus with their nested items, metaobjects by type, URL redirects, and the shop, its payment settings, localization and API versions. Every connection pages by first, last, after and before with Shopify’s cursors, and an unknown id or handle answers the field null. The cart: its fifteen mutations, cartCreate among them, each payload answering the stored cart exactly as cart(id:) reads it, with userErrors and warnings in Shopify’s shapes and lines newest first. A code the store holds applies its percentage on every later write; an unknown code stays applicable: false and repeats its DISCOUNT_NOT_FOUND warning. A selected delivery address and a line give the cart a delivery group with Standard (8.00) and Express (15.00) options, Standard selected, and cost.totalAmount carries the discount and the selected option while checkoutChargeAmount carries neither. The twin ends at the cart’s checkoutUrl, which serves nothing.
Measured against a live Shopify store: the full-suite round of 2026-10-23 (stamp 20261023T085537Z) matched 93 of the suite’s 96 probes, one of them inconclusive, against a target of 80%.
Control plane
Every route under /_rystic/ answers on both plans; routes by plan lists them.
Scenarios
Each world is pinned at 2026-02-02T00:00:00Z and holds the discount code WELCOME10.
shopify-storefront_apparel_catalog: A stocked storefront — four published products with eight variants, two collections, a nested main menu, two pages, a journal with two articles, a lookbook metaobject, two redirects and two locations — so catalogue, content and navigation reads all return real pages from boot.shopify-storefront_checkout_cart: A cart one step from payment — four units across three lines, a buyer identity, two saved delivery addresses (one selected), a delivery group with a chosen shipping option and two cart metafields — so cart reads and line-level updates have a populated cart without a setup write.shopify-storefront_fresh_store: A storefront configured but empty — shop settings, the supported API version, a localization and a single location, plus one brand-new cart holding nothing at all — so every catalogue and cart-line listing returns zero rows and a first write’s effect is unmistakable.shopify-storefront_sold_out_shelf: A shelf standing in every stock state from boot — a hoodie with in-stock, two-left, oversell-enabled and fully sold-out variants, a tee withdrawn from sale, and a pre-order mug with zero on hand — so availability-guarded reads and adds have live targets without inventory writes.
Seeding state
POST /_rystic/seed takes a register map — state_product and state_product_variant (keyed by gid; a variant names its productId), state_collection (its productIds list its members), state_discount_code (keyed by code, with a percentage), state_cart and its lines, addresses, delivery groups and metafields, and the content registers GET /_rystic/registers lists — and merges it into the running store; Worlds covers seed files, GET /_rystic/registers and the seed gate. A product a cart can hold is a state_product and a state_product_variant naming it:
curl -X POST localhost:8080/_rystic/seed -H 'Content-Type: application/json' -d '{
"state_product": {"gid://shopify/Product/1": {"id": "gid://shopify/Product/1", "handle": "test-mug",
"title": "Test Mug", "availableForSale": true}},
"state_product_variant": {"gid://shopify/ProductVariant/11": {"id": "gid://shopify/ProductVariant/11",
"productId": "gid://shopify/Product/1", "title": "Default Title", "availableForSale": true,
"quantityAvailable": 5, "price": {"amount": "12.00", "currencyCode": "USD"}}}
}'
# -> 204; product(handle: "test-mug") then answers it
Determinism & reset
Cart, line, delivery-group and delivery-option ids are sequences the reset rewinds, and a cart’s ?key= comes from the same seeded sequence, so the first cart after a reset has the same id every time. The clock starts at a pinned instant — 2026-01-01T00:00:00Z empty, 2026-02-02T00:00:00Z in every world — and stands still until POST /_rystic/clock sets or advances it. Two identical runs from the same reset give byte-identical answers. Reset and determinism covers reset, the clock and the RNG.
Egress
The Storefront API declares no outbound effects: no request sends an email or calls a webhook, and GET /_rystic/egress is always empty.
Limitations
- Every answer carries
extensions.cost.requestedQueryCost, always1; Shopify computes it from the query. The twin enforces no cost or rate limit: never aTHROTTLEDanswer or a 429. - A request body holds up to 1 MiB (
blob_cap_byteson/_rystic/version). sitemapis served but answers no items.
Left out on purpose: cart completion and payment, gift cards, Shop Pay payment requests, the legacy customer operations and Multipass, cartClone, cartRemovePersonalData and cartBillingAddressUpdate, and the Customer Account API. @defer is ignored.
Known issues.
- A cart mutation on a cart id that names no cart answers
cart: nullwith nouserErrors, where Shopify answers aCartUserErrorINVALID(“The specified cart does not exist”); onlycartLinesAdddoes - The twin accepts a query deeper than 17 and an id that is not a
gid://, answers a wrongly typed variable with an error lackingextensions{value, problems}, and answers request errors with its own status and wording, where Shopify answersMAX_QUERY_DEPTH_EXCEEDED, refuses the id, and answers an empty body 400, malformed JSON 415 and a body with no query 200; treat anyerrorsentry as the refusal, whatever its status or wording - Three of the seeded tee’s six variants answer no
sku, and twelve of the sixteen seeded variants answerquantityAvailable: null, where Shopify answers both; and four products answeravailableForSale: falsewhile each of their variants answerstrue, where Shopify derives a product’savailableForSalefrom its variants - A cart mutation on a cart id without its
?key=applies the change and answers no error, where Shopify applies it and also answers aCartUserErrorINVALID; a mutation with the wrong key is not yet measured cartMetafieldsSetanswers success for anownerIdthat names no cart, where Shopify answersINVALID_OWNERwithmetafields: null, and for a one-characterkey, where Shopify answersTOO_SHORT- A cart line write ignores Shopify’s stock rules: the same variant twice on
cartCreategives two lines,cartLinesUpdateabove stock is not capped withMERCHANDISE_NOT_ENOUGH_STOCK, and a sold-out variant adds a 0-quantity line; an oversell or pre-order variant can’t be added, which is not yet measured on Shopify - The deprecated
priceV2,compareAtPriceV2,weightUnit,Cart.estimatedCostandCartLine.estimatedCostanswer null, which nulls the product or cart, where Shopify answers a value for each sitemap(type: PRODUCT)answerspagesCount: nulland no items, where Shopify answers a count of 1 and every product; the other sitemap types are not yet measuredblog.articleByHandle,metaobject.field(key:),cart.attribute(key:)andcart.metafields(identifiers:)answer null or empty,cart.metafield(namespace, key)ignores the key, and a cart metafield’screatedAtandupdatedAtanswer null, nulling it, where Shopify answers by the arguments- List arguments are partly ignored or misread:
query:onarticles,collectionsandpages, price-range terms onproducts,sortKeyandreverseoncollection.products, andnearonlocations(sortKey: DISTANCE), which Shopify documents as ordering by distance (not yet measured), are ignored;ORandNOTonproductsandsearch, parentheses and a wildcard onproducts,AND,-, quoted phrases and a price term onsearchmatch the wrong records or none; a connection with neitherfirstnorlast,firstabove 250 on any connection butproducts, and apredictiveSearchlimitabove 10 answer data, where Shopify answers an error; send plain terms only, one query per alternative, then filter and sort the returned list in your agent - Delivery options are fixed at Standard 8.00 and Express 15.00 whatever the cart holds, where Shopify’s rates come from the shop’s delivery profile and can be free
- A GraphQL object type can render differently depending on the field that returns it, and nothing checks the twin’s answers against the schema, so a null in a non-null field or an enum value outside its type can reach your agent
- The twin answers GraphQL only as a
POSTof JSON at the one declared endpoint:GET ?query=,application/graphqland form bodies, other API versions and the unversioned path answer 400 or 404, where Shopify answers 200 productTypesanswers a fixed list of six types unioned with the types of the store’s products, whatever the store holds (an empty store still answers the six), where Shopify lists only the types of the store’s productscartLinesRemovewith a line id the cart does not hold answers HTTP 501 withX-Rystic-Marker: unmodeled_guardand a body that does not parse (a runtime defect), andcartLinesUpdatewith one answers success with no error, where Shopify answers the payload with aCartUserError(INVALID_MERCHANDISE_LINEoncartLinesRemove); send only line idscart.lineslists- A cart’s delivery group answers
cartLines: []and adeliveryAddressholding onlycityandzip, and aCartDeliveryAddressanswersformatted: []withformattedAreaandnamenull, where Shopify answers the group’s lines and the full address; read lines fromcart.linesand address fields fromcart.delivery.addresses search’sproductFiltersanswers a fixed availability and price filter set whatever filters the store configures, where Shopify answers the store’s own filters (option, type, vendor), and a price filter in theproductFiltersinput can treat a product as priced 0; filter by price from each product’spriceRangeinsteadnodeanswersnullfor a collection or a page id, whichnodes(ids:)resolves on Shopify, andnodeandnodesboth answernullfor a location id, though a location is aNodein Shopify’s schema;nodes(ids:)resolves collections and pages,collection(id:)andpage(id:)read them directly, andlocationslists the store’s locations- The cart’s line merge, stock cap, discount codes, delivery groups and totals match Shopify only where the cart scenarios compared against Shopify reach them; a combination outside those scenarios is unchecked
- On a cart with a code discount, selecting
targetType,sourceDiscountApplicationordiscountApplicationon aCartCodeDiscountAllocationanswerscart: nullwith a non-null error, where Shopify answers the cart; selectcodeanddiscountedAmountinstead cartLinesUpdatetakes a negative quantity (the line holds it and the cart’s totals drop) andcartLinesAddtakes one without an error (subtracting it from a line the cart holds for that item, removing the line at 0 or below), where Shopify answers aCartUserErrorINVALIDand leaves the cart unchanged; send quantities of 0 or more- In a world whose localization countries carry no
market(every shipped world butapparel_catalogandfresh_store), aCountry’smarketunderlocalizationanswers an emptyidandhandle, which names no market; read the serving market fromlocalization.market - In a world whose location record stores no country name, a pickup
Location’saddress.countryanswersnullwhilecountryCodeandformattedname the country; readaddress.countryCodeoraddress.formatted
Not served. 28 of the 75 operations in the published spec are not served.
- Mutation, 15 of 41 served:
Mutation.cartBillingAddressUpdate,Mutation.cartClone,Mutation.cartGiftCardCodesAdd,Mutation.cartGiftCardCodesRemove,Mutation.cartGiftCardCodesUpdate,Mutation.cartPaymentUpdate,Mutation.cartPrepareForCompletion,Mutation.cartRemovePersonalData, … and 18 more - QueryRoot, 32 of 34 served:
QueryRoot.cartCompletionAttempt,QueryRoot.customer
Not measured. The fidelity suite runs 157 probes, and 6 documented branches have none. 1 needs a credential the measuring account does not hold (401 ×1). The other 5:
| Operation | Status | Why |
|---|---|---|
QueryRoot.cart | 200 | the model describes no input that reaches it |
QueryRoot.pageByHandle | 200 | the model describes no input that reaches it |
QueryRoot.product | 200 | the model describes no input that reaches it |
QueryRoot.products | 200 | the model describes no input that reaches it |
QueryRoot.shop | 200 | the model describes no input that reaches it |
Not modeled. What the model’s operations state they leave out:
- address1, province, zip, phone, latitude and longitude on the oracle store: its one location holds no street address, so only countryCode, formatted and the null city and provinceCode are measured; the shipped worlds’ values are answered as their records hold them. (
Location.address) - The answer to a cartId naming no cart (Shopify reports cart-level failures as userErrors inside a 200 payload, with data.cartAttributesUpdate.cart null): no capture or wire observation of that envelope is in the bundle, so no error branch with a body discriminator can be grounded. Needs a recorded unknown-cart mutation response. (
Mutation.cartAttributesUpdate) - The answer to more than 250 attributes, which the field description forbids: no recorded error envelope, so the rejection’s code, message and field path are unknown. (
Mutation.cartAttributesUpdate) - Whether attributes are replaced wholesale or merged with existing cart attributes is not stated by the documentation in the bundle. (
Mutation.cartAttributesUpdate) - extensions.cost.requestedQueryCost on this mutation (the capture shows 14) is a cost-analysis value the model declares service-wide; it is ignored by this branch’s field policies rather than claimed. (
Mutation.cartAttributesUpdate) - Invalid buyerIdentity values (a countryCode outside the CountryCode enum, a malformed email or phone, an unrecognised customerAccessToken, a companyLocationId the buyer may not use): documented as possible by the input type, but no recorded userErrors payload is in the bundle, so the userErrors-populated outcome is unmodeled. (
Mutation.cartBuyerIdentityUpdate) - The 250-value limits documented on deliveryAddressPreferences, preferences.delivery.deliveryMethod, preferences.delivery.pickupHandle and preferences.wallet have no recorded rejection, so no threshold guard or violating example is declared. (
Mutation.cartBuyerIdentityUpdate) - Unknown or malformed cartId: no capture or wire observation in the bundle shows how the service answers (CartUserError envelope with cart null, or a top-level errors entry), so no error branch, discriminator or existence guard is declared. Needs a recorded failing request before the branch can be modelled. (
Mutation.cartBuyerIdentityUpdate) - deliveryAddressPreferences (deprecated since 2025-01), and preferences.delivery.coordinates and pickupHandle values: typed in arguments_shape, but only deliveryMethod and wallet were measured (asm_cart_buyer_preferences, 2026-10-24); what coordinates or a pickupHandle do to the cart’s delivery groups and to checkout prefill is unmodeled. (
Mutation.cartBuyerIdentityUpdate) - The upper bound on the number of lines/attributes/metafields/addresses (250 per input field, per CartInput) and the error the service answers above it are documented but not measured. (
Mutation.cartCreate) - buyerIdentity.countryCode effects on international pricing, and the @inContext directive: ExtensionsIncontextCart sends @inContext(country: CA) and the answer carries extensions.context country US and buyerIdentity.countryCode US, so the directive’s effect on this shop is unresolved. (
Mutation.cartCreate) - input.discountCodes / input.giftCardCodes acceptance and the applicable flag, input.sourceName channel attribution, buyerIdentity.customerAccessToken and companyLocationId: no capture exercises them. (
Mutation.cartCreate) - quantity 0 on a line: the cart is created with totalQuantity 0 and no line, no userError and no warning (CartWarningsQuantityZero). It is a value variant of br_200_created with no body discriminator telling it apart, so it is not sealed as its own branch. (
Mutation.cartCreate) - Cart address cap: ‘A cart can have up to 20 delivery addresses.’ No observation shows the refusal of the 21st address. (
Mutation.cartDeliveryAddressesAdd) - Input list cap: ‘The input must not contain more than 250 values.’ No observation shows the refusal envelope. (
Mutation.cartDeliveryAddressesAdd) - Unknown or malformed cartId: the field documents a cart lookup but no capture or wire observation in the bundle shows the resulting envelope (a CartUserError code in userErrors, or a top-level errors entry), so no executable error branch and no existence guard are declared. Needs a recorded failing operation before an error branch with x_body_discriminator and x_status_equivalent can be sealed. (
Mutation.cartDeliveryAddressesAdd) - copyFromCustomerAddressId alternative of CartAddressInput: no in-scope operation creates a customer address and no capture exercises it. (
Mutation.cartDeliveryAddressesAdd) - validationStrategy STRICT: documented to reject an invalid address and leave the cart unchanged; no observation of the rejection payload is in the bundle. (
Mutation.cartDeliveryAddressesAdd) - The addressIds argument documents a 250-value cap; the service’s answer to a list above that cap (a GraphQL errors envelope or a userError) is not observed in the bundle, so no refusal branch is declared. (
Mutation.cartDeliveryAddressesRemove) - The payload declares a userErrors list ([CartUserError!]!), but the bundle holds no recorded envelope for any failing case (unknown cartId, an addressId naming no address of that cart, a cart key that does not gate the delivery addresses). No executable error branch is declared until a capture or boundary observation grounds its discriminator pointer, code value and body. (
Mutation.cartDeliveryAddressesRemove) - … and 151 more
Release notes: Changelog