<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mikhail Sapunov</title>
    <description>The latest articles on DEV Community by Mikhail Sapunov (@indie_labs).</description>
    <link>https://dev.to/indie_labs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3897953%2F29abed27-af0c-4f65-9fa9-795f42638457.png</url>
      <title>DEV Community: Mikhail Sapunov</title>
      <link>https://dev.to/indie_labs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/indie_labs"/>
    <language>en</language>
    <item>
      <title>Weekend Experiment: Free Qwen as a Personal API. Here Is What Actually Happened.</title>
      <dc:creator>Mikhail Sapunov</dc:creator>
      <pubDate>Sun, 10 May 2026 15:54:20 +0000</pubDate>
      <link>https://dev.to/indie_labs/weekend-experiment-free-qwen-as-a-personal-api-here-is-what-actually-happened-1bpa</link>
      <guid>https://dev.to/indie_labs/weekend-experiment-free-qwen-as-a-personal-api-here-is-what-actually-happened-1bpa</guid>
      <description>&lt;p&gt;Found a cool service - &lt;a href="https://www.kaggle.com/" rel="noopener noreferrer"&gt;Kaggle&lt;/a&gt;. Gives 30 free GPU hours per week. And I had this idea: what if I run Qwen3-8B there and expose it through an API on Cloudflare Workers?&lt;/p&gt;

&lt;p&gt;Honestly not sure what this is useful for. Just wanted to know if I could pull it off.&lt;/p&gt;

&lt;p&gt;Planned to finish in a couple of hours. Finished over the weekend.&lt;/p&gt;




&lt;h2&gt;
  
  
  So Why Bother?
&lt;/h2&gt;

&lt;p&gt;As I was figuring things out, I realized this could work as a free replacement for a paid AI API - for example in &lt;a href="https://rsearcher.online" rel="noopener noreferrer"&gt;R-Searcher&lt;/a&gt;, my Chrome extension for reading articles. Or just as a personal AI backend with no subscription and no token limits.&lt;/p&gt;

&lt;p&gt;But honestly - the idea came first, the reason came later. Not the other way around.&lt;/p&gt;

&lt;p&gt;So the task: a client sends a request, Qwen on Kaggle processes it, the response comes back. For free. The first problem showed up five minutes in.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: Kaggle Has No Inbound Traffic
&lt;/h2&gt;

&lt;p&gt;Kaggle is a Jupyter notebook on a cloud GPU. No public IP. No incoming connections. You can't just spin up a Flask server and hand out a URL.&lt;/p&gt;

&lt;p&gt;First idea: ngrok. Creates a public tunnel to a local server. Problem: ToS grey area on Kaggle. Could get the account banned.&lt;/p&gt;

&lt;p&gt;Second idea: flip the architecture. Kaggle doesn't accept requests - it makes them.&lt;/p&gt;

&lt;p&gt;The notebook connects to a Cloudflare Worker via WebSocket on startup. The Worker receives a request from the client, pushes the task into the open socket, Kaggle processes it and sends the result back. From Kaggle's side, these are just regular outgoing HTTP requests - no ToS issues.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2tycw2hhy0u20nrw5s71.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2tycw2hhy0u20nrw5s71.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Pitfalls, In Order
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Pitfall 1: Wrong model name
&lt;/h3&gt;

&lt;p&gt;First thing I did - tried to load the model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;MODEL_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Qwen/Qwen3-8B-Instruct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Got a 404. That repository doesn't exist. Qwen3 has no separate Instruct repo — instruct mode is enabled via a parameter in the chat template, and the model is just called &lt;code&gt;Qwen/Qwen3-8B&lt;/code&gt;. Learned this from a traceback about twenty minutes in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;MODEL_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Qwen/Qwen3-8B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# works
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pitfall 2: One GPU can't fit the model
&lt;/h3&gt;

&lt;p&gt;Kaggle gives two T4s at 15GB each - 30GB total. But I defaulted to &lt;code&gt;device_map="cuda:0"&lt;/code&gt; and got OOM: the model in fp16 weighs ~16GB, one card can't handle it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# OOM:
&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoModelForCausalLM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MODEL_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device_map&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cuda:0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple fix — &lt;code&gt;device_map="auto"&lt;/code&gt;. PyTorch distributes layers across both GPUs automatically. In 4-bit quantization the model only takes ~5GB anyway, so it fits with room to spare.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoModelForCausalLM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;MODEL_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;quantization_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;BitsAndBytesConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;load_in_4bit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;device_map&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;low_cpu_mem_usage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# without this: OOM on CPU RAM during load
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pitfall 3: Jupyter already owns the event loop
&lt;/h3&gt;

&lt;p&gt;My WebSocket logic is async. Tried to run it — got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: This event loop is already running
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kaggle is Jupyter, and Jupyter runs its own asyncio event loop. You can't start another one inside it with &lt;code&gt;asyncio.run()&lt;/code&gt;. Fixed with one line — the &lt;code&gt;nest_asyncio&lt;/code&gt; library patches the existing loop to allow nested async:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;nest_asyncio&lt;/span&gt;
&lt;span class="n"&gt;nest_asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pitfall 4: Cloudflare DO eviction was killing the WebSocket
&lt;/h3&gt;

&lt;p&gt;This was the least obvious problem, and the one that took the most time.&lt;/p&gt;

&lt;p&gt;A Cloudflare Durable Object gets evicted from memory after about 30 seconds of inactivity. After eviction, &lt;code&gt;constructor&lt;/code&gt; runs again, &lt;code&gt;this.ws = null&lt;/code&gt;. The health endpoint starts reporting "disconnected", while Kaggle thinks everything is fine - ping/pong is working, TCP connection is alive.&lt;/p&gt;

&lt;p&gt;First attempt: use &lt;code&gt;state.getWebSockets()&lt;/code&gt; instead of &lt;code&gt;this.ws&lt;/code&gt;. Better, but message listeners still got lost on eviction.&lt;/p&gt;

&lt;p&gt;The correct fix is the DO Hibernation API. Instead of attaching &lt;code&gt;addEventListener&lt;/code&gt; to the socket, you declare methods directly on the class. Cloudflare calls them automatically even after revival:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Lost on eviction — listener only lives in memory:&lt;/span&gt;
&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Always works — CF calls these even after revival:&lt;/span&gt;
&lt;span class="nf"&gt;webSocketMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;webSocketClose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wasClean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;webSocketError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more thing: store timestamps in &lt;code&gt;state.storage&lt;/code&gt; instead of &lt;code&gt;this.*&lt;/code&gt;. Otherwise &lt;code&gt;connectedAt&lt;/code&gt; and &lt;code&gt;disconnectedAt&lt;/code&gt; reset on every eviction and the health endpoint lies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall 5: Client timed out before Qwen answered
&lt;/h3&gt;

&lt;p&gt;Standard HTTP request timeout on the client side: 12 seconds. Qwen processing a long article: ~80 seconds. Didn't want to touch the client code.&lt;/p&gt;

&lt;p&gt;Fix: SHA-256 cache on the Worker side. The first request from the client times out — but the Worker keeps waiting for Qwen. When Qwen responds, the result goes into &lt;code&gt;state.storage&lt;/code&gt; with a 60-second TTL. The next request with the same text gets the answer instantly from cache — client is happy, nothing needed changing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;buildRequestCacheKey&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;language&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_getCachedResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// otherwise — run Qwen, wait, cache the result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;It works.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://qwen-personal-backend.indielabs.workers.dev/process &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"text":"What is machine learning?","mode":"explain","language":"en"}'&lt;/span&gt;

&lt;span class="c"&gt;# First request: ~15-80 seconds (Qwen thinking)&lt;/span&gt;
&lt;span class="c"&gt;# Same request again: instant from cache&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"result"&lt;/span&gt;:&lt;span class="s2"&gt;"Machine learning is a way for computers to learn from data..."&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cost: $0. Kaggle is free. Cloudflare Workers free tier.&lt;/p&gt;

&lt;p&gt;Limitations: 30 GPU hours per week, sessions last 12 hours and need a manual restart, first response is 5-15x slower than a paid API. Fine for a personal tool. Probably not for production.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Could Be Good For
&lt;/h2&gt;

&lt;p&gt;I'm still not sure this makes sense in production. But the pattern is interesting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Personal AI assistant with no subscription or token limits&lt;/li&gt;
&lt;li&gt;MVP with AI features without paying for API — validate the idea, then switch to a paid provider&lt;/li&gt;
&lt;li&gt;Privacy — your text goes to your Kaggle, not OpenAI or Google servers&lt;/li&gt;
&lt;li&gt;Codebase RAG — Qwen analyzes your code, builds a dependency map, shares context via MCP with paid models&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/indie-labs-dev/kaggle-notebooks/tree/main/notebooks/qwen-personal-backend" rel="noopener noreferrer"&gt;kaggle-notebooks/qwen-personal-backend&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Kaggle notebook + CF Worker + README. About 10 minutes to set up.&lt;/p&gt;




&lt;p&gt;What would you change in this setup? And where do you see a use case for this pattern - Kaggle as a free AI backend via CF Worker?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>python</category>
      <category>opensource</category>
      <category>learning</category>
    </item>
    <item>
      <title>I Shipped My First Indie Product. Here Is What Actually Happened.</title>
      <dc:creator>Mikhail Sapunov</dc:creator>
      <pubDate>Thu, 07 May 2026 05:23:08 +0000</pubDate>
      <link>https://dev.to/indie_labs/i-shipped-my-first-indie-product-here-is-what-actually-happened-32df</link>
      <guid>https://dev.to/indie_labs/i-shipped-my-first-indie-product-here-is-what-actually-happened-32df</guid>
      <description>&lt;p&gt;37 days. A Chrome extension with an AI reading assistant. Full cycle: idea, build, release, marketing, exit. My first indie project under the Indie Labs brand.&lt;/p&gt;

&lt;p&gt;Result: 11 installs, 0 active users, 0 revenue. Now open-source and self-hosted.&lt;/p&gt;

&lt;p&gt;This is not a success story. But it is an honest one.&lt;/p&gt;




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

&lt;p&gt;&lt;code&gt;R-Searcher&lt;/code&gt; is a Chrome extension that helps you read faster without leaving the page. Open an article, click Read, get three tabs back: &lt;code&gt;Essence&lt;/code&gt; (3 to 5 sentences on whether the article is worth your time), &lt;code&gt;Notes&lt;/code&gt; (a markdown digest of what is worth keeping), and &lt;code&gt;Next Steps&lt;/code&gt; (where to go after this article). Highlight any confusing fragment and get an inline explanation.&lt;/p&gt;

&lt;p&gt;The stack was intentionally lean: Chrome Extension MV3, Cloudflare Worker as the backend, Gemini API for inference.&lt;/p&gt;

&lt;p&gt;The product works. The rest did not go as planned.&lt;/p&gt;




&lt;h2&gt;
  
  
  What went well
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Full cycle completed.&lt;/strong&gt; Build, test, release, marketing: all in one project. Real experience, not a tutorial.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fast pivot.&lt;/strong&gt; I spotted the risk with my own API key being shared across all users, found a solution (self-hosted worker), and shipped it before it became a real cost problem. The project stayed alive and my expenses dropped to zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The product is actually live.&lt;/strong&gt; Extension in the Chrome Store, landing page, documentation, onboarding guide: everything is in place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Knowing when to stop.&lt;/strong&gt; I honestly admitted the project was falling out of my focus and moved it to a low-cost mode instead of dragging it out.&lt;/p&gt;




&lt;h2&gt;
  
  
  What was hard
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Gemini rate limits.&lt;/strong&gt; Google does not offer strict per-user spending caps. That made any free-tier model risky: a traffic spike with no ceiling is a blank check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distribution.&lt;/strong&gt; AI reading extensions are one of the most crowded segments in the Chrome Store right now. Organic traffic without an existing audience is nearly impossible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Business model.&lt;/strong&gt; Self-hosted solved my cost problem but created a new one: onboarding friction. Free users drop off at the "deploy a worker" step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Focus.&lt;/strong&gt; The first indie project pulled attention from other ideas that might have been better fits. Knowing when to move on is a skill I had to learn mid-project.&lt;/p&gt;




&lt;h2&gt;
  
  
  Marketing: the plan vs reality
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The plan:&lt;/strong&gt; Chrome Store brings users organically. Create Indie Labs accounts across Reddit, X, Dev.to, Hashnode, LinkedIn. Post on launch day. Watch installs come in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What actually happened:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chrome Store takes 5 days for review, then another week before the listing gets any impressions. Zero downloads on launch day.&lt;/li&gt;
&lt;li&gt;Google banned my new account as a bot. Had to create a new one.&lt;/li&gt;
&lt;li&gt;Reddit banned my new account as a bot. Same.&lt;/li&gt;
&lt;li&gt;X without a paid subscription gets essentially zero impressions.&lt;/li&gt;
&lt;li&gt;Dev.to and Hashnode posts did not get traction.&lt;/li&gt;
&lt;li&gt;LinkedIn post was received coldly: wrong audience for this product.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Right now the extension has 11 installs and 0 active users. The Store is finally showing it to people, so organic is slowly starting. But it is slow.&lt;/p&gt;

&lt;p&gt;The real lesson: a marketplace is a shelf, not a salesperson. The shelf does not sell anything on its own. You need traffic before the release, not after.&lt;/p&gt;

&lt;p&gt;New accounts get zero-trust treatment everywhere: Google, Reddit, Patreon, all of them. Accounts need history before you use them for promotion. That means posting about the build process weeks before launch, not creating accounts on launch day.&lt;/p&gt;




&lt;h2&gt;
  
  
  Monetization: the plan vs reality
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The plan:&lt;/strong&gt; Lemon Squeezy for Pro licenses, free users get weekly limits. Stripe as a backup. Patreon for early supporters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What actually happened:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lemon Squeezy does not work with Ukraine.&lt;/li&gt;
&lt;li&gt;Stripe does not work with Ukraine.&lt;/li&gt;
&lt;li&gt;Patreon: killed by the same zero-trust problem with new accounts.&lt;/li&gt;
&lt;li&gt;No clear plan B.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result: no monetization path at all.&lt;/p&gt;

&lt;p&gt;I came up with workarounds. Distribute license keys manually, use Patreon as a delivery channel. But each one had a blocker. The payment infrastructure problem was never solved because I did not check it before starting, only after.&lt;/p&gt;

&lt;p&gt;Verify your payment stack before writing the first line of code. Not during development. Not after launch. Before. This is a specific problem for developers in Ukraine: Stripe and Lemon Squeezy are simply not available options. Paddle, Gumroad, and local alternatives need to be evaluated upfront.&lt;/p&gt;




&lt;h2&gt;
  
  
  The technical decision I am actually happy with
&lt;/h2&gt;

&lt;p&gt;The original version was connected to my own Gemini API key. Google does not offer strict per-user spending limits, which meant uncontrolled traffic could cost real money with no way to cap it.&lt;/p&gt;

&lt;p&gt;I refactored to self-hosted: each user deploys their own Cloudflare Worker, connects their own AI provider, and pays for their own usage. The extension stays alive in the Chrome Store without touching my budget.&lt;/p&gt;

&lt;p&gt;This was the right call economically. But it has a cost: the setup step kills conversion. Most users want "install and it works." A step that says "deploy a Cloudflare Worker first" loses the majority of potential users.&lt;/p&gt;

&lt;p&gt;The tradeoff is real. Next time I either build in paid monetization from day one with a provider that actually works for my region, or I design the product so it genuinely works without any setup.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lessons for the next project
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Audience before product.&lt;/strong&gt; Find people with the pain in real communities, talk to them, understand if they actually want a solution: before writing any code. My next move is posting in relevant subreddits about the problem I want to solve next, not promoting anything, just listening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Payment stack on day one.&lt;/strong&gt; Know how you will accept money before you start building. Not a rough plan, an actual working setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Public devlog in parallel with development.&lt;/strong&gt; This builds account history, creates a narrative, and grows an early audience. All three things I needed and did not have. Also: read at least one book on go-to-market before starting. I was not the first to walk into these mistakes and the information exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set a timeline for the pivot decision before you start.&lt;/strong&gt; 37 days was a good pace. But having a pre-committed checkpoint helps you move on without second-guessing.&lt;/p&gt;




&lt;p&gt;The project is closed. The code is on GitHub. The extension is still in the Chrome Store.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/indie-labs-dev/rsearch-copilot" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://rsearcher.online" rel="noopener noreferrer"&gt;rsearcher.online&lt;/a&gt; | &lt;a href="https://indielabs.tech" rel="noopener noreferrer"&gt;indielabs.tech&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;First project under Indie Labs. Building in public.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>I Built a Chrome Extension That Turns Long Articles Into Structured Notes, and It Taught Me Two Expensive Lessons</title>
      <dc:creator>Mikhail Sapunov</dc:creator>
      <pubDate>Wed, 29 Apr 2026 07:41:37 +0000</pubDate>
      <link>https://dev.to/indie_labs/i-built-a-chrome-extension-that-turns-long-articles-into-structured-notes-and-it-taught-me-two-4gc6</link>
      <guid>https://dev.to/indie_labs/i-built-a-chrome-extension-that-turns-long-articles-into-structured-notes-and-it-taught-me-two-4gc6</guid>
      <description>&lt;h1&gt;
  
  
  I Built a Chrome Extension That Turns Long Articles Into Structured Notes, and It Taught Me Two Expensive Lessons
&lt;/h1&gt;

&lt;p&gt;When I started building &lt;code&gt;R-Searcher&lt;/code&gt;, I was not trying to create another AI chat wrapper.&lt;/p&gt;

&lt;p&gt;The idea was much narrower. I wanted a tool that could help people read difficult articles faster without pretending to replace the source. Not an AI search engine. Not a universal assistant. Just a reading layer that sits on top of the article already open in the browser and helps extract value from it faster.&lt;/p&gt;

&lt;p&gt;That became &lt;code&gt;R-Searcher&lt;/code&gt;: a Chrome extension that can analyze the current article into &lt;code&gt;Essence&lt;/code&gt;, &lt;code&gt;Notes&lt;/code&gt;, and &lt;code&gt;Next Steps&lt;/code&gt;, or explain a confusing fragment of text inline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem I wanted to solve
&lt;/h2&gt;

&lt;p&gt;Large language models are already useful, but they still have a trust problem. They are very good at sounding clear and confident, but that does not always mean they stay close enough to the source when precision matters.&lt;/p&gt;

&lt;p&gt;That was the starting point for this project. I did not want to ask an LLM to replace search or replace reading. I wanted to use it as a focused assistant while reading something specific.&lt;/p&gt;

&lt;p&gt;The use case is simple. You open a long article, technical post, research note, or dense essay and want to answer a few practical questions quickly. Is this worth a full read? What are the main takeaways? Which parts are actually worth keeping? And if one paragraph becomes too dense, can the tool explain that fragment without forcing you to leave the page?&lt;/p&gt;

&lt;p&gt;That is the gap I wanted &lt;code&gt;R-Searcher&lt;/code&gt; to cover.&lt;/p&gt;

&lt;p&gt;For me personally, the strongest flow is still article analysis. The &lt;code&gt;Notes&lt;/code&gt; tab often ends up being more useful than the summary itself, because it turns a long article into something I can actually keep. The second most useful flow is inline explanation, especially on technical posts full of abbreviations and terms that are obvious to the writer but not to the reader.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frn4x4pej4m34l0euuzf2.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frn4x4pej4m34l0euuzf2.gif" alt=" " width="720" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the product does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;R-Searcher&lt;/code&gt; has two main flows.&lt;/p&gt;

&lt;p&gt;The first is article analysis. The extension extracts the readable part of the current page, sends it to the backend, and returns a structured result with three sections. &lt;code&gt;Essence&lt;/code&gt; gives the main point in a few sentences. &lt;code&gt;Notes&lt;/code&gt; keeps the details worth remembering. &lt;code&gt;Next Steps&lt;/code&gt; suggests where to go from there.&lt;/p&gt;

&lt;p&gt;The second flow is inline explanation. If I highlight a confusing fragment, the extension sends only that selected text and returns a short plain-language explanation. After that first response, the UI can also offer follow-up actions such as rephrasing, showing an example, or explaining why something matters.&lt;/p&gt;

&lt;p&gt;What mattered to me here was not only the model output, but the shape of the interaction. I wanted the product to feel like an extension of reading, not like a context switch into a separate AI tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  From idea to implementation
&lt;/h2&gt;

&lt;p&gt;The MVP had two hard constraints from day one. It had to be cheap to run, and it had to avoid collecting unnecessary user data.&lt;/p&gt;

&lt;p&gt;Those constraints shaped almost everything.&lt;/p&gt;

&lt;p&gt;The stack ended up being intentionally lean: a &lt;code&gt;Chrome Extension MV3&lt;/code&gt; client, a &lt;code&gt;Cloudflare Worker&lt;/code&gt; as the backend, &lt;code&gt;Cloudflare KV&lt;/code&gt; for quotas and anti-abuse state, and &lt;code&gt;Gemini 2.5 Flash-Lite&lt;/code&gt; as the model layer. Around that, I kept the rest of the product surface light as well: static pages on &lt;code&gt;rsearcher.online&lt;/code&gt; and forms handled through &lt;code&gt;Formspree&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That stack is not flashy, but it fits the job. I did not want to build a whole account system just to let someone summarize an article. Instead, the extension generates a local &lt;code&gt;installId&lt;/code&gt;, which the backend uses as a lightweight fairness identity for weekly quotas. That gave me a middle ground between total anonymity and forced sign-up.&lt;/p&gt;

&lt;p&gt;From a product perspective, that improves privacy. From an engineering perspective, it keeps the system small enough to reason about.&lt;/p&gt;

&lt;p&gt;One principle I wanted to keep strict was that the client should never make the real access decision. The extension can display the latest known remaining quota, cache results locally, and keep the UI responsive, but the actual enforcement happens on the backend. Weekly quotas, short-window burst protection, size caps, and the shared daily token budget all live there.&lt;/p&gt;

&lt;p&gt;That matters because AI products become expensive in surprisingly creative ways if the client becomes too trusted.&lt;/p&gt;

&lt;p&gt;I also did not want article analysis to mean “grab the whole page and pray.” The content script first tries to identify likely article containers and then removes obvious page chrome such as navigation, sidebars, breadcrumbs, and share blocks. It is still heuristic rather than magical, but in practice it makes a big difference.&lt;/p&gt;

&lt;p&gt;The same idea applies to the response format. Analyze results are not returned as one vague paragraph. The worker expects a structured output and normalizes it before it reaches the UI, because the popup is built around &lt;code&gt;Essence&lt;/code&gt;, &lt;code&gt;Notes&lt;/code&gt;, and &lt;code&gt;Next Steps&lt;/code&gt;. If the backend returns messy output, the frontend becomes fragile very quickly.&lt;/p&gt;

&lt;p&gt;The explain flow has a similar design choice. The first explanation returns a tiny metadata block, and that metadata decides which follow-up actions should appear. That way the interface feels a little smarter than just showing the same generic buttons every time.&lt;/p&gt;

&lt;p&gt;A few implementation details I was especially happy with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the extension works without a build step, which kept iteration fast&lt;/li&gt;
&lt;li&gt;analyze results are cached locally by page URL, so reopening the popup does not feel stateless&lt;/li&gt;
&lt;li&gt;the client displays quota state, but the backend remains the source of truth&lt;/li&gt;
&lt;li&gt;the UI supports both popup-based reading and inline explanation on the page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is groundbreaking engineering. But together, it made the product feel much more solid than a typical quick AI wrapper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Component architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2inzjy9fqt2a0phvddrv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2inzjy9fqt2a0phvddrv.png" alt=" " width="800" height="253"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Request flow
&lt;/h2&gt;

&lt;p&gt;At a high level, the request flow is intentionally simple.&lt;/p&gt;

&lt;p&gt;If the user wants to analyze an article, the extension extracts the cleanest readable text it can find on the page. If the user wants an explanation, it sends only the selected fragment instead. That request goes through the extension background worker to the backend.&lt;/p&gt;

&lt;p&gt;From there, the backend decides whether the request is allowed at all. It validates the install identity, checks request size, enforces quotas, applies short-window burst protection, and reserves part of the shared daily token budget. Only then does it call the model.&lt;/p&gt;

&lt;p&gt;When the model returns a response, the worker normalizes it into something the UI can trust. The extension then renders either the article tabs or the inline explain panel, and updates the locally cached usage state for display.&lt;/p&gt;

&lt;p&gt;The important part is not the complexity of the flow, but the boundary: the frontend stays thin and reactive, while the backend owns validation, limits, and response shaping.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftoikjpnd53mk5ba3q826.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftoikjpnd53mk5ba3q826.png" alt=" " width="276" height="662"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where reality entered the chat
&lt;/h2&gt;

&lt;p&gt;Building the product was not effortless, but the code was still the easier part.&lt;/p&gt;

&lt;p&gt;What hurt more were the mistakes outside the codebase. Which, in hindsight, is probably the most indie-dev thing imaginable: you spend weeks thinking the hard part is architecture, and then reality shows up with payments, accounts, and platform rules.&lt;/p&gt;

&lt;p&gt;The biggest lesson came from monetization.&lt;/p&gt;

&lt;h2&gt;
  
  
  The monetization mistake
&lt;/h2&gt;

&lt;p&gt;At one point, I planned a paid higher-tier path for the product and chose &lt;code&gt;Lemon Squeezy&lt;/code&gt; for it. While preparing that flow, I relied too much on AI assistance and not enough on direct verification. I was told that the platform would work for my case from Ukraine, and I accepted that answer too quickly.&lt;/p&gt;

&lt;p&gt;In other words, I outsourced due diligence to a machine that is extremely good at sounding sure of itself. Unsurprisingly, this was not my sharpest product decision.&lt;/p&gt;

&lt;p&gt;That one assumption cost me several days.&lt;/p&gt;

&lt;p&gt;I wired the paid flow into the project, thought through pricing, adjusted the site copy, added licensing logic, and treated it like a solved piece of the launch. Then, when I got close to release and started creating the real accounts in the real platform, I hit the actual constraint: I could not create a store from a Ukrainian location.&lt;/p&gt;

&lt;p&gt;That was the moment when the product almost died, not because of a hard technical limitation, but because I had built part of the launch around a business assumption I had never verified properly.&lt;/p&gt;

&lt;p&gt;This kind of failure is painful precisely because it is avoidable. I did not lose those days to some deep systems bug or impossible model behavior. I lost them because I was lazy at exactly the wrong moment.&lt;/p&gt;

&lt;p&gt;That changed my rule immediately. If a decision touches payments, geography, compliance, or platform access, AI can help generate options, but it cannot be the final authority. Those things need direct confirmation as early as possible, ideally before a single line of integration code is written.&lt;/p&gt;

&lt;p&gt;In the end, I removed the paid flow, stripped out the licensing path, replaced it with a waitlist and higher-limits request flow, and shipped the product anyway. That pivot was frustrating, but it also clarified something useful: if I still wanted the product alive after removing the monetization plan, then the underlying problem was probably worth solving.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distribution mistake
&lt;/h2&gt;

&lt;p&gt;The next failure had nothing to do with pricing.&lt;/p&gt;

&lt;p&gt;When I started setting up the promotion side, I created a fresh Google account and used it as the base identity for everything. Social accounts, signups, project-related access — all of it pointed back to the same root account.&lt;/p&gt;

&lt;p&gt;The next day, that account got suspended.&lt;/p&gt;

&lt;p&gt;Which was a very efficient way for the universe to explain the phrase “single point of failure.”&lt;/p&gt;

&lt;p&gt;Some access was restored later, but the lesson had already landed. I had built too much of the project’s distribution surface on top of one identity provider. It was the same architectural mistake people make in infrastructure, just in a different layer.&lt;/p&gt;

&lt;p&gt;We usually understand the danger of single points of failure in code. We think about backups, redundancy, failover, and monitoring. But when it comes to domains, email, social accounts, and account ownership, it is easy to become strangely optimistic.&lt;/p&gt;

&lt;p&gt;After that, I changed the setup. I bought my own domain, &lt;code&gt;indielabs.tech&lt;/code&gt;, created branded email accounts on top of it, and rebuilt things in a more resilient way. That did not make the product smarter. It made the project less fragile.&lt;/p&gt;

&lt;p&gt;For an indie product, that is not a side detail. That is operational sanity.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away from this
&lt;/h2&gt;

&lt;p&gt;The biggest lesson from &lt;code&gt;R-Searcher&lt;/code&gt; is that code problems are rarely the only real problems in a product.&lt;/p&gt;

&lt;p&gt;You can build a clean MVP, keep the stack lean, get the core feature working, and still get hit hardest by the things that live outside the codebase: payment restrictions, platform availability, account risk, and distribution fragility.&lt;/p&gt;

&lt;p&gt;Two conclusions became very clear for me.&lt;/p&gt;

&lt;p&gt;First, AI advice needs to be verified early in critical places. It is useful for exploration, but dangerous when it quietly replaces direct validation. If the answer can block launch, I now check it immediately in the real platform.&lt;/p&gt;

&lt;p&gt;Second, distribution infrastructure is still infrastructure. Domains, email, ownership, and account independence deserve the same seriousness as servers and queues. Losing access there can hurt just as much as losing access to a production system.&lt;/p&gt;

&lt;h2&gt;
  
  
  What’s next
&lt;/h2&gt;

&lt;p&gt;The next phase for &lt;code&gt;R-Searcher&lt;/code&gt; is not about scaling aggressively. It is about getting real usage, collecting feedback, improving extraction quality, and seeing how people actually use the two main flows in practice.&lt;/p&gt;

&lt;p&gt;Just as importantly, it is also about working on distribution more deliberately than I did before. That was one of the original reasons for building this smaller product in the first place: not only to ship code, but to learn how the whole product journey behaves in the real world.&lt;/p&gt;

&lt;p&gt;If I have one final takeaway, it is this: sometimes the most valuable part of building a small product is not the product itself, but the mistakes it forces you to encounter while the blast radius is still small.&lt;/p&gt;

&lt;p&gt;If you are building small AI tools, I would love to know which part has been harder for you so far: the engineering, the monetization, or the distribution.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
