<?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:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[skeptrune's corner]]></title><description><![CDATA[well written posts]]></description><link>https://skeptrune.substack.com</link><image><url>https://substackcdn.com/image/fetch/$s_!kyAq!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff724cd09-4641-4111-9112-5fc599162751_200x200.png</url><title>skeptrune&apos;s corner</title><link>https://skeptrune.substack.com</link></image><generator>Substack</generator><lastBuildDate>Sat, 11 Apr 2026 08:56:48 GMT</lastBuildDate><atom:link href="https://skeptrune.substack.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[skeptrune]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[me@skeptrune.com]]></webMaster><itunes:owner><itunes:email><![CDATA[me@skeptrune.com]]></itunes:email><itunes:name><![CDATA[skeptrune]]></itunes:name></itunes:owner><itunes:author><![CDATA[skeptrune]]></itunes:author><googleplay:owner><![CDATA[me@skeptrune.com]]></googleplay:owner><googleplay:email><![CDATA[me@skeptrune.com]]></googleplay:email><googleplay:author><![CDATA[skeptrune]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[Startup Marketing 101]]></title><description><![CDATA[I was pretty bad at marketing as a founder, but I've learned a few things since.]]></description><link>https://skeptrune.substack.com/p/startup-marketing-101</link><guid isPermaLink="false">https://skeptrune.substack.com/p/startup-marketing-101</guid><dc:creator><![CDATA[skeptrune]]></dc:creator><pubDate>Tue, 24 Feb 2026 15:39:29 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/a2917325-0f4d-4493-a9df-f9d4b5a46513_4096x2304.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Everyone in startups has heard the advice: &#8220;don&#8217;t tunnel vision on product, make sure you do marketing.&#8221; If advice were a horse, that one would have been beaten dead a decade ago. Some version of it appears on my X feed every single day.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!nbMM!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc2d13970-47b8-4fa3-8993-324bb539579a_2580x2916.webp" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!nbMM!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc2d13970-47b8-4fa3-8993-324bb539579a_2580x2916.webp 424w, https://substackcdn.com/image/fetch/$s_!nbMM!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc2d13970-47b8-4fa3-8993-324bb539579a_2580x2916.webp 848w, https://substackcdn.com/image/fetch/$s_!nbMM!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc2d13970-47b8-4fa3-8993-324bb539579a_2580x2916.webp 1272w, https://substackcdn.com/image/fetch/$s_!nbMM!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc2d13970-47b8-4fa3-8993-324bb539579a_2580x2916.webp 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!nbMM!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc2d13970-47b8-4fa3-8993-324bb539579a_2580x2916.webp" width="1456" height="1646" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/c2d13970-47b8-4fa3-8993-324bb539579a_2580x2916.webp&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1646,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:183824,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/webp&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://skeptrune.substack.com/i/188990770?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc2d13970-47b8-4fa3-8993-324bb539579a_2580x2916.webp&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!nbMM!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc2d13970-47b8-4fa3-8993-324bb539579a_2580x2916.webp 424w, https://substackcdn.com/image/fetch/$s_!nbMM!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc2d13970-47b8-4fa3-8993-324bb539579a_2580x2916.webp 848w, https://substackcdn.com/image/fetch/$s_!nbMM!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc2d13970-47b8-4fa3-8993-324bb539579a_2580x2916.webp 1272w, https://substackcdn.com/image/fetch/$s_!nbMM!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc2d13970-47b8-4fa3-8993-324bb539579a_2580x2916.webp 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>I heard this constantly while founding my previous startup <a href="https://www.ycombinator.com/companies/trieve">Trieve</a>, and I bought into it. You can find old <a href="https://www.tiktok.com/@trieveai?lang=en">TikTok posts</a> from August 2023, four months before we raised funding or got into YC. Then, ironically, some switch flipped once we became venture backed. The posting stopped. We turned inward and focused on product. It saddens me in hindsight, because our product was getting a whole lot better at exactly the moment we went quiet.</p><h2><strong>What Changed?</strong></h2><p>Startup media and accelerator programs create an expectation of a &#8220;launch&#8221; event, think of <a href="https://www.youtube.com/watch?v=Qp-AwObTrvE">TechCrunch Disrupt in the Silicon Valley TV show</a>, Supabase&#8217;s infamous <a href="https://supabase.com/launch-week">&#8220;launch week&#8221;</a>, and of course the OG themselves - <a href="https://www.producthunt.com/launch">ProductHunt</a>.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!qSuI!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F15c768c8-c69c-460c-8cfc-8dacdd85d2cb_800x600.webp" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!qSuI!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F15c768c8-c69c-460c-8cfc-8dacdd85d2cb_800x600.webp 424w, https://substackcdn.com/image/fetch/$s_!qSuI!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F15c768c8-c69c-460c-8cfc-8dacdd85d2cb_800x600.webp 848w, https://substackcdn.com/image/fetch/$s_!qSuI!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F15c768c8-c69c-460c-8cfc-8dacdd85d2cb_800x600.webp 1272w, https://substackcdn.com/image/fetch/$s_!qSuI!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F15c768c8-c69c-460c-8cfc-8dacdd85d2cb_800x600.webp 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!qSuI!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F15c768c8-c69c-460c-8cfc-8dacdd85d2cb_800x600.webp" width="800" height="600" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/15c768c8-c69c-460c-8cfc-8dacdd85d2cb_800x600.webp&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:600,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:87380,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/webp&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://skeptrune.substack.com/i/188990770?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F15c768c8-c69c-460c-8cfc-8dacdd85d2cb_800x600.webp&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!qSuI!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F15c768c8-c69c-460c-8cfc-8dacdd85d2cb_800x600.webp 424w, https://substackcdn.com/image/fetch/$s_!qSuI!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F15c768c8-c69c-460c-8cfc-8dacdd85d2cb_800x600.webp 848w, https://substackcdn.com/image/fetch/$s_!qSuI!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F15c768c8-c69c-460c-8cfc-8dacdd85d2cb_800x600.webp 1272w, https://substackcdn.com/image/fetch/$s_!qSuI!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F15c768c8-c69c-460c-8cfc-8dacdd85d2cb_800x600.webp 1456w" sizes="100vw"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>I&#8217;m kind of an idiot and let being &#8220;post-fundraise&#8221; change my mindset. We felt like we had the capital to just burn money on ads when it made sense and therefore went heads down building in silence for some number of weeks to then pop our heads out once or twice a month and do a big launch event. That was, without question, horrendous strategy and terrible CEOing on my part.</p><p>The best comparison I can think of is composing a song out of nothing but thirty-second rests and cymbal crashes. People tune you out. Good marketing should feel more like an EDM track: a steady beat with the occasional drop. You want consistent content that people can engage with, punctuated every so often by a big announcement that gets them excited.</p><h2><strong>Executing the Slow Drip Launch</strong></h2><p>You can post and launch all of the small things you ship along the way to the final product. Get the login page working? Post about it. Add the ability to invite users into your org? Post about it. Put new actionable insights in the dashboard? Post about it.</p><p>Each of these is a chance to build awareness and improve your yapping abilities, so once your product is finally stable and working, you have the skillset and audience necessary to get a base of people familiar with it and excited to share.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!VKgr!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6e4b91c-5a23-47e4-9d78-79018438361b_801x1000.webp" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!VKgr!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6e4b91c-5a23-47e4-9d78-79018438361b_801x1000.webp 424w, https://substackcdn.com/image/fetch/$s_!VKgr!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6e4b91c-5a23-47e4-9d78-79018438361b_801x1000.webp 848w, https://substackcdn.com/image/fetch/$s_!VKgr!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6e4b91c-5a23-47e4-9d78-79018438361b_801x1000.webp 1272w, https://substackcdn.com/image/fetch/$s_!VKgr!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6e4b91c-5a23-47e4-9d78-79018438361b_801x1000.webp 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!VKgr!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6e4b91c-5a23-47e4-9d78-79018438361b_801x1000.webp" width="801" height="1000" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/c6e4b91c-5a23-47e4-9d78-79018438361b_801x1000.webp&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1000,&quot;width&quot;:801,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:30872,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/webp&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://skeptrune.substack.com/i/188990770?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6e4b91c-5a23-47e4-9d78-79018438361b_801x1000.webp&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!VKgr!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6e4b91c-5a23-47e4-9d78-79018438361b_801x1000.webp 424w, https://substackcdn.com/image/fetch/$s_!VKgr!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6e4b91c-5a23-47e4-9d78-79018438361b_801x1000.webp 848w, https://substackcdn.com/image/fetch/$s_!VKgr!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6e4b91c-5a23-47e4-9d78-79018438361b_801x1000.webp 1272w, https://substackcdn.com/image/fetch/$s_!VKgr!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6e4b91c-5a23-47e4-9d78-79018438361b_801x1000.webp 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>The alternative is to put all your eggs in one basket and wait until you have a big announcement to make. This is the strategy that most startups follow, and it is a high-risk, low-reward play. If your launch goes viral, you can get a huge boost in awareness and users, but content on social media is rarely evergreen and gets buried in feeds quickly.</p><p>Your best case scenario is a couple days of electricity in return for weeks or months&#8217; worth of work. And, worst case, your launch falls flat and you get literally nothing out of it.</p><h3><strong>Tactic #1: Personal Brands</strong></h3><p>I hate to quote Roy Lee, but he&#8217;s not wrong when he says <a href="https://x.com/im_roy_lee/status/2009677516701565112?s=20">&#8220;most of u tech ppl are doomed to be ngmi forever on x. ur just not funny or sarcastic or arrogant enough for this place&#8221;</a>. Founders, including myself, are typically nerdy software-engineer type folks who are boring to the extent that building a personal brand on X or elsewhere is going to be a struggle.</p><p>However, I&#8217;m here to tell you that with enough failure, any skill issue can be overcome. It takes a lot more effort than being naturally interesting, but you absolutely can activitymax your way into an audience by posting a lot, replying, and engaging with people active in your niche online.</p><p>That&#8217;s not to say you can post terrible content nobody likes and succeed, you definitely do still have to aim to entertain, but you can pick that up as a skill over time. You just have to be comfortable posting into the void for a while until you start to figure it out. Failure is part of the process with marketing the same way it is with everything else.</p><p>The light at the end of the tunnel is that success on social media tends to compound. While it&#8217;s true that social media feeds are more competitive than ever and <a href="https://www.milkkarten.net/p/social-media-followers-feed">no longer show your content consistently to followers</a>, there will be some people who consistently engage with your content and see it day after day.</p><p>Their engagement kind of serves as a core that makes your content count as a live shot on goal, so the platform you&#8217;re posting on at least tests if your content resonates with a wider audience. The size of that &#8220;test group&#8221; gets bigger as your following grows, and you therefore start to more consistently go viral over time.</p><p>Finally, I want to note that you should endeavor to not do this alone. Ideally you hire people or have co-founders and you all have different angles and audiences, so you can test different messaging and content styles to see what resonates as you build.</p><p>Imagine you have a classical cast - engineer, designer, and businessperson. Engineer can post knee-high sock photos about how you&#8217;re using Rust btw, the designer can share overdone figmas nobody&#8217;s ever going to build, and the businessperson can complain about how they were rejected by 67 VCs before getting their mom to finally write the first check.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!JdQi!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe17af88b-4bb1-45fb-8c8e-80ac6a48c326_1600x900.webp" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!JdQi!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe17af88b-4bb1-45fb-8c8e-80ac6a48c326_1600x900.webp 424w, https://substackcdn.com/image/fetch/$s_!JdQi!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe17af88b-4bb1-45fb-8c8e-80ac6a48c326_1600x900.webp 848w, https://substackcdn.com/image/fetch/$s_!JdQi!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe17af88b-4bb1-45fb-8c8e-80ac6a48c326_1600x900.webp 1272w, https://substackcdn.com/image/fetch/$s_!JdQi!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe17af88b-4bb1-45fb-8c8e-80ac6a48c326_1600x900.webp 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!JdQi!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe17af88b-4bb1-45fb-8c8e-80ac6a48c326_1600x900.webp" width="1456" height="819" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/e17af88b-4bb1-45fb-8c8e-80ac6a48c326_1600x900.webp&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:819,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:93890,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/webp&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://skeptrune.substack.com/i/188990770?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe17af88b-4bb1-45fb-8c8e-80ac6a48c326_1600x900.webp&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!JdQi!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe17af88b-4bb1-45fb-8c8e-80ac6a48c326_1600x900.webp 424w, https://substackcdn.com/image/fetch/$s_!JdQi!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe17af88b-4bb1-45fb-8c8e-80ac6a48c326_1600x900.webp 848w, https://substackcdn.com/image/fetch/$s_!JdQi!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe17af88b-4bb1-45fb-8c8e-80ac6a48c326_1600x900.webp 1272w, https://substackcdn.com/image/fetch/$s_!JdQi!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe17af88b-4bb1-45fb-8c8e-80ac6a48c326_1600x900.webp 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Over time each person&#8217;s social graph will grow in different directions and you&#8217;ll be able to test product marketing messaging with different hooks and audiences to see what resonates the most so you can double down on the best possible angle for the big launch. Think jackass for startup marketing.</p><h3><strong>Tactic #2: Field Marketing</strong></h3><p>Host an event once you know what you&#8217;re building. If you put some money behind an open bar and a DJ and message some people an invite, you can usually get a pretty good turnout. You want to do your best to get people who you think have the problem your product solves to show up, but even if you just get a bunch of friends, it&#8217;s usually worth it.</p><p>At some point, probably about a third of the way through the event, grab a mic and talk for a few minutes about what you&#8217;re building. Don&#8217;t bother with a demo or presentation or video, just talk naturally about why you decided to nuke your future career prospects and work 996 for a 1% shot at building something people want. If you can get a few laughs and make it feel like a fun story, people will be more likely to remember it and share it with their friends.</p><p>Use something like <a href="https://www.partiful.com/">Partiful</a> to manage RSVPs and send reminders, and make sure to collect contact information from attendees so you can follow up with them after the event. Auto-enroll people who show up in your product newsletter and send them once a week updates about your progress. If you have a launch date, make sure to send them a reminder a few days before so they can be ready to support you on launch day.</p><p>I like this one because it&#8217;s pretty earnest and doesn&#8217;t require being funny or clever to execute well. If you can throw a good party and tell a good story, you can get a lot of mileage out of this tactic. Hosting &#8220;VIP Dinners&#8221; also tends to be a pretty good lead funnel and functions in the same way.</p><h3><strong>Tactic #3: Capitalizing on Trends</strong></h3><p>I think founders, including myself, are often stubborn when it comes to trend-driven marketing. We tend to feel like adding product features purely for the sake of &#8220;going viral&#8221; is a sellout move, and that we should only build things that are directly related to our product vision. While I do think it&#8217;s important to stay true to your vision, I also think it&#8217;s important to be flexible and adapt to trends when they make sense.</p><p>On that note, I think competitive surfing rounds are a reasonable proxy metaphor for how to think about this. When you&#8217;re in a surf competition, you&#8217;re only going to be allowed to be out in the water for a certain amount of time, so you have to be strategic about which waves you choose to ride. You want to pick the waves that are going to get you the most points, but you also want to make sure you&#8217;re not hesitating too long and missing out on rides that could be good but aren&#8217;t perfect.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!uDrs!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F919b7222-f820-4695-abda-3474c4941ee3_800x360.webp" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!uDrs!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F919b7222-f820-4695-abda-3474c4941ee3_800x360.webp 424w, https://substackcdn.com/image/fetch/$s_!uDrs!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F919b7222-f820-4695-abda-3474c4941ee3_800x360.webp 848w, https://substackcdn.com/image/fetch/$s_!uDrs!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F919b7222-f820-4695-abda-3474c4941ee3_800x360.webp 1272w, https://substackcdn.com/image/fetch/$s_!uDrs!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F919b7222-f820-4695-abda-3474c4941ee3_800x360.webp 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!uDrs!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F919b7222-f820-4695-abda-3474c4941ee3_800x360.webp" width="800" height="360" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/919b7222-f820-4695-abda-3474c4941ee3_800x360.webp&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:360,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:36596,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/webp&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://skeptrune.substack.com/i/188990770?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F919b7222-f820-4695-abda-3474c4941ee3_800x360.webp&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!uDrs!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F919b7222-f820-4695-abda-3474c4941ee3_800x360.webp 424w, https://substackcdn.com/image/fetch/$s_!uDrs!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F919b7222-f820-4695-abda-3474c4941ee3_800x360.webp 848w, https://substackcdn.com/image/fetch/$s_!uDrs!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F919b7222-f820-4695-abda-3474c4941ee3_800x360.webp 1272w, https://substackcdn.com/image/fetch/$s_!uDrs!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F919b7222-f820-4695-abda-3474c4941ee3_800x360.webp 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>You&#8217;re always under similar time pressure in startups, if you miss a growth goal for a single quarter or sometimes even month then it can be a huge problem for your employee retention and fundraising prospects. Therefore, you can&#8217;t afford to be too picky about which trends you choose to ride. If there&#8217;s a meme or topic that&#8217;s relevant to your product and has the potential to get you a lot of attention, you should probably jump on it and ship even if it&#8217;s not perfectly aligned with your vision.</p><p>On a lower level, my recommendation to get started on this is turning on post notifications for accounts in your niche that are good at this and more or less copying what they do. Reply to the same things they reply to, post about the same topics, and use the same formats. You can add your own twist to it and actually make product changes over time as you get more comfortable with the format and start to understand what resonates with your audience.</p><h2><strong>I&#8217;m Begging You to Post</strong></h2><p>If you take nothing else away from this, please for all that&#8217;s holy, just post. It increases your odds of getting lucky and making it by orders of magnitude. And, odds are nobody&#8217;s even going to see your content anyways, so stop worrying about embarrassing yourself.</p>]]></content:encoded></item><item><title><![CDATA[Replace Your Standup with a Todo List]]></title><description><![CDATA[Note: You can watch me write this blog post on video here on x.com!]]></description><link>https://skeptrune.substack.com/p/todolist-standup</link><guid isPermaLink="false">https://skeptrune.substack.com/p/todolist-standup</guid><dc:creator><![CDATA[skeptrune]]></dc:creator><pubDate>Fri, 02 Jan 2026 12:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/68c39320-eba7-4c4f-bdd4-ce7f4c8496a6_1200x630.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote><p><strong>Note:</strong> You can watch me write this blog post <a href="https://x.com/skeptrune/status/2007168539204137238">on video here on x.com!</a></p></blockquote><p>Standups create anxiety. You hoard updates during the day because you need content for the meeting. You forget your blockers. You adjust your schedule to be in early, even if you work better at night.</p><p>I ran into this when I started my company. People join startups to escape corporate bureaucracy. When I tried to introduce morning standups, the early hires pushed back hard. It didn't feel like a startup move to them.</p><p>Given that, I went back to the drawing board to break down the actual utility of the meeting. A standup exists to distribute context so a team can parallelize work and maintain synchronization.</p><p>If that's the goal, you don't need a meeting.</p><h2>The Process</h2><ol><li><p>Post your task list in a channel like <code>#standup</code> first thing in the morning</p></li><li><p>Edit the message to cross off tasks as you complete them throughout the day</p></li></ol><p>Here's what one of those messages looks like.</p><pre><code>- Fix login bug on staging
- Review Sarah's PR #234
- Write API docs for /users endpoint
- Sync with design on checkout flow
</code></pre><p>As you work, you come back and edit the message to cross things off.</p><pre><code>- ~~Fix login bug on staging~~
- ~~Review Sarah's PR #234~~
- Write API docs for /users endpoint
- Sync with design on checkout flow
</code></pre><h2>Advanced Version</h2><p>Add timestamps if you want to track how long things are taking you or otherwise provide more context.</p><pre><code>Today:
- [9:15-10:30] ~~Fix login bug on staging~~
- [10:30-11:00] ~~Review Sarah's PR #234~~
- [11:00-?] Write API docs for /users endpoint
- (no start time since previus task is unfinished) Sync with design on checkout flow
</code></pre><p>That's it. Everyone sees what you're working on. No meeting required.</p>]]></content:encoded></item><item><title><![CDATA[Org-Level Email Campaigns are Somehow an Unsolved Problem]]></title><description><![CDATA[No email tool stops a whole org when one person replies. Here's how I built org-level campaign control with Instantly and webhooks.]]></description><link>https://skeptrune.substack.com/p/org-level-email-campaigns-instantly</link><guid isPermaLink="false">https://skeptrune.substack.com/p/org-level-email-campaigns-instantly</guid><dc:creator><![CDATA[skeptrune]]></dc:creator><pubDate>Sun, 28 Dec 2025 12:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/6d056efe-9883-456c-ae77-8ea4bacb0752_1200x628.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Defining the Goal</h2><p>We launched new <a href="https://www.mintlify.com/docs/ai/discord#discord-bot">community discord and slack bots</a> recently at Mintlify and needed to do some customer marketing to let users who had large communities know about them.</p><p>Our goal was to send an email sequence to the ~3-10 people from each organization who we identified as being able to get value from the feature. Understand that the nature of this product is that once one person from an organization enables the bot, the task is complete, and we don't want to keep emailing everyone else from the org.</p><p>I thought this would be easy using any of the email marketing tools out there like <a href="https://resend.com/">Resend</a> or <a href="https://loops.so/">Loops</a>, but this was not the case. All of the available tools shockingly handle campaigns at a user instead of organization level.</p><p>None of them have the concept of "stop emailing this group when any member takes action." They're all built for individual drip campaigns, not org-level outreach.</p><h2>Tool Selection</h2><p>Claude Code has made me <a href="https://thomasorus.com/i-tried-coding-with-ai-i-became-lazy-and-stupid">quite lazy</a> insofar as I try to get everything I can done using it instead of doing things myself manually. Therefore my number one criteria when picking a tool to solve the above problem with was a large API surface that claude could work with.</p><p><a href="https://instantly.ai">Instantly</a> was my final selection given its API was the most robust and <a href="https://developer.instantly.ai/api/v2/analytics/getwarmupanalytics">well documented</a>. It gave claude full access to campaigns, leads, and sequences while also being capable of sending webhooks on reply and unsubscribe events.</p><p>Kind of random aside but <a href="https://jamstack.org/">JAMstack</a> architecture patterns are probably going to make a comeback with AI. I think tools like <a href="https://trpc.io/">trpc</a> are going to fall out of favor relative to <a href="https://www.openapis.org/">openapi</a> driven patterns that AI agents can better understand. Seperation of UI and business logic is the way forward if we want our apps to be accessible by AI.</p><h2>Solution Architecture</h2><p>The Instantly campaign holds the multi-email sequence and is configured to stop on reply. A lead upload script reads a CSV of contacts, groups them by company, and uploads them to Instantly with a <code>companyName</code> custom variable.</p><p>Then a webhook server listens for reply events, finds all leads from the same company, and marks them as "not interested" to stop their sequences.</p><pre><code>CSV (company, email)
    &#8594; Upload Script
    &#8594; Instantly (leads with companyName)

Reply received
    &#8594; Instantly webhook
    &#8594; Webhook server
    &#8594; Find leads by companyName
    &#8594; Update lead status
    &#8594; Sequence stops for whole company
</code></pre><h3>Creating the Campaign</h3><p>The campaign itself is straightforward. Set <code>stop_on_reply</code> to true so Instantly stops the sequence for whoever replies, then define your email steps with delays between them.</p><pre><code>curl -X POST https://api.instantly.ai/api/v2/campaigns \
  -H "Authorization: Bearer $INSTANTLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Feature Launch Outreach",
    "stop_on_reply": true,
    "stop_on_auto_reply": true,
    "sequences": [{
      "steps": [
        {"type": "email", "delay": 0, "variants": [{"subject": "...", "body": "..."}]},
        {"type": "email", "delay": 3, "variants": [{"subject": "...", "body": "..."}]}
      ]
    }]
  }'
</code></pre><h3>Uploading Leads</h3><p>The important bit is to include a <code>companyName</code> custom variable with every lead. Our webhook server uses that to find all leads from the same company and unsubscribe them on relevant events.</p><pre><code>import csv
import requests

API_KEY = "your-api-key"
CAMPAIGN_ID = "your-campaign-id"

with open("contacts.csv") as f:
    reader = csv.DictReader(f)
    for row in reader:
        emails = row["emails"].split(";")
        company = row["company_name"]

        for email in emails:
            requests.post(
                "https://api.instantly.ai/api/v2/leads",
                headers={"Authorization": f"Bearer {API_KEY}"},
                json={
                    "email": email.strip(),
                    "company_name": company,
                    "custom_variables": {"companyName": company},
                    "campaign": CAMPAIGN_ID
                }
            )
</code></pre><h3>Registering the Webhook</h3><p>Tell Instantly to POST to your server whenever someone replies. You'll need the campaign ID from earlier.</p><pre><code>curl -X POST https://api.instantly.ai/api/v2/webhooks \
  -H "Authorization: Bearer $INSTANTLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "target_hook_url": "https://your-server.com/webhook/reply",
    "event_type": "reply_received",
    "campaign_id": "your-campaign-id"
  }'
</code></pre><h2>Webhook Server</h2><p>This is the part that makes it all work. When anyone replies, the webhook server extracts the company name, queries Instantly for all leads with that company name, and updates each lead's status to stop their sequence.</p><pre><code>const express = require('express');
const app = express();
app.use(express.json());

const API_KEY = process.env.INSTANTLY_API_KEY;
const CAMPAIGN_ID = process.env.CAMPAIGN_ID;

app.post('/webhook/reply', async (req, res) =&gt; {
  const event = req.body;

  if (event.event_type !== 'reply_received') {
    return res.json({ status: 'ignored' });
  }

  const leadEmail = event.lead_email;
  const companyName = event.lead?.company_name;

  if (!companyName) {
    return res.json({ status: 'ignored', reason: 'no company' });
  }

  // Find all leads from this company
  const leads = await findLeadsByCompany(companyName);

  // Stop all of them (except the one who replied, they're already stopped)
  for (const lead of leads) {
    if (lead.email !== leadEmail) {
      await stopLead(lead.email);
    }
  }

  res.json({ status: 'ok', stopped: leads.length - 1 });
});

// Instantly's API doesn't let you filter by company_name server-side.
// The search param only works on name/email. So we fetch all leads
// and filter client-side. For large campaigns, you'd want to cache
// this or build your own company-&gt;leads index.
async function findLeadsByCompany(companyName) {
  const leads = [];
  let cursor = null;

  do {
    const resp = await fetch('https://api.instantly.ai/api/v2/leads/list', {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ campaign: CAMPAIGN_ID, starting_after: cursor })
    });
    const data = await resp.json();
    leads.push(...data.items);
    cursor = data.next_starting_after;
  } while (cursor);

  return leads.filter(lead =&gt; lead.company_name === companyName);
}

async function stopLead(email) {
  await fetch('https://api.instantly.ai/api/v2/leads/update-interest-status', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ lead_email: email, interest_value: -1 })
  });
}

app.listen(3000);
</code></pre><p>You can host this anywhere that can receive HTTP requests. I used a cheap VPS with Docker behind Caddy for SSL. Other options include Cloudflare Workers, Railway, Render, AWS Lambda, or Vercel. The server is stateless, so any hosting that can run Node.js will work.</p><h2>Someone Please Build This</h2><p>This solution works, but it's more complex than it should be. The fact that I had to build a webhook server to get org-level behavior is absurd. This should be a checkbox in every B2B email tool.</p><p>What I actually want is to define groups of contacts by company, toggle "stop group on reply" as a campaign setting, and have the email tool handle it without webhooks.</p><p>If you're building email tools, please add this.</p>]]></content:encoded></item><item><title><![CDATA[Working with Me]]></title><description><![CDATA[Several people have recommended I write a "working with me" document to help onboard new collaborators, teammates, and contractors and I finally got around to it.]]></description><link>https://skeptrune.substack.com/p/working-with-me</link><guid isPermaLink="false">https://skeptrune.substack.com/p/working-with-me</guid><dc:creator><![CDATA[skeptrune]]></dc:creator><pubDate>Mon, 15 Dec 2025 12:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/102fe023-b105-456e-beb6-3b021b5ae622_453x453.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Several people have recommended I write a "working with me" document to help onboard new collaborators, teammates, and contractors and I finally got around to it. Here it is!</p><h2>How I Communicate</h2><p>I will always provide you with a quick response or answer, but will often go back and edit the message to clarify or poke at the topic with further thoughts over the course of the next hour or so. I believe word economy is important and try to be quiet if I have nothing valuable to respond with.</p><h2>How to Get My Attention</h2><p>Call my cell phone if you have my number. Otherwise, @ me on Slack. I <strong>strongly</strong> prefer that you call me though. Most of my texts are voice transcriptions and I tend to add emojis to communicate my intended tone. It's easier to get all that right over a phone call when things are urgent. My written communication can often be read with a tone I didn't intend.</p><p>If you are messaging me on Slack, try to do it in a shared channel. DMs create communication silos and I think they are usually more harm than good. Very few things actually need to be private. If you send me a DM that I think should be in a channel I will often just forward it there myself and continue the conversation in that new location in front of a larger group.</p><h2>Planning</h2><p>Please never send me a planning document. Communicate your idea in 3 paragraphs or less and text it to me. I detest opening 3 pages of workslop full of theory about an unstarted task.</p><p>If you really can't get it to work in a single message, make a Slack canvas. Anything is better than a document which lives in another piece of software I have to sign into and open.</p><h2>1:1s</h2><p>I like recurring 1:1s when they are about moving towards goals. If we can set something up where one of us is working towards something and we decide on steps to get there, then a recurring meeting is useful to continuously sync on that.</p><p>Progression is fun, and even more so when you keep track of it with someone who's invested in holding you accountable.</p><h2>Feedback</h2><p>I try to only give unprompted feedback when it's extremely specific and easy to understand. My mindset is that high-level feedback about a general topic or performance is only something that should be shared on request.</p><p>Please send me any feedback you think would be useful. Specificity is always good. I will try to improve where I can at all times.</p><h2>What I Value</h2><p>Please try to do something and fail before asking me for help. I find it much easier to help when you are actively experiencing a problem and want to solve it rather than just wanting my thoughts. Speaking extensively about strategy before doing something is almost always a waste of time.</p><p>I prioritize failure. You are usually some number of failures away from success, and I think you should try to get those over with as fast as you can so you reach success earlier. I get frustrated seeing people play it safe, always doing what they're good at, or ponder endlessly before closing the loop by launching and getting feedback.</p><h2>My Quirks</h2><p>I tend to be skeptical, sarcastic, and kind of a curmudgeon. Complexity is something I avoid on principle, even when it will save me time. Newfangled toys are something I rarely find valuable.</p><p>UX is important to me, but not so much UI. I would rather something be functional and easy to use than pretty. I don't think design is particularly valuable. Just solve the problem.</p><h2>Hours and Availability</h2><p>I don't sleep all that much and am easily awoken. 3am&#8211;8am are the hours I am typically hard to reach, but if you call my cell phone I will usually pick up. Just call me!</p><p>Direct pings are something which I always try to respond to as fast as possible. If it's important enough that you're annoyed with my response time being slow, again, call my cell.</p><h2>What I'm Working On Improving</h2><p>Writing, making videos, and marketing are the core skills I want to improve in the upcoming year. I would appreciate any feedback or ideas you may have for me in those areas.</p>]]></content:encoded></item><item><title><![CDATA[Prompt the Loop When Using Coding Agents]]></title><description><![CDATA[import badpromptVideo from '../../assets/images/blog-posts/PromptingTheAgentLoop/badprompt.mp4'; import goodpromptVideo from '../../assets/images/blog-posts/PromptingTheAgentLoop/goodprompt.mp4';]]></description><link>https://skeptrune.substack.com/p/prompting-the-agent-loop</link><guid isPermaLink="false">https://skeptrune.substack.com/p/prompting-the-agent-loop</guid><dc:creator><![CDATA[skeptrune]]></dc:creator><pubDate>Sun, 02 Nov 2025 12:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/85f0ccd8-fe25-4280-bb5c-52806de25557_610x610.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!X3Q-!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4599e5b3-251c-407d-a9da-31c14f82b4e4_596x288.webp" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!X3Q-!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4599e5b3-251c-407d-a9da-31c14f82b4e4_596x288.webp 424w, https://substackcdn.com/image/fetch/$s_!X3Q-!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4599e5b3-251c-407d-a9da-31c14f82b4e4_596x288.webp 848w, https://substackcdn.com/image/fetch/$s_!X3Q-!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4599e5b3-251c-407d-a9da-31c14f82b4e4_596x288.webp 1272w, https://substackcdn.com/image/fetch/$s_!X3Q-!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4599e5b3-251c-407d-a9da-31c14f82b4e4_596x288.webp 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!X3Q-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4599e5b3-251c-407d-a9da-31c14f82b4e4_596x288.webp" width="596" height="288" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/4599e5b3-251c-407d-a9da-31c14f82b4e4_596x288.webp&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:288,&quot;width&quot;:596,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;Adam's tweet about AI agents doing tedious work&quot;,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Adam's tweet about AI agents doing tedious work" title="Adam's tweet about AI agents doing tedious work" srcset="https://substackcdn.com/image/fetch/$s_!X3Q-!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4599e5b3-251c-407d-a9da-31c14f82b4e4_596x288.webp 424w, https://substackcdn.com/image/fetch/$s_!X3Q-!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4599e5b3-251c-407d-a9da-31c14f82b4e4_596x288.webp 848w, https://substackcdn.com/image/fetch/$s_!X3Q-!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4599e5b3-251c-407d-a9da-31c14f82b4e4_596x288.webp 1272w, https://substackcdn.com/image/fetch/$s_!X3Q-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4599e5b3-251c-407d-a9da-31c14f82b4e4_596x288.webp 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>You can safely ignore advice about coding agents that doesn't mention loop structure.</p><p>"Agent" is an overloaded term, so I want to clarify that I'm using <a href="https://simonwillison.net/2025/Sep/18/agents/">simonw's definition</a> of "a language model which runs tools in a loop to achieve a goal" when I say "agent" throughout this post.</p><blockquote><p>If that doesn't make sense to you then read <a href="https://fly.io/blog/everyone-write-an-agent/">fly.io's guide on writing agents</a> or <a href="https://ampcode.com/how-to-build-an-agent/">ampcode's guide</a> and try building your own micro-agent first before continuing. I promise it's a worthwhile exercise.</p></blockquote><h2>What is This Loop You Speak Of?</h2><p>Consider this example prompt asking claude code to write a <code>generateSlug</code> function.</p><pre><code>add a new function to the @index.ts file called `generateSlug` which accepts a blog post title and returns a URL-friendly slug
</code></pre><p>Sounds clear and like it should work right? WRONG! Watch the output below.</p><div class="native-video-embed" data-component-name="VideoPlaceholder" data-attrs="{&quot;mediaUploadId&quot;:&quot;cbfbe1d3-4a1d-41e6-a13d-a725e1d5d669&quot;,&quot;duration&quot;:null}"></div><p>We're missing unicode characters, special characters, multiple spaces, edge cases. Curse javascript all you want, but this is our fault, not Claude's.</p><p>Our prompt doesn't give the agent anything to test against. You want something more like</p><pre><code>look at @index.ts and start by making sure you have a way to unit test functions which get added to that file. Add jest and new ts files if you need to, add a new script to @package.json. Then scaffold a new generateSlug function in @index.ts, write robust tests for it covering unicode, special chars, multiple spaces, edge cases, run them, watch them fail, implement the slug generator until they all pass. Unit tests should be in the same index.ts file as the function implementation.
</code></pre><p>Watch <em>this baby</em> run!</p><div class="native-video-embed" data-component-name="VideoPlaceholder" data-attrs="{&quot;mediaUploadId&quot;:&quot;0251a411-0f18-45d9-a786-2356926e5a66&quot;,&quot;duration&quot;:null}"></div><h2>It's all the agent loop!</h2><p>The key difference is this phrase from the second prompt:</p><blockquote><p>"run them, watch them fail, implement the slug generator until they all pass"</p></blockquote><p>This gives the agent a loop condition, a test to run repeatedly while iterating. Without it, the agent takes one shot and stops. With it, the agent keeps working until satisfied.</p><h2>Common Loop Condition Patterns</h2><p>The pattern is always the same. Describe the work, specify validation, then tell the agent to iterate until the validation passes. Here are three templates you can adapt.</p><p><strong>For new features</strong></p><pre><code>[describe feature], write tests for [key behaviors], run them, fix until they all pass</code></pre><p><strong>For bug fixes</strong></p><pre><code>reproduce the bug in [file/test], fix the issue, verify the bug no longer occurs and all tests pass</code></pre><p><strong>For build/compile issues</strong></p><pre><code>[make changes], run the build, fix any errors or type issues until it compiles successfully</code></pre><p>All of the above patterns include some kind of check; either tasts pass or fail, builds succeed or error, bugs reproduce or don't. Agents can take these abstract descriptions and turn them into concrete tools which they can run over and over until the condition is met.</p><p>Prompting outside of this pattern is the equivalent of grabbing a jr dev 3 shots past <a href="https://en.wikipedia.org/wiki/Ballmer_Peak">Ballmer's peak</a> and asking them to fix a bug.</p><h2>Conclusion</h2><p>Senior developers know what loop conditions work for different tasks. If you're junior and not sure what's appropriate for a given task, use the planning mode offered by <a href="https://www.claude.com/product/claude-code">claude code</a>, <a href="https://cursor.com/">cursor</a>, or your coding agent of choice to help you craft them.</p><p>Good luck out there!</p>]]></content:encoded></item><item><title><![CDATA[How I Use Claude Code on My Phone with Termux and Tailscale]]></title><description><![CDATA[You don't need a new startup or third-party service to use Claude Code on your phone. You just need SSH, Tailscale, and Termux. Here's how to code from anywhere with the tools you already have.]]></description><link>https://skeptrune.substack.com/p/claude-code-on-mobile-termux-tailscale</link><guid isPermaLink="false">https://skeptrune.substack.com/p/claude-code-on-mobile-termux-tailscale</guid><dc:creator><![CDATA[skeptrune]]></dc:creator><pubDate>Sun, 19 Oct 2025 12:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/b6846689-069a-4432-88ae-d830883bbea5_598x674.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There's a mini gold rush to put <a href="https://claude.ai/claude-code">Claude Code</a> on your phone. Some startups are building custom apps, others are creating managed cloud environments. They're solving real problems, but you're trading raw Unix power for convenience. If you have a desktop and 20 minutes, you can get full kernel access with SSH, <a href="https://termux.dev/en/">termux</a>, and <a href="https://tailscale.com/">tailscale</a>.</p><p>Yesterday I <a href="https://x.com/skeptrune/status/1979668217930084596">posted about</a> shipping a feature to this blog from the passenger seat while driving to Apple Hill, CA from San Francisco. I SSH'd into my office desktop from my phone, prompted Claude to make the changes, tested them on my phone's browser, and pushed to production in 10 minutes. That post got 130k impressions and dozens of people asked for the setup.</p><p>This article walks through doing SSH-based mobile development with Claude Code. If you have a desktop that stays on (or a cheap VPS), you can get full terminal access from your phone with session persistence, port forwarding, and the ability to test your code on your actual mobile browser. The initial setup takes about 20 minutes and just works once configured.</p><h2>The Architecture</h2><p>The setup uses five standard Unix tools that work together without custom integration. A <strong>desktop</strong> runs Claude Code, <strong>tailscale</strong> creates a private network between your devices, <strong>termux</strong> gives you a real terminal on Android, <strong>SSH</strong> handles the connection, and <strong>tmux</strong> keeps your sessions alive when you disconnect.</p><h3>Step 1: Setup Your Desktop</h3><p>You need a computer that stays on. This could be a desktop at home, a desktop at your office, a cloud VM, or a home server. It doesn't need to be powerful. Claude Code just makes API calls, the actual compute happens at Anthropic.</p><p>I keep a desktop at my office that stays on 24/7. It's running Ubuntu with Claude Code installed. The computer does nothing else. It just sits there waiting for me to SSH in and start coding.</p><p>First, install Claude Code globally using npm. This gives you the <code>claude</code> command that you'll use to start coding sessions.</p><pre><code>npm install -g @anthropics/claude-code
</code></pre><p>Next, install tmux for session persistence. When you disconnect from SSH (phone locks, network drops, whatever), tmux keeps your Claude Code session running in the background. When you reconnect, you pick up exactly where you left off.</p><pre><code>sudo apt install tmux  # Ubuntu/Debian
brew install tmux      # macOS
</code></pre><p>With Claude Code and tmux installed, your desktop is ready to host your development sessions.</p><h3>Step 2: Install Tailscale Everywhere</h3><p>Tailscale creates a private network between all your devices. Your phone gets a stable IP address that can reach your desktop, even when you're on different networks. It just works.</p><p>On your desktop, run the Tailscale installer. The script will detect your OS and install the right package. Then bring up the Tailscale connection, which will prompt you to authenticate in your browser.</p><pre><code>curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
</code></pre><p>Install Tailscale on your phone from the Play Store. Sign in with the same account. Your devices are now on the same network.</p><p>You'll need your desktop's Tailscale IP address to connect from your phone. Grab it with this command.</p><pre><code>tailscale ip -4
</code></pre><p>You'll get something like <code>100.64.0.5</code>. That's your desktop's address on the Tailscale network. It's stable, it's private, and it works from anywhere.</p><h3>Step 3: Install Termux on Your Phone</h3><p>Termux is a terminal emulator for Android that gives you a real Linux environment. Not a toy terminal. A real one with bash, ssh, and full package management.</p><p>Install Termux from F-Droid, not the Play Store. The Play Store version is outdated and broken. Get it from https://f-droid.org/en/packages/com.termux/.</p><p>Once installed, you'll need to update the package repositories and install the SSH client. Termux uses <code>pkg</code> as its package manager, which is basically a wrapper around apt.</p><pre><code>pkg update
pkg install openssh
</code></pre><p>With OpenSSH installed, Termux can now connect to your desktop over SSH.</p><h3>Step 4: SSH Into Your Desktop</h3><p>Now for the moment of truth. Open Termux and SSH to your desktop using the Tailscale IP you grabbed earlier. Replace <code>100.64.0.5</code> with your actual IP and <code>your-username</code> with your desktop username.</p><pre><code>ssh your-username@100.64.0.5
</code></pre><p>The first time you connect, SSH will ask you to verify the host fingerprint. Type <code>yes</code>. Then enter your password.</p><p>You're in. You're now running a shell on your desktop, from your phone, over a secure encrypted connection that works anywhere you have internet.</p><h3>Step 5: Use tmux for Session Persistence</h3><p>tmux is what makes this whole setup practical. When you disconnect from SSH, your tmux session keeps running on the desktop. When you reconnect, you attach to the same session and everything is exactly where you left it.</p><p>You're now connected to your desktop via SSH from your phone. Start a new tmux session with a name you'll remember. I usually name mine after the project I'm working on.</p><pre><code>tmux new -s code
</code></pre><p>This creates a session named "code". You can name it anything. Inside the tmux session, launch Claude Code and start working.</p><pre><code>claude
</code></pre><p>Now you're coding. On your phone. Using Claude Code. Running on your desktop.</p><p>When you need to disconnect, don't exit Claude Code. Don't exit tmux. Just close Termux or let your phone lock. The tmux session stays running on your desktop.</p><p>Later, when you want to code again, SSH back in and reattach to your session. Everything will be exactly where you left it.</p><pre><code>ssh your-username@100.64.0.5
tmux attach -t code
</code></pre><p>Your conversation with Claude is still there. Your file context is still loaded. You can continue your previous task immediately.</p><p>In the Apple Hill example from the intro, this is exactly what I did. I SSH'd in, ran <code>tmux attach -t personalsite</code> to reconnect to my development session, and told Claude to add a section about the Public Suffix List and make headings into clickable anchor links. The session had been running for days. I just picked up exactly where I'd left off.</p><h2>Why This Works Better Than Custom Apps</h2><p>Every startup trying to solve "Claude Code on mobile" is building abstractions on top of these primitives. They're not giving you anything you can't already do with SSH and Termux. They're just wrapping it in a prettier UI and charging for hosting.</p><p>When you do it yourself, you get several advantages.</p><p><strong>Port forwarding just works.</strong> With Tailscale, your desktop's ports are directly accessible from your phone. No configuration, no exposing services to the public internet, no proxies adding latency. Your phone and desktop are on the same private network, so anything listening on your desktop is one IP address away.</p><p><strong>Full CLI access to configure your environment.</strong> Want to run your dev server with <code>--host</code> so you can test on your phone's browser? Just add the flag. Need to adjust firewall rules, modify server configs, or install system packages? You have root access. Native mobile coding apps can't offer this level of control because it's too niche for their target users, but for power users it's essential.</p><p><strong>Session persistence that actually works.</strong> tmux was built for this. Your session survives network disconnections, phone reboots, and SSH reconnects. You never lose your place.</p><p><strong>Your own hardware.</strong> Your desktop has your SSH keys, your git credentials, your environment exactly how you configured it. You're not coding in a disposable cloud container.</p><h2>The Mobile Experience</h2><p>I'm not going to pretend coding on a phone is as good as coding on a desktop. It's not. The screen is small. The keyboard is mediocre. You can't see multiple files at once.</p><p>But Claude Code is different from traditional coding. You're not typing out functions character by character. You're describing what you want, reviewing Claude's changes, and approving or rejecting them. That workflow actually works on mobile.</p><p>The Apple Hill example wasn't cherry-picked. I've shipped real features from my phone. I've fixed production bugs while getting coffee. I've reviewed pull requests from the back of an Uber. It's not my primary development environment, but it's shockingly capable when I need it.</p><p>The key is that Claude Code is conversational. You're having a back-and-forth with an AI that writes code for you. That interaction model translates to mobile better than traditional text editing. You're reading more than you're typing, and phones are great for reading.</p><h2>Practical Tips</h2><p>Once you have the basic setup running, there are a few tweaks that make the mobile coding experience dramatically better. These aren't strictly necessary, but they'll save you time and frustration.</p><h3>Test Your Changes on Your Phone's Browser</h3><p>This is the killer feature. You're not just editing code remotely, you can test it on your phone while the dev server runs on your desktop.</p><p>When I was working on the blog changes from Apple Hill, I wanted to see how the clickable anchor links looked on mobile. The trick is starting your dev server with the <code>--host</code> flag, which makes it accessible on your Tailscale network instead of just localhost.</p><pre><code>yarn dev --host
</code></pre><p>For Vite (which Astro uses), this binds the dev server to <code>0.0.0.0</code> instead of <code>127.0.0.1</code>. For other frameworks: <code>npm start -- --host</code> for React, <code>next dev -H 0.0.0.0</code> for Next.js, <code>python manage.py runserver 0.0.0.0:8000</code> for Django.</p><p>Grab your desktop's Tailscale IP again if you forgot it.</p><pre><code>tailscale ip -4
</code></pre><p>Then on your phone's browser, navigate to <code>http://100.64.0.5:4321</code> (replace with your Tailscale IP and port).</p><p>You're now viewing your local dev server, running on your desktop 2.5 hours away, in your phone's browser. I saw the anchor links were styled wrong, told Claude to fix them, refreshed, confirmed they looked good, and pushed. The whole workflow took maybe 10 minutes.</p><p>You're developing with the actual target device in your hand. You can test responsive layouts, check mobile interactions, and iterate immediately instead of deploying to staging or waiting until you're back at your desk.</p><h3>Use SSH Keys</h3><p>Don't type your password every time you SSH. Generate an SSH key on your phone and add it to your desktop's authorized keys.</p><p>Open Termux and generate an ed25519 key (the modern standard). Then use <code>ssh-copy-id</code> to automatically add it to your desktop's authorized keys file.</p><pre><code>ssh-keygen -t ed25519
ssh-copy-id your-username@100.64.0.5
</code></pre><p>Now you can SSH without a password.</p><h3>Create an SSH Config</h3><p>Make connecting easier by adding your desktop to your SSH config. Instead of typing <code>ssh your-username@100.64.0.5</code> every time, you can create an alias. Make a file at <code>~/.ssh/config</code> in Termux with this content.</p><pre><code>Host desktop
    HostName 100.64.0.5
    User your-username
</code></pre><p>Now you can just type <code>ssh desktop</code> instead of remembering the IP and username.</p><h3>Use a Better Keyboard</h3><p>Termux works with external keyboards. I keep a small Bluetooth keyboard in my bag. When I'm actually trying to get work done on my phone, I pull out the keyboard. It makes a massive difference.</p><p>The phone screen is fine for reading. The keyboard makes typing bearable.</p><h3>Set Up tmux Keybindings</h3><p>tmux's default keybindings are terrible on mobile. Remap them to something sensible. On your desktop, create or edit <code>~/.tmux.conf</code> and add these bindings. They make tmux way easier to use on a phone keyboard.</p><pre><code># Use Ctrl-A instead of Ctrl-B (easier to type)
unbind C-b
set -g prefix C-a
bind C-a send-prefix

# Split panes with | and -
bind | split-window -h
bind - split-window -v
</code></pre><p>Now you can manage tmux sessions without finger gymnastics.</p><h3>Run Multiple Sessions</h3><p>You can have multiple tmux sessions for different projects. I usually have one for each repo I'm actively working on. Start them with descriptive names so you remember what's what.</p><pre><code>tmux new -s backend
tmux new -s frontend
tmux new -s experiments
</code></pre><p>When you SSH in and want to see what sessions are running, list them.</p><pre><code>tmux ls
</code></pre><p>Then attach to whichever one you want to work on.</p><pre><code>tmux attach -t frontend
</code></pre><p>This keeps your different projects isolated. You can switch contexts just by attaching to a different session.</p><h2>Security Considerations</h2><p>You're SSH'ing into your desktop over the internet. That's a potential security risk if you do it wrong. Do it right with a few precautions.</p><p><strong>Use Tailscale.</strong> Never expose SSH to the public internet. Use Tailscale to create a private network between your devices. Your SSH traffic stays encrypted and never touches the public internet directly.</p><p><strong>Use SSH keys.</strong> Disable password authentication entirely. Keys are longer, stronger, and can't be brute-forced. Edit <code>/etc/ssh/sshd_config</code> on your desktop and set these values, then restart sshd.</p><pre><code>PasswordAuthentication no
PubkeyAuthentication yes
</code></pre><p><strong>Keep your phone secure.</strong> Your phone now has SSH access to your development machine. If someone steals your phone, they can access your desktop. Use a strong PIN or biometric lock. Enable disk encryption. Consider using a password manager for your SSH key passphrase.</p><p><strong>Monitor SSH access.</strong> Check who's connected to your machine with <code>who</code> or <code>w</code>. Check SSH logs with <code>sudo tail -f /var/log/auth.log</code>. If you see connections you don't recognize, revoke SSH keys and investigate.</p><p>The threat model here is pretty mild. Your SSH traffic is encrypted. Your Tailscale network is private. The main risk is losing your phone, which is why phone security matters.</p><h2>When This Doesn't Work</h2><p>This setup assumes you have a desktop that stays on. If you don't, you need a cloud VM or a home server. That's still not a reason to use a third-party service. Just rent a $5/month VPS from <a href="https://www.digitalocean.com/">DigitalOcean</a> or <a href="https://www.hetzner.com/">Hetzner</a>, install Tailscale and Claude Code, and SSH into it the same way.</p><p>This also assumes you're on Android. If you're on iOS, Termux isn't available. You'll need to use a different SSH client like <a href="https://blink.sh/">Blink</a> or <a href="https://panic.com/prompt/">Prompt</a>. The rest of the setup is the same.</p><p>If you're on unstable internet, SSH can be frustrating. <a href="https://mosh.org/">Mosh</a> (mobile shell) is designed for high-latency or unreliable connections. Install it on both your phone and desktop, then use <code>mosh desktop</code> instead of <code>ssh desktop</code>. It handles disconnections gracefully and keeps your terminal responsive even on bad networks.</p><h2>The Bottom Line</h2><p>Mobile development with Claude Code doesn't require new infrastructure or custom applications. The components you need are SSH, Tailscale, Termux, and a desktop that stays on. These are standard Unix tools that have been solving remote access problems for decades.</p><p>SSH has been the standard for secure remote access since 1995. Tmux has provided session management since 2007. Tailscale is newer, but it's built on WireGuard, which has undergone extensive security audits. These tools are mature, well-documented, and widely deployed in production environments.</p><p>The underlying problem, accessing a remote development environment from a mobile device, was solved long before mobile coding became a focus. This approach applies those established solutions to Claude Code without requiring custom middleware or managed services.</p><p>If you have a desktop or VPS and 20 minutes for setup, you can have this working today. Install termux, configure tailscale, and connect via SSH. The workflow is straightforward and the tools are reliable.</p><p>Glory be to the AI overlords, who grant us the grace to code at the bar without shame.</p>]]></content:encoded></item><item><title><![CDATA[Multi-Tenant SaaS's Wildcard TLS: An Overview of DNS-01 Challenges]]></title><description><![CDATA[How to provision and manage wildcard TLS certificates for multi-tenant systems with tenant-specific subdomains, solving the scaling challenges of per-tenant certificates.]]></description><link>https://skeptrune.substack.com/p/wildcard-tls-for-multi-tenant-systems</link><guid isPermaLink="false">https://skeptrune.substack.com/p/wildcard-tls-for-multi-tenant-systems</guid><dc:creator><![CDATA[skeptrune]]></dc:creator><pubDate>Fri, 17 Oct 2025 00:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/a31c07d1-7ea5-49a6-a2b2-80833a6bbc02_888x499.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>AI app builders are everywhere now. You enter a prompt, get a deployed product on <code>your-app.builder.com</code>, and ship. Replit, Bolt, Lovable, v0, and dozens of other similar platforms launched in the past few months, and they all need instant subdomain provisioning with HTTPS for every user.</p><p>This pattern isn't new. Multi-tenant SaaS has used <code>tenant-id.foo.com</code> subdomains forever. But the explosion of AI builders that spin up hundreds of new subdomains daily makes the certificate management problem more visible. You can't provision individual certificates for every generated app, you need wildcard certificates.</p><p>I'd never set this up before, but at <a href="https://www.mintlify.com">Mintlify</a> we had an internal hackathon today and I built my own AI app builder. That meant I finally had a good excuse to figure out how wildcard TLS actually works. I'm sharing what I learned so you can implement it too.</p><h2>The Problem: Per-Tenant Certificates Don't Scale</h2><p>If you provision individual certificates for each tenant, you're running ACME challenges for every new tenant signup, managing certificate renewals for potentially tens of thousands of certificates, and hitting rate limits from Let's Encrypt (50 certificates per registered domain per week). You need a better approach.</p><h2>Wildcard Certificates: One Cert, Infinite Tenants</h2><p>A wildcard certificate for <code>*.foo.com</code> covers all first-level subdomains. This means any subdomain directly under your base domain gets automatic TLS coverage with a single certificate.</p><pre><code>tenant-a.foo.com     &#10003;
tenant-b.foo.com     &#10003;
tenant-xyz.foo.com   &#10003;
</code></pre><p>The wildcard certificate doesn't extend to the apex domain or nested subdomains, though. Here's what's explicitly excluded from coverage.</p><pre><code>foo.com                      &#10007; (apex domain)
api.tenant-a.foo.com         &#10007; (nested subdomain)
</code></pre><p>For most multi-tenant systems, this is exactly what you want. One certificate, provisioned once, renewed automatically, and it works for every tenant you'll ever onboard.</p><h2>Why You Must Use DNS-01 Challenges</h2><p>To get a wildcard certificate from Let's Encrypt (or any ACME-compliant CA), you must use the DNS-01 challenge type. The more common HTTP-01 challenge doesn't work for wildcards.</p><p>With HTTP-01, the CA verifies domain ownership by requesting a specific file at <code>http://your-domain/.well-known/acme-challenge/token</code>. But for <code>*.foo.com</code>, there's no single HTTP endpoint to verify; the wildcard represents infinite possible subdomains.</p><p>DNS-01 solves this by verifying ownership at the DNS level. Your ACME client requests a wildcard certificate for <code>*.foo.com</code>, Let's Encrypt generates a challenge token, and you create a TXT record at <code>_acme-challenge.foo.com</code> with that token as the value.</p><p>Let's Encrypt queries public DNS for that TXT record, and if the record exists with the correct value, Let's Encrypt knows you control the domain and issues the certificate. This means your certificate provisioning system needs <em>programmatic access</em> to your DNS provider's API to create and delete TXT records on demand.</p><h2>How DNS-01 Automation Works</h2><p>The key to wildcard certificates is automating the DNS-01 challenge. This requires your web server or load balancer to have API access to your DNS provider. When Let's Encrypt needs to verify domain ownership, your system creates a temporary TXT record, waits for DNS propagation, completes the challenge, and cleans up the record.</p><p>I'm using Caddy as my reverse proxy with Cloudflare as my DNS provider, but the architecture is the same regardless of your stack. Nginx with cert-manager on Kubernetes works the same way. HAProxy with acme.sh works the same way. The pattern is universally <code>web server + DNS provider plugin + ACME client = automated wildcard certificates</code>.</p><h3>The Architecture (Cloudflare Example)</h3><p>The system has three layers. Caddy is the web server that needs TLS certificates. The <code>caddy-dns/cloudflare</code> module is a thin adapter (only ~120 lines of Go) that sits between Caddy and the actual DNS API client. The <code>libdns/cloudflare</code> package handles the real work of talking to Cloudflare's API.</p><p>Caddy handles the web server and ACME logic, <code>certmagic</code> handles certificate management and renewal, <code>libdns/cloudflare</code> handles DNS API calls, and the plugin just connects them together.</p><p>This same pattern exists for every major DNS provider. There's <code>caddy-dns/route53</code> for AWS, <code>caddy-dns/googleclouddns</code> for GCP, <code>caddy-dns/azure</code> for Azure, and plugins for dozens of other providers. The code structure is nearly identical, you just swap the API client.</p><h3>Building Caddy with DNS Provider Support</h3><p>Standard Caddy doesn't include DNS provider modules. You need to build a custom binary with the plugin compiled in. For Cloudflare we add some go modules and a community plugin.</p><pre><code># Install xcaddy (Caddy's build tool)
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest

# Build Caddy with the Cloudflare DNS plugin
xcaddy build --with github.com/caddy-dns/cloudflare
</code></pre><p>This uses Caddy's module system to compile the plugin into a single binary. The result is a <code>caddy</code> executable that includes the DNS provider integration.</p><p>For other providers, just swap the module name <code>--with github.com/caddy-dns/route53</code> for AWS, <code>--with github.com/caddy-dns/googleclouddns</code> for GCP, <code>--with github.com/caddy-dns/azure</code> for Azure. You can even include multiple providers if you manage domains across different DNS platforms.</p><h3>Configuring Your Caddyfile</h3><p>Once you've built Caddy with the DNS provider plugin, the actual configuration is remarkably simple. Here's the complete configuration for wildcard TLS with automatic provisioning and renewal.</p><pre><code>*.foo.com {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }

    # Your reverse proxy config
    reverse_proxy localhost:8000
}
</code></pre><p>Three lines of TLS configuration, and you get automatic wildcard certificate provisioning, automatic renewal 30 days before expiration, DNS-01 challenges handled transparently, and zero maintenance.</p><h3>Getting DNS Provider Credentials</h3><p>Your web server needs API credentials to manage DNS records. The specific permissions required are consistent across providers. You need read access to list zones/domains, and write access to create and delete TXT records.</p><p>For Cloudflare, create an API token at <code>https://dash.cloudflare.com/profile/api-tokens</code> with <code>Zone.Zone:Read</code> and <code>Zone.DNS:Edit</code> permissions. For AWS Route53, create an IAM user or role with <code>route53:ListHostedZones</code>, <code>route53:GetChange</code>, and <code>route53:ChangeResourceRecordSets</code> permissions. For GCP Cloud DNS, create a service account with the <code>dns.admin</code> role scoped to your DNS zone.</p><p>The key is following the principle of least privilege, grant only the permissions needed for DNS challenge automation, nothing more.</p><pre><code>export CF_API_TOKEN="your_token_here"
</code></pre><p>The <code>{env.CF_API_TOKEN}</code> placeholder in the Caddyfile will be replaced with this value when Caddy starts.</p><h3>What Happens Under the Hood</h3><p>When you start Caddy, here's the complete flow.</p><p><strong>1. Configuration Parsing</strong></p><p>Caddy reads your Caddyfile and encounters the <code>dns cloudflare</code> directive. The plugin's <code>UnmarshalCaddyfile()</code> function extracts the token from <code>{env.CF_API_TOKEN}</code>.</p><p><strong>2. Token Validation</strong></p><p>The plugin validates the token format with a regex: <code>^[A-Za-z0-9_-]{35,50}$</code>. This catches common mistakes like wrapping the token in quotes or braces, which would cause cryptic API errors later.</p><p><strong>3. Module Provisioning</strong></p><p>Caddy calls the plugin's <code>Provision()</code> function, which replaces environment variable placeholders with actual values and performs final validation.</p><p><strong>4. Certificate Check</strong></p><p>Caddy checks its certificate cache (default <code>~/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/</code>) to see if a valid certificate for <code>*.foo.com</code> already exists. If so, it loads it and you're done.</p><p><strong>5. ACME Challenge Request</strong></p><p>If no valid certificate exists, Caddy's ACME client requests a certificate from Let's Encrypt. Let's Encrypt responds with a DNS-01 challenge of "Prove you control <code>foo.com</code> by creating a TXT record at <code>_acme-challenge.foo.com</code> with value <code>xyz123_random_token</code>."</p><p><strong>6. DNS Record Creation</strong></p><p>Here's where the magic happens. The plugin calls your DNS provider's API to create the challenge record. The specifics vary by provider, but the pattern is universally to find the zone ID, create a TXT record, and return success.</p><p>The Cloudflare implementation illustrates this pattern clearly. The <code>libdns/cloudflare</code> client makes two API requests. First, it queries for the zone ID.</p><pre><code>GET https://api.cloudflare.com/client/v4/zones?name=foo.com
Authorization: Bearer your_token_here
</code></pre><p>Once the zone ID is retrieved, the client creates the TXT record with the challenge token.</p><pre><code>POST https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records
Authorization: Bearer your_token_here
Content-Type: application/json

{
  "type": "TXT",
  "name": "_acme-challenge.foo.com",
  "content": "xyz123_random_token",
  "ttl": 120
}
</code></pre><p>This creates the challenge TXT record with a short TTL (2 minutes). AWS Route53 uses <code>ChangeResourceRecordSets</code>, GCP uses <code>managedZones.changes.create</code>, Azure uses their DNS REST API. Different endpoints, same result.</p><p><strong>7. DNS Propagation Wait</strong></p><p>Caddy polls public DNS servers to verify the TXT record has propagated. By default, it uses your system's DNS resolver, but you can configure a custom resolver.</p><pre><code>*.foo.com {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
        resolvers 1.1.1.1
    }
}
</code></pre><p>Using your DNS provider's public resolver (1.1.1.1 for Cloudflare, 8.8.8.8 for Google, 1.0.0.1 for general use) is often faster because DNS records propagate to the provider's own resolvers first. Caddy makes repeated queries until the record returns the expected value, then proceeds. This step is critical&#8212;if DNS propagation is incomplete when Let's Encrypt checks, the challenge fails.</p><p><strong>8. Challenge Completion</strong></p><p>Caddy tells Let's Encrypt "The TXT record is ready, check it." Let's Encrypt queries multiple DNS servers worldwide to verify the record exists. Once verified, Let's Encrypt issues the wildcard certificate.</p><p><strong>9. Cleanup</strong></p><p>Once the certificate is issued, the challenge TXT record is no longer needed. The plugin automatically deletes the temporary TXT record to keep your DNS zone clean.</p><pre><code>DELETE https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}
Authorization: Bearer your_token_here
</code></pre><p><strong>10. Certificate Storage</strong></p><p>Caddy stores the certificate chain and private key in its certificate cache. The certificate is now ready to use for all <code>*.foo.com</code> traffic.</p><p><strong>11. Automatic Renewal</strong></p><p>Caddy automatically renews certificates 30 days before expiration. The entire DNS-01 challenge flow repeats automatically&#8212;create TXT record, wait for propagation, complete challenge, and finally delete TXT record. All with zero human intervention.</p><h2>The Code: How the Plugin Works</h2><p>The entire plugin is just ~120 lines of Go. Let's look at the key parts.</p><h3>Module Registration</h3><p>The first step is registering the plugin with Caddy's module system so it can be discovered and loaded at runtime. Here's how the Cloudflare provider registers itself.</p><pre><code>type Provider struct{ *cloudflare.Provider }

func init() {
    caddy.RegisterModule(Provider{})
}

func (Provider) CaddyModule() caddy.ModuleInfo {
    return caddy.ModuleInfo{
        ID:  "dns.providers.cloudflare",
        New: func() caddy.Module { return &amp;Provider{new(cloudflare.Provider)} },
    }
}
</code></pre><p>The plugin wraps <code>github.com/libdns/cloudflare</code> and registers itself as a Caddy module with the ID <code>dns.providers.cloudflare</code>. When you write <code>dns cloudflare</code> in your Caddyfile, Caddy loads this module.</p><h3>Caddyfile Parsing</h3><p>The parsing logic handles both inline and block configuration syntaxes, giving you flexibility in how you structure your Caddyfile. Here's how it works.</p><pre><code>func (p *Provider) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
    d.Next() // consume directive name

    if d.NextArg() {
        // Single token syntax: cloudflare {env.CF_API_TOKEN}
        p.Provider.APIToken = d.Val()
    } else {
        // Block syntax: cloudflare { api_token ... }
        for nesting := d.Nesting(); d.NextBlock(nesting); {
            switch d.Val() {
            case "api_token":
                if d.NextArg() {
                    p.Provider.APIToken = d.Val()
                }
            case "zone_token":
                if d.NextArg() {
                    p.Provider.ZoneToken = d.Val()
                }
            }
        }
    }

    if p.Provider.APIToken == "" {
        return d.Err("missing API token")
    }
    return nil
}
</code></pre><p>This implementation supports both inline syntax for simple cases and block syntax when you need multiple configuration options. Here are the two supported formats.</p><pre><code># Inline syntax (recommended)
dns cloudflare {env.CF_API_TOKEN}

# Block syntax (for dual tokens)
dns cloudflare {
    api_token {env.CF_API_TOKEN}
}
</code></pre><h3>Token Validation</h3><p>Before making any API calls, the plugin validates that the token format is correct. This catches configuration errors early with clear error messages. Here's the validation logic.</p><pre><code>var cloudflareTokenRegexp = regexp.MustCompile(`^[A-Za-z0-9_-]{35,50}$`)

func (p *Provider) Provision(ctx caddy.Context) error {
    // Replace placeholders like {env.CF_API_TOKEN} with actual values
    p.Provider.APIToken = caddy.NewReplacer().ReplaceAll(p.Provider.APIToken, "")

    if !cloudflareTokenRegexp.MatchString(p.Provider.APIToken) {
        return fmt.Errorf("API token '%s' appears invalid", p.Provider.APIToken)
    }
    return nil
}
</code></pre><p>This validates the token format before attempting any API calls. Cloudflare tokens are always 35-50 characters of alphanumerics, dashes, or underscores. If you accidentally wrap the token in quotes or the environment variable is unset, this catches it immediately with a clear error message instead of a cryptic "Invalid request headers" from Cloudflare later.</p><h3>The Actual DNS Operations</h3><p>The plugin doesn't implement DNS operations directly. It delegates to <code>libdns/cloudflare</code>, which implements the <code>libdns</code> interface.</p><pre><code>type RecordSetter interface {
    SetRecords(ctx context.Context, zone string, records []Record) ([]Record, error)
}

type RecordDeleter interface {
    DeleteRecords(ctx context.Context, zone string, records []Record) ([]Record, error)
}
</code></pre><p>Caddy's ACME client calls these methods at the appropriate times during the DNS-01 challenge. The plugin is just the adapter that makes Caddy aware of the Cloudflare DNS provider.</p><h2>Debugging and Common Issues</h2><h3>"Invalid request headers"</h3><p>This error means your API token is malformed or the environment variable isn't set. The first step is to verify the token environment variable is properly configured.</p><pre><code>echo $CF_API_TOKEN
</code></pre><p>If the output is empty, you've found the problem. When the environment variable isn't set, Caddy tries to use <code>{env.CF_API_TOKEN}</code> literally as the token, which results in authentication failures from your DNS provider's API.</p><h3>"timed out waiting for record to fully propagate"</h3><p>The DNS propagation check is timing out. This usually means DNS caching is happening&#8212;your local resolver is caching the old "record doesn't exist" response, so use a custom resolver like <code>resolvers 1.1.1.1</code> in your TLS block. Or it's a private DNS issue where <code>foo.com</code> is defined in <code>/etc/hosts</code> or resolved by a private DNS server, causing the public DNS verification to fail. Use a public resolver or temporarily remove the private DNS entry. Finally, it could be zone access&#8212;the token doesn't have access to the zone, so verify the token has <code>Zone:Read</code> permission for <code>foo.com</code>.</p><h3>"expected 1 zone, got 0"</h3><p>The plugin can't find the zone for your domain. This happens if the domain isn't in Cloudflare DNS, the API token doesn't have <code>Zone:Read</code> permission, or the zone name doesn't match (e.g., you're requesting <code>*.sub.foo.com</code> but only <code>foo.com</code> is in Cloudflare).</p><h3>Certificate Transparency Logs</h3><p>All certificates issued by public CAs are logged to Certificate Transparency logs. You can see your wildcard cert at https://crt.sh. Search for <code>%.foo.com</code> to find wildcard certificates.</p><p>This is a feature, not a bug. It proves certificates were issued legitimately and helps detect mis-issuance. But it also means anyone can see that <code>foo.com</code> has a wildcard certificate, though they can't enumerate individual tenant subdomains.</p><h2>Production Deployment Patterns</h2><h3>Docker Compose</h3><p>For containerized deployments, Docker Compose provides a straightforward way to run Caddy with persistent certificate storage. Here's a complete configuration.</p><pre><code>services:
  caddy:
    build:
      context: .
      dockerfile: Dockerfile.caddy
    ports:
      - "443:443"
      - "80:80"
    environment:
      - CF_API_TOKEN=${CF_API_TOKEN}
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    restart: unless-stopped

volumes:
  caddy_data:
  caddy_config:
</code></pre><p>The <code>caddy_data</code> volume persists certificates across container restarts. The <code>caddy_config</code> volume persists Caddy's runtime configuration.</p><h3>Dockerfile with Cloudflare Plugin</h3><pre><code>FROM caddy:builder AS builder

RUN xcaddy build \
    --with github.com/caddy-dns/cloudflare

FROM caddy:latest

COPY --from=builder /usr/bin/caddy /usr/bin/caddy
</code></pre><p>This multi-stage build compiles Caddy with the Cloudflare plugin in the builder stage, then copies just the binary to the final image.</p><h3>Kubernetes with Cert-Manager</h3><p>If you're running Kubernetes, consider using cert-manager instead of running ACME clients on your web servers. Cert-manager is purpose-built for Kubernetes certificate lifecycle management and supports DNS-01 challenges with all major cloud providers.</p><p>Here's an example with Cloudflare, but cert-manager has built-in support for Route53, Cloud DNS, Azure DNS, and dozens of other providers.</p><pre><code>apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: wildcard-foo-com
spec:
  secretName: wildcard-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
  - "*.foo.com"
  - "foo.com"
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@foo.com
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
    - dns01:
        cloudflare:
          email: admin@foo.com
          apiTokenSecretRef:
            name: cloudflare-api-token
            key: api-token
</code></pre><p>Cert-manager provisions the certificate as a Kubernetes Secret, which your Ingress controller (nginx, Traefik, Envoy, etc.) can reference. The <code>dns01</code> solver configuration changes based on your provider&#8212;swap <code>cloudflare</code> for <code>route53</code>, <code>clouddns</code>, or <code>azuredns</code> with the appropriate credential references.</p><h3>Multi-Region Deployments</h3><p>If you're running web servers in multiple regions, certificate storage becomes important. File-based storage works for single-server deployments, but multi-region requires shared certificate storage.</p><p>You have three options, mount the certificate directory from a network filesystem like NFS, EFS, or cloud-provider equivalents, use storage plugins for S3, Consul, Redis, or other distributed stores, or run certificate provisioning centrally and distribute via your secrets management system.</p><p>The simplest approach for most systems is to run certificate provisioning in one region, store certificates in your cloud provider's secrets manager (Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault), and distribute to all regions. This keeps the ACME logic centralized while making certificates available everywhere.</p><h2>Security Considerations</h2><p>The wildcard certificate's private key protects all your tenant subdomains. If it leaks, an attacker can impersonate any tenant. Protect it like you'd protect your database credentials.</p><h3>Adding Your Domain to the Public Suffix List</h3><p>If you're running a multi-tenant platform where each tenant gets a subdomain, you should submit your domain to the <a href="https://publicsuffix.org/">Public Suffix List</a>. The PSL is a registry that browsers use to determine security boundaries between sites.</p><p>Without PSL registration, browsers treat <code>tenant-a.foo.com</code> and <code>tenant-b.foo.com</code> as the same site. This means one tenant could potentially set cookies readable by another tenant, creating security and privacy issues.</p><p>When you add <code>foo.com</code> to the PSL, browsers treat each tenant subdomain as an independent site. Cookies set by <code>tenant-a.foo.com</code> cannot be read by <code>tenant-b.foo.com</code>. This provides proper isolation between tenants at the browser level.</p><p>Major platforms like GitHub (<code>github.io</code>), Vercel (<code>vercel.app</code>), and Netlify (<code>netlify.app</code>) are all registered on the PSL. If you're building tenant infrastructure, you should be too. Submit via the <a href="https://github.com/publicsuffix/list">PSL GitHub repository</a> with documentation proving you control the domain and explaining your multi-tenant use case.</p><h3>Token Scope Limiting</h3><p>Your DNS provider credentials should have the minimum required permissions. For Cloudflare, scope tokens to specific zones with only <code>Zone.Zone:Read</code> and <code>Zone.DNS:Edit</code>. For AWS Route53, use IAM policies that grant access only to specific hosted zones, not all DNS resources in your account. For GCP Cloud DNS, create service accounts with the <code>dns.admin</code> role scoped to individual zones, not project-wide access.</p><p>Don't use global credentials. If your token leaks, the blast radius should be limited to DNS operations on specific zones, not your entire cloud account or DNS infrastructure.</p><h3>Certificate Revocation</h3><p>If you need to revoke a wildcard certificate, you can't selectively revoke it for one tenant, revocation affects all tenants. This is a fundamental tradeoff of wildcard certificates.</p><p>If you need per-tenant revocation capability, you need per-tenant certificates. For most systems, the operational simplicity of wildcards outweighs this limitation.</p><h3>Rate Limits</h3><p>Let's Encrypt rate limits are 50 certificates per registered domain per week, 5 failed validation attempts per account per hostname per hour, and 300 new orders per account per 3 hours. With a wildcard certificate, you're provisioning one certificate regardless of tenant count, so you'll never hit the 50 certificates per week limit. This is a massive advantage over per-tenant certificates.</p><h2>When NOT to Use Wildcard Certificates</h2><p>Skip wildcards if tenants bring their own domains. If tenants use <code>tenant-a.com</code> instead of <code>tenant-a.foo.com</code>, you need per-tenant certificates. You can still automate this with ACME HTTP-01 challenges, but you'll need per-tenant certificate management.</p><p>Skip them if you need deep subdomain nesting. Wildcards only cover one level&#8212;<code>*.foo.com</code> doesn't cover <code>api.tenant-a.foo.com</code>. If your architecture requires nested subdomains, you either need multiple wildcard certificates or per-tenant certificates.</p><p>Skip them if regulatory compliance requires certificate isolation. Some compliance frameworks require cryptographic isolation between tenants. If your wildcard private key is compromised, all tenants are affected. For these environments, per-tenant certificates provide isolation.</p><p>Skip them if you need per-tenant certificate revocation. If you might need to revoke access for individual tenants by revoking their certificate, wildcard certificates won't work.</p><h2>The Bottom Line</h2><p>For multi-tenant systems with <code>tenant-id.foo.com</code> subdomains, wildcard certificates are the right choice. The implementation pattern is the same regardless of your infrastructure, pick a web server (Caddy, Nginx, HAProxy), integrate with your DNS provider's API (Cloudflare, Route53, Cloud DNS, Azure DNS), and let ACME automation handle the rest.</p><p>The alternative, per-tenant certificates, is operationally complex, technically fragile, and doesn't scale past a few hundred tenants. Wildcard certificates are the pragmatic choice, and modern tooling makes them trivial to implement across any cloud platform.</p><p>If you're building <code>tenant-id.foo.com</code> infrastructure, this is the way.</p>]]></content:encoded></item><item><title><![CDATA[Use the Accept Header to serve Markdown instead of HTML to LLMs]]></title><description><![CDATA[Agents don't need to see websites with markup and styling; anything other than plain Markdown is just wasted money spent on context tokens.]]></description><link>https://skeptrune.substack.com/p/use-the-accept-header-to-serve-markdown-instead-of-html-to-llms</link><guid isPermaLink="false">https://skeptrune.substack.com/p/use-the-accept-header-to-serve-markdown-instead-of-html-to-llms</guid><dc:creator><![CDATA[skeptrune]]></dc:creator><pubDate>Sat, 27 Sep 2025 18:52:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/aaeb808d-0813-45ec-8ea7-22a9185d9c64_598x524.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Agents don't need to see websites with markup and styling; anything other than plain Markdown is just wasted money spent on context tokens.</p><p>I decided to make my Astro sites more accessible to LLMs by having them return Markdown versions of pages when the <code>Accept</code> header has <code>text/plain</code> or <code>text/markdown</code> preceding <code>text/html</code>. This was very heavily inspired by <a href="https://x.com/bunjavascript/status/1971934734940098971">this post on X from bunjavascript</a>.</p><p>Hopefully this helps SEO too, since agents are a big chunk of my traffic. The Bun team reported a 10x token drop for Markdown and frontier labs pay per token, so cheaper pages should get scraped more, be more likely to end up in training data, and give me a little extra lift from assistants and search.</p><blockquote><p><strong>Note:</strong> You can check out the feature live by running <code>curl -H "Accept: text/markdown" https://www.skeptrune.com</code> or <code>curl -H "Accept: text/plain" https://www.skeptrune.com</code> in your terminal.</p></blockquote><h2>Static Site Generators are already halfway there</h2><p>Static site generators like Astro and Gatsby already generate a big folder of HTML files, typically in a <code>dist</code> or <code>public</code> folder through an <code>npm run build</code> command. The only thing missing is a way to convert those HTML files to markdown.</p><p>It turns out there's a great CLI tool for this called <a href="https://www.npmjs.com/package/@wcj/html-to-markdown-cli">html-to-markdown</a> that can be installed with <code>npm install -D @wcj/html-to-markdown-cli</code> and run during a build step using <code>npx</code>.</p><p>Here's a quick Bash script an LLM wrote to convert all HTML files in <code>dist/html</code> to Markdown files in <code>dist/markdown</code>, preserving the directory structure:</p><pre><code># convert-to-markdown.sh
mkdir -p dist/markdown

find dist/html -type f -name "*.html" | while read -r file; do
    relative_path="${file#dist/html/}"
    dest_path="dist/markdown/${relative_path%.html}.md"
    mkdir -p "$(dirname "$dest_path")"
    npx @wcj/html-to-markdown-cli "$file" --stdout &gt; "$dest_path"
done
</code></pre><p>Once you have the conversion script in place, the next step is to make it run as a post-build action. Here's an example of how to modify your <code>package.json</code> scripts section:</p><pre><code>"scripts": {
    "build": "astro build &amp;&amp; yarn mv-html &amp;&amp; yarn convert-to-markdown",
    "mv-html": "mkdir -p dist/html &amp;&amp; find dist -type f -name '*.html' -not -path 'dist/html/*' -exec sh -c 'for f; do dest=\"dist/html/${f#dist/}\"; mkdir -p \"$(dirname \"$dest\")\"; mv -f \"$f\" \"$dest\"; done' sh {} +",
  "convert-to-markdown": "bash convert-to-markdown.sh"
}
</code></pre><p>Moving all HTML files to <code>dist/html</code> first is only necessary if you're using Cloudflare Workers, which will serve existing static assets before falling back to your Worker. If you're using a traditional reverse proxy, you can skip that step and just convert directly from <code>dist</code> to <code>dist/markdown</code>.</p><blockquote><p><strong>Note:</strong> I learned after I finished the project that I could have added <code>run_worker_first = ["*"]</code> to my <code>wrangler.json</code> so I didn't have to move any files around. That field forces the worker to always run frst. Shoutout to the kind folks on reddit for telling me.</p></blockquote><h2>Cloudflare Workers-specific configuration</h2><p>I pushed myself to go out of my comfort zone and learn Cloudflare Workers for this project since my company uses them extensively. If you're using a traditional reverse proxy like Nginx or Caddy, you can skip this section (and honestly, you'll have a much easier time).</p><p>If you're coming from traditional reverse proxy servers, Cloudflare Workers force you into a different paradigm. What would normally be a simple Nginx or Caddy rule becomes custom <code>wrangler.jsonc</code> configuration, moving your entire site to a shadow directory so Cloudflare doesn't serve static assets by default, writing JavaScript to manually check headers and using <code>env.ASSETS.fetch</code> to serve files. SO MANY STEPS TO MAKE A SIMPLE FILE SERVER!</p><p>This experience finally made Next.js 'middleware' click for me. It's not actually middleware in the traditional sense of a REST API; it's more like 'use this where you would normally have a real reverse proxy.' Both Cloudflare Workers and Next.js Middleware are essentially JavaScript-based reverse proxies that intercept requests before they hit your application.</p><p>While I'd personally prefer Terraform with a hyperscaler or a VPS for a more traditional setup, new startups love this pattern, so it's worth understanding.</p><p>Here's an example of a working <code>wrangler.jsonc</code> file to refer to a new worker script and also bind your build output directory as a static asset namespace:</p><pre><code>{
  "main": "worker.js",
  "assets": {
    "directory": "./dist",
    "binding": "ASSETS"
  }
}
</code></pre><p>Below is a minimal worker script that inspects the <code>Accept</code> header and serves markdown when requested, otherwise falls back to HTML:</p><pre><code>export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const acceptHeader = request.headers.get("accept") || "";
    const acceptTypes = acceptHeader.split(",");

    const plainIndex = acceptTypes.findIndex(
      (t) =&gt; t.includes("text/plain") || t.includes("text/markdown")
    );
    const htmlIndex = acceptTypes.findIndex((t) =&gt; t.includes("text/html"));
    const prefersMarkdown =
      plainIndex !== -1 &amp;&amp; (htmlIndex === -1 || plainIndex &lt; htmlIndex);

    const tryServeContent = async (format) =&gt; {
      let contentType;
      if (format === "markdown") {
        if (url.pathname == "" || url.pathname == "/") {
          const sitemapResponse = await env.ASSETS.fetch(
            new Request(new URL("/sitemap-0.xml", request.url))
          );
          if (sitemapResponse.ok) {
            const content = await sitemapResponse.text();
            return new Response(content, {
              headers: {
                "Content-Type": "application/xml; charset=utf-8",
                "Cache-Control": "public, max-age=3600",
              },
            });
          }
        }

        contentType = "text/plain; charset=utf-8";
        let distPath = `/markdown${url.pathname}`;

        if (!distPath.endsWith(".md") &amp;&amp; !distPath.endsWith("/")) {
          distPath += "/index.md";
        } else if (distPath.endsWith("/")) {
          distPath += "index.md";
        }

        if (url.pathname === "/") {
          distPath = "/markdown/index.md";
        }

        try {
          const response = await env.ASSETS.fetch(
            new Request(new URL(distPath, request.url))
          );
          if (response.ok) {
            const content = await response.text();
            return new Response(content, {
              headers: {
                "Content-Type": contentType,
                "Cache-Control": "public, max-age=3600",
              },
            });
          }
        } catch (error) {
          console.error(`Error fetching HTML file from ${distPath}:`, error);
        }
      } else {
        contentType = "text/html; charset=utf-8";
        let distPath = `/html${url.pathname}`;

        if (!distPath.endsWith(".html") &amp;&amp; !distPath.endsWith("/")) {
          distPath += "/index.html";
        } else if (distPath.endsWith("/")) {
          distPath += "index.html";
        }

        // Handle root path
        if (url.pathname === "/") {
          distPath = "/html/index.html";
        }

        try {
          const response = await env.ASSETS.fetch(
            new Request(new URL(distPath, request.url))
          );
          if (response.ok) {
            const content = await response.text();
            return new Response(content, {
              headers: {
                "Content-Type": contentType,
                "Cache-Control": "public, max-age=3600",
              },
            });
          }
        } catch (error) {
          console.error(`Error fetching HTML file from ${distPath}:`, error);
        }
      }

      return null;
    };

    if (prefersMarkdown) {
      const markdownResponse = await tryServeContent("markdown");
      if (markdownResponse) return markdownResponse;

      const htmlResponse = await tryServeContent("html");
      if (htmlResponse) return htmlResponse;
    } else {
      const htmlResponse = await tryServeContent("html");
      if (htmlResponse) return htmlResponse;

      const markdownResponse = await tryServeContent("markdown");
      if (markdownResponse) return markdownResponse;
    }

    return await env.ASSETS.fetch(
      new Request(new URL("/html/404.html", request.url))
    );
  },
};
</code></pre><p>Pro tip: make the root path <code>/</code> serve your sitemap.xml instead of markdown content for your homepage such that an agent visiting your root URL can see all the links on your site.</p><h2>Caddy configuration</h2><p>It's likely much easier to set this system up with a traditional reverse proxy file server like Caddy or Nginx. Here's a simple Caddyfile configuration that does the same thing:</p><pre><code>{
    your-personal-domain.com {
        root * /path/to/your/dist
        file_server

        @markdown {
            header Accept *text/markdown*
            header Accept *text/plain*
            not header Accept *text/html*
        }
        handle @markdown {
            rewrite * /markdown{path}/index.md
            try_files {path} {path}.md /markdown/index.md
            file_server
        }

        handle {
            rewrite * /html{path}/index.html
            try_files {path} {path}.html /html/index.html
            file_server
        }

        handle_errors {
            respond "404 Not Found" 404
            try_files /html/404.html
        }
    }
}
</code></pre><p>I will leave Nginx configuration as an exercise for the reader or perhaps the reader's LLM of choice.</p><h2>Conclusion: A More Accessible Web for Agents</h2><p>By serving lean, semantic Markdown to LLM agents, you can achieve a 10x reduction in token usage while making your content more accessible and efficient for the AI systems that increasingly browse the web. This optimization isn't just about saving money; it's about GEO (Generative Engine Optimization) for a changed world where millions of users discover content through AI assistants.</p><p>Astro's flexibility made this implementation surprisingly straightforward. It only took me a couple of hours to get both the personal blog you're reading now and <a href="https://www.patron.com">patron.com</a> to support this feature.</p><p>If you're ready to make your site agent-friendly, I encourage you to try this out. For a fun exercise, copy this article's URL and ask your favorite LLM to "Use the blog post to write a Cloudflare Worker for my own site." See how it does! You can also check out the source code for this feature at <a href="https://github.com/skeptrunedev/personal-site">github.com/skeptrunedev/personal-site</a> to get started.</p><p>I'm excited to see the impact of this change on my site's analytics and hope it inspires others. If you implement this on your own site, I'd love to hear about your experience! Connect with me on <a href="https://x.com/skeptrune">X</a> or <a href="https://www.linkedin.com/in/khami/">LinkedIn</a>.</p>]]></content:encoded></item><item><title><![CDATA[VPS Evangelism and Building LLM-over-DNS]]></title><description><![CDATA[My most valuable skill as a hacker/entrepreneur is that I'm confident deploying arbitrary programs that work locally to the internet.]]></description><link>https://skeptrune.substack.com/p/llm-over-dns</link><guid isPermaLink="false">https://skeptrune.substack.com/p/llm-over-dns</guid><dc:creator><![CDATA[skeptrune]]></dc:creator><pubDate>Wed, 06 Aug 2025 18:52:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/4104559b-41c4-441f-83ae-c40afef64e5b_1166x704.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>My most valuable skill as a hacker/entrepreneur is that I'm confident deploying arbitrary programs that work locally to the internet. Sounds simple, but it's really the core of what got me into Y-Combinator and later helped me raise a seed round.</p><h2>Being on the Struggle Bus Early</h2><p>When I was starting out hacking as a kid, one of the first complete things I built was a weather reply bot for Twitter. It read from the firehouse API, monitored for mentions and city names, then replied with current weather conditions when it got @'ed. My parents got me a Raspberry Pi for Christmas and I found a tutorial online. I got it working locally and then got completely stuck on deployment.</p><p>The obvious next step was using my Pi as a server, but that was a disaster. My program had bugs and would crash while I was away. Then I couldn't SSH back in because my house didn't have a static IP and Tailscale wasn't a thing yet. It only worked on and off when I was home and could babysit it.</p><h2>Skipping Straight to PaaS Hell</h2><p>When I started building web applications, I somehow skipped VPS entirely and went straight to Platform as a Service Solutions like Vercel and Render. I have no idea why. I was googling "how do I deploy my create react app" and somehow the top answer was to deploy to some third-party service that handled build steps, managed SSL, and was incredibly complicated and time-consuming.</p><p>There was always some weird limitation: memory constraints during build, Puppeteer couldn't run because they didn't have the right apt packages. Then I was stuck configuring Docker images, and since AI wasn't a thing yet and I'd never used Docker at a real job, it was all a disaster. I wasted more time trying to deploy my crappy React slop than building it.</p><h2>Getting Saved by a VPS Maximalist</h2><p>During college, I got lucky and met a hacky startup entrepreneur who was hiring. I decided to take a chance and join, even though the whole operation seemed barely legitimate.</p><p>Going into the job, I had this assumption that the "right" way to deploy was on AWS or some other hyperscaler. But this guy's mindset was the complete opposite&#8212;he was a VPS maximalist with a beautifully simple philosophy: rent a VPS, SSH in, do the same thing you did locally (<code>yarn dev</code> or whatever), throw up a reverse proxy, and call it a day. I watched him deploy like this over and over, and eventually he walked me through it myself a few times.</p><p>It was all so small and easy to learn, but it made me exponentially more confident as a builder. I never directly thought, "I can't build this because I won't be able to deploy it," but the general insecurity definitely caused a hesitancy and procrastination that immediately went away.</p><h2>Paying It Forward</h2><p>I've become an evangelist for this approach and wanted to write about it for a long time, but didn't know how to frame it entertainingly. Then on X, I got inspiration when levelsio posted a tweet about <a href="https://x.com/levelsio/status/1952861177731793324">deploying a DNS server on Hetzner that lets you talk to an LLM</a>.</p><p>Want to see it in action? Try this:</p><pre><code>dig @llm.skeptrune.com "what is the meaning of life?" TXT +short
</code></pre><p>Getting that setup is probably more interesting than my rambling, so here's how to deploy your own LLM-over-DNS proxy on a VPS in less than half an hour with nothing other than a rented server.</p><h2>Step 1: Access Your VPS</h2><p>After purchasing your VPS, you'll receive an IP address and login credentials (usually via email). Connect to your server:</p><pre><code>ssh root@&lt;your-vps-ip&gt;
</code></pre><p>Replace <code>&lt;your-vps-ip&gt;</code> with your actual server IP address.</p><h2>Step 2: Clear Existing DNS Services</h2><p>Many VPS images come with <code>systemd-resolved</code> or <code>bind9</code> pre-installed. To avoid conflicts, remove or disable them:</p><pre><code># Check for running DNS services
systemctl list-units --type=service | grep -E 'bind|dns|systemd-resolved'

# Stop and disable systemd-resolved (if present)
systemctl stop systemd-resolved
systemctl disable systemd-resolved

# Remove bind9 (if present)
apt-get remove --purge bind9 -y
</code></pre><h2>Step 3: Install Python Dependencies</h2><p>Install the required Python packages for our DNS server:</p><pre><code>pip install dnslib requests
</code></pre><h2>Step 4: Create the DNS-to-LLM Proxy Script</h2><p>Create a Python script that listens for DNS queries, treats the question as a prompt, sends it to the OpenRouter LLM API, and returns the response in a TXT record:</p><pre><code>from dnslib.server import DNSServer, BaseResolver
from dnslib import RR, QTYPE, TXT
import requests
import codecs

OPENROUTER_API_KEY = ""  # Add your OpenRouter API key here
LLM_API_URL = "https://openrouter.ai/api/v1/chat/completions"

class LLMResolver(BaseResolver):
    def resolve(self, request, handler):
        qname = request.q.qname
        qtype = QTYPE[request.q.qtype]
        prompt = str(qname).rstrip('.')
        
        # Forward prompt to LLM
        try:
            response = requests.post(
                LLM_API_URL,
                headers={
                    "Authorization": f"Bearer {OPENROUTER_API_KEY}",
                    "Content-Type": "application/json"
                },
                json={
                    "model": "openai/gpt-3.5-turbo",
                    "messages": [{"role": "user", "content": prompt}]
                },
                timeout=10
            )
            response.raise_for_status()
            raw_answer = response.json()["choices"][0]["message"]["content"]
        except Exception as e:
            raw_answer = f"Error: {str(e)}"
        
        try:
            answer = codecs.decode(raw_answer.encode('utf-8'), 'unicode_escape')
        except Exception:
            answer = raw_answer.replace('\\010', '\n').replace('\\n', '\n')
        
        reply = request.reply()
        if qtype == "TXT":
            # Split long responses into chunks of 200 chars (safe limit)
            chunk_size = 200
            if len(answer) &gt; chunk_size:
                chunks = [answer[i:i+chunk_size] for i in range(0, len(answer), chunk_size)]
                for i, chunk in enumerate(chunks):
                    reply.add_answer(RR(qname, QTYPE.TXT, rdata=TXT(f"[{i+1}/{len(chunks)}] {chunk}")))
            else:
                reply.add_answer(RR(qname, QTYPE.TXT, rdata=TXT(answer)))
        return reply

if __name__ == "__main__":
    resolver = LLMResolver()
    server = DNSServer(resolver, port=53, address="0.0.0.0")
    server.start_thread()
    import time
    while True:
        time.sleep(1)
</code></pre><p>Save this as <code>llm_dns.py</code> on your VPS.</p><p>Before running, you need to set your OpenRouter API key. For this tutorial, you can paste it directly into the <code>OPENROUTER_API_KEY</code> variable. For anything more serious, you should use an environment variable to keep your key out of the code.</p><p><strong>Security Note</strong>: This is a proof-of-concept. For production use, you'd want proper process management (systemd), logging, rate limiting, and to avoid storing API keys in plaintext.</p><h2>Step 5: Run the DNS-LLM Proxy</h2><p>Start the DNS server (port 53 requires root privileges):</p><pre><code>sudo python3 llm_dns.py
</code></pre><h2>Step 6: Test Your Service</h2><p>From another machine, send a DNS TXT query to test your setup:</p><pre><code>dig @&lt;your-vps-ip&gt; "what is the meaning of life" TXT +short
</code></pre><p>The LLM's response should appear in the output.</p><h2>Troubleshooting</h2><p><strong>Common Issues:</strong></p><ul><li><p><code>Permission denied</code>: Make sure you're running with <code>sudo</code> (port 53 requires root)</p></li><li><p><code>Connection timeout</code>: Check your VPS firewall settings and ensure port 53 is open</p></li><li><p><code>API errors</code>: Verify your OpenRouter API key and check your account has credits</p></li><li><p><code>No response</code>: Try running <code>systemctl status systemd-resolved</code> to ensure it's actually disabled</p></li></ul><h2>Step 7: Secure Your Setup (Optional but Recommended)</h2><p>To restrict access to your DNS-LLM proxy, use UFW (Uncomplicated Firewall) - and yes, it's literally called "uncomplicated" because that's what a VPS is, uncomplicated:</p><pre><code>ufw allow ssh
ufw allow 53
ufw enable
</code></pre><p>This allows SSH access (so you don't lock yourself out) and DNS queries on port 53, while blocking everything else by default.</p><p><strong>Important</strong>: This setup runs as root and stores your API key in plaintext. For anything beyond experimentation, consider using environment variables, proper user accounts, and process managers like systemd.</p><h2>References</h2><ul><li><p><a href="https://dnslib.readthedocs.io/en/latest/">dnslib documentation</a></p></li><li><p><a href="https://openrouter.ai/">OpenRouter API docs</a></p></li></ul><p>That's it! You now have your own LLM-over-DNS proxy running on a simple VPS. No complex infrastructure needed - just SSH, install dependencies, and run your code. This is the beauty of keeping things simple.</p>]]></content:encoded></item><item><title><![CDATA[I couldn't submit a PR, so I got hired and fixed it myself]]></title><description><![CDATA[After joining Mintlify (which acquired my previous company), I finally fixed a search bug that had bothered me for over a year as a user - the debounced search queries weren't being aborted, causing race conditions and poaor search quality. By adding an AbortController to ensure only the most recent search query returns results, I made the search experience crisper and more responsive across their 30,000+ documentation]]></description><link>https://skeptrune.substack.com/p/doing-the-little-things</link><guid isPermaLink="false">https://skeptrune.substack.com/p/doing-the-little-things</guid><dc:creator><![CDATA[skeptrune]]></dc:creator><pubDate>Wed, 30 Jul 2025 00:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/d2040a3f-b49e-49be-badb-767aec7209db_1284x702.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>For over a year, I was bugged by a search quirk on <a href="https://mintlify.com">Mintlify</a> that caused race conditions and wonky search results.</p><p>Here's the fun irony: I was the founder of Trieve, the company that powered search for their 30,000+ documentation sites, yet their debounced search queries weren't being aborted as you typed. Check out this delightful chaos:</p><div class="native-video-embed" data-component-name="VideoPlaceholder" data-attrs="{&quot;mediaUploadId&quot;:&quot;bf8e7b35-e51b-4962-b6e7-f5e56b813fbd&quot;,&quot;duration&quot;:null}"></div><p>I had brought this up in our shared Slack before when I was just a vendor to <s>them</s> <strong>us</strong> (weird), but it wasn't a priority and never got fixed. It was extra frustrating because the race condition on the query was apparent enough that search would sometimes feel low quality since it would return results for a query many characters before the user was done typing.</p><p>Even worse, as the founder of the search company powering this experience, it felt like a poor reflection on Trieve every time someone encountered these wonky results.</p><h2>Fixed It</h2><p>Now that I'm on the team, I was able to finally fix it. I added an <a href="https://developer.mozilla.org/en-US/docs/Web/API/AbortController">AbortController</a> to the debounced search function, so that it aborts any previous queries when a new one is made. This means that the search results are always relevant to what the user is currently typing.</p><p>There's something deeply satisfying about finally being able to fix the things that bug you. It made me feel a bit like George Hotz during his <a href="https://web.archive.org/web/20221122050324/https://twitter.com/realGeorgeHotz/status/1594908473875173377">single week at Twitter</a> in 2022, where he joined with overambitious plans to fix Twitter search, gave up due to hubris, and settled for fixing an annoying login popup before leaving.</p><p>I've always admired engineers who are part hacker, part entrepreneur - people who see a problem and just... fix it. Getting to do something similar here (minus the dramatic exit) felt like a small win in steering my career toward that kind of direct approach.</p><h2>Open Source</h2><p>I prefer building and using open source software whenever possible, and this whole situation is a great example of why.</p><p>With open source - when you encounter a bug or pain point, you can actually fix it yourself. Had this been an open source project during the year I was frustrated with the search race condition, I could have submitted a pull request with the AbortController fix and saved myself (and thousands of other users) the daily annoyance.</p><p>Instead, it remained a persistent irritation until I happened to join the company and gain access to the codebase. There's something to be said for the immediate empowerment that comes with open source - though I understand why many companies choose different models for various business reasons.</p><h2>Self-Congratulation</h2><p>If search feels just a bit crisper and more responsive on Mintlify, it&#8217;s because of me! I fixed a bug that bothered me for over a year, and it feels great to have made that little improvement to the product.</p><p>I can't wait to make more. Fixing small issues like this over and over again is how products become legendary. There's something deeply satisfying about finally having the power to fix the things that annoy you - even if they're tiny.</p><p><strong>Especially if they're tiny.</strong></p>]]></content:encoded></item><item><title><![CDATA[Web Developer's Guide to Midjourney]]></title><description><![CDATA[import MasonryImages from "../../components/blog/HowToUseMidjourney/MasonryImages.astro"; import PinterestStarter from "../../components/blog/HowToUseMidjourney/PinterestStarter.astro"; import SimilarStyleGeneration from "../../components/blog/HowToUseMidjourney/SimilarStyleGeneration.astro"; import DescribeFeature from "../../components/blog/HowToUseMidjourney/DescribeFeature.astro"; import EaglePromptWithStyleReference from "../../components/blog/HowToUseMidjourney/EaglePromptWithStyleReference.astro";]]></description><link>https://skeptrune.substack.com/p/how-to-use-midjourney</link><guid isPermaLink="false">https://skeptrune.substack.com/p/how-to-use-midjourney</guid><dc:creator><![CDATA[skeptrune]]></dc:creator><pubDate>Sun, 01 Jun 2025 18:52:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/0aa19190-69c1-47b1-a430-915d1d683086_2400x1200.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Midjourney&#8217;s learning curve is steep, but climbing it unlocks a superpower for developers and entrepreneurs. Learn how to create stunning, cohesive image sets that actually work for your projects. Go from building style reference galleries to using the describe feature for professional marketing visuals that don&#8217;t scream &#8220;AI-generated.&#8221;</p><h2>Getting Your Ladder</h2><p>I tried to use Midjourney unsuccessfully a few times before, but decided to give it one more try after reading a good tutorial thread on <a href="https://x.com/kubadesign/status/1927056777830440980">X by @kubadesign</a>. His thread got me most of the way there, but I picked up an additional trick with <a href="https://docs.midjourney.com/hc/en-us/articles/32497889043981-Describe">Midjourney's describe feature</a> that I think is worth sharing.</p><p>My initial plan was to use these images for <a href="https://uzi.sh">uzi.sh</a>, a tool for parallel LLM coding agents. While I ultimately chose a different final image for that project because I only needed one, the learning process was valuable. For this set, I aimed for a red color scheme to evoke action and speed, which you can see in the images below.</p><p>&lt;MasonryImages /&gt;</p><h2>Find a Base Image</h2><p>Following Kuba's advice, I went to pinterest and found a cool base image that I liked. I went with something that had a lot of red, but also darker colors on the border since I knew that I would need space for text and other elements if I wanted to use these on an actual website.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!T7w5!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e786334-a1db-47fd-9336-82c9916b117b_747x411.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!T7w5!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e786334-a1db-47fd-9336-82c9916b117b_747x411.png 424w, https://substackcdn.com/image/fetch/$s_!T7w5!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e786334-a1db-47fd-9336-82c9916b117b_747x411.png 848w, https://substackcdn.com/image/fetch/$s_!T7w5!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e786334-a1db-47fd-9336-82c9916b117b_747x411.png 1272w, https://substackcdn.com/image/fetch/$s_!T7w5!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e786334-a1db-47fd-9336-82c9916b117b_747x411.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!T7w5!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e786334-a1db-47fd-9336-82c9916b117b_747x411.png" width="747" height="411" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/4e786334-a1db-47fd-9336-82c9916b117b_747x411.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:411,&quot;width&quot;:747,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:236258,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://skeptrune.substack.com/i/188682878?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e786334-a1db-47fd-9336-82c9916b117b_747x411.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!T7w5!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e786334-a1db-47fd-9336-82c9916b117b_747x411.png 424w, https://substackcdn.com/image/fetch/$s_!T7w5!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e786334-a1db-47fd-9336-82c9916b117b_747x411.png 848w, https://substackcdn.com/image/fetch/$s_!T7w5!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e786334-a1db-47fd-9336-82c9916b117b_747x411.png 1272w, https://substackcdn.com/image/fetch/$s_!T7w5!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e786334-a1db-47fd-9336-82c9916b117b_747x411.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2>Build a Style With Neutral Prompts</h2><p>You can't just start by describing the specific kind of image you want and expect to get good results. <a href="https://docs.midjourney.com/hc/en-us/articles/32180011136653-Style-Reference">Style reference images</a> are needed to give Midjourney the boundaries it needs to stick to your desired aesthetic and theme once you get more specific with your exact asks.</p><p>It's unlikely that you'll be able to find multiple images on the internet which match your desired style exactly, so I recommend using neutral prompts to generate additional images in order to create a cohesive gallery.</p><p>My neutral prompt here was <code>Portrait photography of a woman, glow behind, futuristic vibe, flash photography, color film, analog style, imperfect --ar 3:4 --v 7</code>. Essentially, any prompt describing a general subject and its characteristics without getting too specific about style, camera angle, action, or other details should work well.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!94WD!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88739f33-ec4e-4cfa-90c5-56d2e1918732_736x1103.webp" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!94WD!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88739f33-ec4e-4cfa-90c5-56d2e1918732_736x1103.webp 424w, https://substackcdn.com/image/fetch/$s_!94WD!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88739f33-ec4e-4cfa-90c5-56d2e1918732_736x1103.webp 848w, https://substackcdn.com/image/fetch/$s_!94WD!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88739f33-ec4e-4cfa-90c5-56d2e1918732_736x1103.webp 1272w, https://substackcdn.com/image/fetch/$s_!94WD!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88739f33-ec4e-4cfa-90c5-56d2e1918732_736x1103.webp 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!94WD!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88739f33-ec4e-4cfa-90c5-56d2e1918732_736x1103.webp" width="736" height="1103" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/88739f33-ec4e-4cfa-90c5-56d2e1918732_736x1103.webp&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1103,&quot;width&quot;:736,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;Pinterest starter image&quot;,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Pinterest starter image" title="Pinterest starter image" srcset="https://substackcdn.com/image/fetch/$s_!94WD!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88739f33-ec4e-4cfa-90c5-56d2e1918732_736x1103.webp 424w, https://substackcdn.com/image/fetch/$s_!94WD!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88739f33-ec4e-4cfa-90c5-56d2e1918732_736x1103.webp 848w, https://substackcdn.com/image/fetch/$s_!94WD!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88739f33-ec4e-4cfa-90c5-56d2e1918732_736x1103.webp 1272w, https://substackcdn.com/image/fetch/$s_!94WD!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F88739f33-ec4e-4cfa-90c5-56d2e1918732_736x1103.webp 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2>Using Describe to Get More Specific</h2><p>One of the images I wanted was an eagle diving. However, <code>eagle diving with red glowing background</code> wasn't getting me the results I wanted. I accidentally discovered the describe feature dragging images around, and instantly realized that it was a cheat code for getting the images I wanted.</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!RP-Q!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1cc80e20-dcaf-411f-9524-dc6788c9bd20_2108x492.webp" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!RP-Q!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1cc80e20-dcaf-411f-9524-dc6788c9bd20_2108x492.webp 424w, https://substackcdn.com/image/fetch/$s_!RP-Q!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1cc80e20-dcaf-411f-9524-dc6788c9bd20_2108x492.webp 848w, https://substackcdn.com/image/fetch/$s_!RP-Q!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1cc80e20-dcaf-411f-9524-dc6788c9bd20_2108x492.webp 1272w, https://substackcdn.com/image/fetch/$s_!RP-Q!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1cc80e20-dcaf-411f-9524-dc6788c9bd20_2108x492.webp 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!RP-Q!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1cc80e20-dcaf-411f-9524-dc6788c9bd20_2108x492.webp" width="1456" height="340" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/1cc80e20-dcaf-411f-9524-dc6788c9bd20_2108x492.webp&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:340,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;Starter prompt flow&quot;,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Starter prompt flow" title="Starter prompt flow" srcset="https://substackcdn.com/image/fetch/$s_!RP-Q!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1cc80e20-dcaf-411f-9524-dc6788c9bd20_2108x492.webp 424w, https://substackcdn.com/image/fetch/$s_!RP-Q!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1cc80e20-dcaf-411f-9524-dc6788c9bd20_2108x492.webp 848w, https://substackcdn.com/image/fetch/$s_!RP-Q!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1cc80e20-dcaf-411f-9524-dc6788c9bd20_2108x492.webp 1272w, https://substackcdn.com/image/fetch/$s_!RP-Q!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1cc80e20-dcaf-411f-9524-dc6788c9bd20_2108x492.webp 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p>You can take any one of these and pair them with the style reference images you generated earlier to get the eagle or any other image you described into the style you want. I could not believe how well this worked.</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!6HTm!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8d6ffeee-541f-42e3-a125-854c3728036a_1007x194.webp" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!6HTm!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8d6ffeee-541f-42e3-a125-854c3728036a_1007x194.webp 424w, https://substackcdn.com/image/fetch/$s_!6HTm!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8d6ffeee-541f-42e3-a125-854c3728036a_1007x194.webp 848w, https://substackcdn.com/image/fetch/$s_!6HTm!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8d6ffeee-541f-42e3-a125-854c3728036a_1007x194.webp 1272w, https://substackcdn.com/image/fetch/$s_!6HTm!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8d6ffeee-541f-42e3-a125-854c3728036a_1007x194.webp 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!6HTm!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8d6ffeee-541f-42e3-a125-854c3728036a_1007x194.webp" width="1007" height="194" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8d6ffeee-541f-42e3-a125-854c3728036a_1007x194.webp&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:194,&quot;width&quot;:1007,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;Describe eagle prompt&quot;,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Describe eagle prompt" title="Describe eagle prompt" srcset="https://substackcdn.com/image/fetch/$s_!6HTm!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8d6ffeee-541f-42e3-a125-854c3728036a_1007x194.webp 424w, https://substackcdn.com/image/fetch/$s_!6HTm!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8d6ffeee-541f-42e3-a125-854c3728036a_1007x194.webp 848w, https://substackcdn.com/image/fetch/$s_!6HTm!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8d6ffeee-541f-42e3-a125-854c3728036a_1007x194.webp 1272w, https://substackcdn.com/image/fetch/$s_!6HTm!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8d6ffeee-541f-42e3-a125-854c3728036a_1007x194.webp 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><h2>Post Processing: Adding Film Grain for Web Design</h2><p>Midjourney produces images which are too crisp to fit well as background images for the web. I recommend adding grain to them when you put them into your final site to create a more cohesive feel. If done correctly, you'll end up with a result that looks like the below. CSS is great for this! See a complete guide of how I applied the filter for the <a href="https://uzi.sh">uzi.sh</a> site below, <a href="https://github.com/devflowinc/uzi/blob/main/uzi-site/index.html">full code on Github here</a>.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!5_zi!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b3d052c-2381-41a7-a17a-235ad3677015_2400x1200.webp" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!5_zi!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b3d052c-2381-41a7-a17a-235ad3677015_2400x1200.webp 424w, https://substackcdn.com/image/fetch/$s_!5_zi!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b3d052c-2381-41a7-a17a-235ad3677015_2400x1200.webp 848w, https://substackcdn.com/image/fetch/$s_!5_zi!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b3d052c-2381-41a7-a17a-235ad3677015_2400x1200.webp 1272w, https://substackcdn.com/image/fetch/$s_!5_zi!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b3d052c-2381-41a7-a17a-235ad3677015_2400x1200.webp 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!5_zi!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b3d052c-2381-41a7-a17a-235ad3677015_2400x1200.webp" width="1456" height="728" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/3b3d052c-2381-41a7-a17a-235ad3677015_2400x1200.webp&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:728,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;Film Grain on Uzi Site&quot;,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Film Grain on Uzi Site" title="Film Grain on Uzi Site" srcset="https://substackcdn.com/image/fetch/$s_!5_zi!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b3d052c-2381-41a7-a17a-235ad3677015_2400x1200.webp 424w, https://substackcdn.com/image/fetch/$s_!5_zi!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b3d052c-2381-41a7-a17a-235ad3677015_2400x1200.webp 848w, https://substackcdn.com/image/fetch/$s_!5_zi!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b3d052c-2381-41a7-a17a-235ad3677015_2400x1200.webp 1272w, https://substackcdn.com/image/fetch/$s_!5_zi!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b3d052c-2381-41a7-a17a-235ad3677015_2400x1200.webp 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>You first need to add a svg filter definition to your HTML file such that the CSS can reference it later to put the grain on top of the background image. The filter uses <code>feTurbulence</code> to create a fractal noise pattern, and <code>feColorMatrix</code> to adjust the opacity. You can experiment with values like <code>baseFrequency</code> in <code>feTurbulence</code> or the alpha channel (the <code>0.8</code> in the <code>feColorMatrix</code>) to finetune the grain's intensity and texture.</p><pre><code>&lt;!-- SVG noise filter definition - this goes in your HTML --&gt;
&lt;svg style="display: none"&gt;
  &lt;filter id="noiseFilter"&gt;
    &lt;!-- Creates the fractal noise pattern --&gt;
    &lt;feTurbulence
      type="fractalNoise"
      baseFrequency="0.5"
      numOctaves="3"
      stitchTiles="stitch"
    /&gt;
    &lt;!-- Converts noise to semi-transparent overlay --&gt;
    &lt;feColorMatrix
      type="matrix"
      values="0 0 0 0 0
              0 0 0 0 0
              0 0 0 0 0
              0 0 0 0.8 0"
    /&gt;
  &lt;/filter&gt;
&lt;/svg&gt;
</code></pre><p>Once you have the filter defined, you can apply it to a grain overlay element in your CSS. This element will cover the background image and apply the noise effect.</p><pre><code>/* The grain overlay element that applies the filter */
.grain-overlay {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 2; /* Above background image, below content */
  pointer-events: none; /* Allows clicks to pass through to elements below */
  filter: url(#noiseFilter); /* Applies the SVG filter defined above */
}

/* Background image styling for context */
.background-image {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-image: url("./media/16x9steppecommander.png");
  background-size: cover;
  background-position: center;
  z-index: 1; /* Below grain overlay */
}
</code></pre><p>Finally, you need to structure your HTML to ensure the layering works correctly. The grain overlay should be positioned above the background image but below any content you want to display.</p><pre><code>&lt;!-- HTML structure showing the layering --&gt;
&lt;div class="hero"&gt;
  &lt;!-- Layer 1: Background image (z-index: 1) --&gt;
  &lt;div class="background-image"&gt;&lt;/div&gt;

  &lt;!-- Layer 2: Grain overlay (z-index: 2) --&gt;
  &lt;div class="grain-overlay" style="filter: url(#noiseFilter)"&gt;&lt;/div&gt;

  &lt;!-- Layer 3: Content (z-index: 3) --&gt;
  &lt;div class="content-center"&gt;
    &lt;!-- Your content here --&gt;
  &lt;/div&gt;
&lt;/div&gt;
</code></pre><h2>Be Creative!</h2><p>While there's ongoing debate about AI generated art, I see Midjourney as just another tool in the toolkit. The key is using it to bring your vision to life, not to replace your creativity.</p><p>Take inspiration from what you see, but make it your own. Use AI to bridge the gap between the style you have in your head and what actually shows up on screen. The techniques I've shared here are all about developing your unique voice and letting AI help you express it better.</p><p>The goal isn't to generate something generic. It's to create images that actually work for your projects and feel intentional, not obviously AI generated.</p>]]></content:encoded></item><item><title><![CDATA[LLM Codegen go Brrr – Parallelization with Git Worktrees and Tmux]]></title><description><![CDATA[If you&#8217;re underwhelmed with AI coding agents or simply want to get more out of them, give parallelization a try.]]></description><link>https://skeptrune.substack.com/p/git-worktrees-agents-and-tmux</link><guid isPermaLink="false">https://skeptrune.substack.com/p/git-worktrees-agents-and-tmux</guid><dc:creator><![CDATA[skeptrune]]></dc:creator><pubDate>Mon, 26 May 2025 18:52:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/12170965-b903-48f8-aa4f-655a93d3b4d6_1202x631.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you&#8217;re underwhelmed with AI coding agents or simply want to get more out of them, give parallelization a try. After seeing the results firsthand over the past month, I&#8217;m ready to call myself an evangelist. The throughput improvements are incredible, and I don&#8217;t feel like I&#8217;m losing control of the codebase.</p><p>This realization isn&#8217;t unique to me; the effectiveness of using Git worktrees for simultaneous execution is gaining broader recognition, as evidenced by mentions in Claude Code&#8217;s docs, discussion on Hacker News, projects like Claude Squad, and conversation on X.</p><h3>Example use-case: adding a UI component</h3><p>I'm building a component library called <a href="https://astrobits.dev">astrobits</a> and wanted to add a <code>Toggle</code>. To tackle the task, I deployed two <a href="https://www.anthropic.com/claude-code">Claude Code</a> agents and two <a href="https://openai.com/index/introducing-codex/">Codex</a> agents, all with the same prompt, running in parallel within their own <a href="https://git-scm.com/docs/git-worktrees">git worktrees</a>. Worktrees are essential because they provide each agent with an isolated directory, allowing them to execute simultaneously without overwriting each other's changes.</p><p>The number of agents I choose to rollout depends on the complexity of the task. Over time, you'll develop an intuition for estimating the right number based on the situation. Here, I felt 4 was appropriate.</p><p>&lt;br&gt;&lt;/br&gt; &lt;ImageFourSquareWorktrees /&gt; &lt;br&gt;&lt;/br&gt;</p><p>Voila, results! Only one of the four LLMs produced a solution that actually saved me time. This validates the necessity of rolling multiple agents: if each has a <code>~25%</code> chance of producing something useful, then running four gives a <code>68%</code> chance that at least one will succeed <code>(1 - 0.75^4 &#8776; 0.68)</code>. Four agents was essentially the bare minimum to have reasonable confidence in getting a workable solution.</p><p>With LLMs being so affordable, there's virtually no downside to running multiple agents. The cost difference between using one agent ($0.10) versus four ($0.40) is negligible compared to the 20 minutes of development time saved. Since the financial risk is minimal, you can afford to be aggressive with parallelization. If anything, I could have run even more agents to further increase the odds of getting a perfect solution on the first try.</p><p>And yet, the process of running them is still cumbersome and manual, it's more effort to setup 8 than 4, so I'm often lazy and opt to run the minimum number of agents I think will get the job done. This is where the problem comes in, and why I'm excited to share my proposed solution.</p><h3>Current workflow pain points</h3><p>Right now, I manually create git worktrees using <code>git worktree add -b newbranch ../path</code>, start a <code>tmux</code> session for each one, run Claude Code in the first pane, paste a prompt, <code>leader+c</code> into a new pane, run <code>yarn dev</code> to get a preview, switch to my browser to review, repeat if no agents succeed, then finally commit, push, and create a PR once I'm satisfied with an output.</p><p>Here are the top frustrations:</p><ul><li><p>I can't tell which branch a worktree was most recently rebased onto. For example, if <code>agent-1</code> was rebased onto <code>feature-x</code> but <code>agent-2</code> onto <code>main</code>, it's easy to lose track without manual notes.</p></li><li><p>There is no easy way to send the same prompt to multiple agents at once. For instance, if all agents are stuck on the same misunderstanding of the requirements, I have to copy-paste the clarification into each session.</p></li><li><p>I really wish I had a shortcut to open my IDE for a given worktree without having to <code>tmux a</code>, <code>leader + c</code>, and <code>code .</code> manually. I could use a long one-liner with <code>tmux send-keys and xargs</code> to automate this, but that still feels clunky.</p></li><li><p>Web previewing is a pain. I have to run <code>yarn dev</code> in each worktree, and then hold the mental model of which port each worktree is on. Automating a reverse proxy to handle this with a decent naming scheme would be a game-changer.</p></li><li><p>Committing and creating pull requests (PRs) is also more cumbersome than it should be. For example, after finding a solution in <code>agent-3</code>, I have to manually attach to that tmux session then <code>commit</code>, <code>push</code>, and <code>gh pr</code>.</p></li></ul><p>I feel like I've been through the wringer enough times with this process that I can see a solution shape which would create a smoother experience.</p><h3>Proposing a solution: <em>uzi</em></h3><p>To address these challenges head-on, the ideal developer experience (DX) would involve a lightweight CLI that wraps tmux, automating this complex orchestration. My co-founder Denzell and I felt these pain points acutely enough that we've begun developing such a tool, which we're calling <em><a href="https://github.com/devflowinc/uzi">uzi</a></em>. The core idea behind <em>uzi</em> is to abstract away the manual, repetitive tasks involved in managing multiple AI agent worktrees.</p><p>See some of the <code>uzi</code> commands we are thinking to implement below. Our goal is to make the workflow more seamless while sticking closely to the existing mechanics of worktres and tmux. We want to make sure that we feel at home using <code>uzi</code> alongside standard unix tools like <code>xargs</code>, <code>grep</code>, and <code>awk</code>.</p><ul><li><p><code>uzi start --agents claude:3,codex:2 --prompt "Implement feature X"</code> could initialize and prompt three Claude instances and two Codex instances, each in its own worktree.</p></li><li><p><code>uzi ls</code> would display all active agents, their target branches, and current statuses.</p></li><li><p><code>uzi exec --all -- yarn dev</code> could run a command like <code>yarn dev</code> across all agent worktrees.</p></li><li><p><code>uzi broadcast -- "Refine the previous response by focusing on Y"</code> would send a follow-up prompt to all active agents.</p></li><li><p><code>uzi checkpoint --agent claude-1 --message "Implemented initial draft"</code> could rebase the specified agent's worktree and commit the changes.</p></li><li><p><code>uzi kill --agent codex-2</code> would clean up a specific agent's tmux session and optionally its worktree.</p></li></ul><p>These commands would primarily operate via <code>tmux send-keys</code> instructions to the appropriate sessions. We don't want to reinvent the wheel; we just want to polish the existing process and make it more efficient.</p><h3>The Future is Parallel: Beyond Code</h3><p>While <code>uzi</code> focuses on software developers, its methodology isn't limited to tech; the principle of leveraging multiple agents running in parallel to increase the odds of finding an optimal solution applies universally.</p><p>Consider a company like <a href="https://www.versionstory.com/">versionstory</a>, which is pioneering version control for transactional lawyers. An attorney could leverage their software to run multiple instances of an agent to redline a contract. After reviewing the outputs, they could select and merge the best components to finalize the document. This approach would provide additional confidence in the quality of the final review as it would be based on multiple independent analyses rather than a single agent's output.</p><p>Similarly, a marketing team could employ this parallel strategy to perform data analysis on ad performance. By prompting multiple AI instances, they could quickly gather a range of analyses, review them, and select the most insightful ones to inform their strategy. More coverage of the solution space leads to better decision-making and more effective campaigns.</p><p>This parallel paradigm isn't just a new technique for developers; it's a glimpse into a more efficient, robust, and powerful future for AI-assisted productivity across various fields. I expect to see existing software products start to gain more powerful version control and parallel execution capabilities which emulate the workflow enabled by git worktrees for software development.</p><p>My DMs are open if you want to chat about this topic or have any questions. I'm happy to discuss.</p>]]></content:encoded></item></channel></rss>