<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Eli</title>
    <description>The latest articles on DEV Community by Eli (@eliofbm).</description>
    <link>https://dev.to/eliofbm</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3895802%2F387dead1-d42a-421a-8dd3-1dd3e82f991b.png</url>
      <title>DEV Community: Eli</title>
      <link>https://dev.to/eliofbm</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eliofbm"/>
    <language>en</language>
    <item>
      <title>Your variant has one image, your product has twenty — the Shopify gallery filter question, finally answered</title>
      <dc:creator>Eli</dc:creator>
      <pubDate>Wed, 05 Aug 2026 13:34:15 +0000</pubDate>
      <link>https://dev.to/eliofbm/your-variant-has-one-image-your-product-has-twenty-the-shopify-gallery-filter-question-finally-3kof</link>
      <guid>https://dev.to/eliofbm/your-variant-has-one-image-your-product-has-twenty-the-shopify-gallery-filter-question-finally-3kof</guid>
      <description>&lt;p&gt;I found this one in a Shopify Community thread that collected 13 posts in 24 hours and still has no accepted answer. A merchant on the Horizon theme asked the question store owners have asked for a decade: when a shopper picks Black, show only the black images — no paid app, no Plus.&lt;/p&gt;

&lt;p&gt;The thread is a museum of every classic wrong answer: a layout setting sold as filtering, the one-image-per-variant confusion, a "send me your store URL" service pitch, and a working code snippet the OP couldn't get running. I've watched this question get half-answered for years, so here's the whole picture — including why the "correct" code so often "doesn't work."&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause: two things that look alike
&lt;/h2&gt;

&lt;p&gt;Shopify's data model gives you two different things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A variant can carry its own media&lt;/strong&gt; — &lt;code&gt;variant.featured_image&lt;/code&gt; / &lt;code&gt;variant.featured_media&lt;/code&gt; in Liquid, &lt;code&gt;ProductVariant.media&lt;/code&gt; in the Admin GraphQL API. This is what you set when you assign an image to a variant in the admin. Exactly one is "featured," and themes (Horizon included) swap it into the main gallery slot on selection. Native, free, every plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The product's media set&lt;/strong&gt; — &lt;code&gt;product.media&lt;/code&gt; — is a flat pool shared by &lt;em&gt;all&lt;/em&gt; variants. Any image not assigned to a variant belongs to every color simultaneously. There is no native structure for "these five images belong to Black."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So "show only the selected variant's images" is two different problems:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;What you want&lt;/th&gt;
&lt;th&gt;Native support&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;One representative image per color, swapped on selection&lt;/td&gt;
&lt;td&gt;Yes — variant featured image, every theme&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;A &lt;em&gt;gallery&lt;/em&gt; per color (3–8 shots each), filtered on selection&lt;/td&gt;
&lt;td&gt;No — not in the data model, not a Horizon setting&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;If you're in Case A, stop here:&lt;/strong&gt; assign each variant's featured image in Products → your product → Variants. On Horizon, additionally enable &lt;strong&gt;"Hide other variants' media after selecting a variant"&lt;/strong&gt; in the Media Gallery block settings (field report, community-verified by three independent posters in topic 659928 — it hides down to the one assigned image per variant, not a multi-image set).&lt;/p&gt;

&lt;p&gt;Everything below is Case B.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal reproduction
&lt;/h2&gt;

&lt;p&gt;Five minutes on a dev store:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a product "Test Tee", option &lt;strong&gt;Color&lt;/strong&gt;: &lt;code&gt;Black&lt;/code&gt;, &lt;code&gt;White&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Upload 6 images: 3 black, 3 white. Assign the first black image as Black's featured image, first white as White's.&lt;/li&gt;
&lt;li&gt;Publish with Horizon, Media Gallery in grid mode (the default).&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Black&lt;/strong&gt; on the storefront: the main image swaps — and the other 5 images, including all 3 white shots, stay in the gallery. Select &lt;strong&gt;White&lt;/strong&gt;: same in reverse.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the ceiling of native behavior. No toggle changes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route 1 — Alt-text tagging + gallery filter (free, code, no app)
&lt;/h2&gt;

&lt;p&gt;Tag every color image with its color in &lt;strong&gt;Alt text&lt;/strong&gt; (Products → product → click image → Alt text → &lt;code&gt;Black&lt;/code&gt;, &lt;code&gt;White&lt;/code&gt;, …; leave shared images like size charts untagged). Then filter the gallery on the selected option value, in a Custom Liquid block or section on the product template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight liquid"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;assign&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;current_variant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;selected_or_first_available_variant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;assign&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;selected_color&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;current_variant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;

&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;media&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;media&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
  &lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;media&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;media_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'image'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
    &lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;media&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;alt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;blank&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ow"&gt;or&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;media&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;alt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;selected_color&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
      &amp;lt;img
        src="&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;media&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;"
        alt="&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;media&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;alt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;"
        loading="lazy"&amp;gt;
    &lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endif&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
  &lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endif&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endfor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The notes that make or break this in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;product.media&lt;/code&gt; filtered with &lt;code&gt;where: 'media_type', 'image'&lt;/code&gt; is the documented pattern; &lt;code&gt;media.alt&lt;/code&gt; is the string you typed in the admin. Both documented.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alt text must match the option value exactly&lt;/strong&gt; — &lt;code&gt;Black&lt;/code&gt; ≠ &lt;code&gt;black&lt;/code&gt; ≠ &lt;code&gt;Black&lt;/code&gt; (trailing space). This, plus browser cache, is why the OP reported the community code "not working."&lt;/li&gt;
&lt;li&gt;The snippet filters on first render only. Switching colors is a client-side event — you need a small JS listener on the variant picker that re-filters (or hides/shows) gallery items without a reload. This is the half people paste-and-pray without.&lt;/li&gt;
&lt;li&gt;Untagged images stay visible for every color — exactly what you want for size charts and lifestyle shots.&lt;/li&gt;
&lt;li&gt;This approach was tested on a live Horizon store (grid layout) with before/after screenshots in the thread; the untagged size chart correctly persisted across colors.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Route 2 — Shopify Combined Listings (free, first-party, Plus only)
&lt;/h2&gt;

&lt;p&gt;If each color deserves its own full media set — and its own URL for ads and SEO — the first-party answer is Combined Listings: each color is a &lt;strong&gt;separate product&lt;/strong&gt; with its own images, and the combined listing displays all child products on one product page. Zero code. The catch is hard: &lt;strong&gt;Combined Listings are available only to stores on a Shopify Plus plan&lt;/strong&gt; (shopify.dev, verified 2026-08-05). For the OP, who excluded both apps and Plus, this route is out — but it's the correct answer for Plus stores, and worth knowing before you write any Liquid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route 3 — Apps
&lt;/h2&gt;

&lt;p&gt;Exist, work, out of scope: the constraint was "no paid app," and Routes 1–2 cover the free space completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision tree
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;One image per color?            → Variant featured image (+ Horizon's
                                  "hide other variants' media" toggle). Done.
Multiple images per color?
  ├─ On Plus?                   → Combined Listings. No code.
  └─ Not on Plus?               → Alt-text tagging + Custom Liquid/JS filter.
                                   Match alt text to option names exactly.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Decide your case first: one image per color (native) vs. a gallery per color (code or Plus).&lt;/li&gt;
&lt;li&gt;Case A: assign each variant's featured image; on Horizon, enable "Hide other variants' media" in the Media Gallery block.&lt;/li&gt;
&lt;li&gt;Case B, Route 1: tag every color image's Alt text with the &lt;em&gt;exact&lt;/em&gt; option value (case, spelling, trailing spaces).&lt;/li&gt;
&lt;li&gt;Filter &lt;code&gt;product.media&lt;/code&gt; by &lt;code&gt;media_type == 'image'&lt;/code&gt; and &lt;code&gt;media.alt == selected_color&lt;/code&gt;; leave &lt;code&gt;media.alt == blank&lt;/code&gt; items visible for shared shots.&lt;/li&gt;
&lt;li&gt;Add the JS listener on variant change — server-rendered Liquid only filters the first paint.&lt;/li&gt;
&lt;li&gt;Test on grid &lt;em&gt;and&lt;/em&gt; carousel; the layout setting changes markup, not filtering.&lt;/li&gt;
&lt;li&gt;Hard-refresh / incognito before concluding "the code doesn't work" — stale cached HTML is the #1 false negative.&lt;/li&gt;
&lt;li&gt;On Plus? Skip all of it: Combined Listings gives each color its own product and media set, natively.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Community thread content (the Horizon "Hide other variants' media" setting, the tested alt-text solution, the OP's failed implementation) is a field report from Shopify Community topic 659928; every load-bearing technical claim is verified against Shopify's official documentation (Liquid variant.featured_image/featured_media, media.alt, product.media where-filter, Admin GraphQL ProductVariant.media, Combined Listings) as of 2026-08-05.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://clawmama.run/blog/shopify-horizon-variant-media-filter/" rel="noopener noreferrer"&gt;https://clawmama.run/blog/shopify-horizon-variant-media-filter/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>liquid</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Google kept rejecting a bookstore's print listings — because the ebook was still buyable on the same URL</title>
      <dc:creator>Eli</dc:creator>
      <pubDate>Wed, 05 Aug 2026 13:32:57 +0000</pubDate>
      <link>https://dev.to/eliofbm/google-kept-rejecting-a-bookstores-print-listings-because-the-ebook-was-still-buyable-on-the-179k</link>
      <guid>https://dev.to/eliofbm/google-kept-rejecting-a-bookstores-print-listings-because-the-ebook-was-still-buyable-on-the-179k</guid>
      <description>&lt;p&gt;I stumbled onto this one in a Shopify Community thread that had 13 answers and no accepted one. A book publisher sells every title in three formats — print, ebook, audiobook. They did what every feed management app tells you to do: exclude every digital edition from the Google Merchant Center data source. And Google's automated review kept rejecting their &lt;em&gt;print&lt;/em&gt; book listings anyway.&lt;/p&gt;

&lt;p&gt;I've seen "just filter the feed" handed out as the complete answer to this problem for years, so I dug into why it wasn't working. The reason is structural, and it's documented — just not in one place. Here's the whole picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The structural fact that explains everything
&lt;/h2&gt;

&lt;p&gt;One respondent in the thread (dropfeed) actually audited the merchant's catalog: &lt;strong&gt;423 of the store's 480 products carry a single Format option with Audiobook, Ebook, Paperback and Hardcover as variants of one product.&lt;/strong&gt; All four formats share one URL and one item group.&lt;/p&gt;

&lt;p&gt;Hold that fact next to three documented ones:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Google's Shopping policies don't support ebooks.&lt;/strong&gt; The "Unsupported Shopping content" policy lists "eBooks and digital books (not including audiobooks)" — PDFs, ePub, MOBI — as content that cannot be promoted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google evaluates the landing page, not just the feed.&lt;/strong&gt; The landing page requirements: when your page contains multiple products "such as variants", the product in your product data must be the primary focus and the price must match. The &lt;code&gt;link&lt;/code&gt; attribute doc: one link per product &lt;em&gt;or product variant&lt;/em&gt;. And Google's structured data docs show variants are addressed by preselecting via URL parameters (&lt;code&gt;?size=small&amp;amp;color=green&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On the submitted URL, the ebook is still buyable.&lt;/strong&gt; Ebook and audiobook are &lt;em&gt;variants&lt;/em&gt; of the same Shopify product, so the print URL renders one Add to Cart form whose Format picker lets anyone select — and purchase — a digital edition.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The thread converged on a precise dividing line (field report, but consistent with the policy pages):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Allowed:&lt;/strong&gt; mentioning "also available as ebook and audiobook" in the product copy. Text is not an offer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rejected:&lt;/strong&gt; ebook/audiobook as &lt;em&gt;selectable, purchasable variants&lt;/em&gt; in the Add to Cart form on the submitted URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's why filtering the feed changed nothing: the merchant cleaned the data Google &lt;em&gt;reads&lt;/em&gt;, not the page Google &lt;em&gt;visits&lt;/em&gt;. Preselecting the paperback variant doesn't fix it either — the digital options are still one click away in the same form, still purchasable, same URL.&lt;/p&gt;

&lt;p&gt;A quieter amplifier: if your product page emits JSON-LD offers for the digital variants (many themes and apps do), crawlers read the ebook as an &lt;em&gt;offer&lt;/em&gt; even where a human sees only a dropdown. Google's &lt;code&gt;ProductGroup&lt;/code&gt; pattern (&lt;code&gt;hasVariant&lt;/code&gt;, &lt;code&gt;variesBy&lt;/code&gt;, &lt;code&gt;productGroupID&lt;/code&gt;) is correct markup for genuine multi-format pages — and a liability when some variants are unsupported content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal reproduction (no Google required)
&lt;/h2&gt;

&lt;p&gt;The failure is structural and visible in Liquid on any dev store:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create one product, &lt;code&gt;dune&lt;/code&gt;, with option &lt;strong&gt;Format&lt;/strong&gt;: &lt;code&gt;Paperback&lt;/code&gt; ($18, requires shipping), &lt;code&gt;Ebook&lt;/code&gt; ($9, digital), &lt;code&gt;Audiobook&lt;/code&gt; ($15, digital).&lt;/li&gt;
&lt;li&gt;Exclude the digital variants from your Google feed by whatever method you like.&lt;/li&gt;
&lt;li&gt;Open the product URL with the paperback preselected: &lt;code&gt;/products/dune?variant=&amp;lt;paperback_variant_id&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;View source. The Add to Cart form posts a variant &lt;code&gt;id&lt;/code&gt;, and the Format selector still contains all three options. Any crawler sees three purchasable offers — two of them digital — on the submitted URL.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Feed-side exclusion verified; landing-side exposure intact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route A — Split the formats into separate products (the structural fix)
&lt;/h2&gt;

&lt;p&gt;Print, ebook, and audiobook become separate Shopify products. Publish only the print products to Google surfaces; keep the digital products on the storefront, linked from the print page as text ("Also available as ebook / audiobook"). The submitted print URL offers only physical formats — exactly what passes review.&lt;/p&gt;

&lt;p&gt;The merchant's objection is real: this multiplies the catalog 3-4x. Mitigations exist but don't eliminate the cost — bulk editors, metafield-driven cross-links, and for programmatic control the Admin GraphQL API (&lt;code&gt;publicationCreate&lt;/code&gt; publishes a product to a channel; channel availability is product-level, and tags/metafields alone do not control sync).&lt;/p&gt;

&lt;h2&gt;
  
  
  Route B — Proxy page: a physical-only offer page per title (the theme fix)
&lt;/h2&gt;

&lt;p&gt;Keep the variant structure. Create an alternate page template that renders &lt;em&gt;only the physical variants&lt;/em&gt; of a given product, and submit those proxy URLs to Google. Shopify theme architecture supports alternate templates, and Liquid gives full markup control. Pull the product with &lt;code&gt;all_products['your-handle']&lt;/code&gt; and filter variants by &lt;code&gt;requires_shipping&lt;/code&gt; — true for physical goods, false for digital ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight liquid"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;comment&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;&lt;span class="c"&gt; templates/page.print-proxy.liquid &lt;/span&gt;&lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endcomment&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
&lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;assign&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;book&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;all_products&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dune'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;

&amp;lt;h1&amp;gt;&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;title&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt; — Print edition&amp;lt;/h1&amp;gt;

&amp;lt;form method="post" action="/cart/add"&amp;gt;
  &amp;lt;label for="fmt"&amp;gt;Format&amp;lt;/label&amp;gt;
  &amp;lt;select id="fmt" name="id"&amp;gt;
    &lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;variant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;variants&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
      &lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;requires_shipping&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
        &amp;lt;option value="&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;"&amp;gt;
          &lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;title&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt; — &lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;price&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;
        &amp;lt;/option&amp;gt;
      &lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endif&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
    &lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endfor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
  &amp;lt;/select&amp;gt;
  &amp;lt;button type="submit"&amp;gt;Add to cart&amp;lt;/button&amp;gt;
&amp;lt;/form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mapped to the requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The proxy URL's only purchasable options are physical. The offer on the page matches the submitted offer.&lt;/li&gt;
&lt;li&gt;One product, one clean URL to submit as the &lt;code&gt;link&lt;/code&gt; — Google wants exactly one link per product or variant.&lt;/li&gt;
&lt;li&gt;Digital editions stay on sale at the canonical product URL, linked as text — copy, not an offer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two documented constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;all_products&lt;/code&gt; resolves at most &lt;strong&gt;20 unique handles per page&lt;/strong&gt;. One proxy page per title is fine; proxying a whole catalog from one page is not — use collections or one page per handle.&lt;/li&gt;
&lt;li&gt;Keep the proxy page's JSON-LD honest: emit structured data only for the physical offers on the page; don't list digital variants as offers on the proxy URL. When you submit variant URLs elsewhere, preselect via URL parameters so price/availability match.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;More template work than Route A, but the catalog survives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route C — GMC attribute rules with &lt;code&gt;excluded_destination&lt;/code&gt; (the feed-hygiene fix)
&lt;/h2&gt;

&lt;p&gt;Worth doing regardless of the structural route. In Merchant Center, "feed rules" are now called &lt;strong&gt;attribute rules&lt;/strong&gt; (Google renamed them). Add a rule on your data source that sets &lt;code&gt;excluded_destination&lt;/code&gt; when the title matches digital markers (e.g. "title contains ebook"), excluding from Shopping ads, free listings, display, etc. The attribute is documented precisely for preventing a product from appearing in chosen destinations.&lt;/p&gt;

&lt;p&gt;What it does and doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Does:&lt;/strong&gt; guarantee no digital SKU is ever &lt;em&gt;submitted&lt;/em&gt;, even if your sync app misbehaves or a new digital product is added without tags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does not:&lt;/strong&gt; change what a crawler finds on a shared-format product URL. Necessary hygiene, not a cure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On sync: the Google &amp;amp; YouTube app syncs Shopify product data to Merchant Center automatically or manually — attribute rules sit &lt;em&gt;after&lt;/em&gt; the data arrives, which is why they catch what channel-side visibility settings miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Inventory: which products mix physical + digital formats as variants of one product? (The merchant's audit: 423 of 480.)&lt;/li&gt;
&lt;li&gt;Classify every digital format against Google's Unsupported Shopping content list — ebooks explicitly listed; audiobooks explicitly &lt;em&gt;not&lt;/em&gt; in that entry, but treat any instant-download good as suspect until verified.&lt;/li&gt;
&lt;li&gt;Confirm the failure mode first: does the submitted URL offer digital formats for purchase? If yes, feed filtering alone can't clear the rejection.&lt;/li&gt;
&lt;li&gt;Pick the structural route: split products (A) or proxy pages with &lt;code&gt;requires_shipping&lt;/code&gt;-filtered forms (B). Text mentions can stay; purchasable selectors cannot.&lt;/li&gt;
&lt;li&gt;Add GMC attribute rules setting &lt;code&gt;excluded_destination&lt;/code&gt; on digital markers as a safety net regardless.&lt;/li&gt;
&lt;li&gt;Audit JSON-LD on submitted URLs: emit only offers that are purchasable and supported; keep digital variants out of the proxy page's markup.&lt;/li&gt;
&lt;li&gt;Submit one &lt;code&gt;link&lt;/code&gt; per product/variant; preselect variant URLs via parameters so price and availability match.&lt;/li&gt;
&lt;li&gt;After fixing, request a review on the affected items and watch for re-disapproval.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;The community thread content (catalog audit, the "purchasable variant" diagnosis, the proxy-page suggestion, the attribute-rules recipe) is a field report from Shopify Community topic 658986; every load-bearing technical claim above is verified against Google and Shopify documentation (Unsupported Shopping content policy, landing page requirements, link/excluded_destination attributes, attribute rules, product-variant structured data, Liquid all_products/variant, theme templates, Admin GraphQL publicationCreate — latest stable 2026-07).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://clawmama.run/blog/shopify-gmc-format-variant-rejection/" rel="noopener noreferrer"&gt;https://clawmama.run/blog/shopify-gmc-format-variant-rejection/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>A merchant asked for B2B subscriptions — so I read the schema and found the wall</title>
      <dc:creator>Eli</dc:creator>
      <pubDate>Mon, 03 Aug 2026 13:32:25 +0000</pubDate>
      <link>https://dev.to/eliofbm/a-merchant-asked-for-b2b-subscriptions-so-i-read-the-schema-and-found-the-wall-2ihm</link>
      <guid>https://dev.to/eliofbm/a-merchant-asked-for-b2b-subscriptions-so-i-read-the-schema-and-found-the-wall-2ihm</guid>
      <description>&lt;p&gt;This one started with a community thread I couldn't stop thinking about: a pure-B2B merchant — no D2C checkout at all, ~200k SKUs, selling maintenance bundles — wanted to sell those bundles as quarterly subscriptions. The only answer the thread produced was "make a draft order every month by hand." I've seen that advice given out for years, so I went to check whether it even works at the API level. It doesn't — and the reason is written down in Shopify's own docs. Here is the wall, and the three doors around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause: a one-sentence wall
&lt;/h2&gt;

&lt;p&gt;Shopify's B2B developer documentation states it in the Limitations section:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"B2B doesn't support purchase options, such as subscriptions, pre-orders, and try before you buy."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Purchase options" is the umbrella term, and subscriptions are implemented through &lt;strong&gt;selling plans&lt;/strong&gt;: a &lt;code&gt;SellingPlan&lt;/code&gt; defines how a product can be sold and purchased through recurring billing — when to bill, when to fulfill, what pricing adjustments to apply — and a &lt;code&gt;SellingPlanGroup&lt;/code&gt; attaches those plans to products and variants. No purchase options in B2B means no selling plans in the B2B checkout, which means no &lt;code&gt;SubscriptionContract&lt;/code&gt; — the object that defines recurring purchases for a customer and tracks billing attempts, payment status, and generated orders — is ever born there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "use a draft order" fails at the API level
&lt;/h2&gt;

&lt;p&gt;Draft orders are the standard B2B instrument: the API lets you create draft orders for company contacts to review and approve, attach &lt;code&gt;paymentTerms&lt;/code&gt;, send an &lt;code&gt;invoiceUrl&lt;/code&gt;. So the community reflex is "sell the subscription through a draft order."&lt;/p&gt;

&lt;p&gt;Open the Admin GraphQL reference for &lt;code&gt;DraftOrder&lt;/code&gt; and &lt;code&gt;DraftOrderLineItem&lt;/code&gt; (latest stable, 2026-07) and read the full field list: &lt;strong&gt;there is no selling-plan field anywhere&lt;/strong&gt; — not on the draft order, not on its line items. You cannot smuggle a selling plan through a draft order, so a draft order cannot create a subscription contract either. The wall has no cracks on this side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal reproduction
&lt;/h2&gt;

&lt;p&gt;You don't need a Plus sandbox to confirm the wall; you need the schema. Run an introspection query against any store's Admin GraphQL API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;draftLineItem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;__type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DraftOrderLineItem"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;draftOrder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;__type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DraftOrder"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grep the result for &lt;code&gt;sellingPlan&lt;/code&gt;: zero hits on either type, matching the published object reference. What you &lt;em&gt;will&lt;/em&gt; find on &lt;code&gt;DraftOrder&lt;/code&gt; are the B2B-native fields: &lt;code&gt;paymentTerms&lt;/code&gt;, &lt;code&gt;purchasingEntity&lt;/code&gt;, &lt;code&gt;invoiceUrl&lt;/code&gt;, and the deposit-aware totals (&lt;code&gt;amountDueNowSet&lt;/code&gt; / &lt;code&gt;amountDueLaterSet&lt;/code&gt; — when payment terms exist, "due now" is the deposit and "due later" is the remainder).&lt;/p&gt;

&lt;p&gt;Two checks, five minutes, wall confirmed from primary sources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Door 1 — the D2C lane (no app required)
&lt;/h2&gt;

&lt;p&gt;The B2B object model has a built-in escape hatch: &lt;strong&gt;a company contact is associated with a retail customer record.&lt;/strong&gt; That customer record can buy through the normal online-store checkout, where purchase options &lt;em&gt;are&lt;/em&gt; supported. So the subscription is sold on the D2C lane to the contact's customer account, while the rest of the relationship stays B2B.&lt;/p&gt;

&lt;p&gt;The trade-offs are real: that subscription order won't carry B2B catalog pricing, won't inherit payment terms, and won't roll up into company-level reporting. For a pure-B2B merchant it's a compromise lane, not a native feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Door 2 — the API door: build the contract without checkout
&lt;/h2&gt;

&lt;p&gt;Here's the part almost nobody mentions: a subscription contract does &lt;strong&gt;not&lt;/strong&gt; have to be born at checkout. The Admin GraphQL API exposes &lt;code&gt;subscriptionContractCreate&lt;/code&gt;, which — per the reference — "creates a subscription contract draft, which is an intention to create a new subscription." You supply the customer, a customer payment method, and billing/delivery policies; you then finalize with &lt;code&gt;subscriptionDraftCommit&lt;/code&gt;. No storefront involved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="c"&gt;# Adapted from the official mutation example (API version 2026-07)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="k"&gt;mutation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;createSubscriptionContract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SubscriptionContractCreateInput&lt;/span&gt;&lt;span class="p"&gt;!)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;subscriptionContractCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;draft&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;userErrors&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"customerId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gid://shopify/Customer/544365967"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"currencyCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"nextBillingDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-01T09:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"contract"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ACTIVE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"paymentMethodId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gid://shopify/CustomerPaymentMethod/b7cc6e3267aace169e516ed48be72dff"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"billingPolicy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minCycles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxCycles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"intervalCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"interval"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MONTH"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"deliveryPolicy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"intervalCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"interval"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MONTH"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the draft commits, the contract exists. Billing then runs through &lt;code&gt;subscriptionBillingAttemptCreate&lt;/code&gt;, which charges the contract for the current (or a selected) billing cycle and &lt;strong&gt;creates an Order on success&lt;/strong&gt;; failed attempts expose a &lt;code&gt;processingError&lt;/code&gt;, and the &lt;code&gt;idempotencyKey&lt;/code&gt; argument exists specifically to prevent duplicate charges on retries.&lt;/p&gt;

&lt;p&gt;The fine print, all verifiable in the reference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Both mutations require the &lt;code&gt;write_own_subscription_contracts&lt;/code&gt; access scope, and the acting user needs the &lt;code&gt;manage_orders_information&lt;/code&gt; permission.&lt;/li&gt;
&lt;li&gt;The contract's &lt;code&gt;customerPaymentMethod&lt;/code&gt; must already exist — vaulting a card is a separate problem the API doesn't solve for you.&lt;/li&gt;
&lt;li&gt;The contract belongs to the &lt;strong&gt;customer&lt;/strong&gt;, not the company. B2B pricing, terms, and reporting still don't apply.&lt;/li&gt;
&lt;li&gt;Selling plans and their associated records are &lt;strong&gt;automatically deleted 48 hours after the merchant uninstalls the app that created them&lt;/strong&gt; — if you build on this, back those records up.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Door 3 — emulate recurrence with native B2B primitives
&lt;/h2&gt;

&lt;p&gt;If "real subscriptions" are impossible but "recurring charges on a schedule" is the actual business need, the native building blocks are draft orders with &lt;code&gt;paymentTerms&lt;/code&gt;: deposit semantics via &lt;code&gt;amountDueNowSet&lt;/code&gt;/&lt;code&gt;amountDueLaterSet&lt;/code&gt;, an &lt;code&gt;invoiceUrl&lt;/code&gt; to collect payment, and company association via &lt;code&gt;purchasingEntity&lt;/code&gt;. You generate the recurring draft orders on a schedule (your own automation) instead of a billing policy doing it.&lt;/p&gt;

&lt;p&gt;A community contributor on the same thread described one concrete assembly of this — vaulting a B2B customer's card and using Shopify Flow's payment-schedule trigger with a "charge vaulted payment" action to automate the collection. I flag that specific assembly as a &lt;strong&gt;field report&lt;/strong&gt;: it's practitioner testimony from the thread, not something re-verified line-by-line in the merchant documentation, so treat the exact trigger/action names as "confirm in your own admin" rather than gospel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Accept the wall: B2B doesn't support purchase options (subscriptions, pre-orders, try before you buy) — it's documented, not a bug.&lt;/li&gt;
&lt;li&gt;Stop trying to attach selling plans to draft orders: neither &lt;code&gt;DraftOrder&lt;/code&gt; nor &lt;code&gt;DraftOrderLineItem&lt;/code&gt; has a selling-plan field in the Admin GraphQL API.&lt;/li&gt;
&lt;li&gt;Decide what the business actually needs: contract semantics (Door 2), or just recurring collection (Door 3 / Door 1).&lt;/li&gt;
&lt;li&gt;Door 1: route the subscription through the contact's retail customer record on the D2C checkout; document that B2B pricing/terms/reporting don't apply.&lt;/li&gt;
&lt;li&gt;Door 2: use &lt;code&gt;subscriptionContractCreate&lt;/code&gt; → &lt;code&gt;subscriptionDraftCommit&lt;/code&gt; → &lt;code&gt;subscriptionBillingAttemptCreate&lt;/code&gt;; confirm &lt;code&gt;write_own_subscription_contracts&lt;/code&gt; scope + &lt;code&gt;manage_orders_information&lt;/code&gt; permission; solve card vaulting first; back up selling-plan data against the 48-hour uninstall deletion rule.&lt;/li&gt;
&lt;li&gt;Door 3: model recurrence with draft orders + &lt;code&gt;paymentTerms&lt;/code&gt; + invoice collection; treat community Flow recipes as field reports to verify in your own admin.&lt;/li&gt;
&lt;li&gt;Whatever you build: a subscription order created outside B2B will not appear in company-level B2B reporting — set stakeholder expectations in writing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Originally published at &lt;a href="https://clawmama.run/blog/shopify-b2b-subscription-gap/" rel="noopener noreferrer"&gt;https://clawmama.run/blog/shopify-b2b-subscription-gap/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your AI fortune teller was making things up — so we took the math away from the model</title>
      <dc:creator>Eli</dc:creator>
      <pubDate>Mon, 03 Aug 2026 11:55:51 +0000</pubDate>
      <link>https://dev.to/eliofbm/your-ai-fortune-teller-was-making-things-up-so-we-took-the-math-away-from-the-model-9n9</link>
      <guid>https://dev.to/eliofbm/your-ai-fortune-teller-was-making-things-up-so-we-took-the-math-away-from-the-model-9n9</guid>
      <description>&lt;p&gt;A few weeks ago I bookmarked a viral post: "5 fortune-telling Skills on GitHub you should save." BaZi, ZiWei, QiMen, Tarot — thousands of stars combined. I bookmarked it too. And like most people, I never got past the bookmark.&lt;/p&gt;

&lt;p&gt;When I finally dug in, I found a problem worse than setup friction: &lt;strong&gt;AI fortune-telling fails on the chart itself.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The chart is math, not prose
&lt;/h2&gt;

&lt;p&gt;A BaZi "Four Pillars" chart or a ZiWei star map is deterministic calculation — dense rules, zero room for vibes. Ask a model to compute a chart from memory and it will get the day pillar wrong, then write you a gorgeous, confident, completely wrong reading.&lt;/p&gt;

&lt;p&gt;Experts spot it immediately. Everyone else gets misled beautifully.&lt;/p&gt;

&lt;h2&gt;
  
  
  The projects that got it right
&lt;/h2&gt;

&lt;p&gt;The most interesting repos in this wave take calculation away from the model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TaiBu&lt;/strong&gt; (github.com/hhszzzz/taibu) — fifteen local divination engines: BaZi, ZiWei, LiuYao, MeiHua, QiMen, Tarot, Western astrology, Chinese almanac. Charts are computed by a bundled engine; the model interprets, never calculates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BaZi Skill&lt;/strong&gt; (github.com/jinchenma94/bazi-skill) — interpretation rules distilled from nine classical texts into a structured framework.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mingyu&lt;/strong&gt; (github.com/Brhiza/mingyu) — chart workflows plus a public API that turns charts into AI-readable structured data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we built
&lt;/h2&gt;

&lt;p&gt;We packaged these three into one ready-to-use Agent. You ask in plain language — "born May 20, 1990, 8 AM" — the Agent runs the real engines, then interprets the verified chart with classical rules.&lt;/p&gt;

&lt;p&gt;I tested it with a known birth input: the computed pillars matched manual cross-checking exactly (Geng-Wu year, Xin-Si month, Yi-You day, Geng-Chen hour). That's the bar: the reading can be subjective, the chart cannot.&lt;/p&gt;

&lt;p&gt;If you're building anything in the AI-metaphysics space, steal this architecture: deterministic engine for facts, model for interpretation, and never let the two trade places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it&lt;/strong&gt;: &lt;a href="https://clawmama.run/agents/9d7myk" rel="noopener noreferrer"&gt;Fortune Reading Agent on ClawMama&lt;/a&gt; — one click, then chat with it in Telegram, WhatsApp or Discord.&lt;/p&gt;

&lt;p&gt;Originally published at &lt;a href="https://clawmama.run/blog/fortune-reading-agent" rel="noopener noreferrer"&gt;https://clawmama.run/blog/fortune-reading-agent&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I gave my AI coding assistant a design degree (one skill, 875 likes later)</title>
      <dc:creator>Eli</dc:creator>
      <pubDate>Mon, 03 Aug 2026 06:03:38 +0000</pubDate>
      <link>https://dev.to/eliofbm/i-gave-my-ai-coding-assistant-a-design-degree-one-skill-875-likes-later-1ac0</link>
      <guid>https://dev.to/eliofbm/i-gave-my-ai-coding-assistant-a-design-degree-one-skill-875-likes-later-1ac0</guid>
      <description>&lt;p&gt;I asked my AI coding assistant to make a poster about "a rainy old bookstore." What I got was a book icon, some rain drops, and the visual charm of a school newsletter. That's when I went down the rabbit hole of &lt;em&gt;why&lt;/em&gt; AI-generated visuals feel so... generic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap isn't technical — it's aesthetic
&lt;/h2&gt;

&lt;p&gt;AI coding assistants like Codex can place elements on a canvas all day long. What they can't do is tell you &lt;em&gt;why&lt;/em&gt; one composition feels "quiet" and "premium" while another feels like clip art. Standard models don't carry a design system in their heads. The gap isn't compute — it's aesthetic literacy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Someone decomposed "zine style" into executable rules
&lt;/h2&gt;

&lt;p&gt;gc-minimal-zine-poster is an open-source Codex skill that encodes a complete zine visual language. Instead of asking for "a poster," you invoke a design system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install&lt;/span&gt;
git clone https://github.com/LiamGvchi/gc-minimal-zine-poster.git &lt;span class="se"&gt;\&lt;/span&gt;
  ~/.codex/skills/gc-minimal-zine-poster-v0-1

&lt;span class="c"&gt;# Restart Codex, then use&lt;/span&gt;
&lt;span class="nv"&gt;$gc&lt;/span&gt;&lt;span class="nt"&gt;-minimal-zine-poster-v0-1&lt;/span&gt; &lt;span class="s2"&gt;"a rainy old bookstore"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The skill decomposes "zine style" into four axes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Composition&lt;/strong&gt;: asymmetric balance, generous white space&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typography&lt;/strong&gt;: handwritten or serif fonts, mixed scales&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Color&lt;/strong&gt;: muted palettes, single accent color&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Texture&lt;/strong&gt;: paper grain, subtle noise&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Does it work?
&lt;/h2&gt;

&lt;p&gt;The original author (@BTCqzy1 on X) posted a poster series made with it — 875 likes, ~50k views. Same one-sentence prompt, completely different output: intentional, gallery-worthy visuals instead of generic icons.&lt;/p&gt;

&lt;p&gt;If your AI-generated visuals keep coming out soulless, the fix might not be a better prompt. It might be giving the model a design vocabulary it never had.&lt;/p&gt;

&lt;p&gt;Originally published at &lt;a href="https://clawmama.run/blog/gc-minimal-zine-poster" rel="noopener noreferrer"&gt;https://clawmama.run/blog/gc-minimal-zine-poster&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>ai</category>
      <category>design</category>
    </item>
    <item>
      <title>Our bundle math was wrong because Shopify rejected our cart transform — and then leaked a price</title>
      <dc:creator>Eli</dc:creator>
      <pubDate>Sun, 02 Aug 2026 09:15:08 +0000</pubDate>
      <link>https://dev.to/eliofbm/our-bundle-math-was-wrong-because-shopify-rejected-our-cart-transform-and-then-leaked-a-price-5efc</link>
      <guid>https://dev.to/eliofbm/our-bundle-math-was-wrong-because-shopify-rejected-our-cart-transform-and-then-leaked-a-price-5efc</guid>
      <description>&lt;p&gt;We found this one the way you always find the good ones: a community bug report that made us drop everything and go check our own function logs. A developer building a bundle app posted that when the same variant sits in the cart twice — once as a subscription line, once as a normal line — the &lt;em&gt;normal&lt;/em&gt; line shows up in the Function input with the &lt;strong&gt;selling-plan price&lt;/strong&gt; in &lt;code&gt;cost.subtotalAmount&lt;/code&gt;. Their bundle math, computed from that cost, came out wrong. We run the same architecture in production. Here is the wall, the leak, and the defensive input handling that makes your math survive either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall #1: selling plans hard-reject Cart Transform operations
&lt;/h2&gt;

&lt;p&gt;The Cart Transform API reference is unusually blunt about this. Under &lt;em&gt;Invalid scenarios&lt;/em&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Shopify rejects lineExpand, linesMerge, and lineUpdate operations if a selling plan is present."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rejection is not scoped to the selling-plan line: &lt;strong&gt;if a selling plan is present&lt;/strong&gt;, the operations are rejected. The surface-compatibility table on the same page draws the rest of the wall:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cart: Supported. B2B: Supported. Draft orders: Supported.&lt;/li&gt;
&lt;li&gt;Checkout: &lt;strong&gt;Partially supported&lt;/strong&gt; — that footnote is the selling-plan rejection.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Subscription (Recurring Orders): Not supported.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Order Edit, Create Order API, Pre-order / Try Before You Buy: Not supported.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two more constraints from the same page shape any bundle architecture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One cart transform function per app per store&lt;/strong&gt;; but if several apps each install one, &lt;em&gt;all of them run&lt;/em&gt;. Your operations share the cart with every other bundle or upsell app the merchant installed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lineUpdate&lt;/code&gt; requires a &lt;strong&gt;development store or Shopify Plus&lt;/strong&gt;. On non-Plus production stores, expand and merge are what you have.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the split isn't a style choice. It's the only architecture the platform permits:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bundle type&lt;/th&gt;
&lt;th&gt;Function&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One-time bundle&lt;/td&gt;
&lt;td&gt;Cart Transform (&lt;code&gt;lineExpand&lt;/code&gt; / &lt;code&gt;linesMerge&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Native bundle UX; price presentation handled by the API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subscription bundle&lt;/td&gt;
&lt;td&gt;Discount function (&lt;code&gt;cart.lines.discounts.generate.run&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Cart Transform rejects everything once a selling plan exists&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The documented execution order makes this workable: &lt;strong&gt;Cart Transform runs first (step 1: cart lines); Discount functions run after (steps 2 and 5).&lt;/strong&gt; Your discount logic sees the cart &lt;em&gt;after&lt;/em&gt; the transform has expanded or merged it, which is exactly what you want for subscription bundles priced off component sums.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall #2: the price that leaks through
&lt;/h2&gt;

&lt;p&gt;The community field report (topic 657784; flag this as &lt;strong&gt;unconfirmed platform behavior&lt;/strong&gt;, since the docs define nothing here):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Same variant in the cart twice: line A with a selling plan, line B without. In the Function input, line B's &lt;code&gt;cost.subtotalAmount&lt;/code&gt; reflects the &lt;strong&gt;selling-plan price&lt;/strong&gt;, not the variant's regular price. Bundle calculations that trust &lt;code&gt;cost&lt;/code&gt; are corrupted.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Whether this is a bug that gets fixed next quarter or a permanent edge of variant-level price resolution, the lesson generalizes: &lt;strong&gt;Function input is data crossing a trust boundary. Treat it like an API response, not like truth.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What the docs &lt;em&gt;do&lt;/em&gt; define, on the cart-line input fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cost.subtotalAmount&lt;/code&gt; — "the cost of items in the cart &lt;strong&gt;before applying any discounts&lt;/strong&gt;." Variant-priced.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cost.amountPerQuantity&lt;/code&gt; — the unit cost.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sellingPlanAllocation&lt;/code&gt; — present on lines that carry a selling plan, with &lt;code&gt;priceAdjustments&lt;/code&gt; and &lt;code&gt;perDeliveryPrice&lt;/code&gt; (the docs' own example: 6 deliveries at $48.00 → $8.00 per delivery).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a line has a selling-plan price, the &lt;em&gt;documented&lt;/em&gt; place to read it is &lt;code&gt;sellingPlanAllocation&lt;/code&gt;, not &lt;code&gt;cost&lt;/code&gt;. And the &lt;em&gt;documented&lt;/em&gt; way to know a line is a subscription line is that &lt;code&gt;sellingPlanAllocation&lt;/code&gt; exists on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The defensive pattern: composite keys, not variant IDs
&lt;/h2&gt;

&lt;p&gt;The root mistake in most bundle math we've audited (including, briefly, our own) is grouping cart lines by &lt;code&gt;variant.id&lt;/code&gt; alone. A variant is not a purchasable identity in the cart — a variant &lt;strong&gt;plus a selling-plan context&lt;/strong&gt; is. Once you internalize that, the fix is mechanical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="c"&gt;# run.graphql — query the fields that disambiguate lines, not just prices&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Input&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;cart&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;sellingPlanAllocation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;sellingPlan&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;perDeliveryPrice&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;currencyCode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;priceAdjustments&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;amountPerQuantity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;currencyCode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;subtotalAmount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;currencyCode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;merchandise&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;__typename&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ProductVariant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;bundleOffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"_bundleOfferId"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Group by (variant, sellingPlan) — never by variant alone.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;lineKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;variantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;merchandise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__typename&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ProductVariant&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;merchandise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;planId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sellingPlanAllocation&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;sellingPlan&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;onetime&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;variantId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;::&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;planId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;effectiveUnitPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Documented source of truth for subscription pricing:&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sellingPlanAllocation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sellingPlanAllocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;perDeliveryPrice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// One-time lines only: never mix the two pools.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amountPerQuantity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules fall out of this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Never aggregate across the selling-plan boundary.&lt;/strong&gt; A bundle offer's lines must all come from the same key bucket. If your offer spans both, that's two offers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Read subscription prices from &lt;code&gt;sellingPlanAllocation&lt;/code&gt;.&lt;/strong&gt; If the leaked-price behavior in the field report is real, this is immune to it — you never read &lt;code&gt;cost&lt;/code&gt; on a subscription line, and you never let a subscription line's price contaminate a one-time line's bucket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Keep the input query small.&lt;/strong&gt; The input query is fixed at build time: max &lt;strong&gt;3,000 bytes&lt;/strong&gt; excluding comments, max calculated query cost &lt;strong&gt;30&lt;/strong&gt;, list arguments capped at &lt;strong&gt;100 elements&lt;/strong&gt;. Runtime-changing config belongs in a metafield JSON blob — under &lt;strong&gt;10,000 bytes&lt;/strong&gt;, because larger values come back &lt;em&gt;absent&lt;/em&gt;, not truncated.&lt;/p&gt;

&lt;p&gt;One more warning from Shopify's own bundle guide: line-item properties "can be modified by the browser, so they should not be relied upon for security or validation purposes." Properties like &lt;code&gt;_bundleOfferId&lt;/code&gt; are fine as &lt;em&gt;routing hints&lt;/em&gt;; the authoritative bundle definition belongs in metafields your app owns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal reproduction
&lt;/h2&gt;

&lt;p&gt;In a dev store:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a selling plan group and attach it to variant V. Create a one-time bundle offer and a subscription bundle offer, both containing V.&lt;/li&gt;
&lt;li&gt;Add V to the cart twice: once with the selling plan, once without.&lt;/li&gt;
&lt;li&gt;Attempt a &lt;code&gt;lineExpand&lt;/code&gt; or &lt;code&gt;linesMerge&lt;/code&gt; from your Cart Transform: observe the rejection — the documented behavior — because a selling plan is present.&lt;/li&gt;
&lt;li&gt;In your Discount function, log &lt;code&gt;cart.lines[*].cost.subtotalAmount&lt;/code&gt; for the &lt;em&gt;one-time&lt;/em&gt; line of V and compare it against V's regular price. If the field report still reproduces, the one-time line shows the selling-plan price. With composite-key grouping, your math is unaffected either way.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 is documented fact. Step 4 is the field report: log it and see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers worth pinning above your desk
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Selling plan present ⇒ &lt;code&gt;lineExpand&lt;/code&gt;, &lt;code&gt;linesMerge&lt;/code&gt;, &lt;code&gt;lineUpdate&lt;/code&gt; &lt;strong&gt;rejected&lt;/strong&gt;; subscriptions not supported by Cart Transform at all.&lt;/li&gt;
&lt;li&gt;Execution order: Cart Transform (1) → Discounts (2, 5). Discounts see the transformed cart.&lt;/li&gt;
&lt;li&gt;One cart transform per app per store; multiple apps' transforms all run.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lineUpdate&lt;/code&gt;: dev stores and Plus only.&lt;/li&gt;
&lt;li&gt;Bundle limits from the guide: &lt;strong&gt;150 components&lt;/strong&gt;, &lt;strong&gt;3 options&lt;/strong&gt;, no nested bundles; only the owning app manages a bundle's components.&lt;/li&gt;
&lt;li&gt;Input query: &lt;strong&gt;3,000 bytes&lt;/strong&gt;, cost &lt;strong&gt;30&lt;/strong&gt;, lists ≤ &lt;strong&gt;100&lt;/strong&gt; elements; config metafields over &lt;strong&gt;10,000 bytes&lt;/strong&gt; return nothing.&lt;/li&gt;
&lt;li&gt;Group cart lines by &lt;code&gt;(variant, sellingPlan)&lt;/code&gt;; read subscription prices from &lt;code&gt;sellingPlanAllocation.perDeliveryPrice&lt;/code&gt;, never &lt;code&gt;cost&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Originally published at &lt;a href="https://clawmama.run/blog/shopify-cart-transform-selling-plan/" rel="noopener noreferrer"&gt;https://clawmama.run/blog/shopify-cart-transform-selling-plan/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>I found out why our Shopify filters were missing options: the 1,000-tag ceiling</title>
      <dc:creator>Eli</dc:creator>
      <pubDate>Sun, 02 Aug 2026 01:30:40 +0000</pubDate>
      <link>https://dev.to/eliofbm/i-found-out-why-our-shopify-filters-were-missing-options-the-1000-tag-ceiling-51ko</link>
      <guid>https://dev.to/eliofbm/i-found-out-why-our-shopify-filters-were-missing-options-the-1000-tag-ceiling-51ko</guid>
      <description>&lt;p&gt;We ran a Shopify store that used product tags for everything — color, material, occasion, audience — and the theme rendered the collection filter UI from the collection's tags. Then a merchant report came in: filter options were missing, and the products behind them were unfindable by filtering. Nothing had errored, anywhere. Tracking down why led to one line in Shopify's own Liquid reference that almost nobody reads until it's too late.&lt;/p&gt;

&lt;p&gt;Turns out we're not alone: in the Shopify Community thread that prompted this write-up, three independent experts converged on the same answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause #1: collection.all_tags is hard-capped at 1,000
&lt;/h2&gt;

&lt;p&gt;The classic tag-filter pattern looks like this (per Shopify's own tag-filtering docs, you loop the collection's tags and build add/remove links from &lt;code&gt;current_tags&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight liquid"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;comment&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;&lt;span class="c"&gt; sections/main-collection.liquid — the classic tag filter UI &lt;/span&gt;&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endcomment&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
&amp;lt;ul class="tag-filters"&amp;gt;
  &lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;tag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;all_tags&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
    &amp;lt;li&amp;gt;
      &lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;current_tags&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ow"&gt;contains&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;tag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
        &lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;tag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;link_to_remove_tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;tag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;
      &lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;else&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
        &lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;tag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;link_to_add_tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;tag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;
      &lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endif&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
    &amp;lt;/li&amp;gt;
  &lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endfor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
&amp;lt;/ul&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the part nobody reads until it's too late: the Liquid reference for the &lt;code&gt;collection&lt;/code&gt; object states that &lt;code&gt;all_tags&lt;/code&gt; returns &lt;strong&gt;a maximum of 1,000 tags&lt;/strong&gt;. Not a soft limit, not a pagination hint — a cap. Tag number 1,001 onward simply never reaches your template. Your loop is fine; your data was truncated before it arrived.&lt;/p&gt;

&lt;p&gt;Two properties make this failure nasty:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It is silent.&lt;/strong&gt; Nothing in the admin counts your distinct tags against a budget. The theme renders happily with whatever it got.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It degrades asymmetrically.&lt;/strong&gt; &lt;code&gt;all_tags&lt;/code&gt; also includes tags from products that are filtered out of the current view, so the cap is consumed by your &lt;em&gt;global&lt;/em&gt; tag vocabulary, not just the tags of the products currently visible. A big catalog burns through 1,000 distinct tags far faster than intuition suggests — every typo'd tag (&lt;code&gt;grey&lt;/code&gt; vs &lt;code&gt;gray&lt;/code&gt;), every seasonal tag, every supplier tag counts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Meanwhile, tag-based URLs have their own sharp edges: multiple tags in a collection URL are combined with &lt;strong&gt;AND&lt;/strong&gt; (products must carry &lt;em&gt;all&lt;/em&gt; of them), and if a tag in the URL is used by no product in the store, Shopify redirects to the collection URL with that tag stripped. For "customers browse by attributes" UX, AND-semantics plus redirect-stripping is the opposite of faceted filtering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause #2: tags have no data model
&lt;/h2&gt;

&lt;p&gt;Tags are strings. There is no type, no validation, no admin UI for "this attribute has these allowed values." That is fine for labels, and fatal for filter facets, because nothing stops the vocabulary from growing unbounded — which feeds root cause #1.&lt;/p&gt;

&lt;p&gt;Metafields are the typed counterpart. A metafield is a key-value pair identified by a &lt;strong&gt;namespace + key&lt;/strong&gt; (e.g. &lt;code&gt;custom.material&lt;/code&gt;), with a &lt;strong&gt;value&lt;/strong&gt; and a &lt;strong&gt;type&lt;/strong&gt;. You first create a &lt;strong&gt;metafield definition&lt;/strong&gt;, and definitions establish a data schema: type validation, Shopify admin integration, and query filtering. In other words, a definition is where "material is one of a fixed list of strings" becomes enforceable instead of aspirational.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal reproduction
&lt;/h2&gt;

&lt;p&gt;You can demonstrate the cap in a dev store in an afternoon:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create one collection and ~50 products. Against those products, set &lt;strong&gt;1,001+ distinct tags&lt;/strong&gt; (e.g. &lt;code&gt;tag-0001&lt;/code&gt; … &lt;code&gt;tag-1100&lt;/code&gt;, spread across products).&lt;/li&gt;
&lt;li&gt;In the theme, render &lt;code&gt;{{ collection.all_tags.size }}&lt;/code&gt; on the collection template.&lt;/li&gt;
&lt;li&gt;Observe the output: &lt;strong&gt;1000&lt;/strong&gt;, regardless of how many distinct tags exist beyond that.&lt;/li&gt;
&lt;li&gt;Render the tag-filter loop above: tags past the cap never appear as filter options, and there is no error anywhere to tell you why.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That &lt;code&gt;size&lt;/code&gt; never exceeding 1000 is the entire bug report.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: metafield-backed storefront filtering
&lt;/h2&gt;

&lt;p&gt;Shopify documents two filtering methods for themes, and explicitly marks &lt;strong&gt;storefront filtering&lt;/strong&gt; as the recommended one (tag filtering being the other). The migration looks like this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Design the attribute schema.&lt;/strong&gt; For each tag-family that acts as a filter facet (color, material, occasion…), create a product &lt;strong&gt;metafield definition&lt;/strong&gt;. Check the type against what storefront filtering actually supports — metafield-based filters can reference only these types: &lt;code&gt;single_line_text_field&lt;/code&gt;, &lt;code&gt;list.single_line_text_field&lt;/code&gt;, &lt;code&gt;metaobject_reference&lt;/code&gt;, &lt;code&gt;list.metaobject_reference&lt;/code&gt;, &lt;code&gt;number_integer&lt;/code&gt;, &lt;code&gt;number_decimal&lt;/code&gt;, &lt;code&gt;boolean&lt;/code&gt;. Multi-value facets (a product that is both "wool" and "silk") map to &lt;code&gt;list.single_line_text_field&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Backfill values from tags.&lt;/strong&gt; Walk the catalog, and for each product copy its tag values into the matching metafields. With the Admin GraphQL API, &lt;code&gt;metafieldsSet&lt;/code&gt; sets metafield values (creating them if they don't exist yet); one call accepts a maximum of 25 metafields with a 10MB total payload cap, and the operation is atomic — either the whole call persists or nothing does. Batch accordingly. (We scripted this in chunks of 25 and logged every failure for retry; your backfill should be idempotent so re-runs are safe.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Create the filters in admin.&lt;/strong&gt; Storefront filters are not theme config — per the docs, &lt;strong&gt;filters need to be created in the Shopify admin before they can be applied&lt;/strong&gt; (this is what the Search &amp;amp; Discovery app manages). One budget to know up front: &lt;strong&gt;a store can create a maximum of 25 filters.&lt;/strong&gt; Consolidate ruthlessly — 25 well-typed facets beat 40 half-used ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Verify the URL contract.&lt;/strong&gt; Applied filters show up in the URL. A metafield facet at product scope uses &lt;code&gt;filter.p.m.&amp;lt;namespace&amp;gt;.&amp;lt;key&amp;gt;=value&lt;/code&gt; — e.g. &lt;code&gt;/collections/all?filter.p.m.custom.material=wool&lt;/code&gt; — with comma-separated values for multi-select. If your theme (or a headless storefront) builds its own filter links, this is the contract to match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. One more ceiling to plan around.&lt;/strong&gt; Storefront filtering has its own documented limit: &lt;code&gt;collection.filters&lt;/code&gt; returns &lt;strong&gt;empty for collections containing over 5,000 products&lt;/strong&gt;. For mega-catalogs, split big collections into smaller filtered collections rather than relying on the filter UI of a 10k-product "All" collection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist: are you near the ceiling?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Render &lt;code&gt;{{ collection.all_tags.size }}&lt;/code&gt; on your biggest collection. At or near &lt;strong&gt;1,000&lt;/strong&gt;? Your tag filter UI is already dropping tags.&lt;/li&gt;
&lt;li&gt;Count your &lt;em&gt;global&lt;/em&gt; distinct tag vocabulary (exports, not one collection) — the cap is global-pressure.&lt;/li&gt;
&lt;li&gt;Any facet that should be multi-select OR? Tag URLs combine with AND — that's a metafield filter job.&lt;/li&gt;
&lt;li&gt;Budget your filters: &lt;strong&gt;25 max&lt;/strong&gt; per store. Consolidate before you create.&lt;/li&gt;
&lt;li&gt;Backfill with &lt;code&gt;metafieldsSet&lt;/code&gt; in ≤25-metafield atomic batches; make the script idempotent.&lt;/li&gt;
&lt;li&gt;Any single collection over &lt;strong&gt;5,000 products&lt;/strong&gt;? &lt;code&gt;collection.filters&lt;/code&gt; will be empty there — split it.&lt;/li&gt;
&lt;li&gt;Keep tags for operational labels (pick lists, automation triggers); keep &lt;em&gt;filterable attributes&lt;/em&gt; in metafields. Two vocabularies, two jobs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Verified against Shopify's official documentation (Liquid &lt;code&gt;collection&lt;/code&gt; object, theme filtering methods, storefront filtering, metafields, &lt;code&gt;metafieldsSet&lt;/code&gt;) as of August 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Originally published at &lt;a href="https://clawmama.run/blog/shopify-tag-filtering-1000-limit/" rel="noopener noreferrer"&gt;https://clawmama.run/blog/shopify-tag-filtering-1000-limit/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Your Shopify Discount Function Can Silently Stop Working: The 10,000-Byte Metafield Trap</title>
      <dc:creator>Eli</dc:creator>
      <pubDate>Fri, 31 Jul 2026 12:40:55 +0000</pubDate>
      <link>https://dev.to/eliofbm/your-shopify-discount-function-can-silently-stop-working-the-10000-byte-metafield-trap-2lj7</link>
      <guid>https://dev.to/eliofbm/your-shopify-discount-function-can-silently-stop-working-the-10000-byte-metafield-trap-2lj7</guid>
      <description>&lt;p&gt;You built the right architecture: business rules in a JSON metafield, a Shopify Discount Function that reads them, no redeploys when a promotion changes. It works in testing. It works for months. Then the merchant adds one pricing tier too many — and every discount on the store silently stops applying. No error at checkout. Nothing in the order. The Function runs fine; it just returns nothing.&lt;/p&gt;

&lt;p&gt;This failure mode costs money precisely because nothing looks broken. Here's why it happens and the design that survives it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Full bilingual writeup: &lt;a href="https://clawmama.run/blog/shopify-functions-metafield-silent-failure/" rel="noopener noreferrer"&gt;canonical post on the ClawMama blog&lt;/a&gt;. This is part 2 of a Shopify-Functions-in-production series — &lt;a href="https://clawmama.run/blog/shopify-affiliate-discount-code-conflict/" rel="noopener noreferrer"&gt;part 1: rejecting discount codes cleanly&lt;/a&gt;. We build open-source Shopify agent skills — &lt;a href="https://github.com/clawmama-run/shopify-growth-operator-agent" rel="noopener noreferrer"&gt;github.com/clawmama-run/shopify-growth-operator-agent&lt;/a&gt; — and a &lt;a href="https://app.clawmama.run/agents/x356xc" rel="noopener noreferrer"&gt;ready-to-use Shopify Agent&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The two limits behind it
&lt;/h2&gt;

&lt;p&gt;Both are documented on Shopify's Functions API page, buried in a limits table most people read once during setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limit 1: the input query is static and capped at 3,000 bytes.&lt;/strong&gt; Your Function's input query is fixed at build time (&lt;code&gt;run.graphql&lt;/code&gt;). You can't add a field at runtime every time the merchant invents a new rule type. That's exactly why the config-as-JSON-blob pattern exists: one metafield field in the query, arbitrary structure inside.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limit 2: a metafield value over 10,000 bytes is not returned.&lt;/strong&gt; From the docs: &lt;em&gt;"Metafields with values exceeding 10,000 bytes in size will not be returned."&lt;/em&gt; Not truncated — &lt;strong&gt;absent&lt;/strong&gt;. The metafield node comes back empty, as if never set.&lt;/p&gt;

&lt;p&gt;Follow the failure through your Function:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The config JSON grows past 10,000 bytes (a few dozen pricing tiers with conditions gets there fast).&lt;/li&gt;
&lt;li&gt;The input query asks for the metafield; Shopify withholds it.&lt;/li&gt;
&lt;li&gt;Your Function sees "no config" and does its unconfigured behavior — parse nothing, match nothing, return empty operations.&lt;/li&gt;
&lt;li&gt;The store simply stops discounting. Checkout is valid. Orders complete. Nobody is notified.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And your last line of defense can't save you: Function logs are capped at 1 kB written, truncated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design that survives
&lt;/h2&gt;

&lt;p&gt;Keep the config-in-metafield pattern (it's Shopify's own best practice for complex configuration) — but make it &lt;strong&gt;size-aware and fail-loud&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Budget the bytes, and measure them.&lt;/strong&gt; 10,000 is the hard ceiling; treat ~8,000 as your alert line. The admin UI where merchants edit config should display the current byte size. &lt;code&gt;new TextEncoder().encode(json).length&lt;/code&gt; — count bytes, not characters; CJK characters are 3 bytes each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Fail loud, not silent.&lt;/strong&gt; Decide what the Function does when the config metafield comes back absent, and make "absent" distinguishable from "legitimately empty." Apply a safe default and alert, or apply nothing but notify through your app's own monitoring (a daily Admin API job that reads the metafield — where it &lt;em&gt;is&lt;/em&gt; returned — and checks size). The worst option is the default: shrug, return empty operations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Shard before you hit the wall.&lt;/strong&gt; Split config by domain — VIP tiers, quantity breaks, B2B rules — under your reserved &lt;code&gt;$app:&lt;/code&gt; namespace, queried as separate fields. Three 4 KB metafields all come back; one 12 KB metafield comes back as nothing. Mind the query-side caps while sharding: list arguments max 100 elements, input query cost limit 30.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Test the boundary.&lt;/strong&gt; Add a deliberately-over-10KB fixture config to your dev store test suite. Lock in "we get alerted," not "discounts vanish quietly."&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your exposure today
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Via Admin GraphQL, fetch the config metafield and measure the byte length of &lt;code&gt;value&lt;/code&gt; (Admin API returns full values regardless of size — the cutoff applies to Function input queries only).&lt;/li&gt;
&lt;li&gt;Under 8 KB: add the size display + alert, re-check as rule count grows.&lt;/li&gt;
&lt;li&gt;Over: your discounts may already be intermittent. Check recent orders for missing discount applications, then shard.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where an agent fits
&lt;/h2&gt;

&lt;p&gt;The design fix is one-time. The ongoing risk is drift: rule counts creep, someone pastes a giant JSON, a new promotion type doubles the config. That's a monitoring loop a store operations agent can own — watching config size and discounted-order rate, flagging anomalies to the owner with evidence attached, before a customer notices.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Verified against Shopify's Functions API documentation (input query limits, fixed limits) as of July 2026. Field report via a Shopify Community thread — the practitioner who documented this failure mode had the numbers exactly right.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Affiliate Links vs. Discount Codes on Shopify: Why They Collide and How to Separate Them</title>
      <dc:creator>Eli</dc:creator>
      <pubDate>Thu, 30 Jul 2026 08:56:20 +0000</pubDate>
      <link>https://dev.to/eliofbm/affiliate-links-vs-discount-codes-on-shopify-why-they-collide-and-how-to-separate-them-3ghh</link>
      <guid>https://dev.to/eliofbm/affiliate-links-vs-discount-codes-on-shopify-why-they-collide-and-how-to-separate-them-3ghh</guid>
      <description>&lt;p&gt;A customer clicks your affiliate's referral link on Monday. On Wednesday they come back through your Instagram bio, grab the 10% popup code, and check out. Now one of two bad things happens: the affiliate loses their commission to last-touch attribution, or you pay commission &lt;em&gt;and&lt;/em&gt; a discount on an order that needed only one incentive.&lt;/p&gt;

&lt;p&gt;Every merchant running an affiliate program (GoAffPro, UpPromote, ReferralCandy…) alongside discount codes hits this. The question comes up constantly in the Shopify Community: &lt;strong&gt;can I block discount codes for customers who arrived through an affiliate link?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Short answer: no native setting exists. But there's a working architecture — and a newer piece of the Discount Function API makes the checkout experience much cleaner than it used to be.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Full bilingual writeup with architecture details: &lt;a href="https://clawmama.run/blog/shopify-affiliate-discount-code-conflict/" rel="noopener noreferrer"&gt;canonical post on the ClawMama blog&lt;/a&gt;. We build open-source Shopify agent skills — &lt;a href="https://github.com/clawmama-run/shopify-growth-operator-agent" rel="noopener noreferrer"&gt;github.com/clawmama-run/shopify-growth-operator-agent&lt;/a&gt; — and a &lt;a href="https://app.clawmama.run/agents/x356xc" rel="noopener noreferrer"&gt;ready-to-use Shopify Agent&lt;/a&gt; if you want the monitoring part without the setup.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Shopify can't do this natively
&lt;/h2&gt;

&lt;p&gt;Discount eligibility is conditioned on customer segments, products, collections, order minimums, combination rules. It never sees &lt;em&gt;how the customer arrived&lt;/em&gt;. The referral source lives in your affiliate app's tracking script and cookie. Discount codes are validated at checkout entry — and checkout has no idea the customer clicked a referral link three days ago. No plan has an eligibility rule that reads "came from affiliate X."&lt;/p&gt;

&lt;p&gt;So the fix must bridge two systems: get referral state &lt;em&gt;into&lt;/em&gt; the cart, then let checkout logic act on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixes that look right but backfire
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;0% commission when a code is used.&lt;/strong&gt; The most common affiliate-app setting people reach for. It solves double-payment by transferring the cost to your affiliate. First time a partner notices they drove a sale and earned nothing, they stop promoting you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cart Validation Function.&lt;/strong&gt; You can block the order — but the customer sees a generic error, not "this code doesn't apply to you." Reads as "the store is broken."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zeroing the discount via an automatic-discount Function.&lt;/strong&gt; The code gets &lt;em&gt;accepted&lt;/em&gt; and the total doesn't move. The store took their code and gave nothing. Arguably the worst ending.&lt;/p&gt;

&lt;p&gt;Root problem in all three: the customer is never told, clearly, that the code can't be used on referral orders — and the affiliate's credit is an afterthought.&lt;/p&gt;

&lt;h2&gt;
  
  
  The working architecture
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Capture referral state as a cart attribute.&lt;/strong&gt; Cart attributes travel with the cart into checkout, and Discount Functions can read them. A small theme script detects the affiliate's referral signal (URL param or the app's cookie) and writes it via the Ajax API: &lt;code&gt;POST /cart/update.js&lt;/code&gt; with &lt;code&gt;attributes: { referral_source: "affiliate" }&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Attach a Discount Function to an automatic discount.&lt;/strong&gt; Code discounts are validated before Functions get a say. So your Function runs on an automatic discount you create yourself (&lt;code&gt;discountAutomaticAppCreate&lt;/code&gt;) — it doesn't need to discount anything; its job is to give your Function a seat at checkout. The input query reads your cart attribute plus &lt;code&gt;enteredDiscountCodes&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Reject with &lt;code&gt;enteredDiscountCodesReject&lt;/code&gt;.&lt;/strong&gt; If referral state is present and the customer entered one of your marketing codes, return:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"enteredDiscountCodesReject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"codes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WELCOME10"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WELCOME10 can't be combined with a referral order — your referral already gives you the best deal."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The customer sees a specific, human explanation. The affiliate keeps their commission because referral tracking was never touched. Nobody gets double-paid.&lt;/p&gt;

&lt;p&gt;Three constraints from Shopify's docs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Only Functions backed by an &lt;strong&gt;automatic discount&lt;/strong&gt; can reject codes.&lt;/li&gt;
&lt;li&gt;You can only reject codes still &lt;strong&gt;rejectable&lt;/strong&gt; — entered but not yet applied. Act on &lt;code&gt;enteredDiscountCodes&lt;/code&gt; at entry time, not retroactively.&lt;/li&gt;
&lt;li&gt;The rejection message displays in checkout and should be &lt;strong&gt;localized&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;enteredDiscountCodesReject&lt;/code&gt; arrived with the &lt;strong&gt;2026-01 API version&lt;/strong&gt; — which is why older threads about this problem end with "you can't do it cleanly." You can now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cookie-window trap
&lt;/h2&gt;

&lt;p&gt;Affiliate referral cookies persist for days or weeks. If your theme script writes the referral attribute and it lingers, a customer who clicked a partner's link three weeks ago gets their birthday-email code rejected with no idea why.&lt;/p&gt;

&lt;p&gt;Decide deliberately: &lt;strong&gt;session-scoped&lt;/strong&gt; (reject only when the referral was detected this session — fewer angry customers, some attribution leakage) or &lt;strong&gt;window-scoped&lt;/strong&gt; (reject for the full cookie window — cleaner economics, but you must communicate the rule). Whichever you pick, tell your affiliates upfront.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test before you ship
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shop Pay / accelerated checkouts&lt;/strong&gt; — does the rejection message surface?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic discounts + codes&lt;/strong&gt; combination rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Happy path&lt;/strong&gt; — non-referral customers must still use the code normally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;POS and draft orders&lt;/strong&gt; if you use them — Function behavior differs by surface.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where an agent fits
&lt;/h2&gt;

&lt;p&gt;The rejection logic is a one-time build. The ongoing work is monitoring: catching orders where a code and a referral both fired, spotting new conflict patterns (new partner, new code campaign), flagging anomalies before they become a month-end commission dispute. That's a loop a store-operations agent can own — surfacing conflicts with evidence while the rule itself stays a decision you make once.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Verified against Shopify's Discount Function documentation as of July 2026. &lt;code&gt;enteredDiscountCodesReject&lt;/code&gt; requires API version 2026-01+.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>AI-Era CRM Should Understand the Relationship, Not Just the Pipeline</title>
      <dc:creator>Eli</dc:creator>
      <pubDate>Mon, 27 Jul 2026 05:14:31 +0000</pubDate>
      <link>https://dev.to/eliofbm/ai-era-crm-should-understand-the-relationship-not-just-the-pipeline-4i4n</link>
      <guid>https://dev.to/eliofbm/ai-era-crm-should-understand-the-relationship-not-just-the-pipeline-4i4n</guid>
      <description>&lt;p&gt;Before your last meeting with someone important, did you open your company's CRM?&lt;/p&gt;

&lt;p&gt;Probably not. You scrolled through the chat history.&lt;/p&gt;

&lt;p&gt;A sales CRM is good at answering, "Where is this customer in the pipeline?" It is often worse at answering, "Where did I leave things with this person?"&lt;/p&gt;

&lt;p&gt;That problem led us to build &lt;a href="https://github.com/clawmama-run/guanxi-crm" rel="noopener noreferrer"&gt;&lt;code&gt;guanxi-crm&lt;/code&gt;&lt;/a&gt;, a free, open-source, local-first relationship-memory tool. It stores readable Markdown as the source of truth, uses a rebuildable SQLite index for recall, and gives AI agents a strict CLI for identity resolution and relationship history.&lt;/p&gt;

&lt;p&gt;You can read the &lt;a href="https://clawmama.run/blog/ai-era-crm-should-understand-relationships/" rel="noopener noreferrer"&gt;bilingual ClawMama Blog version&lt;/a&gt;, install the project from GitHub, or use the ready-to-run &lt;a href="https://clawmama.run/agents/cevwhv" rel="noopener noreferrer"&gt;Personal Relationship Management Agent&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The point is not to replace sales CRM. It is to preserve the context that conventional records usually flatten: how two people met, what changed, what was promised, which channel works best, and what should be remembered before the next conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The things that do not fit in fields
&lt;/h2&gt;

&lt;p&gt;Try putting these into a conventional CRM:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;He recently got divorced and does not want to discuss it.&lt;br&gt;&lt;br&gt;
His daughter takes the &lt;em&gt;gaokao&lt;/em&gt; next year, and he is anxious.&lt;br&gt;&lt;br&gt;
He dislikes phone calls. Send a text instead.&lt;br&gt;&lt;br&gt;
Last time we spoke, he was interested in the book I recommended.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which fields should hold them?&lt;/p&gt;

&lt;p&gt;Usually they end up in Notes: the unstructured box that every CRM provides and few people read carefully later.&lt;/p&gt;

&lt;p&gt;The structured record keeps the name, employer, title, email address, last-contact date, and deal value. Those fields are useful for a sales organization. They are less useful five minutes before a conversation. The title is on LinkedIn. The email is in an address book. What you need is context: what changed, where the last conversation stopped, and what deserves care this time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fields were a historical compromise
&lt;/h2&gt;

&lt;p&gt;This was not careless design. When CRMs emerged, structure was the practical choice.&lt;/p&gt;

&lt;p&gt;Databases needed schemas, and lists had to be scannable. To find everyone in manufacturing, based in Shanghai, with no contact in three months, a system needed columns for industry, city, and date. Each person became a row.&lt;/p&gt;

&lt;p&gt;That compression was useful, but it was lossy. The first information to disappear was whatever could not be classified in advance.&lt;/p&gt;

&lt;p&gt;For decades, there was no good alternative. Teams could store free-form meeting notes, but retrieving one relevant sentence from hundreds of thousands of words was difficult. Unstructured text was easy to write and hard to reuse.&lt;/p&gt;

&lt;p&gt;Fields were not the ideal representation of a relationship. They were a reasonable design under a technical constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  That constraint is disappearing
&lt;/h2&gt;

&lt;p&gt;Large language models make natural-language records readable by machines.&lt;/p&gt;

&lt;p&gt;Consider this note:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;He said his company was making layoffs. He thinks his job is safe, but his mood seemed off.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A model can distinguish the background event from his personal situation, preserve the uncertainty, and surface the note before the next meeting.&lt;/p&gt;

&lt;p&gt;That suggests a different architecture: record the observation in natural language, then derive structure when it is read.&lt;/p&gt;

&lt;p&gt;This does not mean schemas disappear. Stable IDs, dates, entity types, and explicit participants still matter. The shift is narrower: do not force every human detail into a fixed taxonomy merely so a machine can read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A person is not a static contact card
&lt;/h2&gt;

&lt;p&gt;One person can be your former colleague, current customer, friend's spouse, and an investor in your side project.&lt;/p&gt;

&lt;p&gt;The relevant role changes with the situation. Real roles overlap and evolve, and no schema designer will anticipate every category, including something as specific as "my cousin's landlord."&lt;/p&gt;

&lt;p&gt;People change too. When someone changes employers, overwriting the old company removes useful history. Six months without contact is itself information. A communication pattern that has cooled may matter more than the current value of a job-title field.&lt;/p&gt;

&lt;p&gt;Identity is just as untidy. Is Zhang San in WeChat the same person as San Zhang on LinkedIn, 张叁 on a business card, or the person represented only by a phone number? An email address or phone number is identity evidence. It is not the person.&lt;/p&gt;

&lt;p&gt;A reliable system should not silently turn uncertain evidence into a confident identity merge. If it finds two people named Zhang Wei, it should stop and ask which one you mean. Missing information can be completed later. A wrong identity merge quietly corrupts every record that follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;em&gt;guanxi&lt;/em&gt; adds to the conversation
&lt;/h2&gt;

&lt;p&gt;The Chinese word &lt;em&gt;guanxi&lt;/em&gt; (关系) is often translated as "connections" or "networking." Both translations are incomplete. Depending on context, it can refer to a relationship shaped over time by shared history, trust, social roles, favors, obligations, and an understanding of what each person can reasonably ask of the other.&lt;/p&gt;

&lt;p&gt;That does not make Chinese relationships mysterious or uniquely transactional. Every culture distinguishes a contact from a person who has shown up for you repeatedly. The useful part of the Chinese term is that it makes accumulated relational context hard to ignore.&lt;/p&gt;

&lt;p&gt;This is also why &lt;em&gt;guanxi&lt;/em&gt; should not be reduced to "who can help me." The important memory may be that someone once made an introduction for you. It may also be that you promised to send a document, that their parent has been ill, or that a subject is better left alone. Reciprocity includes care and responsibility, not only access.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes when CRM is designed for agents
&lt;/h2&gt;

&lt;p&gt;Traditional CRM and agent CRM do not have to compete. Sales and customer success still need accounts, opportunities, activities, and stages. An agent-native system adds another layer: the person and the context behind the workflow.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Traditional CRM is good at&lt;/th&gt;
&lt;th&gt;An agent CRM should also do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tracking accounts, opportunities, activities, and stages&lt;/td&gt;
&lt;td&gt;Preserving the conversations, promises, preferences, introductions, and history behind them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collecting data through forms, imports, and integrations&lt;/td&gt;
&lt;td&gt;Extracting facts from conversations and events while retaining readable source context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Using a primary email or contact record as identity&lt;/td&gt;
&lt;td&gt;Supporting multiple, shared, and changing identifiers, and stopping when a match is ambiguous&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recalling work through timelines, dashboards, tasks, and pipeline views&lt;/td&gt;
&lt;td&gt;Producing a pre-meeting brief with recent interactions, forgotten details, referrals, and explicit follow-ups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adding AI on top of an existing UI and schema&lt;/td&gt;
&lt;td&gt;Giving agents native interfaces such as CLI, JSON, authoritative IDs, and documented recovery paths&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requiring people to use the CRM application&lt;/td&gt;
&lt;td&gt;Letting people inspect the same memory through an agent, CLI, Obsidian, or Markdown files&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An agent interface is not simply an API that returns success or failure. When identity resolution fails, the error needs to provide candidates and a safe next action. Otherwise an agent may route around the guardrail and invent an ID.&lt;/p&gt;

&lt;p&gt;Incomplete information should be accepted. A note such as "someone I met at a conference, works in supply chain, surname may be Wang" is worth saving. Identity ambiguity, however, should fail loudly.&lt;/p&gt;

&lt;h2&gt;
  
  
  We banned scores for people
&lt;/h2&gt;

&lt;p&gt;We adopted a product rule: no importance, priority, or value score on a person, and no leaderboard of "best contacts."&lt;/p&gt;

&lt;p&gt;Once a tool asks users to judge whether someone is useful, records start filling with titles, employers, influence, and access. The ordinary details that create genuine context disappear. Sorting by observed facts is different. It can be reasonable to ask who has not been contacted recently. That answer describes the record. It does not claim to measure a human being's worth.&lt;/p&gt;

&lt;p&gt;Relationship memory also contains sensitive information: family events, health concerns, job changes, private conversations. The data should be portable, inspectable, and stored with deliberate privacy boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  An implementation: Markdown as truth, SQLite as recall
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;guanxi-crm&lt;/code&gt; is deliberately modest: a single-user, offline, headless CLI with no application server or dedicated graphical UI.&lt;/p&gt;

&lt;p&gt;Its authoritative data lives in UTF-8 Markdown. SQLite is a disposable recall index that can be rebuilt from those files. People have stable IDs, while names, aliases, email addresses, and phone numbers remain identity evidence rather than primary keys. Shared identifiers and duplicate names may remain ambiguous instead of being silently resolved.&lt;/p&gt;

&lt;p&gt;Interactions are separate, dated records. Their participants must be explicit; mentions in free-form text do not automatically become participants. The repository also ships a &lt;code&gt;relationship-memory&lt;/code&gt; Skill that tells an agent to resolve authoritative IDs before writing and to stop for clarification when identity is ambiguous.&lt;/p&gt;

&lt;p&gt;People can inspect the same vault directly in Obsidian, including Markdown, backlinks, and graph view. If you do not want to install the CLI and Skill yourself, ClawMama packages the workflow as a ready-to-use &lt;a href="https://clawmama.run/agents/cevwhv" rel="noopener noreferrer"&gt;Personal Relationship Management Agent&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One useful test
&lt;/h2&gt;

&lt;p&gt;Many CRM fields were designed for a world in which machines could not read human language. That world is changing.&lt;/p&gt;

&lt;p&gt;A relationship-memory system does not need a grand metric. One question is enough:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Two months from now, before you meet someone, can it surface one thing you had forgotten but should remember?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If it cannot, it is still just a better address book.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>opensource</category>
      <category>architecture</category>
    </item>
    <item>
      <title>AI-Era CRM Should Understand the Relationship, Not Just the Pipeline</title>
      <dc:creator>Eli</dc:creator>
      <pubDate>Mon, 27 Jul 2026 03:33:50 +0000</pubDate>
      <link>https://dev.to/eliofbm/ai-era-crm-should-understand-the-relationship-not-just-the-pipeline-5fjd</link>
      <guid>https://dev.to/eliofbm/ai-era-crm-should-understand-the-relationship-not-just-the-pipeline-5fjd</guid>
      <description>&lt;p&gt;Before your last meeting with someone important, did you open your company's CRM?&lt;/p&gt;

&lt;p&gt;Probably not. You scrolled through the chat history.&lt;/p&gt;

&lt;p&gt;A sales CRM is good at answering, "Where is this customer in the pipeline?" It is often worse at answering, "Where did I leave things with this person?"&lt;/p&gt;

&lt;p&gt;AI-era CRM should handle both. Adding a chat box to the existing interface is not enough. The system has to preserve relationship context: how two people met, what changed, what was promised, which channel works best, and what should be remembered before the next conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The things that do not fit in fields
&lt;/h2&gt;

&lt;p&gt;Try putting these into a conventional CRM:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;He recently got divorced and does not want to discuss it.&lt;br&gt;&lt;br&gt;
His daughter takes the &lt;em&gt;gaokao&lt;/em&gt; next year, and he is anxious.&lt;br&gt;&lt;br&gt;
He dislikes phone calls. Send a text instead.&lt;br&gt;&lt;br&gt;
Last time we spoke, he was interested in the book I recommended.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which fields should hold them?&lt;/p&gt;

&lt;p&gt;Usually they end up in Notes: the unstructured box that every CRM provides and few people read carefully later.&lt;/p&gt;

&lt;p&gt;The structured record keeps the name, employer, title, email address, last-contact date, and deal value. Those fields are useful for a sales organization. They are less useful five minutes before a conversation. The title is on LinkedIn. The email is in an address book. What you need is context: what changed, where the last conversation stopped, and what deserves care this time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fields were a historical compromise
&lt;/h2&gt;

&lt;p&gt;This was not careless design. When CRMs emerged, structure was the practical choice.&lt;/p&gt;

&lt;p&gt;Databases needed schemas, and lists had to be scannable. To find everyone in manufacturing, based in Shanghai, with no contact in three months, a system needed columns for industry, city, and date. Each person became a row.&lt;/p&gt;

&lt;p&gt;That compression was useful, but it was lossy. The first information to disappear was whatever could not be classified in advance.&lt;/p&gt;

&lt;p&gt;For decades, there was no good alternative. Teams could store free-form meeting notes, but retrieving one relevant sentence from hundreds of thousands of words was difficult. Unstructured text was easy to write and hard to reuse.&lt;/p&gt;

&lt;p&gt;Fields were not the ideal representation of a relationship. They were a reasonable design under a technical constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  That constraint is disappearing
&lt;/h2&gt;

&lt;p&gt;Large language models make natural-language records readable by machines.&lt;/p&gt;

&lt;p&gt;Consider this note:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;He said his company was making layoffs. He thinks his job is safe, but his mood seemed off.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is more than a bag of searchable keywords. A model can distinguish the background event from his personal situation, preserve the uncertainty, and surface the note before the next meeting.&lt;/p&gt;

&lt;p&gt;That suggests a different architecture: record the observation in natural language, then derive structure when it is read.&lt;/p&gt;

&lt;p&gt;If a user wants to filter people by industry, extract industry at query time. If they want a meeting brief, retrieve recent changes, previous topics, and communication preferences. The person writing the first note should not have to predict which facet will matter three years later.&lt;/p&gt;

&lt;p&gt;This does not mean schemas disappear. Stable IDs, dates, entity types, and explicit participants still matter. The shift is narrower: do not force every human detail into a fixed taxonomy merely so a machine can read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A person is not a static contact card
&lt;/h2&gt;

&lt;p&gt;One person can be your former colleague, current customer, friend's spouse, and an investor in your side project.&lt;/p&gt;

&lt;p&gt;The relevant role changes with the situation. During a contract renewal, "customer" comes forward. At dinner with friends, it recedes. Real roles overlap and evolve, and no schema designer will anticipate every category, including something as specific as "my cousin's landlord."&lt;/p&gt;

&lt;p&gt;People change too. When someone changes employers, overwriting the old company removes useful history. Six months without contact is itself information. A communication pattern that has cooled may matter more than the current value of a job-title field.&lt;/p&gt;

&lt;p&gt;Identity is just as untidy. Is Zhang San in WeChat the same person as San Zhang on LinkedIn, 张叁 on a business card, or the person represented only by a phone number? An email address or phone number is something a person holds for a period of time. It is not the person. Identifiers can change, be shared, or be reassigned.&lt;/p&gt;

&lt;p&gt;A reliable system should not silently turn uncertain evidence into a confident identity merge.&lt;/p&gt;

&lt;p&gt;If it finds two people named Zhang Wei, an agent can answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I found two matches: a colleague from 2019 and a photographer you met last month. Which one do you mean?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That admission of uncertainty is useful. A mistaken merge may produce no error at all, while every future note quietly attaches to the wrong person.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;em&gt;guanxi&lt;/em&gt; adds to the conversation
&lt;/h2&gt;

&lt;p&gt;The open-source project behind this idea is called &lt;code&gt;guanxi-crm&lt;/code&gt;, so the name needs some explanation.&lt;/p&gt;

&lt;p&gt;The Chinese word &lt;em&gt;guanxi&lt;/em&gt; (关系) is often translated as "connections" or "networking." Both translations are incomplete. Depending on context, it can refer to a relationship shaped over time by shared history, trust, social roles, favors, obligations, and an understanding of what each person can reasonably ask of the other.&lt;/p&gt;

&lt;p&gt;That does not make Chinese relationships mysterious or uniquely transactional. Every culture distinguishes a contact from a person who has shown up for you repeatedly. The useful part of the Chinese term is that it makes accumulated relational context hard to ignore.&lt;/p&gt;

&lt;p&gt;This is also why &lt;em&gt;guanxi&lt;/em&gt; should not be reduced to "who can help me." The important memory may be that someone once made an introduction for you. It may also be that you promised to send a document, that their parent has been ill, or that a subject is better left alone. Reciprocity includes care and responsibility, not only access.&lt;/p&gt;

&lt;p&gt;A contact database captures identifiers. Relationship memory has to preserve context, change, trust, and boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes when CRM is designed for agents
&lt;/h2&gt;

&lt;p&gt;Traditional CRM and agent CRM do not have to compete. Sales and customer success still need accounts, opportunities, activities, and stages. An agent-native system adds another layer: the person and the context behind the workflow.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Traditional CRM is good at&lt;/th&gt;
&lt;th&gt;An agent CRM should also do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tracking accounts, opportunities, activities, and stages&lt;/td&gt;
&lt;td&gt;Preserving the conversations, promises, preferences, introductions, and history behind them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collecting data through forms, imports, and integrations&lt;/td&gt;
&lt;td&gt;Extracting facts from conversations and events while retaining readable source context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Using a primary email or contact record as identity&lt;/td&gt;
&lt;td&gt;Supporting multiple, shared, and changing identifiers, and stopping when a match is ambiguous&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recalling work through timelines, dashboards, tasks, and pipeline views&lt;/td&gt;
&lt;td&gt;Producing a pre-meeting brief with recent interactions, forgotten details, referrals, and explicit follow-ups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adding AI on top of an existing UI and schema&lt;/td&gt;
&lt;td&gt;Giving agents native interfaces such as CLI, JSON, authoritative IDs, and documented recovery paths&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requiring people to use the CRM application&lt;/td&gt;
&lt;td&gt;Letting people inspect the same memory through an agent, CLI, Obsidian, or Markdown files&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last two rows matter more than they first appear. An agent interface is not simply an API that returns success or failure. When an identity operation fails, the error has to provide candidates and a safe next action. Otherwise the agent may route around the guardrail and invent an ID from a filename or a fragment of context.&lt;/p&gt;

&lt;p&gt;This leads to two useful asymmetries.&lt;/p&gt;

&lt;p&gt;Incomplete information should be accepted. A note such as "someone I met at a conference, works in supply chain, surname may be Wang" is worth saving. A rigid form may reject it because required fields are missing. Worse, an agent asked to complete the form may invent values just to pass validation.&lt;/p&gt;

&lt;p&gt;Identity ambiguity, however, should fail loudly. Missing information can be completed later. A wrong identity merge quietly corrupts every record that follows.&lt;/p&gt;

&lt;p&gt;The same discipline applies to inference. Facts such as contact recency and frequency can be computed. A person's "importance" or "value" should not be.&lt;/p&gt;

&lt;h2&gt;
  
  
  We banned scores for people
&lt;/h2&gt;

&lt;p&gt;While designing the system, we adopted a product rule: no importance, priority, or value score on a person, and no leaderboard of "best contacts."&lt;/p&gt;

&lt;p&gt;The reason is partly ethical and partly practical. Once a tool asks users to judge whether someone is useful, the records start filling with titles, employers, influence, and access. The ordinary details that create genuine context disappear. The resulting data becomes less human and, eventually, less useful.&lt;/p&gt;

&lt;p&gt;Sorting by observed facts is different. It can be reasonable to ask who has not been contacted recently or which relationships have frequent interactions. Those answers describe the record. They do not claim to measure a human being's worth.&lt;/p&gt;

&lt;p&gt;Relationship memory also contains highly sensitive information: family events, health concerns, job changes, private conversations. Making prose machine-readable increases the need for user control. The data should be portable, inspectable, and stored with deliberate privacy boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  An implementation: Markdown as truth, SQLite as recall
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/clawmama-run/guanxi-crm" rel="noopener noreferrer"&gt;&lt;code&gt;guanxi-crm&lt;/code&gt;&lt;/a&gt; is an open-source, local-first relationship-memory CLI built around these constraints.&lt;/p&gt;

&lt;p&gt;Its authoritative data lives in UTF-8 Markdown files. SQLite is a disposable recall index that can be rebuilt from those files. People have stable IDs, while names, aliases, email addresses, and phone numbers remain identity evidence rather than primary keys. Shared identifiers and duplicate names are allowed to remain ambiguous instead of being silently resolved.&lt;/p&gt;

&lt;p&gt;Interactions are separate, dated records. Their participants must be explicit; mentions in free-form text do not automatically become participants. A sensitive marker can keep a line in the source while excluding it from ordinary briefs. The repository also ships a &lt;code&gt;relationship-memory&lt;/code&gt; Skill that tells an agent to resolve authoritative IDs before writing and to stop for clarification when identity is ambiguous.&lt;/p&gt;

&lt;p&gt;The current project is deliberately modest: a single-user, offline, headless CLI with no application server or dedicated graphical UI. People can still open the vault directly in Obsidian to browse Markdown, backlinks, and its graph view. The system provides a trustworthy memory layer that an agent can query; it does not pretend to infer an entire social world from a contact list.&lt;/p&gt;

&lt;p&gt;For people who prefer a ready-to-use version rather than installing the CLI and Skill, the &lt;a href="https://clawmama.run/agents/cevwhv" rel="noopener noreferrer"&gt;Personal Relationship Management Agent on ClawMama&lt;/a&gt; packages the workflow into an Agent that can be used directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  One useful test
&lt;/h2&gt;

&lt;p&gt;Many CRM fields were designed for a world in which machines could not read human language.&lt;/p&gt;

&lt;p&gt;That world is changing. We can now preserve relationships closer to the way people actually remember them: not only "name, company, title, deal value," but "his daughter's exam is next year," "he prefers text," and "he wanted to discuss that book again."&lt;/p&gt;

&lt;p&gt;A relationship-memory system does not need a grand metric. One question is enough:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Two months from now, before you meet someone, can it surface one thing you had forgotten but should remember?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If it cannot, it is still just a better address book.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>opensource</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Shopify Sidekick vs. Shopify Magic: What Each One Actually Does After Spring '26</title>
      <dc:creator>Eli</dc:creator>
      <pubDate>Sun, 26 Jul 2026 05:36:06 +0000</pubDate>
      <link>https://dev.to/eliofbm/shopify-sidekick-vs-shopify-magic-what-each-one-actually-does-after-spring-26-52e8</link>
      <guid>https://dev.to/eliofbm/shopify-sidekick-vs-shopify-magic-what-each-one-actually-does-after-spring-26-52e8</guid>
      <description>&lt;p&gt;Type &lt;code&gt;shopify.com/magic&lt;/code&gt; into a browser today and you land on the Sidekick page. As of 2026-07-26, the old Magic product URL redirects to &lt;a href="https://www.shopify.com/sidekick" rel="noopener noreferrer"&gt;shopify.com/sidekick&lt;/a&gt;. That redirect is the first useful fact in this comparison, because it shows how Shopify now frames the two names. The second useful fact: Magic is not discontinued. Shopify still documents Shopify Magic features and still uses the name on feature surfaces. What changed is the packaging: one assistant at the front door, the generation features behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Shopify Magic is the generation layer.&lt;/strong&gt; Shopify's documentation presents it as a family of AI features embedded across the platform rather than an assistant you talk to: product and marketing text generation and rewriting, Shopify Email copy and subject-line suggestions, Shopify Inbox suggested replies, and product-media work like background removal, replacement, and generated scenes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sidekick is the store-aware assistant.&lt;/strong&gt; Shopify's &lt;a href="https://www.shopify.com/sidekick" rel="noopener noreferrer"&gt;Sidekick page&lt;/a&gt; calls it an "AI Shopify expert" with direct access to store data, an understanding of commerce workflows, and the ability to take action in the admin. It requires no separate purchase. Shopify's exact FAQ wording: "No. Sidekick is included with your Shopify plan. Features and usage limits vary by plan."&lt;/p&gt;

&lt;p&gt;So the decision is not "which AI is better." It is which job you are delegating. Blank page or product photo: Magic. A job that starts from store data or ends in an admin change: Sidekick. Either way, the merchant still defines the goal, supplies source facts, approves customer-visible or financial changes, and verifies results. Neither product replaces operations staff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare by five dimensions, not a feature checklist
&lt;/h2&gt;

&lt;p&gt;Feature lists go stale within a quarter. These five dimensions hold up better:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Context&lt;/strong&gt;: Magic works on the content field in front of it. Sidekick reads store data, within the staff member's permissions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output&lt;/strong&gt;: Magic produces drafts and generated media. Sidekick produces answers, analysis, configured objects, and supported actions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permission and approval&lt;/strong&gt;: both operate inside your admin session and staff rights; writes wait for confirmation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verification&lt;/strong&gt;: drafts get read, media gets compared against the physical product, actions get confirmed and checked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach&lt;/strong&gt;: Magic lives on native Shopify surfaces. Sidekick works in the admin, in the Shopify mobile app, and, since Spring '26, in a selected set of partner apps.&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Shopify Magic&lt;/th&gt;
&lt;th&gt;Shopify Sidekick&lt;/th&gt;
&lt;th&gt;Your part&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it is&lt;/td&gt;
&lt;td&gt;Generation features embedded in Shopify surfaces&lt;/td&gt;
&lt;td&gt;Store-aware assistant in the admin&lt;/td&gt;
&lt;td&gt;Decide which job to delegate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical output&lt;/td&gt;
&lt;td&gt;Text drafts, email copy, inbox replies, edited media&lt;/td&gt;
&lt;td&gt;Answers, analysis, configured objects, supported actions&lt;/td&gt;
&lt;td&gt;Review before anything goes live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data context&lt;/td&gt;
&lt;td&gt;The content field in front of it&lt;/td&gt;
&lt;td&gt;Store data, within your staff permissions&lt;/td&gt;
&lt;td&gt;Supply the facts AI cannot know&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reach&lt;/td&gt;
&lt;td&gt;Native Shopify surfaces&lt;/td&gt;
&lt;td&gt;Admin, mobile app, selected partner apps&lt;/td&gt;
&lt;td&gt;Everything outside that reach&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What Spring '26 changed
&lt;/h2&gt;

&lt;p&gt;Per Shopify's &lt;a href="https://www.shopify.com/editions/spring2026" rel="noopener noreferrer"&gt;Spring '26 Editions&lt;/a&gt; page, Sidekick now works with partner apps (starting with Judge.me, Klaviyo, Loop, Smile, and others), opens admin sessions with "Actionable guidance from Sidekick" covering acquisition, conversion, and repeat sales, asks multiple-choice clarifying questions, keeps working in the background across chats and closed windows, runs across the Shopify mobile app by typing or voice, creates customer records from plain-language descriptions, generates test events for Shopify Flow automations, and gains a generated-app editor with code editing, previews, and version history.&lt;/p&gt;

&lt;p&gt;The operational consequence matters more than the feature count. Sidekick is moving from answering questions toward taking action, background work, app reach, and lightweight software creation. Every one of those moves increases what a wrong or premature action can touch. Permissions, confirmation, testing, and post-action verification matter more after this release, not less.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six scenarios, and who does what
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A product description.&lt;/strong&gt; Magic's home ground: first drafts and tone variants in the product editor. Thin product facts produce fluent, generic copy. No generation feature recovers missing specs, verified claims, or brand policy. You supply substance and edit for truth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A product photo background.&lt;/strong&gt; Magic can remove or replace backgrounds and generate scenes. Treat output as a candidate: compare edges, proportions, textures, labels, shadows, and reflections against the physical product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What was my best product last month?"&lt;/strong&gt; A Sidekick question with a trap inside. "Best" can mean gross sales, net sales, units, margin, conversion, repeat purchase, a channel, or a time window. Sidekick queries store data; you own the definition. For high-stakes reporting, deterministic reports and exports remain the safer path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Creating a discount or a customer record.&lt;/strong&gt; Sidekick can do both. Discounts touch money and are customer-visible: review the exact configuration before confirming, then check the storefront after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing a Flow automation.&lt;/strong&gt; Sidekick can generate test events to verify workflow logic before it touches real orders. An untested automation is an assumption, and a failed trigger is silent until someone looks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Acting in a third-party app.&lt;/strong&gt; Spring '26 names an initial partner set, which proves selective expansion, not universal compatibility. Confirm your specific app and action are supported before designing a process around them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissions, approval, and what still requires a merchant
&lt;/h2&gt;

&lt;p&gt;Sidekick inherits staff permissions. Shopify's exact FAQ wording: "Every staff member can use Sidekick based on their admin permissions. It respects access controls, so team members only interact with data and features they're authorized to use." Your permission hygiene now doubles as AI risk management.&lt;/p&gt;

&lt;p&gt;Approval requirements protect the merchant and define the product's ceiling: a tool that waits for confirmation before a write is not unattended store operations. Five things stay human regardless: defining success (including metric definitions), supplying reliable source facts, approving customer-visible or financial changes, verifying outcomes after actions, and owning the systems Sidekick cannot reach, which still includes most external and legacy tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Methodology and limitations
&lt;/h2&gt;

&lt;p&gt;Research cutoff was 2026-07-26. I directly verified the /magic to /sidekick redirect and fetched the current &lt;a href="https://www.shopify.com/sidekick" rel="noopener noreferrer"&gt;Sidekick product page&lt;/a&gt; and &lt;a href="https://www.shopify.com/editions/spring2026" rel="noopener noreferrer"&gt;Spring '26 Editions page&lt;/a&gt;. Capability examples cited are Shopify's own, labeled as such, not independent validation. No hands-on testing was performed for this article, and no claims are made about sales lift or output accuracy, because no broad independent dataset exists for the current Spring '26 Sidekick. Shopify's Help Center blocked automated fetching (HTTP 403), so Magic feature categories come from stable documented categories rather than a fresh fetch. Partner app support is selective and evolving.&lt;/p&gt;

&lt;p&gt;The longer version of this analysis, with a FAQ and per-audience recommendations, lives on the &lt;a href="https://clawmama.run/blog/shopify-sidekick-vs-magic-analysis/" rel="noopener noreferrer"&gt;ClawMama blog&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ai</category>
      <category>ecommerce</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
