<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Adi Leviim on Medium]]></title>
        <description><![CDATA[Stories by Adi Leviim on Medium]]></description>
        <link>https://medium.com/@adi_leviim?source=rss-a8963cd80b5c------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*bYOm68fSxvg4SKIZasXnBg.png</url>
            <title>Stories by Adi Leviim on Medium</title>
            <link>https://medium.com/@adi_leviim?source=rss-a8963cd80b5c------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 11:00:05 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@adi_leviim/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[The Accent-Stripping One-Liner Broke Our Search Results in Korean]]></title>
            <link>https://levelup.gitconnected.com/the-accent-stripping-one-liner-broke-our-search-results-in-korean-402c4b329ff4?source=rss-a8963cd80b5c------2</link>
            <guid isPermaLink="false">https://medium.com/p/402c4b329ff4</guid>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[javascript]]></category>
            <category><![CDATA[unicode]]></category>
            <dc:creator><![CDATA[Adi Leviim]]></dc:creator>
            <pubDate>Wed, 28 Oct 2026 16:34:58 GMT</pubDate>
            <atom:updated>2026-10-28T16:34:58.619Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Stack Overflow’s top accent fix changes string length, so for three months we cut Korean snippets in the wrong place.</em></p><figure><img alt="A still life on a deep aubergine backdrop. A long cream paper ribbon runs diagonally across the frame, printed with a row of blank grey rectangles like placeholder words, one of them gold about a third of the way along. On the right, brass tailor’s scissors have cut the ribbon far past the gold rectangle, and the cut-off piece is blank. In the lower left, a two-line headline reads: SEARCH FOUND IT. THE PREVIEW MISSED IT." src="https://cdn-images-1.medium.com/max/1024/1*MADCL8CE0i1nKrqRg1cEuA.png" /><figcaption>Search found the word. The preview was cut somewhere else. Credit: Image generated with ChatGPT</figcaption></figure><p>In July we made our search accent-insensitive. Type “skoda” and you find “Škoda”. Type “cafe” and you find “café”. It took one line, the same line most JavaScript developers reach for, and it fixed search for every user writing in French, Czech, Polish or Vietnamese.</p><p>For the next three months, that line also meant a Korean user searching their chat history got results whose preview did not contain the word they searched for. Sometimes the preview was just “…”. The search was right. The preview was cut in the wrong place.</p><p>I co-founded AI Toolbox, a Chrome extension used by 40,000 people that adds full-text search to ChatGPT, Gemini, Claude and Grok. Each search result shows a snippet: a short window of the message with your word in the middle. This is the story of how a famous one-liner broke that window for Korean and Japanese text, why our tests never noticed, and the small change that fixed it.</p><h3>The One-Liner Everyone Copies</h3><p>The Stack Overflow question <a href="https://stackoverflow.com/questions/990904/remove-accents-diacritics-in-a-string-in-javascript">“Remove accents/diacritics in a string in JavaScript”</a> had been viewed 592,224 times as of October 2026. Its top answer, at a score of 2,101, is this:</p><pre>const str = &quot;Crème Brûlée&quot;<br>str.normalize(&quot;NFD&quot;).replace(/[̀-ͯ]/g, &quot;&quot;)<br>&gt; &quot;Creme Brulee&quot;</pre><p>normalize(&quot;NFD&quot;) splits a precomposed letter into a base letter plus a combining mark, so &quot;è&quot; becomes &quot;e&quot; followed by U+0300. The regular expression then deletes every mark in the range U+0300 to U+036F. Our version lowercased first and mapped a few atomic letters such as &quot;ł&quot; and &quot;ø&quot;, and we ran it on both the query and the text, so &quot;skoda&quot; and &quot;Škoda&quot; both became &quot;skoda&quot;.</p><p>The answer is correct for what it was asked: comparing strings. The bug came from what we did next. To build a snippet, we found the match in the folded text and then cut the original text at the same position. Our own comment said why that was safe:</p><blockquote><em>“the folded string keeps the SAME length and the SAME character offsets as the lowercased original. That length-preservation is deliberately relied on by the snippet/highlight code”</em></blockquote><p>And it said why other scripts were not at risk:</p><blockquote><em>“Non-Latin scripts (CJK, Arabic, Hebrew, Cyrillic) carry no combining marks to strip and pass through unchanged”</em></blockquote><p>Both sentences were wrong. Here is the snippet code from our Gemini module, as it shipped in July:</p><pre>export function getContextSnippet(content: string, query: string, contextChars: number = 58): string {<br>  // Accent-folded, length-preserving lookup: an accent-insensitive query still<br>  // centres the snippet on the match, and the offset is valid on the original.<br>  const matchIndex = foldAccents(content).indexOf(foldAccents(query));</pre><pre>  if (matchIndex === -1) return content.substring(0, contextChars * 2);</pre><pre>  const start = Math.max(0, matchIndex - contextChars);<br>  const end = Math.min(content.length, matchIndex + query.length + contextChars);<br>  let snippet = content.substring(start, end);</pre><p>matchIndex comes from one string. content.substring cuts another. The ChatGPT, Claude, Grok and cross-app search modules each had their own copy of the same pattern, five in all.</p><h3>What NFD Actually Does to Korean</h3><p>NFD does not only split accented Latin letters. It decomposes anything that has a canonical decomposition, and the regular expression only removes marks in one block.</p><p>Every Korean syllable decomposes. “한” becomes three jamo, U+1112 U+1161 U+11AB. “가” becomes two. The Unicode normalization report, <a href="https://www.unicode.org/reports/tr15/">UAX #15</a>, notes that “the decompositions and compositions for Hangul syllables are algorithmic”: a syllable splits into two jamo, or three when it has a final consonant. None of those jamo are in U+0300 to U+036F, so the regular expression keeps all of them.</p><p>Japanese voiced kana do the same thing. “が” becomes “か” plus U+3099, and “パ” becomes “ハ” plus U+309A. Arabic alef with madda, “آ”, becomes alef plus U+0653. Even <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize">MDN’s page on </a><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize">normalize()</a> states it plainly: the NFD form of &quot;ñ&quot; has a length of 2.</p><figure><img alt="A Unicode code chart titled What NFD does, and what the regex removes. è U+00E8 splits into e and a grave accent U+0300, removed: 1 to 1. 한 U+D55C splits into jamo U+1112, U+1161 and U+11AB, all kept: 1 to 3. が into か and a voiced mark U+3099, kept: 1 to 2. パ into ハ and a semi-voiced mark U+309A, kept: 1 to 2. آ into ا and madda U+0653, kept: 1 to 2. й into и and a breve U+0306, removed: 1 to 1." src="https://cdn-images-1.medium.com/max/1024/1*CrOUxTXj1kgnFWrXe8Gzpg.png" /><figcaption>The regex deletes the marks in one block. Everything else NFD produces stays, and the string gets longer. Credit: Illustration by author, decompositions from JavaScript’s String.prototype.normalize</figcaption></figure><p>So folding is not length-preserving at all for Korean. “한국어” is 3 characters and folds to 8. In the sentence “결론적으로 데이터베이스”, the word “데이터베이스” sits at position 6 in the original and at position 14 in the folded text, and the whole sentence grows from 12 characters to 26.</p><p>One more claim in that comment was off. Cyrillic does not pass through unchanged: “й” decomposes to “и” plus U+0306, so it folds to “и”, and “ё” folds to “е”. Searching Russian is still accent-insensitive in the way Russian readers expect for “ё”, so we kept that behavior and corrected the comment.</p><h3>Three Months of Previews That Missed the Word</h3><p>Feed a position from the folded text into the original, and the snippet window lands two to three times too far along a Korean message, because every syllable before the match counts as two or three.</p><p>I ran the July code against a Korean message with “데이터베이스” in the middle. The word starts at position 138; the folded text put it at 274, so the window it returned was a stretch of the message well after the word, and the preview never showed it. With the word near the end of a message, the computed start fell past the end of the text, substring returned an empty string, and the preview was just &quot;...&quot;. Japanese text drifted by one position for every voiced kana before the match, so its previews were off-centre rather than empty.</p><figure><img alt="A theater seating chart under a stage, titled The folded text numbers its seats differently. The top row, Folded text, has 26 numbered seats with seats 14 to 25 in gold. The bottom row, Original text, has 12 seats holding the characters of 결론적으로 데이터베이스, with 데이터베이스 in gold at seats 6 to 11 and a dashed empty space where seat 14 would be. A ticket reads Seat 14, where the search found the word." src="https://cdn-images-1.medium.com/max/1024/1*eDJOefubnsu62jwvp7Mu5w.png" /><figcaption>The folded text says seat 14. In the original row, seat 14 is past the end. Credit: Illustration by author, offsets measured on the sentence shown</figcaption></figure><p>Nothing else looked wrong, which is why it lasted. Matching folded both sides the same way, so Korean searches found the right messages. Our tests checked matching and highlighting, and none of them sliced Korean text. Before July 23 every snippet builder used toLowerCase().indexOf(), which keeps the length for these scripts, so the bug arrived in the same commit that made search accent-insensitive.</p><h3>The Second Bug Hiding Next to It: \b</h3><p>Exact Match, a Premium option in our Advanced search, matches whole words only, and JavaScript’s word boundary does not know what a word is outside English.</p><p><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Word_boundary_assertion">MDN’s word boundary page</a> defines a word character as a letter from A to Z or a to z, a digit from 0 to 9, or an underscore. So /\bкот\b/.test(&quot;мой кот спит&quot;) returns false. A Korean, Cyrillic, Greek or Arabic term has no boundary for \b to find.</p><p>Our search already knew this. One helper adds \b only on a side where the term starts or ends with an ASCII word character, and otherwise falls back to a substring match:</p><pre>export function exactWordPattern(term: string): string {<br>  const lead = /^\w/.test(term) ? &quot;\\b&quot; : &quot;&quot;;<br>  const trail = /\w$/.test(term) ? &quot;\\b&quot; : &quot;&quot;;<br>  return `${lead}${escapeRegExp(term)}${trail}`;<br>}</pre><p>But the snippet builders in the ChatGPT and Claude modules, and the highlighter shared by all four, built their own \b...\b patterns. With Exact Match on, search found the Russian message and then the preview code could not find the word in it. In ChatGPT and Claude the result showed the whole message with nothing highlighted; in Gemini and Grok the window was right but the word was not highlighted. The fix was to use the same rule in all three places, so a term cannot match in search and then fail to match in its own preview.</p><h3>The Fix: Return a Map, Not an Offset</h3><p>Any transform that can change a string’s length has to give you a way back to the original. Ours now does, in one shared function that all five snippet builders call:</p><pre>export function locateFolded(original: string, query: string | RegExp): FoldedMatch | null {<br>  if (!original) return null;<br>  const folded = foldAccents(original);<br>  let at = -1;<br>  let len = 0;<br>  if (typeof query === &quot;string&quot;) {<br>    const needle = foldAccents(query);<br>    if (!needle) return null;<br>    at = folded.indexOf(needle);<br>    len = needle.length;<br>  } else {<br>    const hit = new RegExp(query.source, query.flags.replace(&quot;g&quot;, &quot;&quot;)).exec(folded);<br>    if (hit) {<br>      at = hit.index;<br>      len = hit[0].length;<br>    }<br>  }<br>  if (at &lt; 0 || len === 0) return null;<br>  if (folded.length === original.length &amp;&amp; !HAS_MARK.test(original)) {<br>    return { index: at, length: len };<br>  }<br>  const stop = at + len;<br>  let pos = 0;<br>  let index = -1;<br>  for (const m of original.matchAll(CLUSTER)) {<br>    const start = m.index ?? 0;<br>    const next = pos + foldedLength(m[0]);<br>    if (index &lt; 0 &amp;&amp; next &gt; at) index = start;<br>    if (next &gt;= stop) return { index, length: start + m[0].length - index };<br>    pos = next;<br>  }<br>  return null;<br>}</pre><p>Matching itself barely changed. It still folds the whole string once and calls indexOf; the only difference is Greek final sigma, below. What changed is that a match is translated back into the original before anything is sliced. Three details carry the weight.</p><p>It walks clusters, not characters. CLUSTER is /\P{M}\p{M}*|\p{M}+/gu: a base character plus the marks after it. NFD only reorders marks inside such a cluster, so folding cluster by cluster gives exactly the same string as folding the whole text. The one context-sensitive lowercase rule in JavaScript, Greek final sigma, would break that, so we now fold &quot;ς&quot; to &quot;σ&quot; as well.</p><p>It skips the walk when it can prove nothing moved. Folding only ever deletes combining marks. If the original has none, every character folds to at least its own length, so if the total length is unchanged, every character folded to exactly its own length and the offsets are already correct. That covers nearly all English and European text.</p><p>It counts Korean syllables with arithmetic. Korean text cannot take the shortcut, so foldedLength computes a syllable&#39;s folded length directly, two jamo or three, instead of normalizing it. A test checks that shortcut against normalize for all 11,172 Hangul syllables.</p><figure><img alt="Column chart titled The cost of mapping a match back to the original, in milliseconds per lookup. Latin text, 98,000 characters: old code with no mapping 0.60, first fix 29.05, final fix 1.11. Korean text, 103,000 characters with the match at the end: old code 1.38, first fix 35.66, final fix 9.75." src="https://cdn-images-1.medium.com/max/1024/1*XsyPhxLDMIijcMKc2pAa9g.png" /><figcaption>Mapping offsets back costs a fraction of a millisecond for Latin text, and stays under 10ms in the worst Korean case. Credit: Chart by author, Node.js timings averaged over 20 runs on a 100,000 character message</figcaption></figure><p>The first version of the fix walked every cluster of every message and took 29ms on a 100,000 character English message, against 0.6ms for the old code. With the shortcut it takes 1.1ms. A 100,000 character Korean message with the match at the very end, the worst case, takes 9.8ms. Real messages are far shorter, and the snippet runs once per visible result.</p><p>The Korean message whose preview missed the word now shows the sentence it sits in, 결론적으로 데이터베이스 마이그레이션은 미루기로 했습니다, with the word in the middle.</p><h3>What I Would Tell Anyone Copying That Answer</h3><p>The Stack Overflow answer is fine. It answers “make these two strings compare equal”, and for that it works. The bug is using its output as a coordinate system.</p><ul><li>If you only compare, you may not need to fold at all. new Intl.Collator(undefined, { sensitivity: &quot;base&quot; }).compare(&quot;cafe&quot;, &quot;Café&quot;) returns 0, with no string changed. It cannot find a substring position, which is why search code folds instead.</li><li>If you fold and need positions, keep a map back. Any offset found in a transformed string belongs to that string only.</li><li>Test the scripts your users write in, not the ones you write in. One Korean sentence and one Japanese sentence in the snippet tests would have caught this in July.</li><li>A comment that states an invariant needs a test that checks it. Ours said “length-preservation is deliberately relied on” in capital letters, and no test checked the length.</li></ul><h3>Key Takeaways</h3><ul><li>normalize(&quot;NFD&quot;) changes string length for far more than accented Latin letters: every Korean syllable, Japanese voiced kana, and some Arabic letters.</li><li>An offset found in a folded or normalized string is not an offset in the original. Map it back before you slice.</li><li>JavaScript’s \b only recognizes the letters A to Z and a to z, the digits 0 to 9, and underscore, so whole-word patterns built from it never match Korean, Cyrillic, Greek or Arabic terms.</li><li>Search, snippet and highlight code should share one matching rule. When they disagree, the result list contradicts itself.</li><li>A fast path that proves offsets are unchanged keeps the fix nearly free for Latin text.</li></ul><h3>FAQ</h3><p>Does normalize(&quot;NFD&quot;) change a string&#39;s length? Yes. NFD splits precomposed characters into a base plus combining marks, so &quot;ñ&quot; goes from length 1 to 2. Stripping U+0300 to U+036F restores the length only for accented Latin letters. Korean syllables become two or three jamo and Japanese voiced kana gain a sound mark, so those strings get longer.</p><p>Why does \b not work for Korean or Russian in JavaScript? In JavaScript a word character for \b is a letter from A to Z or a to z, a digit from 0 to 9, or an underscore. Korean, Cyrillic, Greek and Arabic letters are not word characters, so there is no boundary next to them, and /\bкот\b/ never matches &quot;кот&quot;.</p><p>How do you make search accent-insensitive without breaking highlight positions? Fold the query and the text the same way for matching, then map the match back to the original string before slicing or highlighting. Folding cluster by cluster, a base character plus its marks, lets you record where each folded character came from.</p><h3>Resources</h3><ul><li><a href="https://stackoverflow.com/questions/990904/remove-accents-diacritics-in-a-string-in-javascript">Remove accents/diacritics in a string in JavaScript, Stack Overflow</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize">String.prototype.normalize(), MDN</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Word_boundary_assertion">Word boundary assertion, MDN</a></li><li><a href="https://www.unicode.org/reports/tr15/">UAX #15: Unicode Normalization Forms</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator">Intl.Collator, MDN</a></li></ul><p>AI Toolbox adds full-text search to ChatGPT, Gemini, Claude and Grok, in whatever language your chats are in, Korean and Japanese included. Search is on the free plan. Exact Match and Advanced search are on Premium, at $9.99 a month, $59 a year or $99 lifetime per AI app, or $199 lifetime for all four with All Access. <a href="https://chromewebstore.google.com/detail/ai-toolbox-folders-prompt/jlalnhjkfiogoeonamcnngdndjbneina">Add AI Toolbox to Chrome</a>.</p><p>About the author: Adi Leviim is the co-founder of AI Toolbox (formerly ChatGPT Toolbox), a Chrome extension with four modules (ChatGPT, Gemini, Claude, Grok) used by 40,000+ people across 150+ countries to search, organize, and export their AI conversations. He writes about the reality of building AI products with 7+ years of full-stack development experience. Follow him on Medium for honest takes on SaaS, AI tools, and shipping software that people actually use.</p><p><a href="https://ai-toolbox.co/">AI Toolbox</a> | <a href="https://medium.com/@adi_leviim">Medium</a> | <a href="https://x.com/adileviim">Twitter/X</a> | <a href="https://www.linkedin.com/in/adi-leviim/">LinkedIn</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=402c4b329ff4" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/the-accent-stripping-one-liner-broke-our-search-results-in-korean-402c4b329ff4">The Accent-Stripping One-Liner Broke Our Search Results in Korean</a> was originally published in <a href="https://levelup.gitconnected.com/">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[A 5% tint broke our contrast, and Lighthouse still scored us 100]]></title>
            <link>https://medium.com/design-bootcamp/a-5-tint-broke-our-contrast-and-lighthouse-still-scored-us-100-7d219ab77bfc?source=rss-a8963cd80b5c------2</link>
            <guid isPermaLink="false">https://medium.com/p/7d219ab77bfc</guid>
            <category><![CDATA[accessibility]]></category>
            <category><![CDATA[ux-design]]></category>
            <category><![CDATA[colors]]></category>
            <category><![CDATA[web-design]]></category>
            <category><![CDATA[design-systems]]></category>
            <dc:creator><![CDATA[Adi Leviim]]></dc:creator>
            <pubDate>Mon, 26 Oct 2026 21:26:54 GMT</pubDate>
            <atom:updated>2026-10-26T21:26:54.521Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Contrast belongs to a pair of colors, so every tint and gradient you add is a new test, and your checker may skip it.</em></p><p><em>Editor’s note: I wrote this from my own audit of our website.</em></p><p><em>Disclosure: I co-founded AI Toolbox, and its website is the case study here.</em></p><figure><img alt="A pale gray studio poster headed A 5% tint broke our contrast. On the left, a glossy green ring dial reads 100 with Accessibility beneath it. On the right, a brass magnifying loupe hovers over a terracotta Aa printed on a near-white card, and a red tag hanging from the loupe handle reads 2.94:1, needs 3:1." src="https://cdn-images-1.medium.com/max/1024/1*HDJ4tWkC4amu_MzyerjwDg.png" /><figcaption>A perfect score and a failing pair, on the same page, on the same day. Credit: Image generated with ChatGPT</figcaption></figure><p>On 26 October 2026 I ran Lighthouse on our homepage and it reported Accessibility: 100. On that same page, one word in a 36px heading sat at 2.94:1 against its background, where WCAG asks for 3:1. The white label on our footer’s main button sat between 3.6:1 and 4.4:1, where WCAG asks for 4.5:1.</p><p>Neither failure came from a bad color. Every color involved passes some test on its own. They fail as pairs, and a pair only exists where a color lands on a background.</p><p>That gap is not rare. WebAIM’s 2026 scan of the top one million home pages found <a href="https://webaim.org/projects/million/">low contrast text on 83.9% of them</a>, up from 79.1% in 2025, with an average of 34 instances per page. It is the most common failure in that report. Our site gives each of the four AI apps our extension works with its own accent color, and puts those accents on white, on pale tints and on gradients. That is a lot of pairs. Over two audits, one in September and one this week, I found five ways a passing color still made a failing pair, or a failing pair still got a pass.</p><h3>1. A brand color is not a text color</h3><p>A color chosen for recognition usually fails as text on white, so a design system needs two tokens per brand color: one for decoration and one for text.</p><p>Our three borrowed accents measure, on white: Gemini blue #4285f4 at 3.56:1, Claude orange #d97757 at 3.12:1, and X blue #1d9bf0, which we use for Grok, at 3.00:1. <a href="https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html">WCAG success criterion 1.4.3</a> asks for 4.5:1 for normal text and 3:1 for large text, which it defines as 18 point, or 14 point bold, about 24px and 18.5px. <a href="https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html">Criterion 1.4.11</a> asks for 3:1 for the parts of a component you need to see. So all three accents are fine as borders, focus rings, icons and large headings, and none of them is fine as a paragraph or a button label.</p><p>X blue clears 3:1 by four ten thousandths: 3.0004. WCAG does not round. Its own guidance says a computed “4.499:1 would not meet the 4.5:1 threshold”, so a value just under the line is a fail, and any color even slightly lighter than X blue would fail as a border too.</p><p>Our fix is two custom properties per accent:</p><pre>.accentGemini {<br>  --accent: #{$gemini-blue};<br>  --accent-strong: #{$gemini-blue--text}; // 5.4:1 with white<br>}</pre><p>--accent paints tints, borders and glows. --accent-strong goes anywhere text meets the color, including white text on a solid accent fill. The text twins are #1967d2 at 5.37:1, #a8502e at 5.45:1 and #1177c9 at 4.66:1, so every one passes 4.5:1.</p><figure><img alt="Three paint chip cards hang on a tan pegboard under a label reading Two tokens per brand color. Each card pairs a brand color with a darker text twin. Gemini blue: — accent #4285f4, decoration, 3.56:1 on white; — accent-strong #1967d2, text, 5.37:1 on white. Claude orange: #d97757, 3.12:1; #a8502e, 5.45:1. X blue: #1d9bf0, 3.00:1; #1177c9, 4.66:1." src="https://cdn-images-1.medium.com/max/1024/1*SjeM1xc0_T57NY_Jff5JYw.png" /><figcaption>The brand color and its text twin are two tokens. Only the second one may carry text. Credit: Illustration by author, ratios computed with the WCAG 2.2 relative luminance formula</figcaption></figure><p>One more thing turned up while I checked these. Two files in our codebase disagreed about the same ratios. One comment said Gemini blue was 3.3:1 and another said 3.6:1. Both are plain comments that nothing checks, and the real value is 3.56. A comment that holds a computed number goes stale, so generate it or leave it out.</p><h3>2. A tint is a new background</h3><p>A color that passes on white has passed on white and nowhere else. Every tinted surface is a different background, and every text token has to be tested on each one it can land on.</p><p>Our muted gray #737373 measured 4.74:1 on white, a pass. Our sections sit on pale tints, so this is one gray, four backgrounds. On #f6f7fb it measured 4.43:1. On #eff8fe, 4.41:1. On #eef0f7, 4.16:1. That is three fails from one passing token, on backgrounds most people would call white. We replaced it with #666666, which measures 5.74:1 on white and 5.36, 5.34 and 5.04 on those three tints. In the proof sheet below, each number is printed in the gray it measures, on the background it was measured against.</p><figure><img alt="A printer’s proof sheet on a green cutting mat, titled One gray, four backgrounds. Top row, the old muted gray #737373 on white, #f6f7fb, #eff8fe and #eef0f7: 4.74:1 passes, then 4.43, 4.41 and 4.16 all fail. Bottom row, its replacement #666666 on the same four: 5.74, 5.36, 5.34 and 5.04, all pass. A note reads Body text needs 4.5:1." src="https://cdn-images-1.medium.com/max/1024/1*oxb_rqon8Ecv7yZF_BkhKg.png" /><figcaption>The same gray passed on white and failed on all three tints. The replacement passes on all four. Credit: Illustration by author, ratios computed with the WCAG 2.2 relative luminance formula</figcaption></figure><p>The text twins from section 1 have the same weakness. Our X blue text color is 4.66:1 on white and 4.33:1 on its own 7% tint, #eff8fe, which is the panel we put behind its feature row. So that one surface got a third token, #0f66ab, at 5.57:1 on the tint.</p><p>So a text token’s comment should name its worst surface, not just white. Our muted gray’s comment now gives 5.7:1 on white and 5.0:1 on the #eef0f7 tints, which tells the next designer where the margin runs out.</p><h3>3. Our own audit script turned failures into passes</h3><p>A contrast checker that cannot read a color will usually guess, and the usual guess is white. Ours did, and it cleared two elements that Lighthouse had correctly flagged.</p><p>In September I wrote a script that starts at each text element, walks up through its ancestors to find the first solid background, and computes the ratio. It read colors from getComputedStyle() with a regular expression for rgb() and rgba(). Our tinted panels are built with CSS <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-mix">color-mix()</a>, and Chrome reports a mixed color in a different form. I checked it again on 26 October in Chrome 156:</p><pre>color(srgb 0.937961 0.972549 0.995882)</pre><p>The regular expression never matched that string. The script skipped the tinted panel, kept walking up to the white page, and measured the text against white.</p><p>The fix was to stop parsing color strings and let the browser convert them, by painting each color onto a one pixel canvas and reading it back:</p><pre>const ctx = document.createElement(&#39;canvas&#39;).getContext(&#39;2d&#39;, {willReadFrequently:true});<br>const norm = css =&gt; { ctx.clearRect(0,0,1,1); ctx.fillStyle = css; ctx.fillRect(0,0,1,1);<br>  const d = ctx.getImageData(0,0,1,1).data; return {r:d[0],g:d[1],b:d[2],a:d[3]/255}; };</pre><p>The lesson is not about regular expressions. Before you trust a checker’s pass, show it a known failure and watch it fail. I had never done that, so I had no idea it could pass something by mistake.</p><h3>4. Gradients are where automated checks stop</h3><p>axe-core, the engine behind Lighthouse’s accessibility audits, does not measure text on a gradient. It sets the element aside for a person to review, and Lighthouse only counts passes and fails.</p><p>Deque’s documentation for the <a href="https://dequeuniversity.com/rules/axe/4.13/color-contrast">color contrast rule</a> says: “This rule will not report on text elements that have a background-image&quot;. A CSS gradient is a background image. Lighthouse&#39;s <a href="https://developer.chrome.com/docs/lighthouse/accessibility/scoring">scoring documentation</a> says: &quot;Each accessibility audit is pass or fail.&quot; An element that axe could not decide is neither, so it never touches the score.</p><p>On 26 October I ran axe-core 4.13.0 on our homepage with only the contrast rule switched on. It returned 0 violations, 340 passes and 99 results that need review. Ten of the 99 sat in our footer’s closing section, and every one of the ten carried the same message: “Element’s background color could not be determined due to a background gradient.” I measured those ten by hand. Eight passed. Two failed.</p><p>The first is the word “Claude” in the footer headline. It is 36px and extra bold, which makes it large text, so it needs 3:1. Its orange measured 3.12:1 on white, a pass. But the section has a 5% tint of our brand colors behind it, and at the word’s position the background was just dark enough to bring it to 2.94:1. A tint most readers would never notice took a passing heading under the line.</p><p>The second is the footer’s main button. Its label is white, 15.2px and bold, which is under the large text size, so 4.5:1 applies. The button’s three gradient stops measured 5.77, 3.71 and 3.12 against white, and behind the label the background ran from 3.66 to 4.41:1. No part of the label passed.</p><p>The fix for the heading already existed in our own stylesheet, two lines below the mistake:</p><pre>&amp;--claude { color: $claude-orange; }</pre><pre>&amp;--grok { color: $grok-blue--text; }</pre><p>The Grok accent used its text token. The Claude accent used the raw brand color. We swapped in $claude-orange--text, and the word now measures 5.14:1 at the same spot.</p><p>For the button, a gradient can be checked by hand, and which points you check depends on the text color. For light text on a gradient between hex colors, test the stops. The browser blends hex colors in sRGB, and that blend is never lighter than its lighter end, so the lowest contrast with white always sits on a stop. For dark text, stops are not enough. The middle of a blend can be darker than both ends, as a mix of pure red and pure green is darker than the red, so sample along it. I tested 20,000 random color pairs: white text never hit its worst point between two stops, and black text did in 5,002 of them. The chart below plots white text across the footer button’s gradient, from its left stop to its right stop.</p><figure><img alt="Line chart titled White text across the footer button’s gradient. The Before line falls from 5.77 at the left stop to 3.71 at the middle stop and 3.12 at the right stop, crossing under the dashed 4.5:1 minimum just before a shaded band marked Behind the label. The After line stays between 5.38 and 5.77 all the way across. Strips showing both gradients run under the axis." src="https://cdn-images-1.medium.com/max/1024/1*i5kLXKpUQntPYxOPLpBjUw.png" /><figcaption>Behind the label, the old gradient stayed under 4.5:1. The new stops keep every point at 5.38:1 or more. Credit: Chart by author, contrast computed across the button’s CSS gradient</figcaption></figure><p>The fixed button keeps the hue and darkens two stops: #5e6292 to #7d5aa8 to #a8502e, which measure 5.77, 5.38 and 5.45. By the rule above, every point between them is at least 5.38:1, and behind the label it now runs from 5.39 to 5.57:1.</p><h3>5. A screenshot eyedropper reads a different color</h3><p>A screenshot is not always in the color space WCAG measures, so eyedropping one can make a failing pair look like a pass.</p><p>To check the gradient math, I took a screenshot with the label hidden and measured its pixels. The numbers came back higher, from 4.18 to 5.20:1, which would have put part of the label above 4.5. The screenshot carried the display’s ICC color profile, and its pixels were 7 to 12 levels darker per channel than the CSS colors. At the label’s left edge the CSS gradient was rgb(131, 108, 181). The raw screenshot pixel said rgb(122, 95, 169). One pixel, three readings, once you count the corrected one below.</p><p>Converted back to sRGB through the profile embedded in the file, that pixel became rgb(131, 107, 180), within one level of the CSS, and the range across the label dropped to 3.61 to 4.44:1, within a few hundredths of the math. WCAG <a href="https://www.w3.org/TR/WCAG22/#dfn-relative-luminance">defines relative luminance</a> for the sRGB color space, so sRGB is where the measurement has to happen.</p><figure><img alt="A dark calibration card titled One pixel, three readings, with three purple patches carrying a white Aa. CSS value rgb(131, 108, 181), label range 3.66 to 4.41:1. Raw screenshot pixel rgb(122, 95, 169), tagged Display profile, label range 4.18 to 5.20:1. Converted to sRGB rgb(131, 107, 180), label range 3.61 to 4.44:1." src="https://cdn-images-1.medium.com/max/1024/1*VehMeM-R2nXMMa4ppZz93w.png" /><figcaption>The raw screenshot made the background darker, which made white text look safer than it is. Credit: Illustration by author, pixel values from a Chrome screenshot of our footer button</figcaption></figure><p>The error ran in the dangerous direction. A darker background makes white text look better, so the screenshot made a fail look like a partial pass. Measure from the CSS values, or convert a screenshot to sRGB before you trust an eyedropper on it.</p><h3>What we changed, and what the tools saw</h3><p>We shipped two tokens for every brand color, a new value for the muted gray token, a third token for the one tint the X blue text color could not survive, and an audit script that converts colors through a canvas. After this audit we also fixed both footer failures from section 4, plus one more gradient I found while fixing them: a full-width purchase button elsewhere on the site, whose white label sat at 2.96:1 at its lightest point. It now measures 5.00:1 there.</p><p>Then I ran both tools again on the fixed homepage. Lighthouse reported Accessibility: 100, the same score it gave the page when both elements failed. axe marked the same ten footer elements for review, with the same gradient message. The fixes are real, and the tools could see them no better than they saw the failures.</p><p>These are the four checks I now run on every audit:</p><ul><li>Show the checker a known failure first, and confirm it fails.</li><li>Test each text token on the darkest surface it can sit on, not on white.</li><li>List every result the tool marked for review, and measure those by hand.</li><li>Measure text on a gradient at the stops for light text, and along the blend for dark text, always in sRGB.</li></ul><p>A score of 100 meant that every pair the tool could finish measuring had passed, before the fix and after it. It never said every pair on the page had been measured.</p><h3>Key takeaways</h3><ul><li>Contrast is a property of two colors. A design system that stores single colors cannot know whether its pairs pass, so test each text token on every surface it can land on.</li><li>Give every brand color two tokens: one for decoration and one for text. Brand colors chosen for recognition rarely reach 4.5:1 on white.</li><li>A pale tint is a new background. Ours moved a 4.74:1 gray to 4.16:1 and a 3.12:1 heading to 2.94:1.</li><li>axe-core does not measure text on gradients, and Lighthouse scores only pass or fail, so a page can score 100 with failing text on a gradient.</li><li>For light text on a gradient, check the stops. For dark text, sample along the blend. Measure in sRGB, not from a raw screenshot.</li></ul><p>If you build design systems or audit them, follow me here on Medium. I write about what breaks between the design file and the shipped page.</p><p>About the author: Adi Leviim is a full-stack engineer and product builder with 7+ years of experience shipping commercial software to global audiences. He writes about AI UX, building for millions of users, and the design reality of shipping production AI tools. Follow him on Medium for essays on AI UX, founder engineering, and the gap between AI demos and production AI.</p><p><a href="https://medium.com/@adi_leviim">Medium</a> | <a href="https://www.linkedin.com/in/adi-leviim/">LinkedIn</a> | <a href="https://ai-toolbox.co/">Personal site</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7d219ab77bfc" width="1" height="1" alt=""><hr><p><a href="https://medium.com/design-bootcamp/a-5-tint-broke-our-contrast-and-lighthouse-still-scored-us-100-7d219ab77bfc">A 5% tint broke our contrast, and Lighthouse still scored us 100</a> was originally published in <a href="https://medium.com/design-bootcamp">Bootcamp</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Our Backend Ran Hot for Four Days. Every Open Tab Was Doing the Same Work.]]></title>
            <link>https://levelup.gitconnected.com/our-backend-ran-hot-for-four-days-every-open-tab-was-doing-the-same-work-84d829441284?source=rss-a8963cd80b5c------2</link>
            <guid isPermaLink="false">https://medium.com/p/84d829441284</guid>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[chrome-extension]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[javascript]]></category>
            <category><![CDATA[web-development]]></category>
            <dc:creator><![CDATA[Adi Leviim]]></dc:creator>
            <pubDate>Mon, 26 Oct 2026 03:08:29 GMT</pubDate>
            <atom:updated>2026-10-26T03:08:29.446Z</atom:updated>
            <content:encoded><![CDATA[<p><em>ChatGPT asks who the user is on every page load. We turned each of those into up to ten requests per open tab.</em></p><figure><img alt="Dark editorial poster. Headline: Our Backend Ran Hot For Four Days. Twenty small browser windows on the left send amber beams into a server rack glowing red with flames above it and a gauge reading 100% CPU. A right panel headed The Fix, Tell One Tab shows one green window linked to a share icon that broadcasts dotted lines to three dim windows. A bottom strip reads 50 open tabs, 10 endpoints each, 5,751 batches in 28 minutes" src="https://cdn-images-1.medium.com/max/1024/1*Y1v94v0iWiwhWwwskfa1gg.png" /><figcaption>Nothing here is broken. Every one of those tabs is doing exactly what it was told, which is the entire problem. Credit: Image generated with ChatGPT</figcaption></figure><p>Our backend VM sat at 100% CPU for four days in September 2026. One user with about 50 ChatGPT tabs open sent 5,751 request batches in 28 minutes, and almost all of them were fetching data the browser already had.</p><p>The bug was not in the code that ran. It was in the number of places that ran it. I co-build a Chrome extension for AI chat apps, so the details below are ours, but the shape is not specific to extensions: it appears anywhere N clients share one piece of state and each one reacts independently to a signal they all receive.</p><h3>The Trigger Belonged to Somebody Else</h3><p>ChatGPT requests /backend-api/settings/user on every page load, and started doing it more often in late September 2026. We listened for that request because it carries the user&#39;s token, which makes it a natural signal that the signed-in account may have changed. Listening was reasonable. Treating it as rare was not.</p><p>That is the first transferable lesson, and it is the one I would tattoo on a junior engineer: if you key work off somebody else’s request pattern, you have taken a dependency on a number they can change without telling you, without a version bump, and without appearing in your changelog. When an input rate moves under a fixed multiplier, the result looks identical to a regression on a dashboard and is invisible in a diff.</p><p>The honest version of the postmortem is that the fan-out was always wrong. The vendor’s change did not create the bug. At most it raised the input. The multiplier was ours.</p><h3>One Request Became N Times Ten</h3><p>Here is what each of those signals did. The extension cleared its shared store, then told every open ChatGPT tab to reload its settings and libraries. That reload is up to ten requests per tab, each to a different endpoint: five for settings (language, theme, sound, default export format, OS preference) and five for libraries (prompts, prompt chains, pinned chats, pinned folders, smart tag rules). Users who switched prompts off skip two of them and free users skip the tag rules, so ten is the ceiling.</p><p>So with N tabs open, each tab’s page load triggered one settings request, and each settings request fanned out to all N tabs at up to ten requests each. Loading N tabs cost N times N batches.</p><figure><img alt="Typographic arithmetic on a flat ground reading 1 settings request times 50 open tabs times at most 10 endpoints equals at most 500 requests, with a line underneath reading 5,751 batches in 28 minutes, one user" src="https://cdn-images-1.medium.com/max/1024/1*pMFRMHJM6Jqpw9SMVEWlUA.png" /><figcaption>Nothing in this equation is a mistake on its own. The fault is that one term is the number of tabs and nobody thought of it as a multiplier. Credit: Diagram by author, figures from our own incident notes</figcaption></figure><p>Now the part that stings. The extension’s store lives in chrome.storage, which is shared by every tab in the browser and broadcasts changes through chrome.storage.onChanged. One tab&#39;s reload already refills the store for all of them. Every one of the other N minus 1 reloads was pure waste, however often ChatGPT asked. We had written a broadcast where a single assignment would have done, and the architecture that made it cheap to share state was the same architecture that made the broadcast pointless.</p><h3>The Fix Is an Election, Not a Cache</h3><p>The instinct in that situation is to add a cache. The right move was to stop doing the work N times, which means electing one actor and letting the shared medium do its job.</p><p>The refresh now reaches only the tab that made the request, at most once per tab per cooldown for the same account. A real account switch still reaches every tab, because correctness beats thrift: every tab must drop the previous account’s data immediately. The whole decision is one pure function:</p><pre>export function planAuthRefresh(input: {<br>  tabId: number | null;<br>  previousEmail: string | null;<br>  tokenEmail: string | null;<br>  marks: ReadonlyMap&lt;number, RefreshMark&gt;;<br>  now: number;<br>  cooldownMs?: number;<br>}): AuthRefreshPlan {<br>  const cooldown = input.cooldownMs ?? REFRESH_COOLDOWN_MS;<br>  const prev = norm(input.previousEmail);<br>  const next = norm(input.tokenEmail);<br><br>  if (prev &amp;&amp; next &amp;&amp; prev !== next) return { kind: &quot;all&quot;, slot: null };<br><br>  const unknown = !prev || !next;<br>  const slot = unknown ? UNKNOWN_ACCOUNT_SLOT : input.tabId ?? NO_TAB_SLOT;<br>  const mark = input.marks.get(slot);<br>  if (mark &amp;&amp; norm(mark.email) === next &amp;&amp; input.now - mark.at &gt;= 0 &amp;&amp; input.now - mark.at &lt; cooldown) {<br>    return { kind: &quot;skip&quot; };<br>  }<br>  if (unknown || input.tabId === null) return { kind: &quot;all&quot;, slot };<br>  return { kind: &quot;tab&quot;, tabId: input.tabId, slot };<br>}</pre><figure><img alt="Decision table for the refresh planner with four rows. Same account and a known tab refreshes that tab only, with a five minute cooldown. A different account refreshes every tab with no cooldown. First sign-in or an unreadable account refreshes every tab, cooldown slot minus two. A request with no tab refreshes every tab, cooldown slot minus one" src="https://cdn-images-1.medium.com/max/1024/1*LWC8NbK7OyfdZ4XFY6bkaA.png" /><figcaption>The negative slots are the subtle part. Without them, an unreadable account could loop and a request with no tab would have no cooldown. Credit: Diagram by author, from the shipping planner</figcaption></figure><p>Two details in there are easy to miss. The cooldown is keyed by a slot, not always by a tab id, because two cases need a slot of their own. A request with no tab, which can come from a worker, has no tab id to key on, so it gets slot -1. A request whose account cannot be read refreshes every tab, so it is rate limited as one shared slot, -2, whichever tab sent it. Both therefore still get a cooldown and cannot spin. And the comparison input.now - mark.at &gt;= 0 exists because a clock that jumps backwards produces a negative age, which would otherwise count as inside the cooldown and suppress refreshes until the clock caught up.</p><figure><img alt="Two radial diagrams on a dark ground. Before: a white centre dot with 50 orange spokes running out to 50 tab dots, every one of them lit. After: the same ring with a single green spoke lit and the other 49 dim. Captions note that the shared store broadcasts the result to the other 49" src="https://cdn-images-1.medium.com/max/1024/1*Ktwqv-IAS8ffqhdDnkyD2A.png" /><figcaption>The fix removed no feature and no request from the critical path. It removed 49 copies of the same request. Credit: Diagram by author</figcaption></figure><p>Electing a tab introduces a failure the broadcast did not have: the elected tab can close or navigate away before it does the work, and then no tab refills the store and every tab would read an empty prompt list until its own next refresh. So delivery is tried up to five times, 500 milliseconds apart, while the content script boots, and if the chosen tab disappears within a 15 second grace window, another tab is elected in its place. An election needs a re-election.</p><h3>Put the Guard Under Every Trigger, Not Under the One You Found</h3><p>This is the part I would want a reviewer to insist on. Deduplicating the trigger we had just debugged would have been a patch. Instead the decision sits in a second pure function that every path has to pass, whatever called it:</p><pre>export function shouldLoadBackendData(<br>  last: BackendLoadMark | null,<br>  current: { email: string | null; resetId: unknown; now: number },<br>  cooldownMs = REFRESH_COOLDOWN_MS<br>): boolean {<br>  if (!last) return true;<br>  if (norm(last.email) !== norm(current.email)) return true;<br>  if (last.resetId !== current.resetId) return true;<br>  const age = current.now - last.startedAt;<br>  return age &lt; 0 || age &gt;= cooldownMs;<br>}</pre><p>Four reasons to load, and everything else is a repeat of a load that already happened. The note at the top of that source file is the actual design document: <em>That holds for every trigger, including any added later.</em> A guard that lives next to one caller protects one caller. A guard that lives under all of them protects the code nobody has written yet, which in a codebase with several entry points into the same work is most of the risk.</p><p>On top of that sits a single-flight loader: one load at a time, and a request that arrives while a load is running is decided again when that load finishes rather than queued blindly, because the store may have been reset, or the account switched, in between. The subtle line is that the loader claims the running slot synchronously after its only await, so two requests arriving together cannot both start a load.</p><h3>The Second Fan-Out Was a Cache Keyed Wrong</h3><p>While fixing the first one we found a smaller version of the same shape, with a different cause. The extension captures a workspace header from ChatGPT’s requests, and it used to remember one value for the whole browser.</p><p>Two tabs open on different workspaces then alternated that single value on every request they made, and each flip was read as a real workspace change, which told every tab to refetch its folders and pins. One user, two tabs, and a memo that was supposed to prevent work became the thing generating it.</p><p>The fix is three lines, and the lesson is worth more than the code: a memo whose key is coarser than the fact it stores does not merely fail to help, it inverts into an amplifier. Remembering the header per tab means only a real change in a tab counts.</p><pre>export function isNewHeaderForTab(seen: Map&lt;number, string&gt;, tabId: number, header: string): boolean {<br>  if (seen.get(tabId) === header) return false;<br>  seen.set(tabId, header);<br>  return true;<br>}</pre><h3>Why the Policy Is a Pure Function</h3><p>Every piece of policy above takes its inputs as arguments and returns a decision: what to refresh, whether to load, whether this header is new. None of them touch the DOM, chrome.*, or the network. The plumbing that does touch those things is kept as thin as it can be.</p><p>That split is why there is a test file for this at all. You cannot write a unit test for “every tab reloaded and the VM caught fire”, but you can absolutely write one for “a request from tab 7 for the same account 1,000 milliseconds later should be skipped”. The suite asserts the cooldown boundary from both sides, a clock that moves backwards, case-insensitive email comparison, a refresh for a new account in a tab whose last mark belongs to the old one, and the two negative slots. I re-ran it before publishing this and it passes.</p><p>If you take one habit from the whole incident, take that one. Extract the policy from the plumbing, even when the policy is a handful of if statements, because a handful of if statements is exactly what you will get wrong at 2am while the graphs are red.</p><h3>Conclusion</h3><p>Everything in this story was individually correct. ChatGPT was right to ask who the user is. We were right to listen for it. Each tab was right to want its data. chrome.storage was right to broadcast. The outage lived in the multiplication, which is the one place nobody reviews, because the multiplication is not written down anywhere in the code.</p><p>So the question worth asking in review is not “is this call necessary?” but “how many copies of this will be running, and who told them to run?” From inside each tab, the answer to the first question looked like yes every time. The answer to the second was fifty.</p><h3>Key Takeaways</h3><ul><li>A shared store plus a broadcast is a quadratic fan-out waiting for an input rate to rise. One tab’s write already reached every tab through chrome.storage.onChanged; the other N minus 1 reloads were waste every time.</li><li>If you key work off a third party’s request pattern, your amplification factor is a dependency on a number they can change silently. When ChatGPT started calling one endpoint more often, that change multiplied straight through our fan-out.</li><li>Elect one actor instead of telling everyone, and then handle the failure an election creates: ours tries delivery up to five times and re-elects another tab inside a 15 second grace window if the chosen one closes.</li><li>Put the guard under every trigger rather than next to the one you just debugged, so paths nobody has written yet inherit it.</li><li>A memo whose key is coarser than the fact it stores inverts into an amplifier. Ours was browser-wide for a per-tab value, and two tabs on different workspaces flip-flopped it into a refetch storm.</li></ul><h3>FAQ</h3><p>Why does a Chrome extension fan out to every tab at all? Because each tab runs its own content script with its own in-page state, so the obvious way to update the UI everywhere is to message every tab. It is unnecessary when the data lives in chrome.storage, which is shared across tabs and broadcasts through chrome.storage.onChanged, so one tab&#39;s write already reaches all of them.</p><p>How do you stop repeated reloads without breaking account switches? Separate the two cases in the policy. For the same account, refresh only the tab that asked, with a cooldown per tab, five minutes in our case. For a different account, refresh every tab with no cooldown at all, because every tab must drop the previous account’s data immediately.</p><p>What breaks when you elect a single tab to do shared work? The elected tab can close or navigate away before it finishes, leaving the shared store empty for everyone. Delivery needs retries while the content script boots, and a grace window, ours is 15 seconds, after which another tab is elected. An election without a re-election is a single point of failure.</p><h3>Resources</h3><ul><li><a href="https://developer.chrome.com/docs/extensions/reference/api/storage">chrome.storage.onChanged</a>, the shared, broadcasting store this bug was fighting against</li><li><a href="https://developer.chrome.com/docs/extensions/develop/concepts/messaging">Message passing in Chrome extensions</a>, including the connection errors the retry loop handles</li><li><a href="https://levelup.gitconnected.com/a-cookie-banner-is-an-analytics-outage-not-a-legal-checkbox-55dcb7d330a3">A cookie banner is an analytics outage, not a legal checkbox</a>, on four other ways our own measurements were wrong</li><li><a href="https://levelup.gitconnected.com/chatgpts-api-listed-775-of-10-500-chats-and-said-that-was-all-c7382bab2cbf">ChatGPT’s API listed 775 of 10,500 chats and said that was all</a>, on a vendor endpoint that misreports its own completeness</li></ul><p>What is the largest amplification factor hiding in your codebase right now, and do you know what it is multiplied by? If you have ever had a vendor’s traffic pattern change underneath you, I would like to hear what it broke.</p><p>Follow me for more posts about building and measuring AI products. I write up the real incidents, including the ones where the bug was mine.</p><p>About the author: Adi Leviim is the co-founder of AI Toolbox (formerly ChatGPT Toolbox), a Chrome extension with four modules (ChatGPT, Gemini, Claude, Grok) used by 40,000+ people across 150+ countries to search, organize, and export their AI conversations. He writes about the reality of building AI products with 7+ years of full-stack development experience. Follow him on Medium for honest takes on SaaS, AI tools, and shipping software that people actually use.</p><p><a href="https://ai-toolbox.co/">AI Toolbox</a> | <a href="https://medium.com/@adi_leviim">Medium</a> | <a href="https://x.com/adileviim">Twitter/X</a> | <a href="https://www.linkedin.com/in/adi-leviim/">LinkedIn</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=84d829441284" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/our-backend-ran-hot-for-four-days-every-open-tab-was-doing-the-same-work-84d829441284">Our Backend Ran Hot for Four Days. Every Open Tab Was Doing the Same Work.</a> was originally published in <a href="https://levelup.gitconnected.com/">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[I Optimized Our Site So AI Could Find It. Finding It Was Never the Problem.]]></title>
            <link>https://ai.plainenglish.io/i-optimized-our-site-so-ai-could-find-it-finding-it-was-never-the-problem-8b35d30986f3?source=rss-a8963cd80b5c------2</link>
            <guid isPermaLink="false">https://medium.com/p/8b35d30986f3</guid>
            <category><![CDATA[chatgpt]]></category>
            <category><![CDATA[analytics]]></category>
            <category><![CDATA[content-strategy]]></category>
            <category><![CDATA[seo]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <dc:creator><![CDATA[Adi Leviim]]></dc:creator>
            <pubDate>Sun, 25 Oct 2026 11:57:37 GMT</pubDate>
            <atom:updated>2026-10-25T11:57:37.511Z</atom:updated>
            <content:encoded><![CDATA[<p><em>ChatGPT fetched our pages 12,755 times in a week and cited us in about 4% of answers. I asked it why, and it answered.</em></p><figure><img alt="Concrete funnel on brick red: white pages pour in, one chartreuse page leaves. Words: Crawled constantly. Cited almost never." src="https://cdn-images-1.medium.com/max/1024/1*cPuE1EjItiGQKBA7YAkEwQ.png" /><figcaption>Retrieval and citation are different events. Nearly every argument about AI search is really about the gap between them. Credit: Illustration by author</figcaption></figure><p>For a year the advice I followed was about access. Allow the crawlers in robots.txt. Ship an llms.txt. Add schema. Make the content machine-readable so the models can find you.</p><p>Then I read our server logs. In one week, ChatGPT-User, the bot that fetches a page live when somebody asks ChatGPT about it, hit our site 12,755 times across 499 distinct pages. Over the same period we appeared in about 4% of the answers the engine gave on our own subject area.</p><p>Access was never the problem. We were being read constantly and quoted almost never, and those are two entirely different events with two entirely different causes. I co-founded a Chrome extension company, so our site is the dataset here. Every figure below is ours, dated, and measured twice where I could manage it.</p><h3>Your Crawl Log Already Says You Are Being Read</h3><p>The bot that matters is probably not the one your robots.txt argues about. Ahrefs Bot Analytics recorded 338,935 bot visits to our site in the seven days to 2 September 2026. AI bots were 37,559 of those, 11.1% of all bot traffic, and the split inside that group is the finding.</p><p>GPTBot, OpenAI&#39;s training crawler and the one every &quot;should I block AI?&quot; blog post is about, visited 148 times. ChatGPT-User, which fires at the moment a real person asks ChatGPT something, visited 12,755 times. The retrieval bot outnumbered the training bot by roughly 86 to 1.</p><figure><img alt="Horizontal bar chart of AI bot visits in the seven days to 2 September 2026: ChatGPT-User 12,755, Meta-ExternalAgent 7,734, Applebot 3,210, OAI-SearchBot 2,958, YouBot 2,794, DuckAssistBot 2,167, PerplexityBot 1,471, Google-NotebookLM 1,314, Amazonbot 1,181, Claude-Code 560, ClaudeBot 400, Claude-User 398, GPTBot 148. The three OpenAI agents are highlighted" src="https://cdn-images-1.medium.com/max/1024/1*bv_N1t-5DhLIqLCNsojrPg.png" /><figcaption>Three OpenAI agents, three different jobs. GPTBot trains, OAI-SearchBot decides whether you can appear at all, ChatGPT-User is live demand. Credit: Chart by author, from an Ahrefs Bot Analytics export</figcaption></figure><p>Before you act on that, read OpenAI’s own crawler documentation, because the three agents do genuinely different jobs and only one of them is the lever people think it is. GPTBot is the training crawler: &quot;Disallowing GPTBot indicates a site&#39;s content should not be used in training generative AI foundation models.&quot; OAI-SearchBot is the one that governs whether you can show up at all: &quot;Sites that are opted out of OAI-SearchBot will not be shown in ChatGPT search answers.&quot; And ChatGPT-User, the 12,755, is neither: &quot;ChatGPT-User is not used for crawling the web in an automatic fashion. Because these actions are initiated by a user, robots.txt rules may not apply.&quot;</p><p>So the decision most sites agonize over, blocking GPTBot, has no effect on whether you appear in ChatGPT&#39;s answers today. The one that does is OAI-SearchBot, which visited us 2,958 times in the same week. And the volume leader, the user-initiated fetch, is not something you can tune at all. It is a readout, not a dial.</p><p>That reframes what a high retrieval count means. It is not a success metric. It means the index already considers you a candidate and real people’s questions keep reaching your pages. Everything after the fetch is a different fight.</p><p>One more detail from the same export, which I did not expect: our author page was the third most fetched page on the whole site by AI bots, behind only the homepage and a service worker file. Something in these systems is checking who wrote the thing.</p><h3>Being Fetched and Being Cited Are Different Events</h3><p>Citation share is the number that matters, and it is not one number. Pulling Ahrefs Brand Radar for the prompts in our category, our domain held 4.6% of cited responses on ChatGPT, against 32.1% for help.openai.com. An earlier pull, taken on 1 September 2026 when the dataset came back complete rather than capped, gave 4.2%. Two samples six weeks apart, the same answer.</p><p>Here is the part worth internalizing. Those are the same pages, the same domain, the same schema, measured across seven engines on the same day:</p><figure><img alt="Dot plot of ai-toolbox.co citation share by AI engine, September 2026: ChatGPT 4.2 percent, Google AI Mode 4.4 percent, Perplexity 10.8 percent, Google AI Overviews 12.1 percent, Gemini 15.2 percent, Copilot 18.7 percent, Claude 22.2 percent, on a 0 to 25 percent axis" src="https://cdn-images-1.medium.com/max/1024/1*JXWzsywudXHBS_kerY2D0A.png" /><figcaption>One domain, one content set, a fivefold spread. A single “AI visibility score” averages away the only interesting thing here. Credit: Chart by author, from Ahrefs Brand Radar</figcaption></figure><p>Claude cites us five times more often than ChatGPT does, on identical content. Google’s two surfaces, AI Overviews and AI Mode, disagree with each other by a factor of nearly three. Whatever “optimizing for AI” means, it does not mean one thing.</p><p>A caveat I owe you, because it changes how much weight the numbers carry: Ahrefs caps that endpoint at 250 pages per engine, and only the smaller datasets come back under the cap. The ChatGPT and Claude figures from September were complete. The rest are top-250 samples, so treat the middle of that chart as directional and the two ends as solid.</p><h3>I Asked the Engine Why, and It Answered</h3><p>In June 2026 we ran 65 category queries through ChatGPT and logged every citation. About 22% of them cited us at all. Then I did the obvious thing and asked it directly why it was not using pages it had plainly retrieved.</p><p>It said it discounts them. The words it used were “marketing-heavy”, “partially promotional”, “not neutral documentation”, and “third-party, not official”, and it said it cross-checks our claims against the official OpenAI, Anthropic and Google documentation rather than treating us as ground truth.</p><p>A model describing its own behavior is a self-report, not a mechanism, and I would not publish that on its own. What makes it worth reporting is that the prediction it implies is testable, and the test agreed. We win the queries where no official documentation exists, the gaps a vendor fills. We lose, consistently, every query where an official help page covers the same ground. That is the same sentence as “help.openai.com holds 32.1%&quot;, viewed from the other side.</p><p>If your category has thorough official docs, your ceiling is lower than the GEO blog posts imply, and no amount of schema markup changes it. Find the questions the official documentation does not answer. That is the whole strategy, and it is less satisfying than a checklist.</p><h3>The Measurement That Changed How We Write</h3><p>Citations per page flatters long pages. Citations per thousand words of prose does not, and it reorders the table completely. I measured prose only, the text inside paragraph tags, so navigation and footers could not inflate anyone.</p><p>Four competitors scored between 7.9 and 10.7 citations per thousand prose words. Our export pages scored 3.7. Our search pages scored 0.9. This is not a coverage problem: we had 36 export pages and 9 search pages at the time, more than anyone we measured. The winning pages clustered around 1,400 words of prose. Ours ran 2,100 to 2,900, with up to 243 list items and a heading roughly every 80 words.</p><p>We had been writing for a reader who scans, and producing something no engine wants to quote: a page with no quotable middle, only an index.</p><p>Then I found the control, which is the single most useful thing in this entire exercise. Two pages on the same domain, same author, same schema, same template, differing mainly in their first sentence:</p><figure><img alt="Two cards side by side on a deep navy ground comparing how two pages on the same domain opened. The left card, headed 11 citations, begins Exporting a ChatGPT conversation means saving your chat history as a local file. The right card, headed 2 citations, begins AI Toolbox, formerly ChatGPT Toolbox, adds full-text search to ChatGPT. A footer notes the measurement date of 26 August 2026 and that the second page has since been rewritten" src="https://cdn-images-1.medium.com/max/1024/1*ujrK-ARNURm6KpEnyyzh_A.png" /><figcaption>One page opens with what the reader is trying to do. The other opens with a product name. Everything else about them matches. Credit: Diagram by author, openings taken from our own pages</figcaption></figure><p>The page that opened with <em>Exporting a ChatGPT conversation means saving your chat history as a local file</em> earned 11 citations. The page that opened with <em>AI Toolbox, formerly ChatGPT Toolbox, adds full-text search to ChatGPT</em> earned 2. Honest caveat: those two pages sit in different demand pools, 311 responses against 140, so the ratio is not attributable to the opening alone. But every winner we examined opened with the user’s problem or with the native method, and none of them opened with a product name.</p><p>We rewrote the second page. It now opens <em>ChatGPT has built-in search that reads message text, not just conversation titles</em>, which names the native behavior first and leaves the product to earn its place later. I would love to end this section with the lift that produced. I cannot: our measurement account has not refreshed since 24 September, so the after number does not exist yet. Treat the rewrite as a hypothesis with good odds, not a result, and be suspicious of anyone whose GEO case study never has that paragraph in it.</p><p>We also found a structural version of the same mistake. Our shared blog template rendered a full install card, complete with rating and user count, above the article body on every single post. An engine parsing the page met a sales widget before it met a fact. We moved it below the intro. That change is unmeasured too, and it is still the first one I would make on any site, because it costs an afternoon and it changes what a parser sees first.</p><h3>Two Theories I Had to Throw Away</h3><p>Negative results are the cheapest thing to publish and the rarest, so here are the two I was most confident about and both were wrong.</p><figure><img alt="Three lanes on a pale sand ground. Two faded lanes marked Ruled out read Promotional schema hurts your citations and Brand-named URLs get discounted, each with its contradicting evidence. A dark green lane marked Survived reads The opening sentence, and whether anyone else says it" src="https://cdn-images-1.medium.com/max/1024/1*a2qXGDssGecLJrom7X0sRg.png" /><figcaption>Both dead theories were things I believed and would have acted on. The survivor is the one that costs editorial effort rather than configuration. Credit: Diagram by author, from our own citation measurements</figcaption></figure><p>Promotional schema hurts you. We emit Product, AggregateRating and AggregateOffer markup from the root layout, which means it is on every page, including the page cluster that holds 62% citation share in its category and the one that holds 5%. Identical schema, opposite outcomes. Whatever is separating those pages, it is not the structured data.</p><p>Brand-named URLs get discounted. Neutral hub pages averaged 0.51 citations per page and brand-named hubs averaged 0.43, which looks like support until you notice the most neutrally named hub in the set scored 0.10, the worst of all of them. The naming convention is noise.</p><p>What survived testing was duller and harder: the opening sentence, the prose-to-scaffolding ratio, and cross-source agreement. That last one is the lever I had completely missed. A claim that appears only on your own domain reads to these systems as single-source and unverified, and duplicating it across your own pages still counts as one source. Getting a true, specific claim independently stated somewhere else is worth more than another page saying it louder at home.</p><h3>What I Would Check First on Any Site</h3><p>If you want to know where you actually stand, five measurements, in this order, will tell you more than any audit tool:</p><ol><li>Split the AI bots by job, not into one “AI traffic” line. OAI-SearchBot is the one you can actually gate, ChatGPT-User is a readout of live demand, GPTBot is about a future model. Ours ran 2,958, 12,755 and 148 in the same week, and averaging them would have told me nothing.</li><li>Measure citation share per engine, never as an average. Ours ranges from 4.2% to 22.2% on the same content.</li><li>Compute citations per thousand words of prose. Per page rewards padding, which is how we spent a year writing 2,900-word pages that nothing quoted.</li><li>Read your own first sentence out loud. If it names your product before it names the reader’s problem, that is the cheapest fix available to you.</li><li>Look at what renders above your article body in the DOM, not in the design. Ours was a sales card, on 256 pages, for months.</li></ol><h3>Conclusion</h3><p>The industry sold a crawling problem because crawling is easy to sell: it has a checklist, a file you can upload, and a yes or no outcome. Our logs say the crawling was solved long before we started worrying about it. What is left is a selection problem, and selection is editorial. These systems are deciding, every time somebody asks a question, whether your page reads like a source or like an advertisement. Ours read like an advertisement, and the engine said so in plain language when I asked.</p><p>That is a harder problem than schema markup, and it has the advantage of being the same problem good technical writing always had.</p><h3>Key Takeaways</h3><ul><li>Retrieval is not the bottleneck. ChatGPT-User fetched our site 12,755 times in seven days, across 499 pages, while we held about 4% of the engine&#39;s citations in our category.</li><li>GPTBot visited 148 times in the same week, and blocking it affects training, not answers. OpenAI documents OAI-SearchBot as the agent that decides whether you appear in ChatGPT search answers, and says robots.txt &quot;may not apply&quot; to the user-initiated ChatGPT-User at all.</li><li>There is no single “AI visibility” number. Identical content scored 4.2% on ChatGPT and 22.2% on Claude in the same measurement.</li><li>Citations per thousand words of prose beat citations per page as a diagnostic. Competitors scored 7.9 to 10.7, our export pages 3.7, our search pages 0.9, and the winners clustered near 1,400 prose words.</li><li>Asked directly, ChatGPT described our pages as “marketing-heavy” and “not neutral documentation” and said it cross-checks them against official vendor docs. That is a self-report, but the prediction it implies held up: we lose every query an official help page already answers.</li></ul><h3>FAQ</h3><p>Does blocking GPTBot remove me from ChatGPT answers? No. OpenAI documents GPTBot as the training crawler, and says sites opted out of OAI-SearchBot &quot;will not be shown in ChatGPT search answers&quot;. OAI-SearchBot is therefore the agent that controls visibility. In our logs for the week to 2 September 2026, GPTBot visited 148 times, OAI-SearchBot 2,958 times and ChatGPT-User 12,755 times.</p><p>Why does an AI engine crawl a page and then not cite it? Because retrieval and selection are separate steps. The index decides which pages become candidates, and the model then chooses among them. Our site was fetched 12,755 times in a week while holding roughly 4% of ChatGPT citations in its category, which is a selection outcome, not a crawling one.</p><p>Which pages get cited most by AI engines? In our measurements, pages that open with the reader’s problem or the native method, run near 1,400 words of prose, and make verifiable specific claims. Pages that open with a product name, run 2,100 to 2,900 words and carry heavy list scaffolding scored between 0.9 and 3.7 citations per thousand prose words, against 7.9 to 10.7 for the pages beating them.</p><h3>Resources</h3><ul><li><a href="https://ahrefs.com/webmaster-tools">Ahrefs Bot Analytics</a>, the source of the per-bot crawl counts used here</li><li><a href="https://platform.openai.com/docs/bots">OpenAI’s crawler documentation</a>, which distinguishes GPTBot, ChatGPT-User and OAI-SearchBot</li><li><a href="https://developers.google.com/search/blog/2022/10/performance-data-deep-dive">A deep dive into Search Console performance data filtering and limits</a>, on why dimensional reporting undercounts</li><li><a href="https://levelup.gitconnected.com/chatgpts-api-listed-775-of-10-500-chats-and-said-that-was-all-c7382bab2cbf">ChatGPT’s API listed 775 of 10,500 chats and said that was all</a>, on trusting what an API reports about its own completeness</li></ul><p>Which bot is actually fetching your pages, and have you ever separated it from the training crawler in your logs? If you have citation-share numbers across more than one engine, I would like to know how wide your spread is.</p><p>Follow me for more posts about building and measuring AI products. I publish the real numbers, including the ones that proved me wrong.</p><p>About the author: Adi Leviim is the co-founder of AI Toolbox (formerly ChatGPT Toolbox), a Chrome extension with four modules (ChatGPT, Gemini, Claude, Grok) used by 40,000+ people across 150+ countries to search, organize, and export their AI conversations. He writes about the reality of building AI products with 7+ years of full-stack development experience. Follow him on Medium for honest takes on SaaS, AI tools, and shipping software that people actually use.</p><p><a href="https://ai-toolbox.co/">AI Toolbox</a> | <a href="https://medium.com/@adi_leviim">Medium</a> | <a href="https://x.com/adileviim">Twitter/X</a> | <a href="https://www.linkedin.com/in/adi-leviim/">LinkedIn</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8b35d30986f3" width="1" height="1" alt=""><hr><p><a href="https://ai.plainenglish.io/i-optimized-our-site-so-ai-could-find-it-finding-it-was-never-the-problem-8b35d30986f3">I Optimized Our Site So AI Could Find It. Finding It Was Never the Problem.</a> was originally published in <a href="https://ai.plainenglish.io/">Artificial Intelligence in Plain English</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[A Cookie Banner Is an Analytics Outage, Not a Legal Checkbox]]></title>
            <link>https://levelup.gitconnected.com/a-cookie-banner-is-an-analytics-outage-not-a-legal-checkbox-55dcb7d330a3?source=rss-a8963cd80b5c------2</link>
            <guid isPermaLink="false">https://medium.com/p/55dcb7d330a3</guid>
            <category><![CDATA[analytics]]></category>
            <category><![CDATA[google-analytics]]></category>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[programming]]></category>
            <dc:creator><![CDATA[Adi Leviim]]></dc:creator>
            <pubDate>Mon, 19 Oct 2026 15:47:28 GMT</pubDate>
            <atom:updated>2026-10-19T15:47:28.187Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Our GA4 sessions fell 78% in two weeks while real installs set a record. Four silent undercounts, and the fix for each.</em></p><figure><img alt="Yellow umbrella over a bucket marked MEASURED in heavy rain, its scale nearly empty, with the words The rain never stopped" src="https://cdn-images-1.medium.com/max/1024/1*zfu8OvakY5u7xIZELDTt7A.png" /><figcaption>Both things can be true. The banner is the right call, and the number under it stopped describing the world. Credit: Illustration by author</figcaption></figure><p>In June our GA4 property reported three different purchase counts for the same 28 days. One event recorded 9 purchases for every 100 that actually happened. Another recorded 167. The third was correct, and it was the one nobody had put on a dashboard.</p><p>Four months later the same property lost 78% of its sessions in two weeks, and the traffic was fine.</p><p>I co-found AI Toolbox, a Chrome extension used by more than 40,000 people, so every number below comes from properties I own and re-queried this week rather than from a vendor’s case study. Four separate undercounts over five months, four different mechanisms, and one thing in common: a number in an analytics UI is the output of a pipeline you assembled yourself, and these pipelines fail quietly and almost always downward.</p><h3>The Tag That Loaded After the Event Fired</h3><p>A client-side analytics event that fires on component mount will lose a race against any tag loaded with a deferred strategy, and the loss is total rather than partial. In our case the gtag.js snippet was mounted through Next.js as &lt;Script strategy=&quot;lazyOnload&quot;&gt;, while trackPurchase ran in a useEffect on the mount of /payment-success. For the 28 days to 9 June 2026, the browser purchase event recorded 8.8 purchases for every 100 that the server-side Measurement Protocol mirror recorded.</p><p>The ugly part is that nothing errors. window.gtag is simply undefined at the moment you call it, the event evaporates, and the dashboard renders a confident, wrong, much smaller number.</p><figure><img alt="Black scoreboard panel with a fine dot grid, headed Purchases recorded, percent of actual, with three columns reading browser tag 9, server mirror 100, phantom event 167, and a footer line reading one GA4 property, one 28 day window, three answers" src="https://cdn-images-1.medium.com/max/1024/1*IRZ1jzVmA1ML573QzqoP8w.png" /><figcaption>Three events, one property, one window. The middle column was the only one anybody had checked against the payment processor. Credit: Diagram by author, figures from our own GA4 property</figcaption></figure><p>The fix is to separate the queue from the loader. Google’s snippet is two lines that touch no network, so they can run synchronously in the document head while the 100KB script arrives whenever it likes:</p><pre>window.dataLayer = window.dataLayer || [];<br>function gtag(){dataLayer.push(arguments);}</pre><p>With the stub inline, an event fired at mount is pushed onto an array and replayed when the real script lands. We moved it out of the deferred &lt;Script&gt; and into an inline tag in the root document in June 2026, and the client count converged on the server count.</p><p>The transferable rule is wider than Google Analytics. Any SDK you load asynchronously needs a synchronous queue in front of it, because the events you most want are the ones that happen earliest: a purchase on mount, an error on first paint, a bounce at 400 milliseconds. Those are exactly the events a loader race deletes.</p><h3>The Metric With No Code Behind It</h3><p>An analytics property can mint metrics that exist nowhere in your repository, so a code search is not an audit. Our third purchase number, purchase_completed, reported 167 events for every 100 real purchases, from a user count that matched the success page&#39;s page_view user count exactly. I grepped the site, the backend and the extension for the string and found nothing, because it was not code. It was a GA4 Admin rule under Data display, Events, Create event, cloning page_view on /payment-success.</p><p>That number had been sitting in a report for months. It was never a bug in the sense of a broken line; it was a metric somebody created in a web UI in 2025 and then forgot, and it inflated conversions by two thirds.</p><p>Four places can mint a metric without touching your codebase, and all four need checking when a number looks wrong: GA4’s created and modified events, a Google Tag Manager container, server-side tagging, and the SDK’s own automatic events. Write them into your onboarding notes, because the next engineer will grep, find nothing, and conclude the dashboard is correct.</p><p>The same comparison exposed a second gap worth knowing. For every 100 purchases the server mirror recorded, only about 83 users reported a page view on the success page at all, which puts roughly a sixth of our buyers behind a blocker or a failed script. If you key an ad platform’s conversion off a browser event, that is the fraction of your own customers you are bidding without.</p><h3>The Consent Banner Is a Measurement Change</h3><p>A consent banner that gates the tag changes what your analytics property measures, from traffic to the product of traffic and consent rate, and the step change looks exactly like a traffic collapse. We shipped ours on 16 September 2026 after a privacy scan flagged GA4 loading before consent. Our implementation is deliberate: the inline snippet in the root document only defines the dataLayer queue and sets every consent type to denied, and consent.ts injects gtag.js and Microsoft Clarity only once the visitor accepts.</p><p>Then the floor fell out. In the week of 7 September the site property recorded 19,596 sessions and 6,965 install_click events. In the week of 21 September it recorded 4,283 sessions and 2,254 install_click events, a 78% drop in sessions and a 68% drop in the event we treat as intent to install.</p><p>Over the same weeks the Chrome Web Store developer property, which Google maintains and our banner does not touch, recorded 2,020 real installs in the week of 7 September and 2,401 in the week of 12 October, the highest of the quarter. One button, two dashboards, opposite directions.</p><figure><img alt="Line chart titled Same weeks, opposite directions, indexed to 100 in the week of September 7 2026, showing site GA4 sessions falling to 44, 22, 26, 29 and 32 across the weeks to October 12 while Chrome Web Store installs move 88, 90, 100, 110 and 119, with a vertical marker at the September 16 consent banner release" src="https://cdn-images-1.medium.com/max/1024/1*0aWqxMISgNr7TuqhhuOY7w.png" /><figcaption>Real installs dipped about 10% in the fortnight and recovered to a record. The site property fell by more than three quarters and stayed there. Credit: Chart by author, from our site GA4 property and the Chrome Web Store developer GA4 property</figcaption></figure><p>There is no modeled backfill for an implementation like ours, and Google says so in the prerequisites for behavioral modeling: consent mode must be enabled across all pages, and “Google tags load in all cases, not only if the user consents.” The property also needs “at least 1,000 events per day with analytics_storage=&#39;denied&#39; for at least 7 days&quot; plus at least 1,000 daily users sending granted events, over at least 7 of the previous 28 days. We send nothing while consent is denied, by design, so there are no cookieless pings to model from and no gap filling.</p><p>Our server-side mirror did not rescue the numbers either, because it honors the same gate: the Measurement Protocol call is wrapped in hasAnalyticsConsent(). That was the right call rather than an oversight. A server-side mirror that ignores the banner is not a measurement win, it is a consent bug with better uptime.</p><p>So the bug here was never the banner. The bug was that for four weeks we kept reading the property as if it described visitors, until I sat down to work out why a record month looked like the worst month of the year. Annotate the property on the day the banner ships, write the new definition of the metric next to the chart, and stop comparing across the boundary.</p><h3>A Dimension Does Not Slice the Data, It Changes Which Data Exists</h3><p>Adding a high-cardinality dimension to a Search Console query changes the population you are measuring, not just its breakdown, so a dimensional sum is never a total. Our weekly report used to call the Search Analytics API with dimensions=[&quot;page&quot;,&quot;query&quot;] and sum the rows. The API caps rowLimit at 25,000 and defaults to 1,000, and a truncated response is a successful response, so the first version of that report quietly undercounted every page in the site.</p><p>Pagination fixes the truncation and reveals the bigger problem underneath. For 18 September to 15 October 2026, paged to exhaustion, the two calls disagree like this:</p><ul><li>dimensions=[&quot;page&quot;,&quot;query&quot;]: 83,253 rows, 873,659 impressions, 6,641 clicks</li><li>dimensions=[&quot;page&quot;]: 845 rows, 3,235,252 impressions, 24,374 clicks</li></ul><figure><img alt="Grid of one hundred small squares on a warm sand ground, headed Clicks you can see when you group by query, with 27 squares filled in dark ink and 73 drawn as empty outlines, labelled 27 itemised by query and 73 anonymised, no row in the API, above a note reading ai-toolbox.co, 18 September to 15 October 2026, 24,374 clicks in total" src="https://cdn-images-1.medium.com/max/1024/1*2kcW0-UnT-JQxd6MJ_i7ZQ.png" /><figcaption>The withheld three quarters are not an error state. They are a privacy design that your keyword report cannot tell you about. Credit: Diagram by author, totals from the Search Console Search Analytics API</figcaption></figure><p>The query breakdown accounts for 27% of the clicks. The missing three quarters are not lost, they are withheld: Google defines anonymized queries as “those that aren’t issued by more than a few dozen users over a two-to-three month period,” and states that “while the actual anonymized queries are always omitted from the tables, they are included in chart totals, unless you filter by query.” There is no row for them in the API, so no amount of paging will produce them. The same post is explicit about where the data is complete: “For requests that don’t involve query or URL dimensions, such as countries, devices, and Search Appearances, Search Console will display and export all the data.”</p><p>For a long-tail site the ratio is brutal, and ours is long tail by construction. Keyword-level reporting is still the most useful view we have for deciding what to write, and it is a 27% sample of the traffic it is describing. Both statements are true at once, which is the only honest way to use it.</p><p>Even the two defensible totals disagree. The same window with no dimensions returns 3,110,784 impressions, about 4% below the page-grouped sum, because grouping by page changes the unit of aggregation from the property to the URL. When two correct queries differ by 4%, any analysis that hinges on 4% is not an analysis.</p><p>The rule I now apply everywhere: totals come from an undimensioned query, dimensions are for composition only, and an aggregate built by summing a paginated API is a hypothesis until a second query confirms it. I learned the same lesson the hard way from a listing endpoint that <a href="https://levelup.gitconnected.com/chatgpts-api-listed-775-of-10-500-chats-and-said-that-was-all-c7382bab2cbf">claimed to have reached the end of a 10,500 chat history after 775 rows</a>. An API’s answer about how much data exists is a claim, not a proof.</p><h3>Pick a Witness That Is Not in Your Pipeline</h3><p>The cheapest protection against a silent undercount is a second source for each number that matters, chosen so that it cannot fail the same way as the first. The site property and the server mirror share a consent gate, so neither can audit the other. The Chrome Web Store developer property shares nothing with our site: different owner, different collection path, no banner of ours in front of it, which is why it was the thing that settled the argument in October.</p><figure><img alt="Dark charcoal checklist card with a yellow header band reading Before you trust a number, five rows pairing a metric with an independent witness: installs with the store developer dashboard, orders with the payment processor, search clicks with undimensioned Search Console totals, page requests with CDN or server logs, and client events with a server mirror, each with an empty check box and a note on the failure mode it does not share" src="https://cdn-images-1.medium.com/max/1024/1*wRp0vDNgNkR78wR9Q71www.png" /><figcaption>The test for a witness is not accuracy, it is independence. If both sources break for the same reason, you have one source. Credit: Checklist by author</figcaption></figure><p>Four practices came out of this, and all four are cheap:</p><p>Name the witness before you build the report. For installs it is the store’s own developer property. For orders it is the payment processor, not an event on a thank-you page. For search clicks it is an undimensioned Search Console total. For requests it is the CDN or server log, which no client-side blocker can touch.</p><p>Treat every instrumentation change as a data boundary. A banner, a tag migration, a consent update or an SDK upgrade all deserve an annotation on the property and a note in the weekly report, because six weeks later nobody remembers the deploy date and everybody can see the cliff.</p><p>Reconcile once a month, not once a crisis. Two numbers from independent sources, written down with the date. The gap itself becomes the metric: measured at the step change, our site property now records something like a quarter to a third of the traffic it used to record for the same or better demand, and knowing that ratio is nearly as good as having the true number.</p><p>Write down which numbers you have decided not to measure. We chose a stricter privacy posture over modeled analytics, and that choice costs us modeled sessions permanently. A documented blind spot is a design decision. An undocumented one is a bug waiting for someone to report a disaster that is not happening.</p><h3>Key Takeaways</h3><ul><li>A consent banner that gates the tag turns your analytics property into a measure of traffic times consent rate. Ours fell 78% in sessions in two weeks while real installs rose to a quarterly record.</li><li>Behavioral modeling cannot fill that gap unless tags load before consent, which a strict implementation deliberately prevents. Google’s own prerequisite is that tags “load in all cases, not only if the user consents.”</li><li>An async analytics SDK needs a synchronous queue in front of it. Our client purchase event caught 8.8 per 100 real purchases until the gtag stub moved inline.</li><li>Metrics can be created in an analytics admin UI, a tag manager or a server container, so grep is not an audit. One of ours inflated purchases by 67% and existed in no repository.</li><li>A dimensional sum is not a total. Grouping Search Console data by query returned 27% of the clicks for the same window, because anonymized queries have no row in the API.</li></ul><h3>FAQ</h3><p>Why did GA4 sessions drop after we added a cookie banner? Under basic consent mode the Google tag loads only after the visitor accepts, so sessions from everyone who ignores or rejects the banner are never collected. The property now measures traffic multiplied by consent rate. Our site GA4 property fell from 19,596 sessions to 4,283 in two weeks with no change in real traffic.</p><p>Will behavioral modeling recover the missing GA4 data? Only with an advanced implementation. Google requires that consent mode run on all pages, that Google tags “load in all cases, not only if the user consents,” and that the property collect at least 1,000 events per day with analytics_storage=&#39;denied&#39; for at least 7 days, plus 1,000 daily granted users. A tag that never loads sends no cookieless pings, so there is nothing to model.</p><p>Why does the sum of Search Console query rows not match the totals? Anonymized queries, which Google defines as queries not issued by more than a few dozen users over a two to three month period, are omitted from tables and from any query-filtered view while remaining in unfiltered totals. In our 28 day sample, page and query rows summed to 6,641 clicks against 24,374 for the page dimension alone.</p><p>Where should purchase or conversion events be measured instead of the browser? In a source that does not depend on the visitor’s browser: the payment processor for orders, the store’s developer dashboard for installs, server logs for requests. Our client-side purchase event recorded 8.8 events per 100 real purchases before the loader race was fixed, and roughly a sixth of buyers never reported a page view at all.</p><h3>Resources</h3><ul><li><a href="https://support.google.com/analytics/answer/11161109">Behavioral modeling for consent mode, prerequisites and thresholds</a></li><li><a href="https://developers.google.com/tag-platform/security/guides/consent">Consent mode implementation guide, basic and advanced</a></li><li><a href="https://developers.google.com/webmaster-tools/v1/searchanalytics/query">Search Analytics API query reference, rowLimit and startRow</a></li><li><a href="https://developers.google.com/search/blog/2022/10/performance-data-deep-dive">A deep dive into Search Console performance data filtering and limits</a></li><li><a href="https://support.google.com/webmasters/answer/17010575">Performance report, troubleshooting data discrepancies</a></li><li><a href="https://levelup.gitconnected.com/chatgpts-api-listed-775-of-10-500-chats-and-said-that-was-all-c7382bab2cbf">ChatGPT’s API listed 775 of 10,500 chats and said that was all</a></li><li><a href="https://levelup.gitconnected.com/i-built-a-context-meter-for-four-ai-chat-apps-counting-tokens-was-the-easy-part-f2c0f5afc26a">I built a context meter for four AI chat apps, counting tokens was the easy part</a></li></ul><p>Which of your dashboards has a witness that cannot fail the same way it does? If you have reconciled two independent sources recently and found a gap, I would like to hear the size of it.</p><p>Follow me for more posts about building and measuring AI products. I write up the real numbers, including the ones that turned out to be wrong.</p><p>About the author: Adi Leviim is the co-founder of AI Toolbox (formerly ChatGPT Toolbox), a Chrome extension with four modules (ChatGPT, Gemini, Claude, Grok) used by 40,000+ people across 150+ countries to search, organize, and export their AI conversations. He writes about the reality of building AI products with 7+ years of full-stack development experience. Follow him on Medium for honest takes on SaaS, AI tools, and shipping software that people actually use.</p><p><a href="https://ai-toolbox.co/">AI Toolbox</a> | <a href="https://medium.com/@adi_leviim">Medium</a> | <a href="https://x.com/adileviim">Twitter/X</a> | <a href="https://www.linkedin.com/in/adi-leviim/">LinkedIn</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=55dcb7d330a3" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/a-cookie-banner-is-an-analytics-outage-not-a-legal-checkbox-55dcb7d330a3">A Cookie Banner Is an Analytics Outage, Not a Legal Checkbox</a> was originally published in <a href="https://levelup.gitconnected.com/">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[ChatGPT’s API Listed 775 of 10,500 Chats and Said That Was All]]></title>
            <link>https://levelup.gitconnected.com/chatgpts-api-listed-775-of-10-500-chats-and-said-that-was-all-c7382bab2cbf?source=rss-a8963cd80b5c------2</link>
            <guid isPermaLink="false">https://medium.com/p/c7382bab2cbf</guid>
            <category><![CDATA[chatgpt]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[javascript]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[chrome-extension]]></category>
            <dc:creator><![CDATA[Adi Leviim]]></dc:creator>
            <pubDate>Fri, 16 Oct 2026 15:25:13 GMT</pubDate>
            <atom:updated>2026-10-16T15:25:13.904Z</atom:updated>
            <content:encoded><![CDATA[<p><em>A capped list that ends like a complete one can’t be caught by paging it. ChatGPT’s own search found the rest.</em></p><figure><img alt="Chat list panel on pink ending at a line reading End of list, with a magnifying glass below showing more rows" src="https://cdn-images-1.medium.com/max/1024/1*ig0ggnGiPbux0iFGOn1otQ.png" /><figcaption>The list ended cleanly, with no error and no hint. The rest of the history was still there, one search away. Credit: Illustration by author</figcaption></figure><p>In July 2026 a customer told us our search was missing most of their ChatGPT history. Their account held about 10,500 chats. Our index held 775.</p><p>We assumed our sync was broken, because it usually is when numbers disagree like that. It wasn’t. ChatGPT’s own listing endpoint had served exactly 775 chats and then ended the list the way it ends every list, with nothing to say it had stopped early.</p><p>I co-founded AI Toolbox, the Chrome extension involved, so this is partly a story about our product and you should read it with that in mind. The engineering lesson travels further than the product: a paginated API you don’t own can tell you it is finished when it isn’t, and no amount of careful paging will catch it.</p><h3>What the Listing Endpoint Actually Returned</h3><p>ChatGPT’s web app loads chat history from an internal endpoint, GET /backend-api/conversations, paged with offset and limit and sorted with order=updated. It is not a public API. Everything below was measured live in July 2026 on two accounts, ours at about 1,980 chats and the customer&#39;s at about 10,500, and the behaviour could change at any time.</p><p>On the large account, the listing stopped at exactly 775 chats. Every offset past 775 returned a clean, empty page. Nothing failed, nothing returned an error, and the result was stable across repeated runs.</p><figure><img alt="Hand tally counter reading 0775, labelled Chats listed, beside Chats in the account about 10,500" src="https://cdn-images-1.medium.com/max/1024/1*xROwggkjZaq9xVv4CiWvEg.png" /><figcaption>Hitting the cap looked like reaching the end. Only the gap between 775 and the account’s real size gave it away. Credit: Illustration by author, figures from our July 2026 measurements</figcaption></figure><p>The detail that killed our first idea was which 775 chats came back. They were not the newest. On a list sorted by last update, the final page of the 775 covered December 2022 to July 2023, so the served set was scattered across the whole history. “Just sync from the old end” was dead on arrival, because there was no old end to reach.</p><p>Two smaller facts from the same session are worth knowing if you ever touch this endpoint. order=created returns a 500, so the list only sorts by update time. And the archived list (is_archived=true) is a separate set, which was complete on every account we checked.</p><h3>Why the total Field Couldn&#39;t Warn Us</h3><p>The response carries a total field, and our first instinct was to compare it with what we had collected. That doesn&#39;t work, because total is not the size of the library. On both accounts it behaved like a has-more flag dressed up as a count:</p><pre>// What `total` measured on both accounts (July 2026):<br>//   more pages exist:  total === offset + items.length + 1<br>//   final page:        total === offset + items.length<br>const isLastPage = page.total === offset + page.items.length;</pre><p>That rule is a reliable end-of-list signal, and on the 10,500-chat account it fired at 775. The API wasn’t lying about its own pagination. It was accurately describing a list that had been cut short somewhere we couldn’t see.</p><p>This is the general trap. A client that pages until the server says “done” has verified the server’s statement, not the data. If the only source of truth is the paginated list, a hidden cap and a complete list look identical from the client side. You need a second, independent index to compare against.</p><h3>ChatGPT’s Own Search Had the Whole History</h3><p>ChatGPT’s search, opened from Search in the sidebar, runs on a different endpoint, GET /backend-api/global/search, and its index covered the full history, including everything the listing hid. On the customer&#39;s account, native search found 2023 chats that no listing walk could reach, and those chats opened normally by URL.</p><p>OpenAI’s help center says the same thing in its own words: “An older chat that is no longer visible in the sidebar has not necessarily been deleted. Search for a word or phrase from the conversation to find and reopen it.” If old chats seem to have vanished from a large account, search for a word you remember before assuming they are gone.</p><p>For a recovery tool, the search endpoint had three useful properties:</p><ul><li>limit tops out at 40. A request for 41 returns a 422 with a clean validation message.</li><li>Cursor walks had no depth cap we could find. One query walked 54 pages to natural exhaustion on the 1,980-chat account.</li><li>One common token covered most of a library. A single walk for the letter “a” returned 95% of the known chats (1,881 of about 1,980 unique conversation ids).</li></ul><p>So the fix was not to page the list harder. It was to treat search as a second index, walk it, and diff it against what we already had.</p><h3>Turning Search Into a Recovery Walk</h3><p>AI Toolbox’s Deep Import walks the search cursor to exhaustion for a handful of high-coverage single tokens, collects every conversation id the local cache doesn’t know, then fetches those chats through the same batch endpoint and save pipeline the normal sync uses. Simplified, the discovery loop looks like this:</p><pre>const TOKENS = [&quot;a&quot;, &quot;e&quot;, &quot;i&quot;, &quot;o&quot;, &quot;1&quot;, &quot;и&quot;, &quot;的&quot;, &quot;ה&quot;, &quot;ال&quot;]; // Latin, digits, Cyrillic, CJK, Hebrew, Arabic</pre><pre>for (const token of TOKENS) {<br>  let cursor: string | null = null, pages = 0, added = 0;<br>  do {<br>    const page = await globalSearch(token, { limit: 40, cursor });<br>    for (const item of page.items) if (!known.has(item.conversationId)) { pending.add(item.conversationId); added++; }<br>    cursor = page.hasMore ? page.cursor : null;<br>  } while (cursor &amp;&amp; ++pages &lt; 500);<br>  walks++;<br>  // Stop once another full walk stops paying for itself.<br>  if (walks &gt;= 2 &amp;&amp; added &lt; Math.max(10, Math.ceil(0.01 * librarySize))) break;<br>}<br>// Then: fetch `pending` 10 ids at a time and save through the normal sync pipeline.</pre><p>Three choices in that loop did most of the work:</p><ul><li>Discovery is proportional to search pages, never to chats. On a 10,500-chat account a walk is about 265 pages, so finding the hidden chats costs several hundred requests, not ten thousand.</li><li>The token list spans scripts. A Hebrew or Japanese history may barely contain the letter “a”, so the walk includes tokens that cover other alphabets.</li><li>The stop rule is about marginal yield. After at least two walks, it stops when the latest walk adds fewer new chats than 1% of the library or 10 chats, whichever is larger.</li></ul><p>Once fetched, recovered chats are ordinary cached chats. The sync’s prune step already refuses to delete chats just because a later, truncated listing doesn’t mention them, so the import runs once per device and never has to repeat.</p><h3>Rate Limits Were the Real Design Constraint</h3><p>The hardest requirement had nothing to do with search. A user must never get rate-limited by ChatGPT because of us. A one-time recovery that locks someone out of their own chats for an hour would be worse than the missing history.</p><p>So every Deep Import request goes through the same cross-tab request queue as the rest of the extension, which allows about 1.2 requests a second, and Deep Import adds its own 900 millisecond gap on top. That puts a long import at roughly half the rate a normal sync is allowed, and the user’s own chatting still goes first. A 429 pauses the whole queue and honours Retry-After. If a request stays rate-limited through every retry, the run parks itself as resumable instead of continuing to push.</p><p>The price of being polite is time. On a 10,500-chat account the scan and fetch take roughly 30 to 45 minutes of background work. Progress is saved to chrome.storage.local as it goes, so closing the tab pauses the run rather than losing it, and a heartbeat stops two open tabs from running it twice.</p><h3>Detect for Free, Recover on Premium</h3><p>Deep Import is part of AI Toolbox’s ChatGPT module, and the split between free and paid follows the cost of each step. Detection is free: after a full sync, at most once a week, the extension spends one search request, diffs that page against its cache, and shows an Older chats found card if 3 or more chats are missing from the list. Running the recovery itself is Premium.</p><p>You can also start it by hand. In AI Toolbox Settings, the last section, Data &amp; Storage, has a row called Missing very old chats? (large accounts) with a Scan and recover button.</p><figure><img alt="AI Toolbox Settings row Missing very old chats? (large accounts) with its Scan and recover button highlighted" src="https://cdn-images-1.medium.com/max/1024/1*OMGDdGgyoavvHpkMzoXltg.png" /><figcaption>The row is phrased as the symptom a user notices, not as the feature’s name, because nobody searches for “Deep Import”. Credit: Screenshot by author, from the AI Toolbox tutorials</figcaption></figure><p>Before a fresh scan starts, a dialog says what will happen and how long it can take, because a silent 45-minute job is exactly the kind of wait people assume is broken.</p><figure><img alt="AI Toolbox dialog Scan your full history? with a 30 to 45 minute note and a Start scan button" src="https://cdn-images-1.medium.com/max/940/1*cHSW48BoSzpsCC3EtqzrVQ.png" /><figcaption>Setting the expectation before the wait matters more on a 45-minute job than any progress bar during it. Credit: Screenshot by author, from the AI Toolbox tutorials</figcaption></figure><p>When it finishes, the button reports how many chats it recovered, or “No hidden chats found” if the account was never capped. To install it, <a href="https://chromewebstore.google.com/detail/ai-toolbox-folders-prompt/jlalnhjkfiogoeonamcnngdndjbneina?utm_source=medium&amp;utm_medium=article&amp;utm_campaign=install&amp;utm_content=medium45_recover">AI Toolbox is free on the Chrome Web Store</a>. Premium for the ChatGPT module is $9.99 a month or $99 lifetime, and All Access for all four modules is $199 lifetime. Premium unlocks the extension’s features. It does not include ChatGPT itself.</p><figure><img alt="Terminal window printing a table of three routes to a hidden ChatGPT chat. ChatGPT’s own search: finds hidden chats, matching chats one query at a time, included. AI Toolbox Search History: not on its own, searches every message it has indexed from the list, free for the first 5 results per search. AI Toolbox Deep Import: finds hidden chats and adds them to search, folders and export, Premium at $9.99 a month or $99 lifetime. Comments note detection is free when 3 or more chats are missing" src="https://cdn-images-1.medium.com/max/1024/1*0vh9HhkmXxSYO2tl_TEj-w.png" /><figcaption>ChatGPT’s own search already reaches hidden chats for free. Deep Import is for getting all of them back into one searchable, exportable index. Credit: Table by author, from our July 2026 measurements and the AI Toolbox extension, version 5.0.54</figcaption></figure><p>On the 10,500-chat account that started all this, Deep Import has since run to completion. The cap was real, it was stable, and search reached past it.</p><h3>Four Rules for Paginating an API You Don’t Own</h3><p>These came out of this bug, but none of them are specific to ChatGPT:</p><ol><li>“End of list” is a claim, not a proof. If completeness matters, compare the list against a second, independent index, even a sampled one. One search page a week was enough for us to detect the cap.</li><li>Look at which items you got, not just how many. The scatter of the 775 told us more than the count did. A cap that returns the newest items calls for a different fix than one that returns a random-looking slice.</li><li>Make discovery cost scale with pages, not items. Walking a search cursor and diffing ids kept discovery on a 10,500-chat account to several hundred requests.</li><li>Treat politeness as a hard requirement. Running below the host’s rate, yielding to the user and parking on persistent 429s cost us half an hour of wall-clock time and saved us the one failure users would never forgive.</li></ol><h3>Key Takeaways</h3><ul><li>On a very large account measured in July 2026, ChatGPT’s /backend-api/conversations listing stopped at exactly 775 of about 10,500 chats and ended like a normal, complete list.</li><li>The listing’s total field tracks pagination, not library size, so it cannot reveal a cap.</li><li>ChatGPT’s own search index covered the full history, so searching for a remembered word is the first thing to try when old chats seem gone.</li><li>AI Toolbox Deep Import walks that search index to recover hidden chats into search, folders and export. Detection is free and the recovery is Premium.</li><li>The design constraint that shaped everything was never rate-limiting the user.</li></ul><h3>FAQ</h3><p>Why are my old ChatGPT chats missing from the sidebar? On very large accounts, ChatGPT’s chat listing can stop early while looking complete. In July 2026 we measured one 10,500-chat account whose listing ended at exactly 775 chats, spread across its history. The other chats still existed, opened by URL and were found by ChatGPT’s own search.</p><p>How do I find an old ChatGPT chat that is not in my history list? Open Search from the ChatGPT sidebar and type a word you remember from the chat. OpenAI’s help center says an older chat missing from the sidebar has not necessarily been deleted, and search can find and reopen it. To bring all hidden chats back into one searchable index, AI Toolbox Deep Import, a Premium feature, recovers them.</p><p>What is AI Toolbox Deep Import? Deep Import is a Premium feature in the AI Toolbox Chrome extension’s ChatGPT module. It walks ChatGPT’s own search index to find chats the history list no longer shows, then adds them to AI Toolbox search, folders and export. Detection is free. A recovery on a 10,500-chat account takes about 30 to 45 minutes.</p><p>Can Deep Import get my ChatGPT account rate-limited? It is built not to. Every Deep Import request shares the extension’s request queue, about 1.2 requests a second, plus an extra 900 millisecond gap, so it runs at about half the normal sync rate. It pauses on rate-limit responses, honours Retry-After, and parks itself as resumable if limits persist.</p><h3>Resources</h3><ul><li><a href="https://www.ai-toolbox.co/tutorials/chatgpt/deep-import">How to recover very old ChatGPT chats that are missing, AI Toolbox tutorial</a></li><li><a href="https://help.openai.com/en/articles/10056348-finding-your-chats-projects-and-files-in-chatgpt">Finding your chats, projects, and files in ChatGPT, OpenAI Help Center</a></li><li><a href="https://developer.chrome.com/docs/extensions/reference/api/storage">chrome.storage API, Chrome for Developers</a></li></ul><p>If your ChatGPT history is large enough to have hit this, <a href="https://chromewebstore.google.com/detail/ai-toolbox-folders-prompt/jlalnhjkfiogoeonamcnngdndjbneina?utm_source=medium&amp;utm_medium=article&amp;utm_campaign=install&amp;utm_content=medium45_close">try AI Toolbox free on the Chrome Web Store</a>. The free plan detects hidden chats and searches every message it has indexed, and Premium runs the recovery for $9.99 a month or $99 lifetime.</p><p>Have you hit a paginated API that ended early without saying so? I’d like to know how you caught it.</p><p>About the author: Adi Leviim is the co-founder of AI Toolbox (formerly ChatGPT Toolbox), a Chrome extension with four modules (ChatGPT, Gemini, Claude, Grok) used by 40,000+ people across 150+ countries to search, organize, and export their AI conversations. He writes about the reality of building AI products with 7+ years of full-stack development experience. Follow him on Medium for honest takes on SaaS, AI tools, and shipping software that people actually use.</p><p><a href="https://ai-toolbox.co/">AI Toolbox</a> | <a href="https://medium.com/@adi_leviim">Medium</a> | <a href="https://x.com/adileviim">Twitter/X</a> | <a href="https://www.linkedin.com/in/adi-leviim/">LinkedIn</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c7382bab2cbf" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/chatgpts-api-listed-775-of-10-500-chats-and-said-that-was-all-c7382bab2cbf">ChatGPT’s API Listed 775 of 10,500 Chats and Said That Was All</a> was originally published in <a href="https://levelup.gitconnected.com/">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Stop Exporting Your Whole ChatGPT Account to Save One Project]]></title>
            <link>https://medium.com/@adi_leviim/stop-exporting-your-whole-chatgpt-account-to-save-one-project-70965861d82b?source=rss-a8963cd80b5c------2</link>
            <guid isPermaLink="false">https://medium.com/p/70965861d82b</guid>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[chrome-extension]]></category>
            <category><![CDATA[ai-tools]]></category>
            <category><![CDATA[productivity]]></category>
            <category><![CDATA[chatgpt]]></category>
            <dc:creator><![CDATA[Adi Leviim]]></dc:creator>
            <pubDate>Wed, 14 Oct 2026 08:06:48 GMT</pubDate>
            <atom:updated>2026-10-14T08:06:48.713Z</atom:updated>
            <content:encoded><![CDATA[<p><em>OpenAI’s export is all or nothing and can take seven days. One project can leave as a folder of separate transcripts.</em></p><figure><img alt="Isometric block of white cubes with a small orange chunk lifted out of the top, beside the words You only needed this part" src="https://cdn-images-1.medium.com/max/1024/1*eSfoD6S3PZaT0B_Jwi1-ug.png" /><figcaption>ChatGPT will export your whole account. Most people asking for an export want one project out of it. Credit: Illustration by author</figcaption></figure><p>Someone typed this sentence into Google, word for word:</p><blockquote><em>“i’m looking for a tool that can connect to my chatgpt account and create individual transcripts for all the chat threads in a designated project.”</em></blockquote><p>Google’s AI Overview answered by naming one tool. Within ten minutes, that person had subscribed, installed it and exported 65 threads from a single ChatGPT Project.</p><p>I know this because the tool is AI Toolbox, a Chrome extension I co-founded, and the buyer described the search to us afterwards. Read everything below with that bias in mind. I have tried to make the native route and the limits as clear as the product, because the request itself is one every heavy ChatGPT user eventually makes.</p><p>A ChatGPT Project is where the chats for one client, one course or one book end up living together. When that work is finished, handed over or about to be deleted, the project is the unit you want to keep. ChatGPT has no button for it.</p><h3>ChatGPT Can Export Your Account, Not a Project</h3><p>ChatGPT has no export option for a single Project. Its only self-service export covers the whole account: Settings, Data controls, Export, then Confirm export. OpenAI’s help article “Exporting your ChatGPT history and data” says “Exports can take up to 7 days to arrive” and “The download link expires 24 hours after you receive it.” It also says self-service export is not available in Business, Enterprise or ChatGPT for Healthcare workspaces.</p><p>Inside a project, OpenAI offers two related actions, and neither is an export. You can share a single chat, and “People with the link can view only the shared chat, not the project’s other chats, files, instructions, or history.” And you can delete the project, which OpenAI describes plainly: “Deleting a project permanently removes its chats and instructions, along with files stored only in the project.” The same paragraph ends with “This action cannot be undone.”</p><p>So the native path to keeping one project is to request everything, wait for an email, download it within a day and dig the project’s chats out of the archive yourself. I wrote about that wait in more detail in <a href="https://medium.com/@adi_leviim/chatgpts-export-can-take-seven-days-the-link-dies-in-one-now-what-56d55796be54">ChatGPT’s Export Can Take Seven Days. The Link Dies in One. Now What?</a>, so here I will stay on the project case.</p><h3>Why One Project Is the Unit People Want to Keep</h3><p>A project is the natural unit to export because it already matches a real piece of work. The requests we see fall into four groups:</p><ul><li>A knowledge repository. The buyer above wanted one transcript per thread, so the project could live as files next to their other notes.</li><li>A handover. A consultant finishing an engagement wants to give the client the project’s chats, not their entire ChatGPT history.</li><li>Before deleting. Deleting a project is permanent, so the files are the only copy that survives.</li><li>Shared projects. OpenAI’s help page says members “can archive or delete chats they own,” and when they do, other members “can no longer see it.” A file on your own disk does not depend on anyone else’s cleanup.</li></ul><p>OpenAI does cover one of these cases natively. When you leave a shared project, you get the option to copy your chats into a new project named “Copy” plus the original name. That keeps the chats inside ChatGPT, which is useful, but it is not a file you can open anywhere else.</p><h3>How to Export One ChatGPT Project as Separate Files</h3><p>AI Toolbox adds an Export project item to the menu ChatGPT already shows for every project. Clicking it downloads one ZIP file that holds one file per chat, in Text, Markdown, JSON or PDF. It needs a Chromium browser, such as Chrome, Edge, Brave or Arc, and it runs on chatgpt.com.</p><ol><li>Install AI Toolbox from the <a href="https://chromewebstore.google.com/detail/ai-toolbox-folders-prompt/jlalnhjkfiogoeonamcnngdndjbneina?utm_source=medium&amp;utm_medium=article&amp;utm_campaign=install&amp;utm_content=medium44_step1">Chrome Web Store</a>, then open chatgpt.com.</li><li>Open the project’s menu. In the left sidebar, move your mouse over the project and click its three dots. The three dots at the top of the project’s own page open the same menu.</li><li>Click Export project. It is the first item, above Share project.</li></ol><figure><img alt="ChatGPT project menu with Export project highlighted as the first item and the label Export project: added by AI Toolbox" src="https://cdn-images-1.medium.com/max/1024/1*-4t7--MzG5vHLDy67hFO3g.png" /><figcaption>The export lives in ChatGPT’s own project menu, so there is no new screen to learn. Everything below it is ChatGPT’s. Credit: Screenshot by author, from the AI Toolbox tutorials</figcaption></figure><ol><li>Pick a format in the Select Export Format window and click Export.</li><li>Watch the count. A progress window counts each chat as it is written. If you stop it early, the archive keeps every chat that finished.</li></ol><figure><img alt="AI Toolbox export progress card with a green check mark, Export complete, a count of 4 of 4 and a full progress bar" src="https://cdn-images-1.medium.com/max/912/1*dEq-K14EqOPYcNAuUn5ZpA.png" /><figcaption>The count is per chat, so a 65 thread project shows exactly how far along it is. Credit: Screenshot by author, from the AI Toolbox tutorials</figcaption></figure><ol><li>Open the ZIP. The file is named bulk_conversations_ plus the date. Inside is a folder named after the project, with one file per chat named after the chat&#39;s title.</li></ol><p>Three behaviours are worth knowing before you rely on it. Very large exports ask where to save and write straight to disk, so a big project does not have to fit in memory. If ChatGPT stops returning the project’s chat list partway through, the export stops with an error instead of quietly handing you part of the project. And when two chats share a title, the second file gets a short ID added so neither overwrites the other.</p><h3>What Is Inside Each File</h3><p>Every exported chat carries its title, a link back to the chat on chatgpt.com, its dates and every message in order with who wrote it and when. The four formats package that differently:</p><figure><img alt="Four tiles styled like periodic table elements on a dark ground. 1 Tx Text, .txt, plain transcript with title, link and dates, free for one chat. 2 Md Markdown, .md, notes apps, with a dates and link header, Premium. 3 Js JSON, .json, scripts, search indexes and other tools, Premium. 4 Pd PDF, .pdf, sharing and printing on A4 pages, Premium. Footer: exporting a whole project with more than one chat is Premium in every format" src="https://cdn-images-1.medium.com/max/1024/1*WAIwBq2vmF2klxremDOWRw.png" /><figcaption>Markdown is the one to pick for a notes repository, because each file opens with a header your notes app can sort and filter on. Credit: Illustration by author, formats and plans from the AI Toolbox extension source, version 5.0.54</figcaption></figure><p>The Markdown header is the part that makes a folder of transcripts behave like a database. Each file starts like this, with a model line added when ChatGPT reports one:</p><pre>---<br>title: &quot;Q3 launch plan&quot;<br>date: 2026-08-03<br>created: 2026-08-03T09:12:44.000Z<br>updated: 2026-09-18T16:40:02.000Z<br>exported: 2026-10-14T08:30:11.000Z<br>url: https://chatgpt.com/c/&lt;chat id&gt;<br>source: AI Toolbox<br>---</pre><p>Obsidian and most other Markdown tools read that block as properties, so you can sort a project’s chats by when they were last updated. The JSON format carries the same created_at, updated_at and exported_at fields for scripts.</p><h3>What Is Free and What Is Premium</h3><p>Exporting a whole project with more than one chat is a Premium feature of AI Toolbox. On the free plan you can export any single chat as Text, and a project that holds exactly one chat exports the same way. Markdown, JSON and PDF are Premium for every export.</p><figure><img alt="Comparison table titled Getting one project out of ChatGPT. ChatGPT’s own export: whole account, download link by email or SMS, up to 7 days, link expires 24 hours after you receive it, a ZIP of account data, included with ChatGPT. AI Toolbox Free: one chat or a one-chat project, a browser download, no wait, no link, Text, free. AI Toolbox Premium: a whole project, one ZIP with a file per chat, no wait, no link, Text, Markdown, JSON and PDF, $9.99 a month or $99 lifetime" src="https://cdn-images-1.medium.com/max/1024/1*eEvyW1w3u92ZAN2zkMSpgg.png" /><figcaption>The native export wins on price and covers everything. It loses on scope and on the wait, which are the two things a project export is for. Credit: Table by author, from the OpenAI Help Center and the AI Toolbox extension, version 5.0.54</figcaption></figure><p>AI Toolbox Premium for the ChatGPT module costs $9.99 a month or $99 once for lifetime access. All Access, which covers the ChatGPT, Gemini, Claude and Grok modules, is $199 lifetime. Premium is a subscription to the extension’s features. It does not include access to any AI model, and you keep paying OpenAI for ChatGPT as usual. AI Toolbox has 40,000 users and a 4.8 out of 5 rating from 996 Chrome Web Store ratings as of October 2026.</p><p>If you only need one project once, and you can wait, the native export is free and complete. It is the better choice when you want everything, including account data AI Toolbox does not touch.</p><h3>The Question I Could Not Answer With a Feature</h3><p>After the export, the buyer asked how to keep the transcripts current as the project keeps growing. They guessed they would have to remember to export each new thread by hand. The honest answer is that AI Toolbox has no scheduled or incremental export today. I checked the extension source before writing this, because I wanted the answer to be yes.</p><p>What works instead is re-exporting the whole project and extracting the new ZIP over the old folder. Because each file is named after its chat’s title, a chat that grew since last time replaces its older file, and the updated line in the Markdown header tells you which ones changed. It has three rough edges:</p><ul><li>A renamed chat arrives as a new file. The file with the old name stays behind until you delete it.</li><li>A deleted chat’s file stays. For an archive that is usually what you want, but the folder stops being an exact mirror of the project.</li><li>Same-titled chats can swap names. Which of two identically titled chats gets the ID suffix depends on the order of that export, so check those two by their url line.</li></ul><p>That is a workable routine for a weekly or monthly refresh. It is not the same as a folder that updates itself, and I would rather say so here than have someone find out after paying.</p><h3>What the AI Overview Got Right</h3><p>The answer that sent us this customer ended with: “exporting a full grouped Project into a compressed ZIP file is a feature restricted to their Premium plan ($9.99/month or lifetime options).” It told the searcher what was paid before they ever reached our site.</p><p>That is one buyer, so I would not build a theory on it. But it matches what we see elsewhere: when a page states plainly what is free and what is paid, an AI summary repeats it accurately, and the buyer arrives ready. When a page is vague, the summary fills the gap with a guess, and the guess becomes a refund request. Writing the gating down clearly turned out to be a sales decision, not a legal one.</p><h3>Key Takeaways</h3><ul><li>ChatGPT has no export for a single Project. Its self-service export covers the whole account, can take up to 7 days, and the link expires 24 hours after you receive it.</li><li>Deleting a ChatGPT Project is permanent, so export its chats first if you might need them.</li><li>AI Toolbox adds Export project to ChatGPT’s own project menu and saves one file per chat in a ZIP, in Text, Markdown, JSON or PDF.</li><li>Whole-project export is Premium. One chat, or a project holding one chat, exports free as Text.</li><li>There is no scheduled export. Re-exporting and extracting over the old folder keeps a repository current, with three rough edges.</li></ul><h3>FAQ</h3><p>Can you export a single ChatGPT Project? Not natively. ChatGPT’s self-service export covers the whole account and can take up to 7 days to arrive. The AI Toolbox Chrome extension adds an Export project item to each project’s menu that downloads one ZIP with a file per chat, in Text, Markdown, JSON or PDF. Exporting a project with more than one chat is a Premium feature.</p><p>What happens to the chats when you delete a ChatGPT Project? OpenAI’s help center says deleting a project permanently removes its chats and instructions, along with files stored only in the project, and that the action cannot be undone. Files saved separately in Library are not deleted. Export the project’s chats before deleting it if you might need them later.</p><p>Is exporting a ChatGPT Project free with AI Toolbox? Partly. AI Toolbox’s free plan exports any single ChatGPT chat as Text, and a project that holds exactly one chat exports the same way. Exporting a whole project with more chats, or using Markdown, JSON or PDF, needs Premium at $9.99 a month or $99 lifetime for the ChatGPT module.</p><p>Can AI Toolbox keep a ChatGPT Project export up to date automatically? No. AI Toolbox has no scheduled or incremental export as of version 5.0.54. To refresh a repository, export the project again and extract the ZIP over the old folder. Files are named after chat titles, so updated chats replace older copies, while renamed chats arrive as new files.</p><h3>Resources</h3><ul><li><a href="https://help.openai.com/en/articles/7260999-how-do-i-export-my-chatgpt-history-and-data">Exporting your ChatGPT history and data, OpenAI Help Center</a></li><li><a href="https://help.openai.com/en/articles/10169521-projects-in-chatgpt">Projects in ChatGPT, OpenAI Help Center</a></li><li><a href="https://www.ai-toolbox.co/ai-toolbox-chatgpt-features/how-to-export-chatgpt-projects-2026">How to export a ChatGPT Project, the full guide on ai-toolbox.co</a></li></ul><p>If you keep work in ChatGPT Projects, <a href="https://chromewebstore.google.com/detail/ai-toolbox-folders-prompt/jlalnhjkfiogoeonamcnngdndjbneina?utm_source=medium&amp;utm_medium=article&amp;utm_campaign=install&amp;utm_content=medium44_close">try AI Toolbox free on the Chrome Web Store</a>. The free plan includes folders, search and single-chat Text export, and Premium unlocks whole-project export for $9.99 a month or $99 lifetime.</p><p>I am still deciding whether a scheduled export is worth building. If you keep a project as files, would you want it refreshed on a schedule, or is exporting on demand enough? Tell me in the comments.</p><p>About the author: Adi Leviim is the co-founder of AI Toolbox (formerly ChatGPT Toolbox), a Chrome extension with four modules (ChatGPT, Gemini, Claude, Grok) used by 40,000+ people across 150+ countries to search, organize, and export their AI conversations. He writes about the reality of building AI products with 7+ years of full-stack development experience. Follow him on Medium for honest takes on SaaS, AI tools, and shipping software that people actually use.</p><p><a href="https://ai-toolbox.co/">AI Toolbox</a> | <a href="https://medium.com/@adi_leviim">Medium</a> | <a href="https://x.com/adileviim">Twitter/X</a> | <a href="https://www.linkedin.com/in/adi-leviim/">LinkedIn</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=70965861d82b" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[ChatGPT’s Export Can Take Seven Days. The Link Dies in One. Now What?]]></title>
            <link>https://medium.com/@adi_leviim/chatgpts-export-can-take-seven-days-the-link-dies-in-one-now-what-56d55796be54?source=rss-a8963cd80b5c------2</link>
            <guid isPermaLink="false">https://medium.com/p/56d55796be54</guid>
            <category><![CDATA[chatgpt]]></category>
            <category><![CDATA[productivity]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[chrome-extension]]></category>
            <category><![CDATA[ai-tools]]></category>
            <dc:creator><![CDATA[Adi Leviim]]></dc:creator>
            <pubDate>Tue, 13 Oct 2026 08:55:58 GMT</pubDate>
            <atom:updated>2026-10-13T08:55:58.075Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Both numbers come from OpenAI’s own help centre. Here is the faster path, and when the official export is still right.</em></p><figure><img alt="Illustration on a mustard background of a dark deli-counter display unit. The black LED panel reads NOW SERVING in small amber caps, a very large glowing 7, and DAYS underneath. A torn paper ticket from a roll below reads COLLECT WITHIN 24 HOURS. Large type on the right reads Your chat, with history in white below it." src="https://cdn-images-1.medium.com/max/1024/1*iIHLBtSfPba6NzvlYrjnXg.png" /><figcaption>Both numbers are published by OpenAI. Neither is a complaint from a user forum. Credit: Illustration by author</figcaption></figure><p>Go to ChatGPT, open Settings, Data controls, Export data, and confirm. Then read what OpenAI’s help centre tells you to expect:</p><blockquote><em>“Exports can take up to 7 days to arrive.”</em></blockquote><blockquote><em>“The download link expires 24 hours after you receive it.”</em></blockquote><p>So the official way to get a copy of your own conversations is a queue you cannot see, followed by a window you can miss. If the email lands while you are away for the weekend, you start again.</p><p>There is a third line worth knowing before you plan around it:</p><blockquote><em>“An export cannot restore chats that have already been deleted.”</em></blockquote><p>And self-service export is not available at all in ChatGPT Business, Enterprise or Healthcare workspaces. Those go through the workspace owner.</p><p>I co-build a Chrome extension that exports AI conversations, so I have an obvious interest here. I will keep the claims to things you can check, and I will tell you plainly where the official export is still the better tool.</p><h3>What the Official Export Is Actually For</h3><p>It is a complete account archive. Not just conversations: the full ZIP of eligible account data, which is what you want if you are exercising a data right, leaving the platform, or need everything in one audited package. You can also request it through OpenAI’s Privacy Portal.</p><p>If that is the job, use it, and start it early because of the seven days.</p><p>What it is not good for is the ordinary case. You finished a long chat, you want that chat, as a readable file, now. For that, waiting a week for a ZIP of your entire account and then digging through JSON is the wrong shape of tool.</p><h3>The Problem Underneath the Waiting</h3><p>The seven days is the symptom. The reason people go looking for an export in the first place is usually something else, and a buyer described it to me more precisely than I would have:</p><blockquote><em>“The problem is the access to the numerous chats. For example, in ChatGPT I can have projects as the main categorization and then it cannot directly read the full chat list even within a project, the only capability is to search and that is not friendly enough.”</em></blockquote><p>That is the real complaint. Once you are past a few hundred conversations, the sidebar is not a list you can work with, search is the only instrument you have, and there is no way to select a set of chats and do one thing to all of them. ChatGPT has no multi-select. So “export” becomes the escape hatch: if I can get these out, at least I can grep them.</p><p>Which is why the official export disappoints twice. It takes a week, and when it lands it is a ZIP of your whole account rather than the fifteen conversations you actually wanted.</p><h3>The Faster Path, Step by Step</h3><p>This is the flow I use. It runs in the browser, against the conversation already on screen, so there is no queue and no email.</p><p>Step 1. Open Manage Chats. It lives behind the round AI Toolbox button at the bottom right of ChatGPT, not in the sidebar.</p><figure><img alt="Screenshot labelled Step 1: quick buttons fanned out from the round AI Toolbox button in ChatGPT, with Manage Chats highlighted." src="https://cdn-images-1.medium.com/max/756/1*ZnwmOpqxQIbiQsA9uf0FNQ.png" /><figcaption>The round button is the entry point. Nothing in ChatGPT’s own sidebar opens this. Credit: Screenshot by author</figcaption></figure><p>If you want to follow along, <a href="https://chromewebstore.google.com/detail/ai-toolbox-folders-prompt/jlalnhjkfiogoeonamcnngdndjbneina?utm_source=medium&amp;utm_medium=article&amp;utm_campaign=install&amp;utm_content=medium43_step1">install the extension from the Chrome Web Store</a> first. It is free and the first export below costs nothing.</p><p>Step 2. Tick the chats you want. Search and filters narrow the list first, which matters once you are past a few hundred conversations.</p><figure><img alt="Screenshot labelled Step 2: two chats ticked in the Manage Chats window with a Selected chats: 2 counter highlighted." src="https://cdn-images-1.medium.com/max/1024/1*CBPJICQaUGWqREVuVr-2-Q.png" /><figcaption>Selecting is the part ChatGPT itself has no equivalent for. There is no multi-select in the native sidebar. Credit: Screenshot by author</figcaption></figure><p>Step 3. Pick a format and export. Text, Markdown, JSON or PDF.</p><figure><img alt="Screenshot labelled Step 3: the Select Export Format window over Manage Chats, showing Text, Markdown, JSON and PDF options with Markdown selected and the Export button highlighted." src="https://cdn-images-1.medium.com/max/1024/1*t2lVy_5U1IfaCc95Xb7wjQ.png" /><figcaption>Markdown for notes, JSON if you are going to parse it, PDF to send to someone who will never open a .md file. Credit: Screenshot by author</figcaption></figure><p>Step 4. You get files, not a queue. One file per chat, and a ChatGPT project comes out as its own folder.</p><figure><img alt="Screenshot: an unzipped export open in Finder showing one Markdown file per conversation, with a ChatGPT project appearing as its own folder, highlighted." src="https://cdn-images-1.medium.com/max/1024/1*SHnw4AiWA5OYxZmnxj9OzA.png" /><figcaption>This is the step the official export defers for up to a week. Credit: Screenshot by author</figcaption></figure><h3>What This Costs, Exactly</h3><p>I would rather you read the real split than find it at the moment you click.</p><p>Free: one chat, as TXT. On every platform, from the export dialog, as a plain .txt file rather than a ZIP. That is the whole free export, and it is permanent, not a trial.</p><p>Premium: anything beyond that. A second chat in the same action, or any other format, so Markdown, JSON and PDF are paid, and so is exporting many chats at once.</p><p>It is $9.99 a month or $99 one time for a single platform, and $199 one time for all four. These are extension features, not an AI subscription, and they do not change anything about your ChatGPT plan.</p><p>If all you need is this one conversation as a text file, take the free one and do not pay me anything.</p><h3>The Part That Decided It for Most People Who Tell Me</h3><p>The same flow runs on Claude, Gemini and Grok.</p><p>A buyer put the reason better than my own marketing page does. They searched for how to create folders in ChatGPT, got two results, and told me afterwards: “I chose yours because I want it to work across Claude, Gemini, ChatGPT, etc.”</p><p>That is the honest differentiator. Single-platform tools are fine until you are the sort of person who has a research thread in Claude, a draft in ChatGPT and a search in Gemini, and wants one archive at the end of the week.</p><p>AI Toolbox is a Chrome extension used by 40,000+ people across 150+ countries to search, organize and export conversations in ChatGPT, Claude, Gemini and Grok, rated 4.7 out of 5 across 900+ Chrome Web Store ratings, with a permanently free plan. Cross-device sync of your folders and prompts is free on every plan.</p><p><a href="https://chromewebstore.google.com/detail/ai-toolbox-folders-prompt/jlalnhjkfiogoeonamcnngdndjbneina?utm_source=medium&amp;utm_medium=article&amp;utm_campaign=install&amp;utm_content=medium43_crossplatform">Install it free on the Chrome Web Store</a>, or read the <a href="https://www.ai-toolbox.co/ai-toolbox-chatgpt-features/how-to-export-chatgpt-conversations">full export guide</a> with every format compared.</p><h3>When to Still Use OpenAI’s Export</h3><p>Three cases, and I would use it in all of them.</p><p>You want everything, including account data that is not conversations. The official ZIP is the only complete copy.</p><p>You are making a formal data request. Use the Privacy Portal, keep the record.</p><p>You are on a Business, Enterprise or Healthcare workspace, where self-service export is not available to you and the workspace owner has to run it anyway.</p><p>In every one of those, start it a week before you need it, and watch for the email, because of the 24 hours.</p><h3>Key Takeaways</h3><ul><li>OpenAI publishes both numbers: exports can take up to seven days to arrive, and the download link expires 24 hours after you get it.</li><li>The official export cannot restore deleted chats, and is not self-service on Business, Enterprise or Healthcare workspaces.</li><li>For a single conversation or a selected batch, an in-browser export returns files immediately, in TXT, Markdown, JSON or PDF.</li><li>One chat as TXT is free on every platform. More than one chat, or any other format, is Premium.</li><li>If you use more than one assistant, pick a tool that covers all of them, because the archive you actually want at the end of the month is not one platform’s.</li></ul><h3>FAQ</h3><p>How long does a ChatGPT data export take? Up to seven days, per OpenAI’s help centre. The email can arrive sooner, but seven days is the published ceiling, and the download link then expires 24 hours after you receive it.</p><p>Can I export just one ChatGPT conversation? Yes. Open the chat’s three dot menu and export it. As a TXT file this is free on the free plan; Markdown, JSON and PDF are Premium.</p><p>Does the export include deleted chats? No. OpenAI states plainly that an export cannot restore chats that have already been deleted. Export before you delete, not after.</p><p>Does any of this work on Claude, Gemini or Grok? Yes, the same export flow runs on all four, which is the reason most people who write to me say they chose it.</p><p>About the author: Adi Leviim is the co-founder of AI Toolbox (formerly ChatGPT Toolbox), a Chrome extension with four modules (ChatGPT, Gemini, Claude, Grok) used by 40,000+ people across 150+ countries to search, organize, and export their AI conversations. He writes about the reality of building AI products with 7+ years of full-stack development experience. Follow him on Medium for honest takes on SaaS, AI tools, and shipping software that people actually use.</p><p><a href="https://chromewebstore.google.com/detail/ai-toolbox-folders-prompt/jlalnhjkfiogoeonamcnngdndjbneina?utm_source=medium&amp;utm_medium=article&amp;utm_campaign=install&amp;utm_content=medium43_close">Try AI Toolbox free</a> | <a href="https://ai-toolbox.co/">AI Toolbox</a> | <a href="https://medium.com/@adi_leviim">Medium</a> | <a href="https://www.linkedin.com/in/adi-leviim/">LinkedIn</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=56d55796be54" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Custom AI Assistant You Built Is a Rental]]></title>
            <link>https://medium.com/@adi_leviim/the-custom-ai-assistant-you-built-is-a-rental-b5d09369a174?source=rss-a8963cd80b5c------2</link>
            <guid isPermaLink="false">https://medium.com/p/b5d09369a174</guid>
            <category><![CDATA[productivity]]></category>
            <category><![CDATA[ai-tools]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[chatgpt]]></category>
            <category><![CDATA[google-gemini]]></category>
            <dc:creator><![CDATA[Adi Leviim]]></dc:creator>
            <pubDate>Thu, 08 Oct 2026 08:46:28 GMT</pubDate>
            <atom:updated>2026-10-08T08:46:28.214Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Google is retiring Gems and OpenAI is retiring custom GPTs. Both migrations keep your words and drop the wiring.</em></p><figure><img alt="A cream library due-date card, tilted slightly, on a deep raspberry background. The card has a black header band reading DATE DUE, column headings ITEM and RETURN BY, then two filled rows: Gems, Nov 2026, and Custom GPTs, Dec 11 2026. The remaining ruled lines are blank. Large white type to the left reads Your custom, with the word assistant in yellow below it." src="https://cdn-images-1.medium.com/max/1024/1*Dt2h1gJZmibB2fZBpxd1zw.png" /><figcaption>Two vendors, two end dates, both inside the next ten weeks for personal accounts. Credit: Illustration by author</figcaption></figure><p>Three weeks ago I wrote about OpenAI retiring custom GPTs, because we build features that reference them and I wanted to know what would break. I assumed that was a story about one company.</p><p>Then on September 30, Google announced skills and said Gems are going away too.</p><p>Two of the three largest assistant vendors have now deprecated the same category of feature, the thing that lets you save a set of instructions as a named assistant, within three months of each other. I co-build a browser extension that syncs and searches conversations across ChatGPT, Gemini, Claude and Grok, so I had to go and read both migration notices properly. Here is what they actually say, where they differ, and the one thing worth doing about it this month.</p><h3>Two Retirements, Eight Months</h3><figure><img alt="Illustration of a yellow steel tape measure on a pale mint background, headed Two retirements eight months. Green marks Google, rust marks OpenAI. Five flags along the blade: Nov 2026, Gems go away on personal Google accounts. Dec 11 2026, custom GPTs retire. Feb 11 2027, custom GPTs retire for Enterprise with an approved deferral. Mar 1 2027, no sooner than this, Gems end for business and enterprise. Jun 1 2027, no sooner than this, Gems end for education." src="https://cdn-images-1.medium.com/max/1024/1*kXiMOA5VJ04Cgl7DO1ZiSA.png" /><figcaption>The personal-account deadlines are the close ones. Everything Workspace-shaped sits in 2027. Credit: Diagram by author, dates from Google Workspace Updates and the OpenAI Help Center</figcaption></figure><p>Google’s help page is blunt about the consumer side: “Starting in November 2026, Gems will go away on personal Google accounts and automatically transition to skills.”</p><p>The Workspace schedule is longer and comes with a hedge. Google’s own key dates put skills rolling out from October 5 in Workspace and October 13 in the Gemini app, Gems moving into the Settings panel on November 17, then “no sooner than March 1, 2027” for business and enterprise and “no sooner than June 1, 2027” for education.</p><p>OpenAI’s page is similarly specific: “Custom GPTs are scheduled to retire on Dec 11, 2026. For affected Enterprise workspaces with an approved deferral, the retirement date is Feb 11, 2027.”</p><p>If you are on a personal plan at either vendor, your deadline is this quarter.</p><h3>What OpenAI’s Migration Actually Moves</h3><p>OpenAI’s path is manual. You go to My GPTs and select Migrate to plugin, per GPT.</p><p>Three details in that FAQ are worth reading twice.</p><p>“Migration uses the GPT’s latest published version. Drafts and unpublished edits do not transfer.” If your best version of a GPT is an unpublished draft, it is not coming with you.</p><p>“Allow extra time for GPTs that use custom actions. Those integrations do not transfer automatically.” Custom actions are the part you wrote code for.</p><p>“After a GPT is migrated, the original remains usable until retirement but becomes read-only.” So migration is one-way in practice, and the clock on the original keeps running.</p><h3>Google’s Is Automatic, Which Does Less Than It Sounds</h3><p>Google’s path looks friendlier. Gems “automatically transition to skills”, and the Workspace post confirms it will “auto-migrate any Gems remaining in the Gemini app to draft skills in the Gemini app.”</p><p>Read that noun. Draft skills.</p><p>And the sentence after it is the one that matters if you work in Workspace: “Because skills in the Gemini app won’t automatically appear in Workspace, you’ll also need to recreate them in Workspace if you want to use them in Workspace apps.”</p><p>So the automatic migration produces a draft, in one surface, and does not cross into the surface where a lot of organisational use actually happens. Automatic is doing less work in that sentence than it appears to.</p><figure><img alt="Illustration on dark slate headed What survives the migration. Two conveyor belts pass through yellow gates. The OpenAI belt carries Instructions, Reference files and Connected apps through, while a red box reading Custom actions falls into a dashed bin labelled Does not transfer. The Google belt carries Instructions and Knowledge files through plus a box reading Arrives as a draft skill, while a red box reading Its place in Workspace falls into a bin labelled Recreate it there." src="https://cdn-images-1.medium.com/max/1024/1*NI6U8eNQRCjoF4dvc2n9kg.png" /><figcaption>Both vendors preserve the prose you wrote. Both drop some part of the machinery you attached to it. Credit: Diagram by author, from each vendor’s migration documentation</figcaption></figure><h3>Why Both Landed on the Same Replacement</h3><p>It is worth noticing that the two replacements are described almost identically.</p><p>Google calls skills “reusable prompts that guide Gemini.” OpenAI says plugins “can bring reusable instructions, reference files, and connected apps together, so your guidance can work alongside the tools and information a task needs.”</p><p>Strip the branding and both sentences describe the same thing: a block of reusable instructions that can be attached to whatever tools a task happens to need, rather than a standalone assistant you open on purpose.</p><p>That is a real architectural shift, and in fairness it is probably the right one. A custom GPT and a Gem were both destinations. You went to them. Skills and plugins are modifiers: they apply inside the conversation you were already having. Once an assistant can call tools, search the web and read your files on its own, a separate named bot for each job starts to look like a workaround for a capability the base model did not have yet.</p><p>The cost of that shift is borne by whoever built on the old shape. Which is the part the migration notices are about.</p><h3>The Pattern Is the Point</h3><p>Put the two migrations side by side and the common denominator is obvious.</p><p>Your instructions survive both. The text you wrote, the system prompt, the thing you actually spent an afternoon getting right, carries across in both cases.</p><p>Everything around it is negotiable. Custom actions do not transfer at OpenAI. Workspace placement does not transfer at Google. Unpublished edits do not transfer at OpenAI. Live status does not transfer at Google, because what lands is a draft.</p><p>That is not a criticism of either company. Migrating a container between two different architectures while preserving every integration is genuinely hard, and both of them published dates and details well in advance, which is more than a lot of deprecations get.</p><p>It is, however, a clear statement about what you own. You own the prose. The container holding it belongs to someone else, and it has a return-by date you did not choose.</p><h3>What I Changed on Our Side</h3><p>I will declare the interest plainly: I co-build AI Toolbox, a Chrome extension that syncs and searches conversations across four assistants, so this is not a neutral observation for me.</p><p>Here is the part of our code that made the Gems announcement land as a problem rather than a headline. Gemini keeps each Gem’s conversations in a separate bucket that the main conversation list never returns. To make Gem chats searchable at all, our sync has to enumerate the user’s Gems through Gemini’s own undocumented list call, then page each Gem’s history scoped to its own path.</p><p>We wrote that enumeration to fail quietly. If the call errors, or the response shape changes, it returns an empty list and Gem sync simply does nothing, while the main conversation sync carries on untouched. At the time that was ordinary defensiveness about an undocumented endpoint. In hindsight it is the only reason a platform-side rename does not take the whole sync down with it.</p><p>That is the transferable lesson for anyone building on top of someone else’s container: assume the container is temporary, and make every integration with it degrade to a no-op rather than an exception.</p><p>What we do not know yet is what the skills surface looks like from the outside: whether skill conversations get their own bucket the way Gem conversations do, whether there is an equivalent list call, or whether skill chats simply land in the main history and the special handling stops being necessary. I would rather the last one were true. Until the rollout finishes in mid-November there is no way to find out except by watching what the client actually requests, so that is what we are doing, and we are not shipping a guess in the meantime.</p><h3>What to Do This Month</h3><p>Four things, and the first one is the only one that really matters.</p><p>Save the instructions. Open every Gem and every custom GPT you rely on, copy the Instructions field, and put the text somewhere you control. A notes app, a repository, a prompt library, a text file. This costs ten minutes and it is the only step that makes the rest of the deadlines uninteresting.</p><figure><img alt="Screenshot of the AI Toolbox Prompt Library. A searchable grid of saved prompts with category chips and dates, a Prompt of the Day panel at the top, and a detail pane on the right showing a full prompt with Use Prompt and Save Prompt buttons." src="https://cdn-images-1.medium.com/max/1024/1*dBaekHPQFB9cPR4b5YM7eg.png" /><figcaption>Anywhere you control is fine. The point is that the text outlives whatever container it was pasted into. Credit: Screenshot by author</figcaption></figure><p>Note what will not travel. If a GPT uses custom actions, write down what they called and how. If you use Gems inside Workspace, plan to rebuild them there.</p><p>Publish before you migrate, at OpenAI. Drafts do not transfer, so finish the edits first.</p><p>Check the drafts afterwards, at Google. An auto-migrated skill arrives as a draft, which means somebody has to look at it.</p><h3>Key Takeaways</h3><ul><li>Google is replacing Gems with skills and OpenAI is replacing custom GPTs with plugins. On personal accounts the dates are November 2026 and December 11, 2026.</li><li>Workspace and Enterprise timelines run into 2027, with Google hedging as “no sooner than” March 1 for business and June 1 for education, and OpenAI offering February 11, 2027 only to approved Enterprise deferrals.</li><li>OpenAI’s migration is manual and uses the latest published version. Drafts, unpublished edits and custom actions do not come with it.</li><li>Google’s migration is automatic but produces draft skills, and it does not carry into Workspace, where you have to recreate them.</li><li>The instructions text survives both. Keep a copy of it outside the vendor, because that is the only part you actually own.</li></ul><h3>FAQ</h3><p>Are Gemini Gems going away? Yes. Google is replacing Gems with skills, which rolled out from September 30, 2026. Gems go away on personal Google accounts starting November 2026, no sooner than March 1, 2027 for business and enterprise, and no sooner than June 1, 2027 for education.</p><p>Do I need to migrate my Gems manually? Not in the Gemini app, where remaining Gems are auto-migrated to draft skills. You do need to recreate them in Workspace, because skills created in the Gemini app do not automatically appear there.</p><p>What happens to a custom GPT I already migrated? The original stays usable until the retirement date but becomes read-only, so finish any essential edits before you migrate.</p><p>About the author: Adi Leviim is the co-founder of AI Toolbox (formerly ChatGPT Toolbox), a Chrome extension with four modules (ChatGPT, Gemini, Claude, Grok) used by 40,000+ people across 150+ countries to search, organize, and export their AI conversations. He writes about the reality of building AI products with 7+ years of full-stack development experience. Follow him on Medium for honest takes on SaaS, AI tools, and shipping software that people actually use.</p><p>Related: <a href="https://medium.com/@adi_leviim/i-built-features-on-top-of-custom-gpts-openai-is-retiring-them-d48cf4d32937">I Built Features on Top of Custom GPTs. OpenAI Is Retiring Them.</a></p><p>Try AI Toolbox free on the <a href="https://chromewebstore.google.com/detail/ai-toolbox-folders-prompt/jlalnhjkfiogoeonamcnngdndjbneina">Chrome Web Store</a>. The free plan includes folders, search and TXT export. Premium is $9.99 a month or $99 lifetime per module, and All Access is $199 one time for all four. These are extension features, not an AI subscription.</p><p><a href="https://ai-toolbox.co/">AI Toolbox</a> | <a href="https://medium.com/@adi_leviim">Medium</a> | <a href="https://x.com/adileviim">Twitter/X</a> | <a href="https://www.linkedin.com/in/adi-leviim/">LinkedIn</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b5d09369a174" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Four AI Apps, Zero Print Buttons, and One 100KB Cap in the Wrong Place]]></title>
            <link>https://levelup.gitconnected.com/four-ai-apps-zero-print-buttons-and-one-100kb-cap-in-the-wrong-place-54d73a1cc5ac?source=rss-a8963cd80b5c------2</link>
            <guid isPermaLink="false">https://medium.com/p/54d73a1cc5ac</guid>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[chrome-extension]]></category>
            <category><![CDATA[javascript]]></category>
            <dc:creator><![CDATA[Adi Leviim]]></dc:creator>
            <pubDate>Wed, 07 Oct 2026 15:33:31 GMT</pubDate>
            <atom:updated>2026-10-07T15:33:31.635Z</atom:updated>
            <content:encoded><![CDATA[<p><em>No assistant ships a Print option. The export that replaces it broke on an exact-match path allowlist.</em></p><figure><img alt="A white A4 sheet on a deep coral background, tilted slightly, showing browser print-preview chrome: a date and the word Conversation along the top, the URL chatgpt dot com slash c slash 68f2a1 and the page counter 1 slash 17 along the bottom. The page itself is completely blank. Large white type beside it reads Your whole chat, with the word chat in yellow, and the word PRINTED underneath." src="https://cdn-images-1.medium.com/max/1024/1*cxCJQTRz3pjPcLCdTrj4aQ.png" /><figcaption>Chrome prepared a 17-page job from a long ChatGPT conversation. Every page came out like this one. Credit: Illustration by author</figcaption></figure><p>I co-build a browser extension that exports AI conversations, so people write to us about this a lot: they want a chat on paper. A transcript for a lawyer, a research log for a supervisor, a record of a diagnosis conversation to take to an appointment.</p><p>Last week I went looking for the Print button in ChatGPT, Claude and Grok. There isn’t one. Not in any of them, and not documented anywhere by any of the three vendors.</p><p>So I pressed Cmd+P. Chrome prepared a 17-page print job, and every page was blank.</p><p>This is the story of what the apps do give you instead, why the browser’s own print capture fails, and the one-line Express configuration mistake that made our replacement for it fail in exactly the same way, for completely different reasons.</p><h3>Nobody Ships a Print Button</h3><p>Start with what is actually documented, because the gap is wider than it looks.</p><p>ChatGPT has no print option. What it added instead, in a February 13, 2026 release note, is a change to text selection: Cmd+A “now selects only the conversation content, excluding surrounding interface elements.” That is a copy-to-clipboard improvement, shipped by a team that clearly knows people are trying to get the text out. It is not printing. Deep research reports download as PDF, Word or Markdown, but ordinary conversations do not.</p><p>Claude has no print option either. Public share links are view-only. Artifacts created from a template export properly (Documents to Word, PDF, Markdown or Google Docs; Decks to PPTX or PDF), and Claude can create files directly. But the conversation around the artifact is not covered by any of it.</p><p>Grok has a share link, revocable at grok.com/share-links, and a full account data download in Settings under Data Controls whose format is not documented anywhere. There is no single-chat export.</p><p>Three products that people use for hours a day, and the only documented way to get one conversation out as a document is to make it into something else first.</p><h3>Why Ctrl+P Comes Out Blank</h3><p>The browser can always print a page, so why 17 blank sheets?</p><p>Because the transcript is not there. ChatGPT renders messages as you scroll, and the print capture takes the page in its unrendered state. The DOM the printer serialises contains the scroll container and almost nothing else.</p><p>This is not a bug in the usual sense. Virtualising a long transcript is the correct call for scroll performance, and every chat UI of any size does it. But virtualisation makes an implicit trade that nobody writes down: the document only exists in the places you have looked at. Print, Find in page, Reader mode and “save page as” all assume the opposite.</p><p>The workaround everyone discovers is to scroll from the first message to the last, slowly, so the whole thing is realised, and then print. That works. It is also an instruction you cannot give a non-technical user with a straight face.</p><p>Worth saying plainly: I tested this on ChatGPT. I have not run the same test on claude.ai or grok.com, so treat the print preview as the proof on any app. If the pages are blank or the end is missing, the capture failed.</p><h3>So We Built the Export Instead</h3><p>Our extension renders a conversation to PDF server-side, which sidesteps the virtualisation problem entirely: the messages come from the API, not the DOM, so there is nothing to scroll.</p><figure><img alt="Screenshot of the AI Toolbox Select Export Format dialog, with radio buttons for Text, Markdown, JSON and PDF. Markdown is selected. Markdown, JSON and PDF each carry a purple Premium badge; Text has none. Close and Export buttons sit at the bottom." src="https://cdn-images-1.medium.com/max/1024/1*WmFliipbS3mMSrlb4CkhTQ.png" /><figcaption>Text is on the free plan. The PDF path is the one with all the interesting failure modes. Credit: Screenshot by author</figcaption></figure><p>That worked until a paying customer told us it did not. They had bought the plan specifically to export one long chat as a PDF, and got this instead:</p><blockquote><em>“This chat is too long to build as a PDF in your browser”</em></blockquote><h3>Three Limits, Three Units</h3><p>The reason that message appeared turned out to be three separate ceilings, none of which knows about the others.</p><figure><img alt="Illustration of a cream electrical breaker panel on an olive wall, headed What Trips on a Long Chat Export. Three breakers are flipped to OFF. 30 seconds, Chrome’s fetch limit for a background worker, so a server render of a long chat dies in flight. 200 pages, what the in-browser renderer refuses above, which is the fallback where the user sees an error. 100 kilobytes, the first global body parser the jobs route still sits behind." src="https://cdn-images-1.medium.com/max/1024/1*zaE5qKV5H2MR5JRDPusaFg.png" /><figcaption>Three numbers in three different units, in three different layers, and only the last one is visible to the user. Credit: Diagram by author</figcaption></figure><p>A Chrome extension’s background worker gets about 30 seconds for a fetch. A server render of a very long conversation takes longer than that, so the request died in flight. The export then fell back to the in-browser rasteriser, which refuses anything above 200 pages. The customer saw the 200-page refusal and reasonably concluded the product could not do the thing they had paid for.</p><h3>The Fix: Render It as a Job</h3><p>The fix was to stop trying to do it in one request. Above a threshold, the conversation goes to the backend as a job: start it, poll, download.</p><pre>/**<br> * Content characters above which a conversation is rendered as a job.<br> *<br> * Equal to the backend&#39;s PART_BUDGET_CHARS: below it the backend would print<br> * the job as a single part anyway, and the single request is measured at about<br> * 1.2s there, far inside every timeout. Above it, the job path.<br> */<br>export const LONG_PDF_CHARS = 300_000;</pre><pre>export function shouldRenderAsJob(messages: readonly HasContent[]): boolean {<br>  return contentChars(messages) &gt; LONG_PDF_CHARS;<br>}</pre><p>Three requests, each of which answers immediately, so none of them can outlast the 30-second worker limit. The backend prints the document in parts and joins them. Everything below the threshold keeps the single request it always had.</p><p>That shipped, and it worked.</p><h3>The Bug the Fix Created</h3><p>Now the part I actually want to write about.</p><p>Our Express app caps request bodies globally. Two routes legitimately carry a whole conversation as JSON, so they are exempted:</p><pre>// The global parsers below cap bodies low (jsonWithRawBody defaults to 100kb;<br>// express.json to 1mb), so they&#39;d reject a long conversation with 413 *before*<br>// it reached the route&#39;s own larger parser.<br>const LARGE_BODY_PATHS = new Set([&quot;/export/pdf&quot;, &quot;/context-mentions/summarize&quot;, &quot;/usage/sync&quot;]);</pre><pre>const skipLargeBody =<br>  (mw: RequestHandler): RequestHandler =&gt;<br>  (req, res, next) =&gt;<br>    LARGE_BODY_PATHS.has(req.path) ? next() : mw(req, res, next);</pre><pre>app.use(skipLargeBody(jsonWithRawBody));<br>app.use(skipLargeBody(express.json({ limit: &quot;1mb&quot; })));</pre><p>Read the set, then read the route the fix added:</p><pre>router.post(&quot;/export/pdf/jobs&quot;, exportJson, extensionAuth, pdfExportRateLimit, handler);</pre><p>Set.has() is an exact match. /export/pdf/jobs is not /export/pdf. The route carries its own 10MB parser, but the global 100KB parser runs first and rejects the body before the route&#39;s parser is ever reached.</p><figure><img alt="Illustration of three cream envelopes in an oak pigeonhole rack on a pale blue-grey wall, labelled slash export slash pdf, slash context-mentions slash summarize, and slash usage slash sync. A fourth envelope labelled slash export slash pdf slash jobs lies on the floor below the rack. The heading reads LARGE_BODY_PATHS dot has, open bracket, req dot path, close bracket, and a note reads Not in the set." src="https://cdn-images-1.medium.com/max/1024/1*GkQDVgH77Va1470XIIlVQw.png" /><figcaption>The route built specifically for the largest payloads is the only one still behind the smallest cap. Credit: Diagram by author</figcaption></figure><p>The whole point of the jobs route is conversations too big to render in one request. It is, by construction, the route with the largest bodies in the application. And it is the one route not on the list of routes allowed to have large bodies.</p><h3>One 413, Four Steps to the Error</h3><p>What makes this worth a thousand words is not the mistake. It is that the mistake is invisible.</p><figure><img alt="Illustration on an espresso brown background of four cream domino tiles leaning progressively to the right, ending beside a red card. Tile 01, POST /export/pdf/jobs, refused 413 by a parser the route was meant to skip. Tile 02, readJobStart, not 404 not 429 no job id, returns failed. Tile 03, renderPdfAsJob, returns undefined like any failed render. Tile 04, in-browser rasteriser, refuses above 200 pages. The red card reads, this chat is too long to build as a PDF in your browser." src="https://cdn-images-1.medium.com/max/1024/1*kmUIYu_CEOC6t9PHJtfGMg.png" /><figcaption>Every step in this chain behaves exactly as designed. The only thing that is wrong is a missing string in a Set. Credit: Diagram by author</figcaption></figure><p>Follow the 413 through:</p><pre>export function readJobStart(status: number | null, body: unknown): JobStart {<br>  if (status === 404) return { kind: &quot;unsupported&quot; };<br>  if (status === 429) return { kind: &quot;busy&quot; };<br>  // ...<br>  return { kind: &quot;failed&quot; };<br>}</pre><p>A 413 is not 404, so the client does not conclude the backend is too old. It is not 429, so it does not retry. There is no job id, so it returns failed. renderPdfAsJob then returns undefined, which is the same value it returns for any render that genuinely could not be produced, and the caller does what it does for every failed render: falls back to the in-browser rasteriser, which refuses above 200 pages.</p><p>So the user gets the exact error the job path was built to eliminate. No alert fires. The 413 never surfaces as a 413. A graceful degradation path, which is a good thing to have, is also a very effective way to hide a configuration bug for weeks.</p><h3>What I Would Take From This</h3><p>Four things, in rough order of how much they would have saved me.</p><p>An allowlist keyed on an exact path is a trap the moment a route grows a sub-path. req.path.startsWith() over a prefix list, or attaching the limit to the router rather than to a central set, both survive the next route. A Set of strings does not, and nothing warns you.</p><p>Put the limit next to the thing it limits. The body cap for /export/pdf/jobs lives in index.ts, about four hundred lines from the route it governs. Anyone adding a route in routes/export.ts sees the route&#39;s own 10MB parser and reasonably assumes that is the operative number.</p><p>Treat every fallback as a place a bug can hide. A fallback converts a loud failure into a quiet degradation, which is exactly what you want in production and exactly what you do not want while finding this. If a fallback fires, log why, with the status code, at a level someone actually reads.</p><p>Test the case the feature exists for. Every test we had used a normal conversation. The jobs route exists only for abnormal ones, and a fixture above 300,000 characters would have caught this on the first run.</p><h3>Key Takeaways</h3><ul><li>None of ChatGPT, Claude or Grok documents a Print option for a conversation. Printing from the browser is the only universal route, and on a virtualised transcript it can produce blank pages.</li><li>Virtualised rendering silently breaks Print, Find in page and save-as. The document only exists where the user has scrolled.</li><li>A Set.has(req.path) allowlist stops covering a route the moment that route gains a sub-path, and the failure mode is a 413 that looks like something else entirely.</li><li>Keep request-size limits next to their routes, not in a central list in the app entry point.</li><li>A fallback path is a bug silencer. Log the reason it fired.</li></ul><h3>FAQ</h3><p>Can you print a ChatGPT conversation? Not with a built-in option, because there isn’t one. Press Ctrl+P or Cmd+P in a desktop browser, but scroll the whole conversation first and check the print preview before sending it, because a long chat can come out blank.</p><p>Why does my ChatGPT printout come out blank? ChatGPT renders messages as you scroll. If the print capture happens before a message has been rendered, that message is not in the DOM and does not reach the page.</p><p>Is there a free way to get a long chat onto paper? Yes. Export it as plain text and print that from any text editor. TXT export is on the free plan in our extension; the formatted PDF is the paid one.</p><h3>Resources</h3><ul><li><a href="https://www.ai-toolbox.co/ai-toolbox-chatgpt-features/how-to-print-chatgpt-claude-grok-conversation-2026">How to print a ChatGPT, Claude or Grok conversation</a>, the step-by-step version of this, with the per-app detail</li><li><a href="https://support.google.com/chrome/answer/1069693">Chrome Help: print from Chrome</a></li><li><a href="https://expressjs.com/en/api.html#express.json">Express body-parser limits</a></li></ul><p>About the author: Adi Leviim is the co-founder of AI Toolbox (formerly ChatGPT Toolbox), a Chrome extension with four modules (ChatGPT, Gemini, Claude, Grok) used by 40,000+ people across 150+ countries to search, organize, and export their AI conversations. He writes about the reality of building AI products with 7+ years of full-stack development experience. Follow him on Medium for honest takes on SaaS, AI tools, and shipping software that people actually use.</p><p>Try it free on the <a href="https://chromewebstore.google.com/detail/ai-toolbox-folders-prompt/jlalnhjkfiogoeonamcnngdndjbneina">Chrome Web Store</a>. TXT export is on the free plan; Markdown, JSON and PDF are Premium at $9.99 a month or $99 lifetime per module, and All Access is $199 one time for all four. These are extension features, not an AI subscription.</p><p><a href="https://ai-toolbox.co/">AI Toolbox</a> | <a href="https://medium.com/@adi_leviim">Medium</a> | <a href="https://x.com/adileviim">Twitter/X</a> | <a href="https://www.linkedin.com/in/adi-leviim/">LinkedIn</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=54d73a1cc5ac" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/four-ai-apps-zero-print-buttons-and-one-100kb-cap-in-the-wrong-place-54d73a1cc5ac">Four AI Apps, Zero Print Buttons, and One 100KB Cap in the Wrong Place</a> was originally published in <a href="https://levelup.gitconnected.com/">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>