<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Nick Khami&apos;s Blog</title><description>Technical blog covering AI, cryptography, data analysis, and software development. Insights on building systems, tutorials, and lessons learned from production deployments.</description><link>https://www.skeptrune.com/</link><language>en-us</language><lastBuildDate>Fri, 10 Apr 2026 15:14:37 GMT</lastBuildDate><item><title>Engineering Management 101</title><link>https://www.skeptrune.com/posts/engineering-management-101/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/engineering-management-101/</guid><description>I started as an engineering manager about 9 months ago. Prior to that I was a founder/CEO. Here&apos;s what I&apos;ve learned so far.</description><pubDate>Thu, 09 Apr 2026 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I started as an &quot;engineering manager&quot; (more on my personal feelings for the title later) about 9 months ago. Prior to that I was a founder/CEO. The peak size of my team as a founder was 8 and the peak size of my team as an EM has been 9. So I would say that at this point I have around 3.5 years of total management experience.&lt;/p&gt;
&lt;p&gt;I think being a &quot;manager&quot; is mostly the same as being an IC, but with additional responsibilities like managing headcount, budget, and providing coaching. You&apos;re also expected to do things like executive writing, speaking engagements, vendor procurement, 1:1&apos;s, team bonding events, shoutouts, and project planning that you wouldn&apos;t have been responsible for as an IC.&lt;/p&gt;
&lt;p&gt;Personally, rating myself on the Dunning-Kruger chart, I think I&apos;m about here right now.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;src/assets/images/blog-posts/EngineeringManagement101/dunning-kruger.webp&quot; alt=&quot;dunning-kruger&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I still ship code, write product specs, write engineering specs, approve PRs, and post on socials. The biggest adjustment from CEO to EM was the responsibility change, specifically not being responsible for fundraising.&lt;/p&gt;
&lt;p&gt;Thankfully, I didn&apos;t particularly enjoy fundraising. In general I just like working hard and solving problems so there&apos;s nothing specific that I really care to focus on or maintain doing. In the future, I could see myself fundraising again.&lt;/p&gt;
&lt;h2&gt;Servant Leadership&lt;/h2&gt;
&lt;p&gt;I subscribe to the &lt;a href=&quot;https://en.wikipedia.org/wiki/Servant_leadership&quot;&gt;servant leader&lt;/a&gt; approach to a large extent. To me that means my priorities are:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Help the business grow&lt;/li&gt;
&lt;li&gt;Get the people I&apos;m supporting promoted&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Even when it comes to 1, I am usually motivated to make the business grow because it means that there is now space for the folks I support to be promoted. I also really resent the words &quot;manage&quot; and &quot;report to.&quot; I think they create a hierarchy that doesn&apos;t really exist. I use the term &quot;support&quot; instead.&lt;/p&gt;
&lt;p&gt;Once you&apos;re not the IC actually doing the thing, you immediately have less authority over how it gets done. I basically acknowledge that at this point I make general suggestions for things and then people decide to what extent they want to listen. Usually their instincts are right. I hired well at my startup and Mintlify hires well too. That said, people definitely aren&apos;t &quot;reporting to&quot; or &quot;being managed by&quot; me.&lt;/p&gt;
&lt;p&gt;When someone ignores my suggestion and their instinct turns out to be wrong (which is rare in my experience), usually it just comes out as the work being less successful than it otherwise would have been.&lt;/p&gt;
&lt;p&gt;What I care about in this case is that they acknowledge that they&apos;ll try something different next time. Sometimes that&apos;s my original suggestion and sometimes it&apos;s not, but what&apos;s important is that there&apos;s a learning.&lt;/p&gt;
&lt;p&gt;One thing I used to believe is that you were also a servant to those who are supposed to be supporting you. I no longer believe that. You should support who is supporting you, but not serve them. Keep your own priorities intact in places where you might otherwise discard them for someone you have explicit leadership responsibility to.&lt;/p&gt;
&lt;h2&gt;Getting People Promoted&lt;/h2&gt;
&lt;p&gt;I have two expectations:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;People who I am supporting skill up to get promoted. Usually this means embracing additional complexity. As a software engineer that can mean hiring, doing planning, leading and making sure the right work is prioritized throughout, or owning product by interviewing customers, having vision, pitching, etc.&lt;/li&gt;
&lt;li&gt;Once they reach 100% ownership of a new skill level, they maintain that performance proactively and without active support for 6 months leading up to a promotion.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;To help people skill up, you usually have to do some amount of the work for them then hand it off. So, for example, you make a half-finished markdown doc with a list of issues then you hand it off and the person you&apos;re helping to skill up finishes it and gets tickets into the issue tracking system. This method is often described as &lt;a href=&quot;https://paulgraham.com/foundermode.html&quot;&gt;founder mode&apos;ing&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Or, if they are very junior, you can get a &lt;a href=&quot;https://www.reddit.com/r/explainlikeimfive/comments/dco4pd/eli5_what_is_a_pull_request_pr/&quot;&gt;PR&lt;/a&gt; into draft state such that they can finish it. You slowly get them to 100% of the task by doing it repeatedly with this handoff method scaling from them doing 10% then 20%, and so on. Then, following, support them such that they can maintain 100% of the task&apos;s complexity for 6 months without burning out.&lt;/p&gt;
&lt;p&gt;I reject the whole frame of throwing people you support into the deep end with a &quot;what doesn&apos;t kill you makes you stronger&quot; mindset. I think there is &lt;em&gt;failing&lt;/em&gt; and it&apos;s incredibly important to fail. But that should feel like a comfortable and supported experience instead of a painful struggle. People grow more in healthy and supportive environments.&lt;/p&gt;
&lt;h2&gt;The Most Common Failure Mode&lt;/h2&gt;
&lt;p&gt;Sometimes people who move into &quot;management&quot; start thinking work is beneath them. They take so many meetings and burn so much of their time writing docs that people don&apos;t read that they stop actually getting work over the finish line. When you see someone struggling who you are in charge of supporting, you have to get in there and embrace the burden so you can support them in digging their way out.&lt;/p&gt;
&lt;p&gt;This is the most important part of the job.&lt;/p&gt;
&lt;p&gt;Occasionally I see a project where no code goes out to production for an entire day. In these specific cases I &lt;em&gt;always&lt;/em&gt; will jump in and get some work over the finish line. Or it can be gigantic issues in the issue tracker that need to be broken down. I&apos;ll just go in and break them down myself. You have to parachute in sometimes.&lt;/p&gt;
&lt;p&gt;I don&apos;t like the signal that I am taking over and now have distrust, but if you are being earnest about it and doing it in a way that is supportive, then you can avoid those bad vibes. You just need to communicate clearly and explicitly explain your intentions. &quot;Hey guys, I see that we haven&apos;t gotten any PRs in. I&apos;m going to help kickstart the process. If I&apos;m stepping on your toes or repeating work here, please let me know.&quot;&lt;/p&gt;
&lt;h2&gt;On 1:1s&lt;/h2&gt;
&lt;p&gt;My hot take in management is that 1:1s are not &lt;em&gt;entirely&lt;/em&gt; the meeting of who you are supporting. Of course it&apos;s their meeting if they push and take ownership of it, but I have personally rarely found that to be the case.&lt;/p&gt;
&lt;p&gt;I usually ask what they&apos;re working on, what they want to do better, what they would want me to do if they could wave a magic wand, and what feedback they have for me.&lt;/p&gt;
&lt;p&gt;You likely shouldn&apos;t fight overly hard to surface interpersonal problems if they aren&apos;t brought up somewhat naturally. It&apos;s much more important to focus on helping people actually complete their work. The job of an IC is to get contributions out into the world for people to use. Focus your energy on supporting every aspect of that for the person you are working with instead of magnifying problems interpersonally to the extent you can. Of course, if there are major issues then you need to address and work those out, I just personally err on the side of caution here.&lt;/p&gt;
&lt;p&gt;Increasing pace and getting work shipped usually solves most problems in my experience thus far. But again, Dunning-Kruger, this out of all my opinions is where I think I will continue to evolve my thinking the most.&lt;/p&gt;
&lt;h2&gt;Techniques&lt;/h2&gt;
&lt;p&gt;I have a few techniques I like a lot.&lt;/p&gt;
&lt;h3&gt;Async Todolist Standup&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://skeptrune.substack.com/p/todolist-standup&quot;&gt;Replace standup with an async todolist Slack channel.&lt;/a&gt; Have people post a list of what they are doing every day in the morning instead of having a synchronous meeting. There are many reasons for that which I explain in the linked article.&lt;/p&gt;
&lt;h3&gt;Monthly Brag Docs&lt;/h3&gt;
&lt;p&gt;Write a monthly brag doc for everyone you support. I do this after every single month. It gives me a clear picture of what each person did and I often change my opinions on performance level when I write them. Throughout the month you can just lose track of things so it&apos;s really important that you confront your biases on a recurring basis. I use the Slack MCP and Claude Code to look back through everything and compile all the information. AI has really changed the game here.&lt;/p&gt;
&lt;h3&gt;Relentlessly Encourage Public Channels&lt;/h3&gt;
&lt;p&gt;I frequently redirect conversations being had over DM into public channels. I see a lot of my job as supporting the folks I am responsible for in feeling secure communicating there in public. Ultimately it&apos;s incredibly important that their contributions are visible to the entire org as much as possible.&lt;/p&gt;
&lt;p&gt;Sometimes when I redirect, people feel like their idea isn&apos;t clear enough. So you can rewrite their message into something clearer and say &quot;hey, would you be comfortable sending this?&quot;&lt;/p&gt;
&lt;p&gt;People need reassurance that if they make mistakes, you will cover them. Which I do. You have to really encourage the act of failure. No successes will ever happen if people don&apos;t feel comfortable failing. Limit testing in a corporation is not natural for most, so it&apos;s your job as a &quot;manager&quot; to encourage it. You want people to find the level of complexity where they either don&apos;t know what to do or mess up. If they don&apos;t get there frequently then they won&apos;t have rapid skill growth.&lt;/p&gt;
&lt;h2&gt;On Leveling&lt;/h2&gt;
&lt;p&gt;In my opinion, leveling (by title) primarily exists so people don&apos;t feel awkward.&lt;/p&gt;
&lt;p&gt;You want everyone to have clear and fair expectations for themselves and those around them. When someone is overleveled, there is tension because people try to hand off work or get help from someone who is supposed to be at a higher skill threshold than them. But in reality they are not higher skill and it&apos;s just an awful time for everyone.&lt;/p&gt;
&lt;p&gt;Then, when someone is underleveled, there is an issue where that person becomes restricted in how much impact they can actually have. Instead of scaling themselves and spreading techniques and processes, they leave your company for another job, start doing work on the side, or just quiet quit. Occasionally a super motivated person will stick it out, but I think that is relatively rare and ultimately not in their best interest. Anyone who is able to do that without a company change is incredibly high grit and special.&lt;/p&gt;
&lt;p&gt;Rarely can you &quot;demote&quot; someone. More often than not you just have to fire. When I have had to fire, it is usually because of two things. There is either a lack of will -- someone just can&apos;t summon the energy to try -- or it is a lack of skill. Almost always I have learned when firing (including myself out of being a founder/ceo) that for some reason or another there is a lack of will. People get stuck and stop taking actions altogether.&lt;/p&gt;
&lt;p&gt;When it comes to underleveling, you never want to promote too fast. I am very firm on 6 months of sustained performance. When people have asked for faster, if they are crushing it and you need to promote to retain them, then do it with compensation instead of title.&lt;/p&gt;
&lt;h2&gt;Drive-By Management&lt;/h2&gt;
&lt;p&gt;I think the best founders and managers are not naturally bossy people. Rarely are you successful when you come across as domineering. To that end, founders can often struggle with being in a &lt;a href=&quot;https://klinger.io/posts/fyi-how-founders-can-avoid-drive-by-management&quot;&gt;drive by management&lt;/a&gt; mode trying to get work done.&lt;/p&gt;
&lt;p&gt;They sometimes feel overly shy about being bossy. They want everyone to take high ownership of problems and therefore default to understeering. The problem is that without explicit framing, people misinterpret fyi&apos;s as plea&apos;s or plea&apos;s as fyi&apos;s. &lt;a href=&quot;https://x.com/wadefoster&quot;&gt;Wade Foster&lt;/a&gt; from Zapier has a great system for preventing this.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;#fyi&lt;/strong&gt;: something interesting. An article and podcast, etc. I thought you might like it. But if not no worries. Nothing to see here.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;#suggestion&lt;/strong&gt;: a passing thought. I sometimes have good ideas. You might like to hear good ideas. If I&apos;m in your shoes, I consider it. But I&apos;m not in your shoes so do what you&apos;d like. A friendly response if you don&apos;t go with the suggestion is nice, so I make better suggestions over time, but is by no means necessary.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;#recommendation&lt;/strong&gt;: I&apos;ve thought a lot about this. Perhaps even lost sleep. I&apos;ve invested deeply. I think this is a good plan. You can still disagree and go a different direction, but walking me through why you are doing this is kindly requested.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;#plea&lt;/strong&gt;: We don&apos;t have a lot of mandates at Zapier, but this is one. Please do this. If you disagree enough that you can&apos;t go along with it, we should both reconsider our roles here. It&apos;s that important.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I use #fyi and #suggestions all the time. #recommendation much less so. And #plea is almost entirely unused.&lt;/p&gt;
&lt;p&gt;I learned it from &lt;a href=&quot;https://x.com/andreasklinger&quot;&gt;Andreas Klinger&lt;/a&gt; who is a great follow on X by the way. Whenever I am communicating directionally, I explicitly callout where I&apos;m at from fyi to plea. It&apos;s rare I exceed a suggestion, but does happen. Plea&apos;s usually at most once a quarter. You cannot burn your social capital being at a recommendation or above all the time. People will just get annoyed and quit or stop taking you seriously.&lt;/p&gt;
&lt;p&gt;I strongly suggest you adopt some system like this when you begin supporting people as a career path.&lt;/p&gt;
&lt;h2&gt;Personal Brand&lt;/h2&gt;
&lt;p&gt;I think building a personal brand is an incredibly important part of being a leader. You want to be &quot;known&quot; for things. You want to have a reputation. You want people to understand your values and what you care about so they can properly assess how to engage and work with you.&lt;/p&gt;
&lt;p&gt;If you expect to be able to change companies or orgs and continue leading, then it&apos;s important there is an incredibly public track record of your work. Unlike being an IC, you are rarely directly &quot;shipping&quot; things when in manager mode, so you need to have social influence beyond. Good leaders can own go to market for their projects and part of that is having a personal brand. This was true before social media when you would write columns in newspapers and it&apos;s even more true post-social media when you can build a huge following from your phone in the moments between spinning up your favorite coding agent.&lt;/p&gt;
&lt;p&gt;I fall on the &quot;become a thought leader&quot; end of the spectrum. You want to have trust both internally at your org and externally with peers in your industry that you are first-principled, make rational decisions, and can be trusted to own blame for your own decisions and those of who you support. If you don&apos;t have a personal brand then I do not think you would be able to escape middle management. Getting to a &quot;manager of managers&quot; in my opinion requires a personal brand to some extent, likely closer to the &quot;thought leader&quot; end of the spectrum.&lt;/p&gt;
&lt;p&gt;You can be a great IC with no personal brand. I think that&apos;s the appeal of staff IC. But for leadership, you do, unfortunately, need to build one. Look at Bill Belichick, famously terse with media during his Patriots tenure, then immediately started his own podcast and network TV appearances once he was out to justify another coaching role. Same for JJ Redick, built a massive audience as a podcaster soon after retiring from the NBA, then parlayed that into coaching the LA Lakers.&lt;/p&gt;
&lt;p&gt;They built credibility through public performance at first by being on professional sports teams who had games broadcasted on primetime TV slots and then, once that was over, they pivoted to alternative media forms prior to getting back onto a high performing and visible team.&lt;/p&gt;
&lt;p&gt;Post instead of cruising early and dodging media like they did, because odds are that you&apos;re not an athlete performing on primetime TV every week. You need to build your brand through other channels.&lt;/p&gt;
&lt;p&gt;I am always pushing the people I support to build and write in public. If someone is resistant to it, I let it go. It&apos;s not a hard requirement unless they are going from manager to manager of managers level.&lt;/p&gt;
&lt;p&gt;My personal brand, in my opinion, is a strong attracting force for hires and customers. It&apos;s effectively a marketing channel for the business, particularly for brand marketing. The ROI is hard to attribute directly, but it is certainly there.&lt;/p&gt;
&lt;p&gt;I spend between 10 to 20 hours a week outside of the 9-5 work hours on personal brand. Writing, recording, commenting, and scrolling. I think of scrolling as research. It&apos;s very intentional. Final note on this, if you do decide to work on a personal brand then you&apos;ll find that some people don&apos;t like your &quot;style&quot;. I think that&apos;s fine and you shouldn&apos;t worry about it. The beauty of the brand being &quot;personal&quot; is that it&apos;s yours and not everyone has to be aligned with it. Do whatever you&apos;re comfortable doing. Ignore haters.&lt;/p&gt;
&lt;h2&gt;Most Importantly, Please Do Not Be a Cynical Manager&lt;/h2&gt;
&lt;p&gt;If you take nothing else away from this post, please let it be this section. I think the best &quot;managers&quot; (🙄) are optimistic and positive. They earnestly want to contribute to growing the organization they are at and the people they support.&lt;/p&gt;
&lt;p&gt;I heavily disagree with &lt;a href=&quot;https://www.seangoedecke.com/&quot;&gt;Sean Goedecke&apos;s&lt;/a&gt; writing on management. He overindexes on politics and &quot;managing stakeholders&quot; which I think is incredibly cynical and short-sighted. Nothing good is going to happen from analyzing and kissing ass all the time. High performing teams and organizations are all aligned in being high action and throughput. If you are high action and throughput then, in my opinion, things usually work out.&lt;/p&gt;
&lt;p&gt;Of course, that is to an extent. Be a good person, be kind, apologize when you make mistakes, buy people gifts when they do things that help you, say thank you, say please, say good morning. Don&apos;t be a cold and unfeeling curmudgeon. But do also primarily focus on doing your best work and putting things out there into the world.&lt;/p&gt;
&lt;p&gt;Everyone wants to solve problems and build cool shit at the end of the day. Or well, at least everyone I would be excited to work with.&lt;/p&gt;
</content:encoded><category>management</category><category>business</category><author>Nick Khami</author></item><item><title>Startup Marketing 101</title><link>https://www.skeptrune.com/posts/startup-marketing-101/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/startup-marketing-101/</guid><description>I was pretty bad at marketing as a founder, but I&apos;ve learned a few things since.</description><pubDate>Mon, 23 Feb 2026 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Everyone in startups has heard the advice: &quot;don&apos;t tunnel vision on product, make sure you do marketing.&quot; 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.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;src/assets/images/blog-posts/Marketing101/braindead-do-marketing.webp&quot; alt=&quot;braindead-do-marketing&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I heard this constantly while founding my previous startup &lt;a href=&quot;https://www.ycombinator.com/companies/trieve&quot;&gt;Trieve&lt;/a&gt;, and I bought into it. You can find old &lt;a href=&quot;https://www.tiktok.com/@trieveai?lang=en&quot;&gt;TikTok posts&lt;/a&gt; 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.&lt;/p&gt;
&lt;h2&gt;What Changed?&lt;/h2&gt;
&lt;p&gt;Startup media and accelerator programs create an expectation of a &quot;launch&quot; event, think of &lt;a href=&quot;https://www.youtube.com/watch?v=Qp-AwObTrvE&quot;&gt;TechCrunch Disrupt in the Silicon Valley TV show&lt;/a&gt;, Supabase&apos;s infamous &lt;a href=&quot;https://supabase.com/launch-week&quot;&gt;&quot;launch week&quot;&lt;/a&gt;, and of course the OG themselves - &lt;a href=&quot;https://www.producthunt.com/launch&quot;&gt;ProductHunt&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;src/assets/images/blog-posts/Marketing101/demo-day.webp&quot; alt=&quot;demo-day&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I&apos;m kind of an idiot and let being &quot;post-fundraise&quot; 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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;Executing the Slow Drip Launch&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;src/assets/images/blog-posts/Marketing101/slow-and-steady-wins-the-race.webp&quot; alt=&quot;slow-and-steady-wins-the-race&quot; /&gt;&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Your best case scenario is a couple days of electricity in return for weeks or months&apos; worth of work. And, worst case, your launch falls flat and you get literally nothing out of it.&lt;/p&gt;
&lt;h3&gt;Tactic #1: Personal Brands&lt;/h3&gt;
&lt;p&gt;I hate to quote Roy Lee, but he&apos;s not wrong when he says &lt;a href=&quot;https://x.com/im_roy_lee/status/2009677516701565112?s=20&quot;&gt;&quot;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&quot;&lt;/a&gt;. 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.&lt;/p&gt;
&lt;p&gt;However, I&apos;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.&lt;/p&gt;
&lt;p&gt;That&apos;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.&lt;/p&gt;
&lt;p&gt;The light at the end of the tunnel is that success on social media tends to compound. While it&apos;s true that social media feeds are more competitive than ever and &lt;a href=&quot;https://www.milkkarten.net/p/social-media-followers-feed&quot;&gt;no longer show your content consistently to followers&lt;/a&gt;, there will be some people who consistently engage with your content and see it day after day.&lt;/p&gt;
&lt;p&gt;Their engagement kind of serves as a core that makes your content count as a live shot on goal, so the platform you&apos;re posting on at least tests if your content resonates with a wider audience. The size of that &quot;test group&quot; gets bigger as your following grows, and you therefore start to more consistently go viral over time.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Imagine you have a classical cast - engineer, designer, and businessperson. Engineer can post knee-high sock photos about how you&apos;re using Rust btw, the designer can share overdone figmas nobody&apos;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.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;src/assets/images/blog-posts/Marketing101/jackass-the-movie-bam.webp&quot; alt=&quot;jackass-the-movie-bam&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Over time each person&apos;s social graph will grow in different directions and you&apos;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.&lt;/p&gt;
&lt;h3&gt;Tactic #2: Field Marketing&lt;/h3&gt;
&lt;p&gt;Host an event once you know what you&apos;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&apos;s usually worth it.&lt;/p&gt;
&lt;p&gt;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&apos;re building. Don&apos;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.&lt;/p&gt;
&lt;p&gt;Use something like &lt;a href=&quot;https://www.partiful.com/&quot;&gt;Partiful&lt;/a&gt; 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.&lt;/p&gt;
&lt;p&gt;I like this one because it&apos;s pretty earnest and doesn&apos;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 &quot;VIP Dinners&quot; also tends to be a pretty good lead funnel and functions in the same way.&lt;/p&gt;
&lt;h3&gt;Tactic #3: Capitalizing on Trends&lt;/h3&gt;
&lt;p&gt;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 &quot;going viral&quot; is a sellout move, and that we should only build things that are directly related to our product vision. While I do think it&apos;s important to stay true to your vision, I also think it&apos;s important to be flexible and adapt to trends when they make sense.&lt;/p&gt;
&lt;p&gt;On that note, I think competitive surfing rounds are a reasonable proxy metaphor for how to think about this. When you&apos;re in a surf competition, you&apos;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&apos;re not hesitating too long and missing out on rides that could be good but aren&apos;t perfect.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;src/assets/images/blog-posts/Marketing101/pro-surfing-competition.webp&quot; alt=&quot;pro-surfing-competition&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You&apos;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&apos;t afford to be too picky about which trends you choose to ride. If there&apos;s a meme or topic that&apos;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&apos;s not perfectly aligned with your vision.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;I&apos;m Begging You to Post&lt;/h2&gt;
&lt;p&gt;If you take nothing else away from this, please for all that&apos;s holy, just post. It increases your odds of getting lucky and making it by orders of magnitude. And, odds are nobody&apos;s even going to see your content anyways, so stop worrying about embarrassing yourself.&lt;/p&gt;
</content:encoded><category>startups</category><category>marketing</category><category>business</category><author>Nick Khami</author></item><item><title>Replace Your Standup with a Todo List</title><link>https://www.skeptrune.com/posts/todolist-standup/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/todolist-standup/</guid><description>A new approach to async standups that focuses on todolist-driven updates.</description><pubDate>Fri, 02 Jan 2026 12:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; You can watch me write this blog post &lt;a href=&quot;https://x.com/skeptrune/status/2007168539204137238&quot;&gt;on video here on x.com!&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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&apos;t feel like a startup move to them.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;If that&apos;s the goal, you don&apos;t need a meeting.&lt;/p&gt;
&lt;h2&gt;The Process&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Post your task list in a channel like &lt;code&gt;#standup&lt;/code&gt; first thing in the morning&lt;/li&gt;
&lt;li&gt;Edit the message to cross off tasks as you complete them throughout the day&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Here&apos;s what one of those messages looks like.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- Fix login bug on staging
- Review Sarah&apos;s PR #234
- Write API docs for /users endpoint
- Sync with design on checkout flow
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As you work, you come back and edit the message to cross things off.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- ~~Fix login bug on staging~~
- ~~Review Sarah&apos;s PR #234~~
- Write API docs for /users endpoint
- Sync with design on checkout flow
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Advanced Version&lt;/h2&gt;
&lt;p&gt;Add timestamps if you want to track how long things are taking you or otherwise provide more context.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Today:
- [9:15-10:30] ~~Fix login bug on staging~~
- [10:30-11:00] ~~Review Sarah&apos;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
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s it. Everyone sees what you&apos;re working on. No meeting required.&lt;/p&gt;
</content:encoded><category>work</category><category>management</category><author>Nick Khami</author></item><item><title>Org-Level Email Campaigns are Somehow an Unsolved Problem</title><link>https://www.skeptrune.com/posts/org-level-email-campaigns-instantly/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/org-level-email-campaigns-instantly/</guid><description>No email tool stops a whole org when one person replies. Here&apos;s how I built org-level campaign control with Instantly and webhooks.</description><pubDate>Sun, 28 Dec 2025 12:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Defining the Goal&lt;/h2&gt;
&lt;p&gt;We launched new &lt;a href=&quot;https://www.mintlify.com/docs/ai/discord#discord-bot&quot;&gt;community discord and slack bots&lt;/a&gt; recently at Mintlify and needed to do some customer marketing to let users who had large communities know about them.&lt;/p&gt;
&lt;p&gt;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&apos;t want to keep emailing everyone else from the org.&lt;/p&gt;
&lt;p&gt;I thought this would be easy using any of the email marketing tools out there like &lt;a href=&quot;https://resend.com/&quot;&gt;Resend&lt;/a&gt; or &lt;a href=&quot;https://loops.so/&quot;&gt;Loops&lt;/a&gt;, but this was not the case. All of the available tools shockingly handle campaigns at a user instead of organization level.&lt;/p&gt;
&lt;p&gt;None of them have the concept of &quot;stop emailing this group when any member takes action.&quot; They&apos;re all built for individual drip campaigns, not org-level outreach.&lt;/p&gt;
&lt;h2&gt;Tool Selection&lt;/h2&gt;
&lt;p&gt;Claude Code has made me &lt;a href=&quot;https://thomasorus.com/i-tried-coding-with-ai-i-became-lazy-and-stupid&quot;&gt;quite lazy&lt;/a&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://instantly.ai&quot;&gt;Instantly&lt;/a&gt; was my final selection given its API was the most robust and &lt;a href=&quot;https://developer.instantly.ai/api/v2/analytics/getwarmupanalytics&quot;&gt;well documented&lt;/a&gt;. It gave claude full access to campaigns, leads, and sequences while also being capable of sending webhooks on reply and unsubscribe events.&lt;/p&gt;
&lt;p&gt;Kind of random aside but &lt;a href=&quot;https://jamstack.org/&quot;&gt;JAMstack&lt;/a&gt; architecture patterns are probably going to make a comeback with AI. I think tools like &lt;a href=&quot;https://trpc.io/&quot;&gt;trpc&lt;/a&gt; are going to fall out of favor relative to &lt;a href=&quot;https://www.openapis.org/&quot;&gt;openapi&lt;/a&gt; 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.&lt;/p&gt;
&lt;h2&gt;Solution Architecture&lt;/h2&gt;
&lt;p&gt;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 &lt;code&gt;companyName&lt;/code&gt; custom variable.&lt;/p&gt;
&lt;p&gt;Then a webhook server listens for reply events, finds all leads from the same company, and marks them as &quot;not interested&quot; to stop their sequences.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CSV (company, email)
    → Upload Script
    → Instantly (leads with companyName)

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

API_KEY = &quot;your-api-key&quot;
CAMPAIGN_ID = &quot;your-campaign-id&quot;

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

        for email in emails:
            requests.post(
                &quot;https://api.instantly.ai/api/v2/leads&quot;,
                headers={&quot;Authorization&quot;: f&quot;Bearer {API_KEY}&quot;},
                json={
                    &quot;email&quot;: email.strip(),
                    &quot;company_name&quot;: company,
                    &quot;custom_variables&quot;: {&quot;companyName&quot;: company},
                    &quot;campaign&quot;: CAMPAIGN_ID
                }
            )
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Registering the Webhook&lt;/h3&gt;
&lt;p&gt;Tell Instantly to POST to your server whenever someone replies. You&apos;ll need the campaign ID from earlier.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -X POST https://api.instantly.ai/api/v2/webhooks \
  -H &quot;Authorization: Bearer $INSTANTLY_API_KEY&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{
    &quot;target_hook_url&quot;: &quot;https://your-server.com/webhook/reply&quot;,
    &quot;event_type&quot;: &quot;reply_received&quot;,
    &quot;campaign_id&quot;: &quot;your-campaign-id&quot;
  }&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Webhook Server&lt;/h2&gt;
&lt;p&gt;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&apos;s status to stop their sequence.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const express = require(&apos;express&apos;);
const app = express();
app.use(express.json());

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

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

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

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

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

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

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

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

// Instantly&apos;s API doesn&apos;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&apos;d want to cache
// this or build your own company-&amp;gt;leads index.
async function findLeadsByCompany(companyName) {
  const leads = [];
  let cursor = null;

  do {
    const resp = await fetch(&apos;https://api.instantly.ai/api/v2/leads/list&apos;, {
      method: &apos;POST&apos;,
      headers: { &apos;Authorization&apos;: `Bearer ${API_KEY}`, &apos;Content-Type&apos;: &apos;application/json&apos; },
      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 =&amp;gt; lead.company_name === companyName);
}

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

app.listen(3000);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;Someone Please Build This&lt;/h2&gt;
&lt;p&gt;This solution works, but it&apos;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.&lt;/p&gt;
&lt;p&gt;What I actually want is to define groups of contacts by company, toggle &quot;stop group on reply&quot; as a campaign setting, and have the email tool handle it without webhooks.&lt;/p&gt;
&lt;p&gt;If you&apos;re building email tools, please add this.&lt;/p&gt;
</content:encoded><category>ai</category><category>infrastructure</category><category>marketing</category><author>Nick Khami</author></item><item><title>Working with Me</title><link>https://www.skeptrune.com/posts/working-with-me/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/working-with-me/</guid><description>A guide for anyone working with me. Covers communication preferences, feedback, what I value, and my quirks.</description><pubDate>Mon, 15 Dec 2025 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Several people have recommended I write a &quot;working with me&quot; document to help onboard new collaborators, teammates, and contractors and I finally got around to it. Here it is!&lt;/p&gt;
&lt;h2&gt;How I Communicate&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;How to Get My Attention&lt;/h2&gt;
&lt;p&gt;Call my cell phone if you have my number. Otherwise, @ me on Slack. I &lt;strong&gt;strongly&lt;/strong&gt; 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&apos;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&apos;t intend.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;Planning&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;If you really can&apos;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.&lt;/p&gt;
&lt;h2&gt;1:1s&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Progression is fun, and even more so when you keep track of it with someone who&apos;s invested in holding you accountable.&lt;/p&gt;
&lt;h2&gt;Feedback&lt;/h2&gt;
&lt;p&gt;I try to only give unprompted feedback when it&apos;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;What I Value&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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&apos;re good at, or ponder endlessly before closing the loop by launching and getting feedback.&lt;/p&gt;
&lt;h2&gt;My Quirks&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;UX is important to me, but not so much UI. I would rather something be functional and easy to use than pretty. I don&apos;t think design is particularly valuable. Just solve the problem.&lt;/p&gt;
&lt;h2&gt;Hours and Availability&lt;/h2&gt;
&lt;p&gt;I don&apos;t sleep all that much and am easily awoken. 3am–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!&lt;/p&gt;
&lt;p&gt;Direct pings are something which I always try to respond to as fast as possible. If it&apos;s important enough that you&apos;re annoyed with my response time being slow, again, call my cell.&lt;/p&gt;
&lt;h2&gt;What I&apos;m Working On Improving&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
</content:encoded><category>work</category><category>management</category><author>Nick Khami</author></item><item><title>Prompt the Loop When Using Coding Agents</title><link>https://www.skeptrune.com/posts/prompting-the-agent-loop/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/prompting-the-agent-loop/</guid><description>The difference between a language model and an agent isn&apos;t just tool access. It&apos;s about giving the agent a condition to loop on. Here&apos;s how to prompt agents to actually finish tasks instead of just taking first steps.</description><pubDate>Sun, 02 Nov 2025 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import badpromptVideo from &apos;../../assets/images/blog-posts/PromptingTheAgentLoop/badprompt.mp4&apos;;
import goodpromptVideo from &apos;../../assets/images/blog-posts/PromptingTheAgentLoop/goodprompt.mp4&apos;;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;src/assets/images/blog-posts/PromptingTheAgentLoop/adamdotdev-tweet.webp&quot; alt=&quot;Adam&apos;s tweet about AI agents doing tedious work&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You can safely ignore advice about coding agents that doesn&apos;t mention loop structure.&lt;/p&gt;
&lt;p&gt;&quot;Agent&quot; is an overloaded term, so I want to clarify that I&apos;m using &lt;a href=&quot;https://simonwillison.net/2025/Sep/18/agents/&quot;&gt;simonw&apos;s definition&lt;/a&gt; of &quot;a language model which runs tools in a loop to achieve a goal&quot; when I say &quot;agent&quot; throughout this post.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;If that doesn&apos;t make sense to you then read &lt;a href=&quot;https://fly.io/blog/everyone-write-an-agent/&quot;&gt;fly.io&apos;s guide on writing agents&lt;/a&gt; or &lt;a href=&quot;https://ampcode.com/how-to-build-an-agent/&quot;&gt;ampcode&apos;s guide&lt;/a&gt; and try building your own micro-agent first before continuing. I promise it&apos;s a worthwhile exercise.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;What is This Loop You Speak Of?&lt;/h2&gt;
&lt;p&gt;Consider this example prompt asking claude code to write a &lt;code&gt;generateSlug&lt;/code&gt; function.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;add a new function to the @index.ts file called `generateSlug` which accepts a blog post title and returns a URL-friendly slug
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sounds clear and like it should work right? WRONG! Watch the output below.&lt;/p&gt;
&lt;p&gt;&amp;lt;video controls loop muted playsinline style=&quot;width: 100%; max-width: 100%; border-radius: 8px;&quot;&amp;gt;
&amp;lt;source src={badpromptVideo} type=&quot;video/mp4&quot; /&amp;gt;
Your browser does not support the video tag.
&amp;lt;/video&amp;gt;&lt;/p&gt;
&lt;p&gt;We&apos;re missing unicode characters, special characters, multiple spaces, edge cases. Curse javascript all you want, but this is our fault, not Claude&apos;s.&lt;/p&gt;
&lt;p&gt;Our prompt doesn&apos;t give the agent anything to test against. You want something more like&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;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.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Watch &lt;em&gt;this baby&lt;/em&gt; run!&lt;/p&gt;
&lt;p&gt;&amp;lt;video controls loop muted playsinline style=&quot;width: 100%; max-width: 100%; border-radius: 8px;&quot;&amp;gt;
&amp;lt;source src={goodpromptVideo} type=&quot;video/mp4&quot; /&amp;gt;
Your browser does not support the video tag.
&amp;lt;/video&amp;gt;&lt;/p&gt;
&lt;h2&gt;It&apos;s all the agent loop!&lt;/h2&gt;
&lt;p&gt;The key difference is this phrase from the second prompt:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;run them, watch them fail, implement the slug generator until they all pass&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;Common Loop Condition Patterns&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;For new features&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[describe feature], write tests for [key behaviors], run them, fix until they all pass
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;For bug fixes&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;reproduce the bug in [file/test], fix the issue, verify the bug no longer occurs and all tests pass
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;For build/compile issues&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[make changes], run the build, fix any errors or type issues until it compiles successfully
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All of the above patterns include some kind of check; either tasts pass or fail, builds succeed or error, bugs reproduce or don&apos;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.&lt;/p&gt;
&lt;p&gt;Prompting outside of this pattern is the equivalent of grabbing a jr dev 3 shots past &lt;a href=&quot;https://en.wikipedia.org/wiki/Ballmer_Peak&quot;&gt;Ballmer&apos;s peak&lt;/a&gt; and asking them to fix a bug.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Senior developers know what loop conditions work for different tasks. If you&apos;re junior and not sure what&apos;s appropriate for a given task, use the planning mode offered by &lt;a href=&quot;https://www.claude.com/product/claude-code&quot;&gt;claude code&lt;/a&gt;, &lt;a href=&quot;https://cursor.com/&quot;&gt;cursor&lt;/a&gt;, or your coding agent of choice to help you craft them.&lt;/p&gt;
&lt;p&gt;Good luck out there!&lt;/p&gt;
</content:encoded><category>ai</category><category>tutorial</category><author>Nick Khami</author></item><item><title>How I Use Claude Code on My Phone with Termux and Tailscale</title><link>https://www.skeptrune.com/posts/claude-code-on-mobile-termux-tailscale/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/claude-code-on-mobile-termux-tailscale/</guid><description>You don&apos;t need a new startup or third-party service to use Claude Code on your phone. You just need SSH, Tailscale, and Termux. Here&apos;s how to code from anywhere with the tools you already have.</description><pubDate>Sun, 19 Oct 2025 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;There&apos;s a mini gold rush to put &lt;a href=&quot;https://claude.ai/claude-code&quot;&gt;Claude Code&lt;/a&gt; on your phone. Some startups are building custom apps, others are creating managed cloud environments. They&apos;re solving real problems, but you&apos;re trading raw Unix power for convenience. If you have a desktop and 20 minutes, you can get full kernel access with SSH, &lt;a href=&quot;https://termux.dev/en/&quot;&gt;termux&lt;/a&gt;, and &lt;a href=&quot;https://tailscale.com/&quot;&gt;tailscale&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Yesterday I &lt;a href=&quot;https://x.com/skeptrune/status/1979668217930084596&quot;&gt;posted about&lt;/a&gt; shipping a feature to this blog from the passenger seat while driving to Apple Hill, CA from San Francisco. I SSH&apos;d into my office desktop from my phone, prompted Claude to make the changes, tested them on my phone&apos;s browser, and pushed to production in 10 minutes. That post got 130k impressions and dozens of people asked for the setup.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;The Architecture&lt;/h2&gt;
&lt;p&gt;The setup uses five standard Unix tools that work together without custom integration. A &lt;strong&gt;desktop&lt;/strong&gt; runs Claude Code, &lt;strong&gt;tailscale&lt;/strong&gt; creates a private network between your devices, &lt;strong&gt;termux&lt;/strong&gt; gives you a real terminal on Android, &lt;strong&gt;SSH&lt;/strong&gt; handles the connection, and &lt;strong&gt;tmux&lt;/strong&gt; keeps your sessions alive when you disconnect.&lt;/p&gt;
&lt;h3&gt;Step 1: Setup Your Desktop&lt;/h3&gt;
&lt;p&gt;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&apos;t need to be powerful. Claude Code just makes API calls, the actual compute happens at Anthropic.&lt;/p&gt;
&lt;p&gt;I keep a desktop at my office that stays on 24/7. It&apos;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.&lt;/p&gt;
&lt;p&gt;First, install Claude Code globally using npm. This gives you the &lt;code&gt;claude&lt;/code&gt; command that you&apos;ll use to start coding sessions.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm install -g @anthropic-ai/claude-code
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt install tmux  # Ubuntu/Debian
brew install tmux      # macOS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With Claude Code and tmux installed, your desktop is ready to host your development sessions.&lt;/p&gt;
&lt;h3&gt;Step 2: Install Tailscale Everywhere&lt;/h3&gt;
&lt;p&gt;Tailscale creates a private network between all your devices. Your phone gets a stable IP address that can reach your desktop, even when you&apos;re on different networks. It just works.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Install Tailscale on your phone from the Play Store. Sign in with the same account. Your devices are now on the same network.&lt;/p&gt;
&lt;p&gt;You&apos;ll need your desktop&apos;s Tailscale IP address to connect from your phone. Grab it with this command.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tailscale ip -4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You&apos;ll get something like &lt;code&gt;100.64.0.5&lt;/code&gt;. That&apos;s your desktop&apos;s address on the Tailscale network. It&apos;s stable, it&apos;s private, and it works from anywhere.&lt;/p&gt;
&lt;h3&gt;Step 3: Install Termux on Your Phone&lt;/h3&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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/.&lt;/p&gt;
&lt;p&gt;Once installed, you&apos;ll need to update the package repositories and install the SSH client. Termux uses &lt;code&gt;pkg&lt;/code&gt; as its package manager, which is basically a wrapper around apt.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pkg update
pkg install openssh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With OpenSSH installed, Termux can now connect to your desktop over SSH.&lt;/p&gt;
&lt;h3&gt;Step 4: SSH Into Your Desktop&lt;/h3&gt;
&lt;p&gt;Now for the moment of truth. Open Termux and SSH to your desktop using the Tailscale IP you grabbed earlier. Replace &lt;code&gt;100.64.0.5&lt;/code&gt; with your actual IP and &lt;code&gt;your-username&lt;/code&gt; with your desktop username.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh your-username@100.64.0.5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first time you connect, SSH will ask you to verify the host fingerprint. Type &lt;code&gt;yes&lt;/code&gt;. Then enter your password.&lt;/p&gt;
&lt;p&gt;You&apos;re in. You&apos;re now running a shell on your desktop, from your phone, over a secure encrypted connection that works anywhere you have internet.&lt;/p&gt;
&lt;h3&gt;Step 5: Use tmux for Session Persistence&lt;/h3&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;You&apos;re now connected to your desktop via SSH from your phone. Start a new tmux session with a name you&apos;ll remember. I usually name mine after the project I&apos;m working on.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tmux new -s code
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This creates a session named &quot;code&quot;. You can name it anything. Inside the tmux session, launch Claude Code and start working.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;claude
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you&apos;re coding. On your phone. Using Claude Code. Running on your desktop.&lt;/p&gt;
&lt;p&gt;When you need to disconnect, don&apos;t exit Claude Code. Don&apos;t exit tmux. Just close Termux or let your phone lock. The tmux session stays running on your desktop.&lt;/p&gt;
&lt;p&gt;Later, when you want to code again, SSH back in and reattach to your session. Everything will be exactly where you left it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh your-username@100.64.0.5
tmux attach -t code
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Your conversation with Claude is still there. Your file context is still loaded. You can continue your previous task immediately.&lt;/p&gt;
&lt;p&gt;In the Apple Hill example from the intro, this is exactly what I did. I SSH&apos;d in, ran &lt;code&gt;tmux attach -t personalsite&lt;/code&gt; 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&apos;d left off.&lt;/p&gt;
&lt;h2&gt;Why This Works Better Than Custom Apps&lt;/h2&gt;
&lt;p&gt;Every startup trying to solve &quot;Claude Code on mobile&quot; is building abstractions on top of these primitives. They&apos;re not giving you anything you can&apos;t already do with SSH and Termux. They&apos;re just wrapping it in a prettier UI and charging for hosting.&lt;/p&gt;
&lt;p&gt;When you do it yourself, you get several advantages.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Port forwarding just works.&lt;/strong&gt; With Tailscale, your desktop&apos;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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Full CLI access to configure your environment.&lt;/strong&gt; Want to run your dev server with &lt;code&gt;--host&lt;/code&gt; so you can test on your phone&apos;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&apos;t offer this level of control because it&apos;s too niche for their target users, but for power users it&apos;s essential.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Session persistence that actually works.&lt;/strong&gt; tmux was built for this. Your session survives network disconnections, phone reboots, and SSH reconnects. You never lose your place.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Your own hardware.&lt;/strong&gt; Your desktop has your SSH keys, your git credentials, your environment exactly how you configured it. You&apos;re not coding in a disposable cloud container.&lt;/p&gt;
&lt;h2&gt;The Mobile Experience&lt;/h2&gt;
&lt;p&gt;I&apos;m not going to pretend coding on a phone is as good as coding on a desktop. It&apos;s not. The screen is small. The keyboard is mediocre. You can&apos;t see multiple files at once.&lt;/p&gt;
&lt;p&gt;But Claude Code is different from traditional coding. You&apos;re not typing out functions character by character. You&apos;re describing what you want, reviewing Claude&apos;s changes, and approving or rejecting them. That workflow actually works on mobile.&lt;/p&gt;
&lt;p&gt;The Apple Hill example wasn&apos;t cherry-picked. I&apos;ve shipped real features from my phone. I&apos;ve fixed production bugs while getting coffee. I&apos;ve reviewed pull requests from the back of an Uber. It&apos;s not my primary development environment, but it&apos;s shockingly capable when I need it.&lt;/p&gt;
&lt;p&gt;The key is that Claude Code is conversational. You&apos;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&apos;re reading more than you&apos;re typing, and phones are great for reading.&lt;/p&gt;
&lt;h2&gt;Practical Tips&lt;/h2&gt;
&lt;p&gt;Once you have the basic setup running, there are a few tweaks that make the mobile coding experience dramatically better. These aren&apos;t strictly necessary, but they&apos;ll save you time and frustration.&lt;/p&gt;
&lt;h3&gt;Test Your Changes on Your Phone&apos;s Browser&lt;/h3&gt;
&lt;p&gt;This is the killer feature. You&apos;re not just editing code remotely, you can test it on your phone while the dev server runs on your desktop.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;--host&lt;/code&gt; flag, which makes it accessible on your Tailscale network instead of just localhost.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;yarn dev --host
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For Vite (which Astro uses), this binds the dev server to &lt;code&gt;0.0.0.0&lt;/code&gt; instead of &lt;code&gt;127.0.0.1&lt;/code&gt;. For other frameworks: &lt;code&gt;npm start -- --host&lt;/code&gt; for React, &lt;code&gt;next dev -H 0.0.0.0&lt;/code&gt; for Next.js, &lt;code&gt;python manage.py runserver 0.0.0.0:8000&lt;/code&gt; for Django.&lt;/p&gt;
&lt;p&gt;Grab your desktop&apos;s Tailscale IP again if you forgot it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tailscale ip -4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then on your phone&apos;s browser, navigate to &lt;code&gt;http://100.64.0.5:4321&lt;/code&gt; (replace with your Tailscale IP and port).&lt;/p&gt;
&lt;p&gt;You&apos;re now viewing your local dev server, running on your desktop 2.5 hours away, in your phone&apos;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.&lt;/p&gt;
&lt;p&gt;You&apos;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&apos;re back at your desk.&lt;/p&gt;
&lt;h3&gt;Use SSH Keys&lt;/h3&gt;
&lt;p&gt;Don&apos;t type your password every time you SSH. Generate an SSH key on your phone and add it to your desktop&apos;s authorized keys.&lt;/p&gt;
&lt;p&gt;Open Termux and generate an ed25519 key (the modern standard). Then use &lt;code&gt;ssh-copy-id&lt;/code&gt; to automatically add it to your desktop&apos;s authorized keys file.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh-keygen -t ed25519
ssh-copy-id your-username@100.64.0.5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you can SSH without a password.&lt;/p&gt;
&lt;h3&gt;Create an SSH Config&lt;/h3&gt;
&lt;p&gt;Make connecting easier by adding your desktop to your SSH config. Instead of typing &lt;code&gt;ssh your-username@100.64.0.5&lt;/code&gt; every time, you can create an alias. Make a file at &lt;code&gt;~/.ssh/config&lt;/code&gt; in Termux with this content.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Host desktop
    HostName 100.64.0.5
    User your-username
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you can just type &lt;code&gt;ssh desktop&lt;/code&gt; instead of remembering the IP and username.&lt;/p&gt;
&lt;h3&gt;Use a Better Keyboard&lt;/h3&gt;
&lt;p&gt;Termux works with external keyboards. I keep a small Bluetooth keyboard in my bag. When I&apos;m actually trying to get work done on my phone, I pull out the keyboard. It makes a massive difference.&lt;/p&gt;
&lt;p&gt;The phone screen is fine for reading. The keyboard makes typing bearable.&lt;/p&gt;
&lt;h3&gt;Set Up tmux Keybindings&lt;/h3&gt;
&lt;p&gt;tmux&apos;s default keybindings are terrible on mobile. Remap them to something sensible. On your desktop, create or edit &lt;code&gt;~/.tmux.conf&lt;/code&gt; and add these bindings. They make tmux way easier to use on a phone keyboard.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 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
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you can manage tmux sessions without finger gymnastics.&lt;/p&gt;
&lt;h3&gt;Run Multiple Sessions&lt;/h3&gt;
&lt;p&gt;You can have multiple tmux sessions for different projects. I usually have one for each repo I&apos;m actively working on. Start them with descriptive names so you remember what&apos;s what.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tmux new -s backend
tmux new -s frontend
tmux new -s experiments
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When you SSH in and want to see what sessions are running, list them.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tmux ls
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then attach to whichever one you want to work on.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tmux attach -t frontend
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This keeps your different projects isolated. You can switch contexts just by attaching to a different session.&lt;/p&gt;
&lt;h2&gt;Security Considerations&lt;/h2&gt;
&lt;p&gt;You&apos;re SSH&apos;ing into your desktop over the internet. That&apos;s a potential security risk if you do it wrong. Do it right with a few precautions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use Tailscale.&lt;/strong&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use SSH keys.&lt;/strong&gt; Disable password authentication entirely. Keys are longer, stronger, and can&apos;t be brute-forced. Edit &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; on your desktop and set these values, then restart sshd.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PasswordAuthentication no
PubkeyAuthentication yes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Keep your phone secure.&lt;/strong&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Monitor SSH access.&lt;/strong&gt; Check who&apos;s connected to your machine with &lt;code&gt;who&lt;/code&gt; or &lt;code&gt;w&lt;/code&gt;. Check SSH logs with &lt;code&gt;sudo tail -f /var/log/auth.log&lt;/code&gt;. If you see connections you don&apos;t recognize, revoke SSH keys and investigate.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;When This Doesn&apos;t Work&lt;/h2&gt;
&lt;p&gt;This setup assumes you have a desktop that stays on. If you don&apos;t, you need a cloud VM or a home server. That&apos;s still not a reason to use a third-party service. Just rent a $5/month VPS from &lt;a href=&quot;https://www.digitalocean.com/&quot;&gt;DigitalOcean&lt;/a&gt; or &lt;a href=&quot;https://www.hetzner.com/&quot;&gt;Hetzner&lt;/a&gt;, install Tailscale and Claude Code, and SSH into it the same way.&lt;/p&gt;
&lt;p&gt;This also assumes you&apos;re on Android. If you&apos;re on iOS, Termux isn&apos;t available. You&apos;ll need to use a different SSH client like &lt;a href=&quot;https://blink.sh/&quot;&gt;Blink&lt;/a&gt; or &lt;a href=&quot;https://panic.com/prompt/&quot;&gt;Prompt&lt;/a&gt;. The rest of the setup is the same.&lt;/p&gt;
&lt;p&gt;If you&apos;re on unstable internet, SSH can be frustrating. &lt;a href=&quot;https://mosh.org/&quot;&gt;Mosh&lt;/a&gt; (mobile shell) is designed for high-latency or unreliable connections. Install it on both your phone and desktop, then use &lt;code&gt;mosh desktop&lt;/code&gt; instead of &lt;code&gt;ssh desktop&lt;/code&gt;. It handles disconnections gracefully and keeps your terminal responsive even on bad networks.&lt;/p&gt;
&lt;h2&gt;The Bottom Line&lt;/h2&gt;
&lt;p&gt;Mobile development with Claude Code doesn&apos;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.&lt;/p&gt;
&lt;p&gt;SSH has been the standard for secure remote access since 1995. Tmux has provided session management since 2007. Tailscale is newer, but it&apos;s built on WireGuard, which has undergone extensive security audits. These tools are mature, well-documented, and widely deployed in production environments.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Glory be to the AI overlords, who grant us the grace to code at the bar without shame.&lt;/p&gt;
</content:encoded><category>ai</category><category>mobile</category><category>infrastructure</category><category>vibecoding</category><author>Nick Khami</author></item><item><title>Multi-Tenant SaaS&apos;s Wildcard TLS: An Overview of DNS-01 Challenges</title><link>https://www.skeptrune.com/posts/wildcard-tls-for-multi-tenant-systems/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/wildcard-tls-for-multi-tenant-systems/</guid><description>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><pubDate>Fri, 17 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;AI app builders are everywhere now. You enter a prompt, get a deployed product on &lt;code&gt;your-app.builder.com&lt;/code&gt;, 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.&lt;/p&gt;
&lt;p&gt;This pattern isn&apos;t new. Multi-tenant SaaS has used &lt;code&gt;tenant-id.foo.com&lt;/code&gt; 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&apos;t provision individual certificates for every generated app, you need wildcard certificates.&lt;/p&gt;
&lt;p&gt;I&apos;d never set this up before, but at &lt;a href=&quot;https://www.mintlify.com&quot;&gt;Mintlify&lt;/a&gt; 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&apos;m sharing what I learned so you can implement it too.&lt;/p&gt;
&lt;h2&gt;The Problem: Per-Tenant Certificates Don&apos;t Scale&lt;/h2&gt;
&lt;p&gt;If you provision individual certificates for each tenant, you&apos;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&apos;s Encrypt (50 certificates per registered domain per week). You need a better approach.&lt;/p&gt;
&lt;h2&gt;Wildcard Certificates: One Cert, Infinite Tenants&lt;/h2&gt;
&lt;p&gt;A wildcard certificate for &lt;code&gt;*.foo.com&lt;/code&gt; covers all first-level subdomains. This means any subdomain directly under your base domain gets automatic TLS coverage with a single certificate.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tenant-a.foo.com     ✓
tenant-b.foo.com     ✓
tenant-xyz.foo.com   ✓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The wildcard certificate doesn&apos;t extend to the apex domain or nested subdomains, though. Here&apos;s what&apos;s explicitly excluded from coverage.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;foo.com                      ✗ (apex domain)
api.tenant-a.foo.com         ✗ (nested subdomain)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For most multi-tenant systems, this is exactly what you want. One certificate, provisioned once, renewed automatically, and it works for every tenant you&apos;ll ever onboard.&lt;/p&gt;
&lt;h2&gt;Why You Must Use DNS-01 Challenges&lt;/h2&gt;
&lt;p&gt;To get a wildcard certificate from Let&apos;s Encrypt (or any ACME-compliant CA), you must use the DNS-01 challenge type. The more common HTTP-01 challenge doesn&apos;t work for wildcards.&lt;/p&gt;
&lt;p&gt;With HTTP-01, the CA verifies domain ownership by requesting a specific file at &lt;code&gt;http://your-domain/.well-known/acme-challenge/token&lt;/code&gt;. But for &lt;code&gt;*.foo.com&lt;/code&gt;, there&apos;s no single HTTP endpoint to verify; the wildcard represents infinite possible subdomains.&lt;/p&gt;
&lt;p&gt;DNS-01 solves this by verifying ownership at the DNS level. Your ACME client requests a wildcard certificate for &lt;code&gt;*.foo.com&lt;/code&gt;, Let&apos;s Encrypt generates a challenge token, and you create a TXT record at &lt;code&gt;_acme-challenge.foo.com&lt;/code&gt; with that token as the value.&lt;/p&gt;
&lt;p&gt;Let&apos;s Encrypt queries public DNS for that TXT record, and if the record exists with the correct value, Let&apos;s Encrypt knows you control the domain and issues the certificate. This means your certificate provisioning system needs &lt;em&gt;programmatic access&lt;/em&gt; to your DNS provider&apos;s API to create and delete TXT records on demand.&lt;/p&gt;
&lt;h2&gt;How DNS-01 Automation Works&lt;/h2&gt;
&lt;p&gt;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&apos;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.&lt;/p&gt;
&lt;p&gt;I&apos;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 &lt;code&gt;web server + DNS provider plugin + ACME client = automated wildcard certificates&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;The Architecture (Cloudflare Example)&lt;/h3&gt;
&lt;p&gt;The system has three layers. Caddy is the web server that needs TLS certificates. The &lt;code&gt;caddy-dns/cloudflare&lt;/code&gt; module is a thin adapter (only ~120 lines of Go) that sits between Caddy and the actual DNS API client. The &lt;code&gt;libdns/cloudflare&lt;/code&gt; package handles the real work of talking to Cloudflare&apos;s API.&lt;/p&gt;
&lt;p&gt;Caddy handles the web server and ACME logic, &lt;code&gt;certmagic&lt;/code&gt; handles certificate management and renewal, &lt;code&gt;libdns/cloudflare&lt;/code&gt; handles DNS API calls, and the plugin just connects them together.&lt;/p&gt;
&lt;p&gt;This same pattern exists for every major DNS provider. There&apos;s &lt;code&gt;caddy-dns/route53&lt;/code&gt; for AWS, &lt;code&gt;caddy-dns/googleclouddns&lt;/code&gt; for GCP, &lt;code&gt;caddy-dns/azure&lt;/code&gt; for Azure, and plugins for dozens of other providers. The code structure is nearly identical, you just swap the API client.&lt;/p&gt;
&lt;h3&gt;Building Caddy with DNS Provider Support&lt;/h3&gt;
&lt;p&gt;Standard Caddy doesn&apos;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.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Install xcaddy (Caddy&apos;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
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This uses Caddy&apos;s module system to compile the plugin into a single binary. The result is a &lt;code&gt;caddy&lt;/code&gt; executable that includes the DNS provider integration.&lt;/p&gt;
&lt;p&gt;For other providers, just swap the module name &lt;code&gt;--with github.com/caddy-dns/route53&lt;/code&gt; for AWS, &lt;code&gt;--with github.com/caddy-dns/googleclouddns&lt;/code&gt; for GCP, &lt;code&gt;--with github.com/caddy-dns/azure&lt;/code&gt; for Azure. You can even include multiple providers if you manage domains across different DNS platforms.&lt;/p&gt;
&lt;h3&gt;Configuring Your Caddyfile&lt;/h3&gt;
&lt;p&gt;Once you&apos;ve built Caddy with the DNS provider plugin, the actual configuration is remarkably simple. Here&apos;s the complete configuration for wildcard TLS with automatic provisioning and renewal.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;*.foo.com {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }

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

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

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

func (Provider) CaddyModule() caddy.ModuleInfo {
    return caddy.ModuleInfo{
        ID:  &quot;dns.providers.cloudflare&quot;,
        New: func() caddy.Module { return &amp;amp;Provider{new(cloudflare.Provider)} },
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The plugin wraps &lt;code&gt;github.com/libdns/cloudflare&lt;/code&gt; and registers itself as a Caddy module with the ID &lt;code&gt;dns.providers.cloudflare&lt;/code&gt;. When you write &lt;code&gt;dns cloudflare&lt;/code&gt; in your Caddyfile, Caddy loads this module.&lt;/p&gt;
&lt;h3&gt;Caddyfile Parsing&lt;/h3&gt;
&lt;p&gt;The parsing logic handles both inline and block configuration syntaxes, giving you flexibility in how you structure your Caddyfile. Here&apos;s how it works.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;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 &quot;api_token&quot;:
                if d.NextArg() {
                    p.Provider.APIToken = d.Val()
                }
            case &quot;zone_token&quot;:
                if d.NextArg() {
                    p.Provider.ZoneToken = d.Val()
                }
            }
        }
    }

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

# Block syntax (for dual tokens)
dns cloudflare {
    api_token {env.CF_API_TOKEN}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Token Validation&lt;/h3&gt;
&lt;p&gt;Before making any API calls, the plugin validates that the token format is correct. This catches configuration errors early with clear error messages. Here&apos;s the validation logic.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;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, &quot;&quot;)

    if !cloudflareTokenRegexp.MatchString(p.Provider.APIToken) {
        return fmt.Errorf(&quot;API token &apos;%s&apos; appears invalid&quot;, p.Provider.APIToken)
    }
    return nil
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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 &quot;Invalid request headers&quot; from Cloudflare later.&lt;/p&gt;
&lt;h3&gt;The Actual DNS Operations&lt;/h3&gt;
&lt;p&gt;The plugin doesn&apos;t implement DNS operations directly. It delegates to &lt;code&gt;libdns/cloudflare&lt;/code&gt;, which implements the &lt;code&gt;libdns&lt;/code&gt; interface.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;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)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Caddy&apos;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.&lt;/p&gt;
&lt;h2&gt;Debugging and Common Issues&lt;/h2&gt;
&lt;h3&gt;&quot;Invalid request headers&quot;&lt;/h3&gt;
&lt;p&gt;This error means your API token is malformed or the environment variable isn&apos;t set. The first step is to verify the token environment variable is properly configured.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;echo $CF_API_TOKEN
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the output is empty, you&apos;ve found the problem. When the environment variable isn&apos;t set, Caddy tries to use &lt;code&gt;{env.CF_API_TOKEN}&lt;/code&gt; literally as the token, which results in authentication failures from your DNS provider&apos;s API.&lt;/p&gt;
&lt;h3&gt;&quot;timed out waiting for record to fully propagate&quot;&lt;/h3&gt;
&lt;p&gt;The DNS propagation check is timing out. This usually means DNS caching is happening—your local resolver is caching the old &quot;record doesn&apos;t exist&quot; response, so use a custom resolver like &lt;code&gt;resolvers 1.1.1.1&lt;/code&gt; in your TLS block. Or it&apos;s a private DNS issue where &lt;code&gt;foo.com&lt;/code&gt; is defined in &lt;code&gt;/etc/hosts&lt;/code&gt; 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—the token doesn&apos;t have access to the zone, so verify the token has &lt;code&gt;Zone:Read&lt;/code&gt; permission for &lt;code&gt;foo.com&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;&quot;expected 1 zone, got 0&quot;&lt;/h3&gt;
&lt;p&gt;The plugin can&apos;t find the zone for your domain. This happens if the domain isn&apos;t in Cloudflare DNS, the API token doesn&apos;t have &lt;code&gt;Zone:Read&lt;/code&gt; permission, or the zone name doesn&apos;t match (e.g., you&apos;re requesting &lt;code&gt;*.sub.foo.com&lt;/code&gt; but only &lt;code&gt;foo.com&lt;/code&gt; is in Cloudflare).&lt;/p&gt;
&lt;h3&gt;Certificate Transparency Logs&lt;/h3&gt;
&lt;p&gt;All certificates issued by public CAs are logged to Certificate Transparency logs. You can see your wildcard cert at https://crt.sh. Search for &lt;code&gt;%.foo.com&lt;/code&gt; to find wildcard certificates.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;foo.com&lt;/code&gt; has a wildcard certificate, though they can&apos;t enumerate individual tenant subdomains.&lt;/p&gt;
&lt;h2&gt;Production Deployment Patterns&lt;/h2&gt;
&lt;h3&gt;Docker Compose&lt;/h3&gt;
&lt;p&gt;For containerized deployments, Docker Compose provides a straightforward way to run Caddy with persistent certificate storage. Here&apos;s a complete configuration.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  caddy:
    build:
      context: .
      dockerfile: Dockerfile.caddy
    ports:
      - &quot;443:443&quot;
      - &quot;80:80&quot;
    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:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;caddy_data&lt;/code&gt; volume persists certificates across container restarts. The &lt;code&gt;caddy_config&lt;/code&gt; volume persists Caddy&apos;s runtime configuration.&lt;/p&gt;
&lt;h3&gt;Dockerfile with Cloudflare Plugin&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This multi-stage build compiles Caddy with the Cloudflare plugin in the builder stage, then copies just the binary to the final image.&lt;/p&gt;
&lt;h3&gt;Kubernetes with Cert-Manager&lt;/h3&gt;
&lt;p&gt;If you&apos;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.&lt;/p&gt;
&lt;p&gt;Here&apos;s an example with Cloudflare, but cert-manager has built-in support for Route53, Cloud DNS, Azure DNS, and dozens of other providers.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: wildcard-foo-com
spec:
  secretName: wildcard-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
  - &quot;*.foo.com&quot;
  - &quot;foo.com&quot;
---
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
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cert-manager provisions the certificate as a Kubernetes Secret, which your Ingress controller (nginx, Traefik, Envoy, etc.) can reference. The &lt;code&gt;dns01&lt;/code&gt; solver configuration changes based on your provider—swap &lt;code&gt;cloudflare&lt;/code&gt; for &lt;code&gt;route53&lt;/code&gt;, &lt;code&gt;clouddns&lt;/code&gt;, or &lt;code&gt;azuredns&lt;/code&gt; with the appropriate credential references.&lt;/p&gt;
&lt;h3&gt;Multi-Region Deployments&lt;/h3&gt;
&lt;p&gt;If you&apos;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;The simplest approach for most systems is to run certificate provisioning in one region, store certificates in your cloud provider&apos;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.&lt;/p&gt;
&lt;h2&gt;Security Considerations&lt;/h2&gt;
&lt;p&gt;The wildcard certificate&apos;s private key protects all your tenant subdomains. If it leaks, an attacker can impersonate any tenant. Protect it like you&apos;d protect your database credentials.&lt;/p&gt;
&lt;h3&gt;Adding Your Domain to the Public Suffix List&lt;/h3&gt;
&lt;p&gt;If you&apos;re running a multi-tenant platform where each tenant gets a subdomain, you should submit your domain to the &lt;a href=&quot;https://publicsuffix.org/&quot;&gt;Public Suffix List&lt;/a&gt;. The PSL is a registry that browsers use to determine security boundaries between sites.&lt;/p&gt;
&lt;p&gt;Without PSL registration, browsers treat &lt;code&gt;tenant-a.foo.com&lt;/code&gt; and &lt;code&gt;tenant-b.foo.com&lt;/code&gt; as the same site. This means one tenant could potentially set cookies readable by another tenant, creating security and privacy issues.&lt;/p&gt;
&lt;p&gt;When you add &lt;code&gt;foo.com&lt;/code&gt; to the PSL, browsers treat each tenant subdomain as an independent site. Cookies set by &lt;code&gt;tenant-a.foo.com&lt;/code&gt; cannot be read by &lt;code&gt;tenant-b.foo.com&lt;/code&gt;. This provides proper isolation between tenants at the browser level.&lt;/p&gt;
&lt;p&gt;Major platforms like GitHub (&lt;code&gt;github.io&lt;/code&gt;), Vercel (&lt;code&gt;vercel.app&lt;/code&gt;), and Netlify (&lt;code&gt;netlify.app&lt;/code&gt;) are all registered on the PSL. If you&apos;re building tenant infrastructure, you should be too. Submit via the &lt;a href=&quot;https://github.com/publicsuffix/list&quot;&gt;PSL GitHub repository&lt;/a&gt; with documentation proving you control the domain and explaining your multi-tenant use case.&lt;/p&gt;
&lt;h3&gt;Token Scope Limiting&lt;/h3&gt;
&lt;p&gt;Your DNS provider credentials should have the minimum required permissions. For Cloudflare, scope tokens to specific zones with only &lt;code&gt;Zone.Zone:Read&lt;/code&gt; and &lt;code&gt;Zone.DNS:Edit&lt;/code&gt;. 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 &lt;code&gt;dns.admin&lt;/code&gt; role scoped to individual zones, not project-wide access.&lt;/p&gt;
&lt;p&gt;Don&apos;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.&lt;/p&gt;
&lt;h3&gt;Certificate Revocation&lt;/h3&gt;
&lt;p&gt;If you need to revoke a wildcard certificate, you can&apos;t selectively revoke it for one tenant, revocation affects all tenants. This is a fundamental tradeoff of wildcard certificates.&lt;/p&gt;
&lt;p&gt;If you need per-tenant revocation capability, you need per-tenant certificates. For most systems, the operational simplicity of wildcards outweighs this limitation.&lt;/p&gt;
&lt;h3&gt;Rate Limits&lt;/h3&gt;
&lt;p&gt;Let&apos;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&apos;re provisioning one certificate regardless of tenant count, so you&apos;ll never hit the 50 certificates per week limit. This is a massive advantage over per-tenant certificates.&lt;/p&gt;
&lt;h2&gt;When NOT to Use Wildcard Certificates&lt;/h2&gt;
&lt;p&gt;Skip wildcards if tenants bring their own domains. If tenants use &lt;code&gt;tenant-a.com&lt;/code&gt; instead of &lt;code&gt;tenant-a.foo.com&lt;/code&gt;, you need per-tenant certificates. You can still automate this with ACME HTTP-01 challenges, but you&apos;ll need per-tenant certificate management.&lt;/p&gt;
&lt;p&gt;Skip them if you need deep subdomain nesting. Wildcards only cover one level—&lt;code&gt;*.foo.com&lt;/code&gt; doesn&apos;t cover &lt;code&gt;api.tenant-a.foo.com&lt;/code&gt;. If your architecture requires nested subdomains, you either need multiple wildcard certificates or per-tenant certificates.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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&apos;t work.&lt;/p&gt;
&lt;h2&gt;The Bottom Line&lt;/h2&gt;
&lt;p&gt;For multi-tenant systems with &lt;code&gt;tenant-id.foo.com&lt;/code&gt; 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&apos;s API (Cloudflare, Route53, Cloud DNS, Azure DNS), and let ACME automation handle the rest.&lt;/p&gt;
&lt;p&gt;The alternative, per-tenant certificates, is operationally complex, technically fragile, and doesn&apos;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.&lt;/p&gt;
&lt;p&gt;If you&apos;re building &lt;code&gt;tenant-id.foo.com&lt;/code&gt; infrastructure, this is the way.&lt;/p&gt;
</content:encoded><category>infrastructure</category><category>security</category><author>Nick Khami</author></item><item><title>XGBoost Is All You Need</title><link>https://www.skeptrune.com/posts/xgboost-is-all-you-need/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/xgboost-is-all-you-need/</guid><description>Why XGBoost remains the go-to algorithm for structured data problems, and how it consistently outperforms neural networks on tabular datasets.</description><pubDate>Sun, 12 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import LLMFeatureDemo from &quot;../../components/blog/XGBoostIsAllYouNeed/LLMFeatureDemo.astro&quot;;
import ModelComparisonDemo from &quot;../../components/blog/XGBoostIsAllYouNeed/ModelComparisonDemo.astro&quot;;
import FeatureImportanceDemo from &quot;../../components/blog/XGBoostIsAllYouNeed/FeatureImportanceDemo.astro&quot;;&lt;/p&gt;
&lt;p&gt;{/* &amp;lt;!-- TODO: Add a more concrete opening story - maybe a specific moment at the startup where you realized asking LLMs for direct answers was broken? --&amp;gt; */}&lt;/p&gt;
&lt;p&gt;I spent two and a half years at a well-funded search startup building systems that used LLMs to answer questions via RAG (Retrieval Augmented Generation). We&apos;d retrieve relevant documents, feed them to an LLM, and ask it to synthesize an answer.&lt;/p&gt;
&lt;p&gt;I came out of that experience with one overwhelming conviction: we were doing it backwards. The problem was that we were asking LLMs &quot;what&apos;s the answer?&quot; instead of &quot;what do we need to know?&quot;&lt;/p&gt;
&lt;p&gt;LLMs are brilliant at reading and synthesizing information at massive scale. You can spawn infinite instances in parallel to process thousands of documents, extract insights, and transform unstructured text into structured data. They&apos;re like having an army of research assistants who never sleep and work for pennies.&lt;/p&gt;
&lt;h2&gt;A real example: Predicting NFL running back performance&lt;/h2&gt;
&lt;p&gt;{/* &amp;lt;!-- TODO: Add personality - maybe joke about why you picked this problem, or a detail about trying the &quot;ask LLM directly&quot; approach first and failing? --&amp;gt; */}&lt;/p&gt;
&lt;p&gt;Forecasting how many rushing yards an NFL running back will gain in their next game is a perfect example of this architecture. It&apos;s influenced by historical statistics (previous yards, carries, opponent defense), qualitative factors (recent press coverage, injury concerns, offensive line health), and game context (Vegas betting lines, projected workload).&lt;/p&gt;
&lt;h3&gt;The wrong approach: Asking LLMs directly&lt;/h3&gt;
&lt;p&gt;{/* &amp;lt;!-- TODO: Add personality - show a real example of ChatGPT giving a plausible-sounding but wrong prediction? Make it funny? --&amp;gt; */}&lt;/p&gt;
&lt;p&gt;You could ask ChatGPT&apos;s Deep Research feature to predict every game in a week. It would use web search to gather context, think about each matchup, and give you predictions.&lt;/p&gt;
&lt;p&gt;This approach is fundamentally broken. It&apos;s unscalable (each prediction requires manual prompting and waiting), the output is unstructured (you&apos;d need to manually parse each response and log it in a spreadsheet), it&apos;s unreliable (LLMs are trained to sound plausible, not to optimize for numerical accuracy), and you can&apos;t learn from it (each prediction is independent—there&apos;s no way to improve based on what worked).&lt;/p&gt;
&lt;p&gt;This is the &quot;ask the LLM what&apos;s the answer&quot; approach. It feels like you&apos;re doing AI, but you&apos;re really just creating an expensive, slow research assistant that makes gut-feel predictions.&lt;/p&gt;
&lt;h3&gt;The right approach: LLMs for feature engineering&lt;/h3&gt;
&lt;p&gt;{/* &amp;lt;!-- TODO: Add personality - maybe contrast this with how a human would do feature engineering? Show the &quot;aha&quot; moment when you realized this approach? --&amp;gt; */}&lt;/p&gt;
&lt;p&gt;Instead of asking &quot;How many yards will Derrick Henry rush for?&quot;, we ask the LLM to transform unstructured information into structured features. Search for recent press coverage and rate sentiment 1-10. Analyze injury reports and rate concern level 1-5. Evaluate opponent&apos;s run defense and rate weakness 1-10.&lt;/p&gt;
&lt;p&gt;This is scalable (run 100+ feature extractions in parallel), structured (everything becomes a number XGBoost can use), and improves over time (XGBoost learns which features actually matter).&lt;/p&gt;
&lt;p&gt;I started with basic statistical features from the NFL API: yards and carries from the previous week, 3-week rolling averages, that kind of thing. These are helpful, but they miss important context.&lt;/p&gt;
&lt;p&gt;So I had the LLM engineer seven qualitative features: press coverage sentiment, injury concerns, opponent defense weakness, offensive line health, Vegas sentiment, projected workload share, and game script favorability. An agent loop with web search processed context about each player and game to populate these features—searching for news in the week leading up to the game and rating each factor on a numerical scale.&lt;/p&gt;
&lt;p&gt;&amp;lt;LLMFeatureDemo /&amp;gt;&lt;/p&gt;
&lt;p&gt;Once we run this process for every running back each week, we end up with a dataset that has both statistical and LLM-engineered qualitative features.&lt;/p&gt;
&lt;h2&gt;Training XGBoost&lt;/h2&gt;
&lt;p&gt;{/* &amp;lt;!-- TODO: Add personality - what were you hoping for? What did you expect to happen? --&amp;gt; */}&lt;/p&gt;
&lt;p&gt;I split the data chronologically—early weeks for training, later weeks for testing—and trained two models. A baseline using only statistical features (previous yards, carries, rolling averages), and an enhanced model using both statistical and LLM-engineered features.&lt;/p&gt;
&lt;h2&gt;Results&lt;/h2&gt;
&lt;p&gt;{/* &amp;lt;!-- TODO: Add personality - show your emotional reaction to seeing these numbers. Were you shocked? Skeptical? Did you run it again to make sure? --&amp;gt; */}&lt;/p&gt;
&lt;p&gt;&amp;lt;ModelComparisonDemo /&amp;gt;&lt;/p&gt;
&lt;p&gt;The LLM-enhanced model reduced prediction error by &lt;strong&gt;22.6%&lt;/strong&gt;. The baseline model was actually worse than just predicting the average yards (R² of -0.025), while the enhanced model explained 38.6% of the variance.&lt;/p&gt;
&lt;p&gt;But that&apos;s not the interesting part. The interesting part is what XGBoost actually learned.&lt;/p&gt;
&lt;h2&gt;What XGBoost Actually Learned&lt;/h2&gt;
&lt;p&gt;{/* &amp;lt;!-- TODO: Add personality - build up the surprise here. Maybe say &quot;I looked at the feature importance rankings expecting to see...&quot; --&amp;gt; */}&lt;/p&gt;
&lt;p&gt;&amp;lt;FeatureImportanceDemo /&amp;gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Six of the top seven most important features are LLM-engineered.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The top feature is average carries over the last 3 weeks (statistical). The second most important feature is press coverage sentiment (LLM). Then game script prediction (LLM), Vegas sentiment (LLM), projected workload share (LLM), offensive line health (LLM), and injury concern (LLM).&lt;/p&gt;
&lt;p&gt;I didn&apos;t tell XGBoost that press sentiment matters more than injury concerns, or that game script prediction is more important than offensive line health. The model discovered these patterns on its own by analyzing which features actually correlated with rushing yards.&lt;/p&gt;
&lt;p&gt;The most predictive LLM feature, press coverage sentiment, captures momentum and narrative that doesn&apos;t show up in raw statistics. When a running back is getting positive press coverage, they tend to get more carries and perform better. XGBoost found this signal and learned to weight it heavily.&lt;/p&gt;
&lt;p&gt;This is the power of the hybrid approach: LLMs transform messy, unstructured context into clean features. XGBoost discovers which features actually matter. Neither could do this alone.&lt;/p&gt;
&lt;h2&gt;Why This Matters&lt;/h2&gt;
&lt;p&gt;{/* &amp;lt;!-- TODO: Add personality - make the transition to &quot;this is actually a bigger problem&quot; more dramatic. Show frustration with the current state? --&amp;gt; */}&lt;/p&gt;
&lt;p&gt;This isn&apos;t just about NFL predictions. Email prioritization, Slack message routing, pull request quality assessment, prediction market opportunities, customer support triage—every one of these problems has the same structure. Some structured data combined with unstructured context that needs to be transformed into a prediction.&lt;/p&gt;
&lt;p&gt;The architecture is identical every time: use LLMs in parallel to extract features from unstructured data, combine with structured features, train XGBoost to find patterns, deploy and iterate.&lt;/p&gt;
&lt;p&gt;Setting this up from scratch takes way too much time. I want tools that make this trivial—upload your data, describe what you want to predict, and get back a trained model with a deployment-ready API.&lt;/p&gt;
&lt;h2&gt;Why These Tools Don&apos;t Exist&lt;/h2&gt;
&lt;p&gt;{/* &amp;lt;!-- TODO: Add personality - make this section angrier? More pointed? This is your villain reveal. --&amp;gt; */}&lt;/p&gt;
&lt;p&gt;The tools I&apos;m describing could exist today. The technology is mature and proven. So why hasn&apos;t anyone built them?&lt;/p&gt;
&lt;p&gt;Random forests don&apos;t raise $1B rounds.&lt;/p&gt;
&lt;p&gt;Founders are building pure-LLM systems because that&apos;s what gets funded. VCs get excited about foundation models and AGI, not about elegant hybrid architectures that combine 2019-era XGBoost with LLM feature engineering.&lt;/p&gt;
&lt;p&gt;This is the real problem with modern AI development. Not that the technology isn&apos;t good enough—it&apos;s that incentives are backwards. VC-led engineering is bad engineering. The best technical solutions rarely align with what makes a compelling pitch deck.&lt;/p&gt;
&lt;p&gt;Everyone&apos;s building the wrong thing because they&apos;re building what raises money instead of what solves problems.&lt;/p&gt;
&lt;p&gt;If you&apos;re a builder who cares more about solving real problems than raising huge rounds, there&apos;s a massive opportunity here. Build the boring, practical tools that let people deploy these hybrid systems in minutes instead of weeks. Build what actually works instead of what sounds impressive.&lt;/p&gt;
&lt;h2&gt;The Right Tool for the Right Job&lt;/h2&gt;
&lt;p&gt;{/* &amp;lt;!-- TODO: Add personality - end on a more concrete note? What are YOU going to build next? What do you wish existed? --&amp;gt; */}&lt;/p&gt;
&lt;p&gt;The future of ML isn&apos;t pure LLMs or pure classical ML—it&apos;s knowing which tool to use for which job.&lt;/p&gt;
&lt;p&gt;Don&apos;t ask LLMs &quot;what&apos;s the answer?&quot; Ask them &quot;what do we need to know?&quot; Then let XGBoost find the patterns in those answers.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Want to see the full implementation? Check out the &lt;a href=&quot;https://github.com/skeptrunedev/personal-site/blob/main/data-analysis/xgboost-nfl-rb.ipynb&quot;&gt;complete Jupyter notebook walkthrough&lt;/a&gt; with all the code, data processing steps, training, and visualizations.&lt;/p&gt;
</content:encoded><category>ai</category><category>machine-learning</category><author>Nick Khami</author></item><item><title>Use the Accept Header to serve Markdown instead of HTML to LLMs</title><link>https://www.skeptrune.com/posts/use-the-accept-header-to-serve-markdown-instead-of-html-to-llms/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/use-the-accept-header-to-serve-markdown-instead-of-html-to-llms/</guid><description>Make your website accessible to LLM agents by serving plain Markdown when they request text/plain or text/markdown. Save tokens and improve agent experience with this simple Astro and Cloudflare Workers implementation.</description><pubDate>Sat, 27 Sep 2025 18:52:00 GMT</pubDate><content:encoded>&lt;p&gt;Agents don&apos;t need to see websites with markup and styling; anything other than plain Markdown is just wasted money spent on context tokens.&lt;/p&gt;
&lt;p&gt;I decided to make my Astro sites more accessible to LLMs by having them return Markdown versions of pages when the &lt;code&gt;Accept&lt;/code&gt; header has &lt;code&gt;text/plain&lt;/code&gt; or &lt;code&gt;text/markdown&lt;/code&gt; preceding &lt;code&gt;text/html&lt;/code&gt;. This was very heavily inspired by &lt;a href=&quot;https://x.com/bunjavascript/status/1971934734940098971&quot;&gt;this post on X from bunjavascript&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; You can check out the feature live by running &lt;code&gt;curl -H &quot;Accept: text/markdown&quot; https://www.skeptrune.com&lt;/code&gt; or &lt;code&gt;curl -H &quot;Accept: text/plain&quot; https://www.skeptrune.com&lt;/code&gt; in your terminal.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Static Site Generators are already halfway there&lt;/h2&gt;
&lt;p&gt;Static site generators like Astro and Gatsby already generate a big folder of HTML files, typically in a &lt;code&gt;dist&lt;/code&gt; or &lt;code&gt;public&lt;/code&gt; folder through an &lt;code&gt;npm run build&lt;/code&gt; command. The only thing missing is a way to convert those HTML files to markdown.&lt;/p&gt;
&lt;p&gt;It turns out there&apos;s a great CLI tool for this called &lt;a href=&quot;https://www.npmjs.com/package/@wcj/html-to-markdown-cli&quot;&gt;html-to-markdown&lt;/a&gt; that can be installed with &lt;code&gt;npm install -D @wcj/html-to-markdown-cli&lt;/code&gt; and run during a build step using &lt;code&gt;npx&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here&apos;s a quick Bash script an LLM wrote to convert all HTML files in &lt;code&gt;dist/html&lt;/code&gt; to Markdown files in &lt;code&gt;dist/markdown&lt;/code&gt;, preserving the directory structure:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# convert-to-markdown.sh
mkdir -p dist/markdown

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

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

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

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

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

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

        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: {
                &quot;Content-Type&quot;: contentType,
                &quot;Cache-Control&quot;: &quot;public, max-age=3600&quot;,
              },
            });
          }
        } catch (error) {
          console.error(`Error fetching HTML file from ${distPath}:`, error);
        }
      } else {
        contentType = &quot;text/html; charset=utf-8&quot;;
        let distPath = `/html${url.pathname}`;

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

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

        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: {
                &quot;Content-Type&quot;: contentType,
                &quot;Cache-Control&quot;: &quot;public, max-age=3600&quot;,
              },
            });
          }
        } catch (error) {
          console.error(`Error fetching HTML file from ${distPath}:`, error);
        }
      }

      return null;
    };

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

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

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

    return await env.ASSETS.fetch(
      new Request(new URL(&quot;/html/404.html&quot;, request.url))
    );
  },
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pro tip: make the root path &lt;code&gt;/&lt;/code&gt; 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.&lt;/p&gt;
&lt;h2&gt;Caddy configuration&lt;/h2&gt;
&lt;p&gt;It&apos;s likely much easier to set this system up with a traditional reverse proxy file server like Caddy or Nginx. Here&apos;s a simple Caddyfile configuration that does the same thing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
    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 &quot;404 Not Found&quot; 404
            try_files /html/404.html
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I will leave Nginx configuration as an exercise for the reader or perhaps the reader&apos;s LLM of choice.&lt;/p&gt;
&lt;h2&gt;Conclusion: A More Accessible Web for Agents&lt;/h2&gt;
&lt;p&gt;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&apos;t just about saving money; it&apos;s about GEO (Generative Engine Optimization) for a changed world where millions of users discover content through AI assistants.&lt;/p&gt;
&lt;p&gt;Astro&apos;s flexibility made this implementation surprisingly straightforward. It only took me a couple of hours to get both the personal blog you&apos;re reading now and &lt;a href=&quot;https://www.patron.com&quot;&gt;patron.com&lt;/a&gt; to support this feature.&lt;/p&gt;
&lt;p&gt;If you&apos;re ready to make your site agent-friendly, I encourage you to try this out. For a fun exercise, copy this article&apos;s URL and ask your favorite LLM to &quot;Use the blog post to write a Cloudflare Worker for my own site.&quot; See how it does! You can also check out the source code for this feature at &lt;a href=&quot;https://github.com/skeptrunedev/personal-site&quot;&gt;github.com/skeptrunedev/personal-site&lt;/a&gt; to get started.&lt;/p&gt;
&lt;p&gt;I&apos;m excited to see the impact of this change on my site&apos;s analytics and hope it inspires others. If you implement this on your own site, I&apos;d love to hear about your experience! Connect with me on &lt;a href=&quot;https://x.com/skeptrune&quot;&gt;X&lt;/a&gt; or &lt;a href=&quot;https://www.linkedin.com/in/khami/&quot;&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>ai</category><author>Nick Khami</author></item><item><title>VPS Evangelism and Building LLM-over-DNS</title><link>https://www.skeptrune.com/posts/llm-over-dns/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/llm-over-dns/</guid><description>From PaaS hell to VPS enlightenment: Why the skill of confidently deploying anything to the internet is a hacker&apos;s superpower. Plus, a hands-on tutorial showing how to build your own LLM-over-DNS service in 30 minutes—because sometimes the most impressive demos come from the simplest infrastructure.</description><pubDate>Wed, 06 Aug 2025 18:52:00 GMT</pubDate><content:encoded>&lt;p&gt;My most valuable skill as a hacker/entrepreneur is that I&apos;m confident deploying arbitrary programs that work locally to the internet. Sounds simple, but it&apos;s really the core of what got me into Y-Combinator and later helped me raise a seed round.&lt;/p&gt;
&lt;h2&gt;Being on the Struggle Bus Early&lt;/h2&gt;
&lt;p&gt;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 @&apos;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.&lt;/p&gt;
&lt;p&gt;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&apos;t SSH back in because my house didn&apos;t have a static IP and Tailscale wasn&apos;t a thing yet. It only worked on and off when I was home and could babysit it.&lt;/p&gt;
&lt;h2&gt;Skipping Straight to PaaS Hell&lt;/h2&gt;
&lt;p&gt;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 &quot;how do I deploy my create react app&quot; 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.&lt;/p&gt;
&lt;p&gt;There was always some weird limitation: memory constraints during build, Puppeteer couldn&apos;t run because they didn&apos;t have the right apt packages. Then I was stuck configuring Docker images, and since AI wasn&apos;t a thing yet and I&apos;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.&lt;/p&gt;
&lt;h2&gt;Getting Saved by a VPS Maximalist&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Going into the job, I had this assumption that the &quot;right&quot; way to deploy was on AWS or some other hyperscaler. But this guy&apos;s mindset was the complete opposite—he was a VPS maximalist with a beautifully simple philosophy: rent a VPS, SSH in, do the same thing you did locally (&lt;code&gt;yarn dev&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;It was all so small and easy to learn, but it made me exponentially more confident as a builder. I never directly thought, &quot;I can&apos;t build this because I won&apos;t be able to deploy it,&quot; but the general insecurity definitely caused a hesitancy and procrastination that immediately went away.&lt;/p&gt;
&lt;h2&gt;Paying It Forward&lt;/h2&gt;
&lt;p&gt;I&apos;ve become an evangelist for this approach and wanted to write about it for a long time, but didn&apos;t know how to frame it entertainingly. Then on X, I got inspiration when levelsio posted a tweet about &lt;a href=&quot;https://x.com/levelsio/status/1952861177731793324&quot;&gt;deploying a DNS server on Hetzner that lets you talk to an LLM&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Want to see it in action? Try this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dig @llm.skeptrune.com &quot;what is the meaning of life?&quot; TXT +short
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Getting that setup is probably more interesting than my rambling, so here&apos;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.&lt;/p&gt;
&lt;h2&gt;Step 1: Access Your VPS&lt;/h2&gt;
&lt;p&gt;After purchasing your VPS, you&apos;ll receive an IP address and login credentials (usually via email). Connect to your server:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh root@&amp;lt;your-vps-ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replace &lt;code&gt;&amp;lt;your-vps-ip&amp;gt;&lt;/code&gt; with your actual server IP address.&lt;/p&gt;
&lt;h2&gt;Step 2: Clear Existing DNS Services&lt;/h2&gt;
&lt;p&gt;Many VPS images come with &lt;code&gt;systemd-resolved&lt;/code&gt; or &lt;code&gt;bind9&lt;/code&gt; pre-installed. To avoid conflicts, remove or disable them:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Check for running DNS services
systemctl list-units --type=service | grep -E &apos;bind|dns|systemd-resolved&apos;

# 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
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 3: Install Python Dependencies&lt;/h2&gt;
&lt;p&gt;Install the required Python packages for our DNS server:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install dnslib requests
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 4: Create the DNS-to-LLM Proxy Script&lt;/h2&gt;
&lt;p&gt;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:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from dnslib.server import DNSServer, BaseResolver
from dnslib import RR, QTYPE, TXT
import requests
import codecs

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

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

if __name__ == &quot;__main__&quot;:
    resolver = LLMResolver()
    server = DNSServer(resolver, port=53, address=&quot;0.0.0.0&quot;)
    server.start_thread()
    import time
    while True:
        time.sleep(1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Save this as &lt;code&gt;llm_dns.py&lt;/code&gt; on your VPS.&lt;/p&gt;
&lt;p&gt;Before running, you need to set your OpenRouter API key. For this tutorial, you can paste it directly into the &lt;code&gt;OPENROUTER_API_KEY&lt;/code&gt; variable. For anything more serious, you should use an environment variable to keep your key out of the code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Security Note&lt;/strong&gt;: This is a proof-of-concept. For production use, you&apos;d want proper process management (systemd), logging, rate limiting, and to avoid storing API keys in plaintext.&lt;/p&gt;
&lt;h2&gt;Step 5: Run the DNS-LLM Proxy&lt;/h2&gt;
&lt;p&gt;Start the DNS server (port 53 requires root privileges):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo python3 llm_dns.py
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 6: Test Your Service&lt;/h2&gt;
&lt;p&gt;From another machine, send a DNS TXT query to test your setup:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dig @&amp;lt;your-vps-ip&amp;gt; &quot;what is the meaning of life&quot; TXT +short
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The LLM&apos;s response should appear in the output.&lt;/p&gt;
&lt;h2&gt;Troubleshooting&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Common Issues:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Permission denied&lt;/code&gt;: Make sure you&apos;re running with &lt;code&gt;sudo&lt;/code&gt; (port 53 requires root)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Connection timeout&lt;/code&gt;: Check your VPS firewall settings and ensure port 53 is open&lt;/li&gt;
&lt;li&gt;&lt;code&gt;API errors&lt;/code&gt;: Verify your OpenRouter API key and check your account has credits&lt;/li&gt;
&lt;li&gt;&lt;code&gt;No response&lt;/code&gt;: Try running &lt;code&gt;systemctl status systemd-resolved&lt;/code&gt; to ensure it&apos;s actually disabled&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Step 7: Secure Your Setup (Optional but Recommended)&lt;/h2&gt;
&lt;p&gt;To restrict access to your DNS-LLM proxy, use UFW (Uncomplicated Firewall) - and yes, it&apos;s literally called &quot;uncomplicated&quot; because that&apos;s what a VPS is, uncomplicated:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ufw allow ssh
ufw allow 53
ufw enable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This allows SSH access (so you don&apos;t lock yourself out) and DNS queries on port 53, while blocking everything else by default.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Important&lt;/strong&gt;: 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.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://dnslib.readthedocs.io/en/latest/&quot;&gt;dnslib documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://openrouter.ai/&quot;&gt;OpenRouter API docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&apos;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.&lt;/p&gt;
</content:encoded><category>vps</category><category>tutorial</category><author>Nick Khami</author></item><item><title>I couldn&apos;t submit a PR, so I got hired and fixed it myself</title><link>https://www.skeptrune.com/posts/doing-the-little-things/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/doing-the-little-things/</guid><description>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&apos;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><pubDate>Wed, 30 Jul 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import NoAbortVideo from &quot;../../components/blog/DoingTheLittleThings/NoAbortVideo.astro&quot;;&lt;/p&gt;
&lt;p&gt;For over a year, I was bugged by a search quirk on &lt;a href=&quot;https://mintlify.com&quot;&gt;Mintlify&lt;/a&gt; that caused race conditions and wonky search results.&lt;/p&gt;
&lt;p&gt;Here&apos;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&apos;t being aborted as you typed. Check out this delightful chaos:&lt;/p&gt;
&lt;p&gt;&amp;lt;NoAbortVideo /&amp;gt;&lt;/p&gt;
&lt;p&gt;I had brought this up in our shared Slack before when I was just a vendor to &lt;s&gt;them&lt;/s&gt; &lt;strong&gt;us&lt;/strong&gt; (weird), but it wasn&apos;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;Fixed It&lt;/h2&gt;
&lt;p&gt;Now that I&apos;m on the team, I was able to finally fix it. I added an &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/AbortController&quot;&gt;AbortController&lt;/a&gt; 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.&lt;/p&gt;
&lt;p&gt;There&apos;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 &lt;a href=&quot;https://web.archive.org/web/20221122050324/https://twitter.com/realGeorgeHotz/status/1594908473875173377&quot;&gt;single week at Twitter&lt;/a&gt; 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.&lt;/p&gt;
&lt;p&gt;I&apos;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.&lt;/p&gt;
&lt;h2&gt;Open Source&lt;/h2&gt;
&lt;p&gt;I prefer building and using open source software whenever possible, and this whole situation is a great example of why.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Instead, it remained a persistent irritation until I happened to join the company and gain access to the codebase. There&apos;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.&lt;/p&gt;
&lt;h2&gt;Self-Congratulation&lt;/h2&gt;
&lt;p&gt;If search feels just a bit crisper and more responsive on Mintlify, it’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.&lt;/p&gt;
&lt;p&gt;I can&apos;t wait to make more. Fixing small issues like this over and over again is how products become legendary. There&apos;s something deeply satisfying about finally having the power to fix the things that annoy you - even if they&apos;re tiny.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Especially if they&apos;re tiny.&lt;/strong&gt;&lt;/p&gt;
</content:encoded><category>work</category><category>mintlify</category><author>Nick Khami</author></item><item><title>What 7,112 Hacker News users listened to on my side project</title><link>https://www.skeptrune.com/posts/jukebox-hacker-news/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/jukebox-hacker-news/</guid><description>I built an open source collaborative playlist app for fun called Jukebox. After launching it on Hacker News and different subreddits, it accumulated 7112 visitors who played 2685 songs. In this post, I do a bit of data analysis on their usage patterns and what their music tastes were. Spoiler, they have surprisingly mainstream tastes.</description><pubDate>Mon, 14 Jul 2025 18:52:00 GMT</pubDate><content:encoded>&lt;p&gt;import UserEngagementSankey from &quot;../../components/blog/JukeboxAnalysis/UserEngagementSankey.astro&quot;;
import SongsExplorer from &quot;../../components/blog/JukeboxAnalysis/SongsExplorer.astro&quot;;
import ArtistAnalysis from &quot;../../components/blog/JukeboxAnalysis/ArtistAnalysis.astro&quot;;&lt;/p&gt;
&lt;p&gt;I was burnt out from my startup and wanted to recover some of my creative energy, so I decided to build a fun side project called &lt;a href=&quot;https://github.com/skeptrunedev/jukebox&quot;&gt;Jukebox&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I had the idea of building a collaborative playlist app where you could queue music together with friends and family.&lt;/p&gt;
&lt;p&gt;I launched it on Hacker News, where it &lt;a href=&quot;https://hnrankings.info/44500840/&quot;&gt;hit frontpage&lt;/a&gt; and got a lot of traction. In total, it had &lt;strong&gt;7112 visitors&lt;/strong&gt; who played &lt;strong&gt;2877 songs&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Hacker News users are known for their eclectic tastes, so I was curious to see what kind of music they listened to. I did some data analysis on the usage patterns and music genres, and I wanted to share my findings.&lt;/p&gt;
&lt;h2&gt;People Actually Used It!&lt;/h2&gt;
&lt;p&gt;Part of the fun of side projects is that you can use them as an opportunity to build your skills. Personally, one of the core skills I want to improve is marketing.&lt;/p&gt;
&lt;p&gt;Therefore, it was important to me that I actually drove traffic to the app and got people to use it. I&apos;m happy to report that I was able to do that! Here&apos;s a full breakdown of the user engagement:&lt;/p&gt;
&lt;p&gt;&amp;lt;UserEngagementSankey /&amp;gt;&lt;/p&gt;
&lt;p&gt;The data is reliable because each visitor to the site is assigned an anonymous user account. This allows for accurate tracking of how many unique users visited, how many created a &quot;box&quot; (playlist), and how many engaged with the main features.&lt;/p&gt;
&lt;p&gt;Conversion rate into the primary &quot;Create Box&quot; CTA was awesome! However, I was sorely dissapointed to see that only 6.7% of people who created a box actually used the app to queue music together, which was the main reason why I built it in the first place.&lt;/p&gt;
&lt;p&gt;I&apos;d call it a pyhrrhic victory. My product sense was a few rings off the bullseye, but still on the target. I&apos;m not going to continue working on Jukebox, but it certainly fulfilled its core purpose of helping me recover my creative energy and learn some new skills.&lt;/p&gt;
&lt;h2&gt;What Music Did They Listen To?&lt;/h2&gt;
&lt;p&gt;I was originally planning to talk more about how Jukebox was built, but I think the more interesting part is the data analysis of what music Hacker News users listened to.&lt;/p&gt;
&lt;h3&gt;Genres&lt;/h3&gt;
&lt;p&gt;Spotify is generous with their API, so I was able to hydrate the songs data with genres by using their data.&lt;/p&gt;
&lt;p&gt;Hacker News users actually disappointed me with their music tastes. I expected them to be more eclectic, but classic rock and rock were 2 times more popular than any other genre.&lt;/p&gt;
&lt;p&gt;New wave, metal, and rap followed as the next most played genres, but there was a steep drop-off after the top three. The long tail of genres included everything from country and EDM to post-hardcore and progressive rock, but these were much less represented.&lt;/p&gt;
&lt;p&gt;One thing that surprised me was how country music edged out electronic genres in popularity. I expected a tech-focused audience to gravitate more towards electronic or EDM, but country had a stronger showing among the top genres. It’s a reminder that musical preferences can defy stereotypes, even in communities you’d expect to lean a certain way.&lt;/p&gt;
&lt;p&gt;&amp;lt;SongsExplorer /&amp;gt;&lt;/p&gt;
&lt;h3&gt;Artists&lt;/h3&gt;
&lt;p&gt;When it comes to artists, the results were a mix of the expected and the surprising. Michael Jackson topped the list as the most played artist—proving that the King of Pop’s appeal truly spans generations and communities, even among techies. Queen and Key Glock followed closely, showing that both classic rock and modern hip-hop have their place in the hearts (and playlists) of Hacker News users.&lt;/p&gt;
&lt;p&gt;I was surprised to see a strong showing from artists like Taylor Swift and Depeche Mode, as well as a healthy mix of rap, electronic, and indie acts. The diversity drops off after the top few, but there’s still a wide spread: from Daft Punk to Nirvana, Dua Lipa to ABBA, and even some more niche names like Wolf Parade and Day Wave.&lt;/p&gt;
&lt;p&gt;Overall, while classic rock and pop dominate, there’s a clear undercurrent of variety—perhaps reflecting the broad interests of the Hacker News crowd, even if their musical tastes lean a bit more mainstream than I expected.&lt;/p&gt;
&lt;p&gt;&amp;lt;ArtistAnalysis /&amp;gt;&lt;/p&gt;
&lt;h2&gt;AI Makes Me More Willing to Build Things&lt;/h2&gt;
&lt;p&gt;Dens Sumesh, a former intern at my company, originally had the idea for Jukebox and told me about it at dinner one day. I thought it was a great and had potential, so I decided to build it. AI codegen has made me drastically more willing to build things on a whim.&lt;/p&gt;
&lt;p&gt;Typically I would have probably quit after finishing the backend, because React slop is not my favorite thing to work on. However, since the AI is good enough at React to do most of that work for me, I was mentally able to push through and finish the project.&lt;/p&gt;
&lt;p&gt;Another side benefit of building this was that I got a better handle on when AI is an efficient tool versus when it’s better to rely on my own skills. For example, highlighting a component and prompting &lt;code&gt;&quot;use framer-motion to make this fade in buttery smooth&quot;&lt;/code&gt; is a great use of AI.&lt;/p&gt;
&lt;p&gt;However, more complex asks like &lt;code&gt;&quot;add an api route to accept a song, put it in a queue with sqlite, and create a worker that downloads and uploads them to s3, with a final api route to check when they finish&quot;&lt;/code&gt; are more efficiently handled by a human with intuition and experience.&lt;/p&gt;
&lt;p&gt;Framing things out manually, or even prompting the frame, consistently seemed to be a more efficient strategy than trying to get the AI to one-shot entire features. Both approaches can work, but breaking things down helps you maintain control and clarity over the process.&lt;/p&gt;
&lt;p&gt;If you rely too much on one-shot prompts, you can end up in a cycle where your eyes glaze over and you&apos;re pressing the &quot;regenerate&quot; button like it&apos;s a Vegas slot machine. This slot machining makes launching less likely because you spend more time hoping for a perfect result rather than iterating and moving forward. It&apos;s easy to get stuck chasing the ideal output instead of shipping something real and learning from feedback.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Build stuff, share it, get feedback, and learn. Shots on goal lead to more opportunities for improvement and innovation.&lt;/p&gt;
&lt;p&gt;Even though Jukebox is now going into maintenance mode, it was everything I hoped it would be: a fun side project that people actually used.&lt;/p&gt;
&lt;p&gt;If you want the raw data, you can find it on the &lt;a href=&quot;https://github.com/skeptrunedev/personal-site/tree/main/src/assets/data/blog-posts/JukeboxAnalysis/dev.sqlite3&quot;&gt;GitHub repository&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;If you want to see the source code for Jukebox, that&apos;s on &lt;a href=&quot;https://github.com/skeptrunedev/jukebox&quot;&gt;Github at skeptrunedev/jukebox&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>jukebox</category><category>learning</category><category>data-analysis</category><author>Nick Khami</author></item><item><title>Building the Server for Threshold Multisigs</title><link>https://www.skeptrune.com/posts/building-the-server-for-threshold-multisigs/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/building-the-server-for-threshold-multisigs/</guid><description>&quot;TODO&quot;</description><pubDate>Mon, 16 Jun 2025 18:52:00 GMT</pubDate><content:encoded>&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;You&apos;re launching a new Bitcoin ETF worth billions of dollars. You need to secure the funds backing it and are scared to build that security system yourself, so you look for a vendor. You pick Coinbase Custody, probably the most well-known and trusted company in the entire ecosystem, to custody your funds. Trying to be transparent, you publish the Bitcoin addresses holding the funds backing your ETF.&lt;/p&gt;
&lt;p&gt;Then, the whole world realizes that Coinbase has the entire amount secured behind a single private key, and that private key may or may not be an offline threshold multisig wallet.&lt;/p&gt;
&lt;p&gt;This is the story of Bitwise, Coinbase Custody, and the accusations that they were not using multisig wallets to secure their Bitcoin ETF funds.&lt;/p&gt;
&lt;p&gt;Ultimately, the accusations were likely unfounded, Coinbase Custody most likely uses a threshold ECDSA scheme which, while much scarier to implement and maintain than Schnorr, still provides a high level of security. However, the situation is still bizarre and highlights the need for better tools and infrastructure for implementing threshold multisigs in production.&lt;/p&gt;
&lt;h2&gt;State of the Ecosystem&lt;/h2&gt;
&lt;p&gt;I started working on a Bitcoin bridge at ZeroDAO in 2022 and quickly realized that the state of the ecosystem was nascent. There are many libraries for implementing threshold multisigs, but that&apos;s the easy part. The hard part is building a complete server implementation that can be run in production.&lt;/p&gt;
&lt;p&gt;If you want to run a threshold multisig vault, you have to build your own server on top of these libraries. This is a lot of work and requires a deep understanding of the underlying cryptography and protocols. It&apos;s like having a powerful engine, but no car to put it in. You can build your own car, but it&apos;s a lot of work and you have to figure out how to make it safe and reliable.&lt;/p&gt;
&lt;p&gt;Bitwise is basically forced to rely on Coinbase Custody or some other vendor to manage their $4B of Bitcoin for them based on a trust model that is not transparent to the public and maybe not even transparent to them. This is not a good situation for the ecosystem, and it&apos;s not a good situation for the users of these services.&lt;/p&gt;
&lt;h2&gt;Deciding to Do Something About It&lt;/h2&gt;
&lt;p&gt;Startups are fu**ing hard. Out of college, I have spent the past two years working 70+ hour weeks, making less than a third of what I would at a reputable tech company, trying to make something of myself and simultaneously put a dent in the world.&lt;/p&gt;
&lt;p&gt;Our first product, &lt;a href=&quot;https://trieve.ai&quot;&gt;Trieve&lt;/a&gt;, is a relevancy optimized search engine in a simple API. We built it because we were frustrated with the state of having to go through the hassle of setting up an ingestion pipeline, indexing data, and optimizing the underlying engine every time you wanted high quality retrieval. I&apos;m very proud of it!&lt;/p&gt;
&lt;p&gt;Lifetime, we have supported over 300 unique paying customers, made hundreds of thousands of dollars in revenue, served 150M+ searches, and indexed over 1B documents. At this point, it&apos;s a mature product and we have fulfilled our initial ambitions for it. There&apos;s even a &lt;a href=&quot;https://apps.shopify.com/trieve&quot;&gt;shopify app&lt;/a&gt; that over 100 stores are using!&lt;/p&gt;
&lt;p&gt;But I still felt like there was something missing. I wanted to build something that would have a lasting impact and Trieve started to feel like a distraction from that. I stopped getting sparks of joy every time we onboarded a new customer or launched a new feature. AI is great, but overtime I started to feel more like a glorified data janitor than a builder.&lt;/p&gt;
&lt;h2&gt;To Quit or Not to Quit&lt;/h2&gt;
&lt;p&gt;Trieve isn&apos;t big enough to sell for a life-changing amount of money. Denzell and I talked through some acquihire offers, but they didn&apos;t feel right. Denzell and I learned a ton from building Trieve, and we are both very proud of what we accomplished. Our tank of energy was still pretty full, the valve between it and Trieve&apos;s product was just closed.&lt;/p&gt;
&lt;p&gt;So, we started looking around and trying to figure out something that the valve could be opened to. In that process, we got drawn back to the startups we had worked at before Trieve, and the problems we had seen there.&lt;/p&gt;
&lt;h2&gt;Working at a Startup&lt;/h2&gt;
&lt;p&gt;When you decide to join a startup, you typically are not doing it for the money. It&apos;s a raw deal to be an early employee at a startup. You are taking on a lot of risk, working long hours, and making less money than you would at a big tech company. But you do it because you believe in the mission and you want to be part of something bigger than yourself.&lt;/p&gt;
&lt;p&gt;I was extremely lucky to have the experience of being bought into two startups doing something I believed strongly in before founding my own. I worked at &lt;a href=&quot;https://x.com/zerodaoHQ&quot;&gt;ZeroDAO&lt;/a&gt;, &lt;a href=&quot;https://x.com/QuaiNetwork&quot;&gt;Quai&lt;/a&gt;, &lt;a href=&quot;https://getbreezyapp.com/&quot;&gt;Breezy&lt;/a&gt;, and &lt;a href=&quot;https://x.com/BotanixLabs&quot;&gt;Botanix&lt;/a&gt;. Three out of those four startups were in the permissionless blockchain space, and that&apos;s no coincidence. I&apos;m easily nerd sniped by the idea of building something that is open source, permissionless, and can be used by anyone who wants to use it.&lt;/p&gt;
&lt;p&gt;Ironically, ZeroDAO and Botanix in particular were both working specifically on applications which required managing a vault of Bitcoin assets. ZeroDAO, in typical startup fashion, used infrastructure from a vendor, speficially a company called &lt;a href=&quot;https://github.com/renproject&quot;&gt;renvm&lt;/a&gt;, to manage the Bitcoin assets backing their bridge. RenVM was owned by Alameda Research who collapsed in late 2022 along with FTX, and the RenVM team was forced to shut down the service. That was the end of ZeroDAO which was a damn shame because, prior to that, we had built a really cool product that processed over &lt;a href=&quot;https://dune.com/queries/5246861/8621995?sidebar=none&quot;&gt;184BTC in less than 7 months&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;As it turns out, similar to Bitwise, it was also out of scope for ZeroDAO to build their own threshold multisig vault server. Ultimately, the company was forced to shut down because we could not find a vendor to replace RenVM. Unlike Bitwise, we were in no way big enough to force a repuatable company, Coinbase Custody, BitGo, Anchorage, or others, to implement the features we needed.&lt;/p&gt;
&lt;p&gt;Coming out of that experience, I decided to join Botanix to right that wrong. Botanix is building a Bitcoin sidechain which requires a bitcoin vault the same way a bridge does. Ultimately, Trieve started to get traction while I was there and I left to focus on it. At the time trusting that the work would go on at Botanix and the world would get a threshold multisig vault server implementation with or without me working on it.&lt;/p&gt;
&lt;p&gt;But, two years later, I&apos;m still waiting fot that to happen.&lt;/p&gt;
&lt;h2&gt;Doing the Damn Thing&lt;/h2&gt;
&lt;p&gt;It&apos;s a better time to bite this bullet on building this than ever before. The Zcash Foundation has announced that they are doing working on their threshold signature (&lt;a href=&quot;https://eprint.iacr.org/2020/852.pdf&quot;&gt;FROST&lt;/a&gt; if you care about the details) library and have a well-documented and audited implementation in Rust. Lucky for Denzell and I, we are now proficient Rust developers after building Trieve, so we can build on top of that library.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://x.com/seraidex?lang=en&quot;&gt;Serai&lt;/a&gt; also has a fantastic complete &lt;a href=&quot;https://github.com/serai-dex/serai/blob/48db06f901952b24bb38d7c7e256f798f08512cd/spec/coordinator/Coordinator.md&quot;&gt;reference server implementation in Rust&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Building blocks are much more mature than they were two years ago, and it feels like the right time to build on top of them. Also, it doesn&apos;t seem like anyone else is really working on it, so perhaps we can reap first mover advantage in a way that we couldn&apos;t with Trieve.&lt;/p&gt;
&lt;p&gt;Coming from the search engine space for the past 2 years, I like to think we are in a similar spot relative to where Shay Bannon was in 2005 when he started building &lt;a href=&quot;https://github.com/kimchy/compass&quot;&gt;compass&lt;/a&gt;, a server abstraction layer on top of a search engine library called Lucene. Compass was a complete server implementation that made it easy to run a search engine in production, and it was the precursor to Elasticsearch, which is now the most popular search engine in the world.&lt;/p&gt;
&lt;p&gt;Completlely different industry, but I think the analogy holds.&lt;/p&gt;
&lt;h2&gt;Who&apos;s This Going to be For?&lt;/h2&gt;
&lt;p&gt;I love startups more than anything, but we want to make this work for larger custodians and exchanges first. Ideally, we build a standard piece of infrastrcuture that&apos;s used by all of the largest custodians and exchanges in the Bitcoin ecosystem. We want to make it easy for them to run a threshold multisig vault, so they can focus on building their products and services instead of worrying about the underlying security infrastructure.&lt;/p&gt;
&lt;p&gt;Also, we want to help large institutions be their own custodians. Part of Blockchain&apos;s promise is that it&apos;s trustless, and we want to help large institutions take advantage of that. We want to help them run their own threshold multisig vaults, so they can be their own custodians and not have to rely on third-party vendors.&lt;/p&gt;
&lt;p&gt;However, that doesn&apos;t mean we are going to ignore the startup and individual developer use cases. People have big blockchain application dreams, from decentralized exchanges to marketplaces to lending protocols, which all require vaults to manage the assets backing them. While it&apos;s not our primary focus, I still do want to make sure we build something which would have solved the problems we faced at ZeroDAO back when RenVM shut down.&lt;/p&gt;
&lt;p&gt;It&apos;s just an open source server! Anyone is going to be able to run it.&lt;/p&gt;
&lt;h2&gt;What&apos;s Happening With Trieve?&lt;/h2&gt;
&lt;p&gt;We are not going to be shutting down Trieve! It&apos;s a mature product which keeps us profitable and default alive. We are going to keep marketing it and supporting our customers.&lt;/p&gt;
&lt;p&gt;The only difference is that we are cutting back on the ambition of our roadmap. We are going to continue fixing bugs, adding features that our customers request, and making sure the product is stable and reliable. But we are not going to be adding any new major features or trying to expand into new markets.&lt;/p&gt;
</content:encoded><category>threshold-security</category><category>DKG</category><category>FROST</category><author>Nick Khami</author></item><item><title>Taking Dynamic Key Generation (FROST) From Papers to Production</title><link>https://www.skeptrune.com/posts/taking-dkg-from-papers-to-production/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/taking-dkg-from-papers-to-production/</guid><description>&quot;TODO&quot;</description><pubDate>Mon, 16 Jun 2025 18:52:00 GMT</pubDate><content:encoded>&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;FROST (Flexible Round-Optimized Schnorr Threshold) is a protocol for distributed key generation (DKG) and threshold signatures. It allows a group of participants to jointly generate a public/private key pair, where the private key is split among the participants. This enables secure signing without requiring all participants to be online simultaneously.&lt;/p&gt;
&lt;p&gt;Algorithmically, it is a simple 2 round protocol. Many implementation libraries exist, but they aren&apos;t shaped like other kinds of software products that developers are used to deploying. They are often just libraries that require a lot of boilerplate code to get started with, and they don&apos;t provide a clear way to run the protocol in a production environment.&lt;/p&gt;
&lt;p&gt;We at Threshold Security have been working on a single binary Node which is designed to be a reusable piece of infrastructure for running FROST DKG in production. This Node is designed to be run by a group of participants who want to jointly generate a key pair and use it for signing. It provides simple command-line and RPC interfaces for starting the DKG process, managing participants, and generating keys.&lt;/p&gt;
&lt;p&gt;It&apos;s not completely ready for production yet, but we are making good progress. In this post, I will explain the background of FROST, how it works, and what our Node implementation currently supports.&lt;/p&gt;
&lt;h2&gt;FROST DKG Protocol Overview&lt;/h2&gt;
&lt;p&gt;If you are interested in the details of FROST math, I recommend reading the paper &lt;a href=&quot;https://eprint.iacr.org/2020/852.pdf&quot;&gt;FROST: Flexible Round-Optimized Schnorr Threshold Signatures&lt;/a&gt; by Chelsea Komlo and Ian Goldberg. It provides a comprehensive overview of the protocol and its security properties.&lt;/p&gt;
&lt;p&gt;For our purposes, the math doesn&apos;t matter as much as the protocol structure, so I will only be providing explanations for the necessary pieces of the protocol to implement on top of the FROST libraries which already exist. The DKG process is divided into two rounds of communication followed by a simple sum of public keys to produce a final public key. The rounds are as follows:&lt;/p&gt;
&lt;h3&gt;Round 1: Private Key Commitment&lt;/h3&gt;
&lt;p&gt;In the first round, each particpant generates a private key, compute commitments to it, and sends their commitments to all other participants. The commitments are based on the private key and a random nonce, which ensures that the commitments are unique and cannot be predicted by other participants.&lt;/p&gt;
&lt;p&gt;These messages do not contain any secret information, so they can be sent over an insecure channel. The commitments are used to prove that the participants have generated their private keys correctly and to ensure that they are commited to them for the duration of the DKG process.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/src/assets/images/blog-posts/TakingDKGFromPapersToProduction/dkg-stage-1.png&quot; alt=&quot;visual representation of round 1&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;Round 2:&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;/src/assets/images/blog-posts/TakingDKGFromPapersToProduction/dkg-stage-2.png&quot; alt=&quot;visual representation of round 2&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;Round 3&lt;/h3&gt;
&lt;h2&gt;Libraries &amp;amp; Implementations&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/mikelodder7/frost-dkg&quot;&gt;mikelodder7/frost-dkg&lt;/a&gt;&lt;br /&gt;
⭐ 1 An implementation of the Frost Distributed Key Generation.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/cmdruid/frost&quot;&gt;cmdruid/frost&lt;/a&gt;&lt;br /&gt;
⭐ 13 — Flexible, round-optimized threshold signature library for BIP340 taproot.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/bytemare/frost&quot;&gt;bytemare/frost&lt;/a&gt;&lt;br /&gt;
⭐ 20 — Go implementation of RFC9591: the FROST (Flexible Round-Optimized Schnorr Threshold) signing protocol.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/taurushq-io/frost-ed25519&quot;&gt;taurushq-io/frost-ed25519&lt;/a&gt;&lt;br /&gt;
⭐ 68 — Implementation of the FROST protocol for threshold Ed25519 signing.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/topos-protocol/ice-frost&quot;&gt;topos-protocol/ice-frost&lt;/a&gt;&lt;br /&gt;
⭐ 18 — A modular Rust implementation of the static version of the ICE-FROST signature scheme.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/zelular-xyz/pyfrost&quot;&gt;zellular-xyz/pyfrost&lt;/a&gt;&lt;br /&gt;
⭐ 2 — Python implementation of the FROST algorithm.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/LFDT-Lockness/givre&quot;&gt;LFDT-Lockness/givre&lt;/a&gt;&lt;br /&gt;
⭐ 9 — Threshold Schnorr Signatures based on FROST in Rust.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/ZcashFoundation/frost&quot;&gt;ZcashFoundation/frost&lt;/a&gt;&lt;br /&gt;
⭐ 190 — Rust implementation of FROST (Flexible Round-Optimised Schnorr Threshold signatures) by the Zcash Foundation.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/BlockstreamResearch/bip-frost-dkg&quot;&gt;BlockstreamResearch/bip-frost-dkg&lt;/a&gt;&lt;br /&gt;
⭐ 59 — Bitcoin Improvement Proposal proposes ChillDKG, a distributed key generation protocol (DKG) for use with the FROST Schnorr threshold signature scheme.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>threshold-security</category><category>DKG</category><category>FROST</category><author>Nick Khami</author></item><item><title>Web Developer&apos;s Guide to Midjourney</title><link>https://www.skeptrune.com/posts/how-to-use-midjourney/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/how-to-use-midjourney/</guid><description>Midjourney&apos;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&apos;t scream &quot;AI-generated.&quot;</description><pubDate>Sun, 01 Jun 2025 18:52:00 GMT</pubDate><content:encoded>&lt;p&gt;import MasonryImages from &quot;../../components/blog/HowToUseMidjourney/MasonryImages.astro&quot;;
import PinterestStarter from &quot;../../components/blog/HowToUseMidjourney/PinterestStarter.astro&quot;;
import SimilarStyleGeneration from &quot;../../components/blog/HowToUseMidjourney/SimilarStyleGeneration.astro&quot;;
import DescribeFeature from &quot;../../components/blog/HowToUseMidjourney/DescribeFeature.astro&quot;;
import EaglePromptWithStyleReference from &quot;../../components/blog/HowToUseMidjourney/EaglePromptWithStyleReference.astro&quot;;&lt;/p&gt;
&lt;h2&gt;Getting Your Ladder&lt;/h2&gt;
&lt;p&gt;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 &lt;a href=&quot;https://x.com/kubadesign/status/1927056777830440980&quot;&gt;X by @kubadesign&lt;/a&gt;. His thread got me most of the way there, but I picked up an additional trick with &lt;a href=&quot;https://docs.midjourney.com/hc/en-us/articles/32497889043981-Describe&quot;&gt;Midjourney&apos;s describe feature&lt;/a&gt; that I think is worth sharing.&lt;/p&gt;
&lt;p&gt;My initial plan was to use these images for &lt;a href=&quot;https://uzi.sh&quot;&gt;uzi.sh&lt;/a&gt;, 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.&lt;/p&gt;
&lt;p&gt;&amp;lt;MasonryImages /&amp;gt;&lt;/p&gt;
&lt;h2&gt;Find a Base Image&lt;/h2&gt;
&lt;p&gt;Following Kuba&apos;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.&lt;/p&gt;
&lt;p&gt;&amp;lt;PinterestStarter /&amp;gt;&lt;/p&gt;
&lt;h2&gt;Build a Style With Neutral Prompts&lt;/h2&gt;
&lt;p&gt;You can&apos;t just start by describing the specific kind of image you want and expect to get good results. &lt;a href=&quot;https://docs.midjourney.com/hc/en-us/articles/32180011136653-Style-Reference&quot;&gt;Style reference images&lt;/a&gt; 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.&lt;/p&gt;
&lt;p&gt;It&apos;s unlikely that you&apos;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.&lt;/p&gt;
&lt;p&gt;My neutral prompt here was &lt;code&gt;Portrait photography of a woman, glow behind, futuristic vibe, flash photography, color film, analog style, imperfect --ar 3:4 --v 7&lt;/code&gt;. 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.&lt;/p&gt;
&lt;p&gt;&amp;lt;SimilarStyleGeneration /&amp;gt;&lt;/p&gt;
&lt;h2&gt;Using Describe to Get More Specific&lt;/h2&gt;
&lt;p&gt;One of the images I wanted was an eagle diving. However, &lt;code&gt;eagle diving with red glowing background&lt;/code&gt; wasn&apos;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.&lt;/p&gt;
&lt;p&gt;&amp;lt;DescribeFeature /&amp;gt;&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;&amp;lt;EaglePromptWithStyleReference /&amp;gt;&lt;/p&gt;
&lt;h2&gt;Post Processing: Adding Film Grain for Web Design&lt;/h2&gt;
&lt;p&gt;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&apos;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 &lt;a href=&quot;https://uzi.sh&quot;&gt;uzi.sh&lt;/a&gt; site below, &lt;a href=&quot;https://github.com/devflowinc/uzi/blob/main/uzi-site/index.html&quot;&gt;full code on Github here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://uzi.sh&quot;&gt;&lt;img src=&quot;src/assets/images/blog-posts/HowToUseMidjourney/uzi-opengraph.png&quot; alt=&quot;Film Grain on Uzi Site&quot; /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;feTurbulence&lt;/code&gt; to create a fractal noise pattern, and &lt;code&gt;feColorMatrix&lt;/code&gt; to adjust the opacity. You can experiment with values like &lt;code&gt;baseFrequency&lt;/code&gt; in &lt;code&gt;feTurbulence&lt;/code&gt; or the alpha channel (the &lt;code&gt;0.8&lt;/code&gt; in the &lt;code&gt;feColorMatrix&lt;/code&gt;) to finetune the grain&apos;s intensity and texture.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!-- SVG noise filter definition - this goes in your HTML --&amp;gt;
&amp;lt;svg style=&quot;display: none&quot;&amp;gt;
  &amp;lt;filter id=&quot;noiseFilter&quot;&amp;gt;
    &amp;lt;!-- Creates the fractal noise pattern --&amp;gt;
    &amp;lt;feTurbulence
      type=&quot;fractalNoise&quot;
      baseFrequency=&quot;0.5&quot;
      numOctaves=&quot;3&quot;
      stitchTiles=&quot;stitch&quot;
    /&amp;gt;
    &amp;lt;!-- Converts noise to semi-transparent overlay --&amp;gt;
    &amp;lt;feColorMatrix
      type=&quot;matrix&quot;
      values=&quot;0 0 0 0 0
              0 0 0 0 0
              0 0 0 0 0
              0 0 0 0.8 0&quot;
    /&amp;gt;
  &amp;lt;/filter&amp;gt;
&amp;lt;/svg&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/* 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(&quot;./media/16x9steppecommander.png&quot;);
  background-size: cover;
  background-position: center;
  z-index: 1; /* Below grain overlay */
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!-- HTML structure showing the layering --&amp;gt;
&amp;lt;div class=&quot;hero&quot;&amp;gt;
  &amp;lt;!-- Layer 1: Background image (z-index: 1) --&amp;gt;
  &amp;lt;div class=&quot;background-image&quot;&amp;gt;&amp;lt;/div&amp;gt;

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

  &amp;lt;!-- Layer 3: Content (z-index: 3) --&amp;gt;
  &amp;lt;div class=&quot;content-center&quot;&amp;gt;
    &amp;lt;!-- Your content here --&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Be Creative!&lt;/h2&gt;
&lt;p&gt;While there&apos;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.&lt;/p&gt;
&lt;p&gt;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&apos;ve shared here are all about developing your unique voice and letting AI help you express it better.&lt;/p&gt;
&lt;p&gt;The goal isn&apos;t to generate something generic. It&apos;s to create images that actually work for your projects and feel intentional, not obviously AI generated.&lt;/p&gt;
</content:encoded><category>ai</category><category>art</category><author>Nick Khami</author></item><item><title>LLM Codegen go Brrr – Parallelization with Git Worktrees and Tmux</title><link>https://www.skeptrune.com/posts/git-worktrees-agents-and-tmux/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/git-worktrees-agents-and-tmux/</guid><description>If you&apos;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&apos;m ready to call myself an evangelist. The throughput improvements are incredible, and I don&apos;t feel like I&apos;m losing control of the codebase.</description><pubDate>Mon, 26 May 2025 18:52:00 GMT</pubDate><content:encoded>&lt;p&gt;import Pile from &quot;../../components/blog/UsingGitWorktreesWithAI/Pile.astro&quot;;&lt;/p&gt;
&lt;p&gt;This realization isn&apos;t unique to me; the effectiveness of using Git worktrees for simultaneous execution is gaining broader recognition, as evidenced by mentions in &lt;a href=&quot;https://docs.anthropic.com/en/docs/claude-code/tutorials#run-parallel-claude-code-sessions-with-git-worktrees&quot;&gt;Claude Code&apos;s docs&lt;/a&gt;, &lt;a href=&quot;https://news.ycombinator.com/item?id=44043717&quot;&gt;discussion on Hacker News&lt;/a&gt;, projects like &lt;a href=&quot;https://github.com/smtg-ai/claude-squad&quot;&gt;Claude Squad&lt;/a&gt;, and conversation on &lt;a href=&quot;https://x.com/search?q=git%20worktree&amp;amp;src=typed_query&amp;amp;f=live&quot;&gt;X&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&amp;lt;br&amp;gt;&amp;lt;/br&amp;gt;
&amp;lt;Pile /&amp;gt;&lt;/p&gt;
&lt;h3&gt;Example use-case: adding a UI component&lt;/h3&gt;
&lt;p&gt;I&apos;m building a component library called &lt;a href=&quot;https://astrobits.dev&quot;&gt;astrobits&lt;/a&gt; and wanted to add a &lt;code&gt;Toggle&lt;/code&gt;. To tackle the task, I deployed two &lt;a href=&quot;https://www.anthropic.com/claude-code&quot;&gt;Claude Code&lt;/a&gt; agents and two &lt;a href=&quot;https://openai.com/index/introducing-codex/&quot;&gt;Codex&lt;/a&gt; agents, all with the same prompt, running in parallel within their own &lt;a href=&quot;https://git-scm.com/docs/git-worktrees&quot;&gt;git worktrees&lt;/a&gt;. Worktrees are essential because they provide each agent with an isolated directory, allowing them to execute simultaneously without overwriting each other&apos;s changes.&lt;/p&gt;
&lt;p&gt;The number of agents I choose to rollout depends on the complexity of the task. Over time, you&apos;ll develop an intuition for estimating the right number based on the situation. Here, I felt 4 was appropriate.&lt;/p&gt;
&lt;p&gt;&amp;lt;br&amp;gt;&amp;lt;/br&amp;gt;
&amp;lt;ImageFourSquareWorktrees /&amp;gt;
&amp;lt;br&amp;gt;&amp;lt;/br&amp;gt;&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;~25%&lt;/code&gt; chance of producing something useful, then running four gives a &lt;code&gt;68%&lt;/code&gt; chance that at least one will succeed &lt;code&gt;(1 - 0.75^4 ≈ 0.68)&lt;/code&gt;. Four agents was essentially the bare minimum to have reasonable confidence in getting a workable solution.&lt;/p&gt;
&lt;p&gt;With LLMs being so affordable, there&apos;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.&lt;/p&gt;
&lt;p&gt;And yet, the process of running them is still cumbersome and manual, it&apos;s more effort to setup 8 than 4, so I&apos;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&apos;m excited to share my proposed solution.&lt;/p&gt;
&lt;h3&gt;Current workflow pain points&lt;/h3&gt;
&lt;p&gt;Right now, I manually create git worktrees using &lt;code&gt;git worktree add -b newbranch ../path&lt;/code&gt;, start a &lt;code&gt;tmux&lt;/code&gt; session for each one, run Claude Code in the first pane, paste a prompt, &lt;code&gt;leader+c&lt;/code&gt; into a new pane, run &lt;code&gt;yarn dev&lt;/code&gt; 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&apos;m satisfied with an output.&lt;/p&gt;
&lt;p&gt;Here are the top frustrations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I can&apos;t tell which branch a worktree was most recently rebased onto. For example, if &lt;code&gt;agent-1&lt;/code&gt; was rebased onto &lt;code&gt;feature-x&lt;/code&gt; but &lt;code&gt;agent-2&lt;/code&gt; onto &lt;code&gt;main&lt;/code&gt;, it&apos;s easy to lose track without manual notes.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;I really wish I had a shortcut to open my IDE for a given worktree without having to &lt;code&gt;tmux a&lt;/code&gt;, &lt;code&gt;leader + c&lt;/code&gt;, and &lt;code&gt;code .&lt;/code&gt; manually. I could use a long one-liner with &lt;code&gt;tmux send-keys and xargs&lt;/code&gt; to automate this, but that still feels clunky.&lt;/li&gt;
&lt;li&gt;Web previewing is a pain. I have to run &lt;code&gt;yarn dev&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;Committing and creating pull requests (PRs) is also more cumbersome than it should be. For example, after finding a solution in &lt;code&gt;agent-3&lt;/code&gt;, I have to manually attach to that tmux session then &lt;code&gt;commit&lt;/code&gt;, &lt;code&gt;push&lt;/code&gt;, and &lt;code&gt;gh pr&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I feel like I&apos;ve been through the wringer enough times with this process that I can see a solution shape which would create a smoother experience.&lt;/p&gt;
&lt;h3&gt;Proposing a solution: &lt;em&gt;uzi&lt;/em&gt;&lt;/h3&gt;
&lt;p&gt;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&apos;ve begun developing such a tool, which we&apos;re calling &lt;a href=&quot;https://github.com/devflowinc/uzi&quot;&gt;&lt;em&gt;uzi&lt;/em&gt;&lt;/a&gt;. The core idea behind &lt;em&gt;uzi&lt;/em&gt; is to abstract away the manual, repetitive tasks involved in managing multiple AI agent worktrees.&lt;/p&gt;
&lt;p&gt;See some of the &lt;code&gt;uzi&lt;/code&gt; 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 &lt;code&gt;uzi&lt;/code&gt; alongside standard unix tools like &lt;code&gt;xargs&lt;/code&gt;, &lt;code&gt;grep&lt;/code&gt;, and &lt;code&gt;awk&lt;/code&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;uzi start --agents claude:3,codex:2 --prompt &quot;Implement feature X&quot;&lt;/code&gt; could initialize and prompt three Claude instances and two Codex instances, each in its own worktree.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;uzi ls&lt;/code&gt; would display all active agents, their target branches, and current statuses.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;uzi exec --all -- yarn dev&lt;/code&gt; could run a command like &lt;code&gt;yarn dev&lt;/code&gt; across all agent worktrees.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;uzi broadcast -- &quot;Refine the previous response by focusing on Y&quot;&lt;/code&gt; would send a follow-up prompt to all active agents.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;uzi checkpoint --agent claude-1 --message &quot;Implemented initial draft&quot;&lt;/code&gt; could rebase the specified agent&apos;s worktree and commit the changes.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;uzi kill --agent codex-2&lt;/code&gt; would clean up a specific agent&apos;s tmux session and optionally its worktree.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These commands would primarily operate via &lt;code&gt;tmux send-keys&lt;/code&gt; instructions to the appropriate sessions. We don&apos;t want to reinvent the wheel; we just want to polish the existing process and make it more efficient.&lt;/p&gt;
&lt;h3&gt;The Future is Parallel: Beyond Code&lt;/h3&gt;
&lt;p&gt;While &lt;code&gt;uzi&lt;/code&gt; focuses on software developers, its methodology isn&apos;t limited to tech; the principle of leveraging multiple agents running in parallel to increase the odds of finding an optimal solution applies universally.&lt;/p&gt;
&lt;p&gt;Consider a company like &lt;a href=&quot;https://www.versionstory.com/&quot;&gt;versionstory&lt;/a&gt;, 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&apos;s output.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;This parallel paradigm isn&apos;t just a new technique for developers; it&apos;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.&lt;/p&gt;
&lt;p&gt;My DMs are open if you want to chat about this topic or have any questions. I&apos;m happy to discuss.&lt;/p&gt;
</content:encoded><category>ai</category><category>vcs</category><author>Nick Khami</author></item><item><title>AI Horseless Carriages</title><link>https://www.skeptrune.com/posts/ai-horseless-carriages/</link><guid isPermaLink="true">https://www.skeptrune.com/posts/ai-horseless-carriages/</guid><description>How modern AI applications repeat the mistakes of early automobiles by mimicking old paradigms instead of embracing new possibilities.</description><pubDate>Tue, 15 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;AI Horseless Carriages&lt;/h1&gt;
&lt;p&gt;I noticed something interesting the other day: I enjoy using AI to build software more than I enjoy using most AI applications--software built with AI.&lt;/p&gt;
&lt;p&gt;When I use AI to build software I feel like I can create almost anything I can imagine very quickly. AI feels like a power tool. It&apos;s a lot of fun.&lt;/p&gt;
&lt;p&gt;Many AI apps don&apos;t feel like that. Their AI features feel tacked-on and useless, even counter-productive.&lt;/p&gt;
&lt;p&gt;I am beginning to suspect that these apps are the &lt;a href=&quot;#horseless-carriages&quot;&gt;&quot;horseless carriages&quot;&lt;/a&gt; of the AI era. They&apos;re bad because they mimic old ways of building software that unnecessarily constrain the AI models they&apos;re built with.&lt;/p&gt;
&lt;p&gt;To illustrate what I mean by that, I&apos;ll start with an example of a badly designed AI app.&lt;/p&gt;
&lt;h2&gt;Gmail&apos;s AI assistant&lt;/h2&gt;
&lt;p&gt;A little while ago, the Gmail team released a new feature giving users the ability to generate email drafts from scratch using Google&apos;s flagship AI model, Gemini. This is what it looks like:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/images/gmail_prompt.png&quot; alt=&quot;Gmail&apos;s Gemini email draft feature with a prompt I&apos;ve written&quot; /&gt;
&lt;em&gt;Gmail&apos;s Gemini email draft generation feature&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Here I&apos;ve added a prompt to the interface requesting a draft for an email to my boss. Let&apos;s see what Gemini returns:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/images/gmail_response.png&quot; alt=&quot;Gmail&apos;s Gemini email draft generation feature response&quot; /&gt;
&lt;em&gt;Gmail&apos;s Gemini email draft generation feature response&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;As you can see, Gemini has produced perfectly reasonable draft that unfortunately doesn&apos;t sound anything like an email that I would actually write. If I&apos;d written this email myself, it would have sounded something like this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Hey garry, my daughter woke up with the flu so I won&apos;t make it in today&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;The email I would have written&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The tone of the draft isn&apos;t the only problem. The email I&apos;d have written is actually shorter than the original prompt, which means I spent more time asking Gemini for help than I would have if I&apos;d just written the draft myself. Remarkably, the Gmail team has shipped a product that perfectly captures the experience of managing an underperforming employee.&lt;/p&gt;
&lt;p&gt;Millions of Gmail users have had this experience and I&apos;m sure many of them have concluded that AI isn&apos;t smart enough to write good emails yet.&lt;/p&gt;
&lt;p&gt;This could not be further from the truth: Gemini is an astonishingly powerful model that is more than capable of writing good emails. Unfortunately, the Gmail team designed an app that prevents it from doing so.&lt;/p&gt;
&lt;h2&gt;A better email assistant&lt;/h2&gt;
&lt;p&gt;To illustrate this point, here&apos;s a simple demo of an AI email assistant that, if Gmail had shipped it, would actually save me a lot of time:&lt;/p&gt;
&lt;p&gt;&lt;em&gt;[Note: Interactive demo would appear here in the web version]&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;This demo uses AI to &lt;em&gt;read&lt;/em&gt; emails instead of write them from scratch. Each email is categorized and prioritized, and some are auto-archived while others get an automatically-drafted reply. The assistant processes emails individually according to a custom &quot;System Prompt&quot; that explains exactly how I want each one handled. You can try your own labeling logic by editing the System Prompt.&lt;/p&gt;
&lt;p&gt;It&apos;s obvious how much more powerful this approach is, so why didn&apos;t the Gmail team build it this way? To answer this question, let&apos;s look more closely at the problems with their design. We&apos;ll start with its generic tone.&lt;/p&gt;
&lt;h2&gt;AI Slop&lt;/h2&gt;
&lt;p&gt;The draft that Gmail&apos;s AI assistant produced is wordy and weirdly formal and so un-Pete that if I actually sent it to Garry, he&apos;d probably mistake it for some kind of phishing attack. It&apos;s AI Slop.&lt;/p&gt;
&lt;p&gt;Everyone who has used an LLM to do any writing has had this experience. It&apos;s so common that most of us have unconsciously adopted strategies for avoiding it when writing prompts. The simplest such strategy is just writing more detailed instructions that steer the LLM in the right direction, like this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;let my boss garry know that my daughter woke up with the flu and that I won&apos;t be able to come in to the office today. Use no more than one line for the entire email body. Make it friendly but really concise. Don&apos;t worry about punctuation or capitalization. Sign off with &quot;Pete&quot; or &quot;pete&quot; and not &quot;Best Regards, Pete&quot; and certainly not &quot;Love, Pete&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;Prompt hacking our way to success&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Here&apos;s a little draft-writer demo you can use to compare my original prompt with this expanded one:&lt;/p&gt;
&lt;p&gt;&lt;em&gt;[Note: Interactive demo would appear here in the web version]&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The generated draft sounds better, but this is obviously dumb. The new prompt is even longer than the original, and I&apos;d need to write something like this out every time I want a new email written.&lt;/p&gt;
&lt;p&gt;There is a simple solution to this problem that many AI app developers seem to be missing: let me write my own &quot;System Prompt&quot;.&lt;/p&gt;
&lt;h2&gt;System Prompts and User Prompts&lt;/h2&gt;
&lt;p&gt;Viewed from the outside, large language models are actually really simple. They read in a stream of words, the &quot;prompt&quot;, and then start predicting the words, one after another, that are likely to come next, the &quot;response&quot;.&lt;/p&gt;
&lt;p&gt;The important thing to note here is that all of the input and all of the output is text. The LLM&apos;s user interface is just text&amp;lt;sup&amp;gt;1&amp;lt;/sup&amp;gt;.&lt;/p&gt;
&lt;p&gt;LLM providers like OpenAI and Anthropic have adopted a convention to help make prompt writing easier: they split the prompt into two components: a &lt;strong&gt;System Prompt&lt;/strong&gt; and a &lt;strong&gt;User Prompt&lt;/strong&gt;, so named because in many API applications the app developers write the System Prompt and the user writes the User Prompt.&lt;/p&gt;
&lt;p&gt;The System Prompt explains to the model how to accomplish a particular set of tasks, and is re-used over and over again. The User Prompt describes a specific task to be done.&lt;/p&gt;
&lt;p&gt;You can think of the System Prompt as a function, the User Prompt as its input, and the model&apos;s response as its output:&lt;/p&gt;
&lt;p&gt;&lt;em&gt;[Note: Interactive demo would appear here in the web version]&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;In my original example, the User Prompt was&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Let my boss Garry know that my daughter woke up with the flu this morning and that I won&apos;t be able to come in to the office today.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;My original User Prompt&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Google keeps the System Prompt a secret, but judging by the output we can guess what it looks like:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;You are a helpful email-writing assistant responsible for writing emails on behalf of a Gmail user. Follow the user&apos;s instructions and use a formal, businessy tone and correct punctuation so that it&apos;s obvious the user is smart and serious.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;Gmail&apos;s email-draft-writer System Prompt (presumably)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Of course I&apos;m being glib here, but the problem is not just that the Gmail team wrote a bad system prompt. The problem is that I&apos;m not allowed to change it.&lt;/p&gt;
&lt;h2&gt;The Pete System Prompt&lt;/h2&gt;
&lt;p&gt;If, instead of forcing me to use their one-size-fits-all System Prompt, Gmail let me write my own, it would look something like this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;You&apos;re Pete, a 43 year old husband, father, programmer, and YC Partner.&lt;/p&gt;
&lt;p&gt;You&apos;re very busy and so is everyone you correspond with, so you do your best to keep your emails as short as possible and to the point. You avoid all unnecessary words and you often omit punctuation or leave misspellings unaddressed because it&apos;s not a big deal and you&apos;d rather save the time. You prefer one-line emails.&lt;/p&gt;
&lt;p&gt;Do your best to be kind, and don&apos;t be so informal that it comes across as rude.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;The Pete System Prompt&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Intuitively, you can see what&apos;s going on here: when I write my own System Prompt, I&apos;m teaching the LLM to write emails the way that I would. Does it work? Let&apos;s give it a try.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;[Note: Interactive demo would appear here in the web version]&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Try generating a draft using the (imagined) Gmail System Prompt, and then do the same with the &quot;Pete System Prompt&quot; above. The &quot;Pete&quot; version will give you something like this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Garry, my daughter has the flu. I can&apos;t come in today.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;An email draft generated using the Pete System Prompt&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;It&apos;s perfect. That was so easy!&lt;/p&gt;
&lt;p&gt;Not only is the output better for this particular draft, it&apos;s going to be better for &lt;em&gt;every&lt;/em&gt; draft going forward because the System Prompt is reused over and over again. No more banging my head against the wall explaining over and over to Gemini how to write like me!&lt;/p&gt;
&lt;p&gt;And the best part of all? Teaching a model like this is surprisingly fun.&lt;/p&gt;
&lt;p&gt;Spend a few minutes thinking about how YOU write email. Try writing a &quot;You System Prompt&quot; and see what happens. If the output doesn&apos;t look right, try to imagine what you left out of your explanation and try it again. Repeat that a few times until the output starts to feel right to you.&lt;/p&gt;
&lt;p&gt;Better yet, try it with a few other User Prompts. For example, see if you can get the LLM to write these emails in your voice:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Let my wife know I&apos;ll be home from work late and will miss dinner&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;Personal email User Prompt&lt;/em&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Write an email to comcast customer service explaining that they accidentally double billed you last month.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;Customer support request User Prompt&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;There&apos;s something magical about teaching an LLM to solve a problem the same way you would and watching it succeed. Surprisingly, it&apos;s actually easier than teaching a human because, unlike a human, an LLM will give you instantaneous, honest feedback about whether your explanation was good enough or not. If you get an email draft you&apos;re happy with, your explanation was sufficient. If you don&apos;t, it wasn&apos;t.&lt;/p&gt;
&lt;p&gt;By exposing the System Prompt and making it editable, we&apos;ve created a product experience that produces better results and is actually fun to use.&lt;/p&gt;
&lt;p&gt;As of April 2025 most AI still apps don&apos;t (&lt;a href=&quot;https://x.com/jobergum/status/1913481778175631436&quot;&gt;intentionally&lt;/a&gt;) expose their system prompts. Why not?&lt;/p&gt;
&lt;h2&gt;Horseless Carriages&lt;/h2&gt;
&lt;p&gt;Whenever a new technology is invented, the first tools built with it inevitably fail because they mimic the old way of doing things. &quot;Horseless carriage&quot; refers to the early motor car designs that borrowed heavily from the horse-drawn carriages that preceded them. Here&apos;s an example of an 1803 Steam Carriage design I found on &lt;a href=&quot;https://en.wikipedia.org/wiki/Horseless_carriage&quot;&gt;Wikipedia&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/images/steam-carriage.png&quot; alt=&quot;Steam carriage&quot; /&gt;
&lt;em&gt;Trevithick&apos;s London Steam Carriage of 1803&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The brokenness of this design was invisible to everyone at the time and laughably obvious after the fact.&lt;/p&gt;
&lt;p&gt;Imagine living in 1806 and riding on one of these for the first time. Even if the wooden frame held together long enough to get you where you were going, the wooden seats and lack of suspension would have made the ride unbearable.&lt;/p&gt;
&lt;p&gt;You&apos;d probably think &quot;there&apos;s no way I&apos;d choose an engine over a horse&quot;. And you&apos;d have been right, at least until the automobile was invented.&lt;/p&gt;
&lt;p&gt;I suspect we are living through a similar period with AI applications. Many of them are infuriatingly useless in the same way that Gmail&apos;s Gemini integration is.&lt;/p&gt;
&lt;p&gt;The &quot;old world thinking&quot; that gave us the original horseless carriage was swapping a horse out for an engine without redesigning the vehicle to handle higher speeds. What is the old world thinking constraining these AI apps?&lt;/p&gt;
&lt;h2&gt;Old world thinking&lt;/h2&gt;
&lt;p&gt;Up until very recently, if you wanted a computer to do something you had two options for making that happen:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Write a program&lt;/li&gt;
&lt;li&gt;Use a program written by someone else&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Programming is hard, so most of us choose option 2 most of the time. It&apos;s why I&apos;d rather pay a few dollars for an off-the-shelf app than build it myself, and why big companies would rather pay millions of dollars to Salesforce than build their own CRM.&lt;/p&gt;
&lt;p&gt;The modern software industry is built on the assumption that we need developers to act as middlemen between us and computers. They translate our desires into code and abstract it away from us behind simple, one-size-fits-all interfaces we can understand.&lt;/p&gt;
&lt;p&gt;The division of labor is clear: developers decide how software behaves in the general case, and users provide input that determines how it behaves in the specific case.&lt;/p&gt;
&lt;p&gt;By splitting the prompt into System and User components, we&apos;ve created analogs that map cleanly onto these old world domains. The System Prompt governs how the LLM behaves in the general case and the User Prompt is the input that determines how the LLM behaves in the specific case.&lt;/p&gt;
&lt;p&gt;With this framing, it&apos;s only natural to assume that it&apos;s the developer&apos;s job to write the System Prompt and the user&apos;s job to write the User Prompt. That&apos;s how we&apos;ve always built software.&lt;/p&gt;
&lt;p&gt;But in Gmail&apos;s case, this AI assistant is supposed to represent me. These are my emails and I want them written in my voice, not the one-size-fits-all voice designed by a committee of Google product managers and lawyers.&lt;/p&gt;
&lt;p&gt;In the old world I&apos;d have to accept the one-size-fits-all version because the only alternative was to write my own program, and writing programs is hard.&lt;/p&gt;
&lt;p&gt;In the new world I don&apos;t need a middleman tell a computer what to do anymore. I just need to be able to write my own System Prompt, and writing System Prompts is easy!&lt;/p&gt;
&lt;h2&gt;Render unto the user what is the user&apos;s&lt;/h2&gt;
&lt;p&gt;My core contention in this essay is this: when an LLM agent is acting on my behalf I should be allowed to teach it how to do that by editing the System Prompt.&lt;/p&gt;
&lt;p&gt;Does this mean I always want to write my own System Prompt from scratch? No. I&apos;ve been using Gmail for twenty years; Gemini should be able to write a draft prompt for me using my emails as reference examples. I&apos;d like to be able to see that prompt and edit it, though, because the way I write emails and the people I correspond with change over time.&lt;/p&gt;
&lt;p&gt;What about people who don&apos;t know how to write prompts, won&apos;t they need developers to do it for them? Maybe at first, but prompt-writing is surprisingly intuitive and judging by how quickly ChatGPT caught on I think people will figure it out.&lt;/p&gt;
&lt;p&gt;What about agents that aren&apos;t so personal, like an AI accounting agent, or an AI legal agent? Wouldn&apos;t it make more sense for a software developer to hire an expert accountant or lawyer to write one-size-fits-all System Prompts in these cases?&lt;/p&gt;
&lt;p&gt;That might make sense if I were the user. A System Prompt for doing X should be written by an expert in X, and I am not an expert in accounting or law. However, I suspect most accountants and lawyers are going to want to write their own System Prompts too, because their expertise is context-specific.&lt;/p&gt;
&lt;p&gt;YC&apos;s accounting team, for example, operates in a way that is unique to YC. They use a specific mix of in-house and off-the-shelf software. They use YC-specific conventions that would only make sense to other YC employees. The structure of the funds they manage is unique to YC. A one-size-fits-all accounting agent would be about as useful to our team as an expert accountant who knew nothing about YC: not at all.&lt;/p&gt;
&lt;p&gt;This is the case for every accounting team in every company I&apos;ve ever worked for. It&apos;s why so much of finance still runs on Excel: it&apos;s a general tool that can handle an infinite number of specific use cases.&lt;/p&gt;
&lt;p&gt;In most AI apps, System Prompts should be written and maintained by users, not software developers or even domain experts hired by developers.&lt;/p&gt;
&lt;p&gt;Most AI apps should be &lt;em&gt;agent builders&lt;/em&gt;, not agents.&lt;/p&gt;
&lt;h2&gt;...and unto the developer what is the developer&apos;s&lt;/h2&gt;
&lt;p&gt;If developers aren&apos;t writing prompts, what will they do?&lt;/p&gt;
&lt;p&gt;For one, they&apos;ll create UIs for building agents that operate in a particular domain, like an email inbox or a general ledger.&lt;/p&gt;
&lt;p&gt;Most people probably won&apos;t want to write every prompt from scratch, and good agent builders won&apos;t force them to. Developers will provide templates and prompt-writing agents that help users bootstrap their own agents.&lt;/p&gt;
&lt;p&gt;Users also need an interface for reviewing an agent&apos;s work and iterating on their prompts, similar to the little dummy email agent builder I included above. This interface gives them a fast feedback loop for teaching an agent to perform a task reliably.&lt;/p&gt;
&lt;p&gt;Developers will also build &lt;em&gt;agent tools&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Tools are the mechanism by which agents act on the outside world. My email-writing agent needs a tool to submit a draft for my review. It might use another tool to send an email without my review (if I&apos;m feeling confident enough to allow that) or to search my inbox for previous emails from a particular email address or to check YC&apos;s founder directory to see if an email came from a YC founder.&lt;/p&gt;
&lt;p&gt;Tools provide the security layer for agents. Whether or not an agent can do a particular thing is determined by which tools it has access to. It is much easier to enforce boundaries with tools written in code than it is to enforce them between System and User Prompts written in text.&lt;/p&gt;
&lt;p&gt;I suspect that in the future we&apos;ll look back and laugh at the idea that a &quot;prompt injection&quot; (like &quot;Ignore previous instructions...&quot;) was something to be concerned about. The whole idea that developers should secure one part of the prompt from another part of the prompt is silly, and a strong signal that the abstractions we&apos;re using are broken. As &lt;a href=&quot;https://x.com/jobergum/status/1913481778175631436&quot;&gt;this post&lt;/a&gt; makes clear: if any part of the prompt is in user space then the whole thing is in user space.&lt;/p&gt;
&lt;h2&gt;An agent for reading my email&lt;/h2&gt;
&lt;p&gt;As I mentioned above, however, a better System Prompt still won&apos;t save me much time on writing emails from scratch.&lt;/p&gt;
&lt;p&gt;The reason, of course, is that I prefer my emails to be as short as possible, which means any email written in my voice will be roughly the same length as the User Prompt that describes it. I&apos;ve had a similar experience every time I&apos;ve tried to use an LLM to write something. Surprisingly, generative AI models are not actually that useful for generating text.&lt;/p&gt;
&lt;p&gt;The thing that LLMs are great at is reading text and transforming it, and that&apos;s what I&apos;d like to use an agent for. Let&apos;s revisit our email-reading agent demo:&lt;/p&gt;
&lt;p&gt;&lt;em&gt;[Note: Interactive demo would appear here in the web version]&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;It&apos;s not hard to imagine how much time an email-reading agent like this could save me. It already seems to do a better job of detecting spam than Gmail&apos;s built-in spam filter. It&apos;s more powerful and easier to maintain than the byzantine set of filters I use today. It could trigger a notification for every message that I think is urgent, and when I open them up I&apos;d have a draft response ready to go, written in my voice. It could auto-archive the emails I don&apos;t need to read and summarize the ones I do.&lt;/p&gt;
&lt;p&gt;Hell, with access to a few additional tools it could unsubscribe from lists, schedule appointments, and pay my bills too, all without my having to lift a finger.&lt;/p&gt;
&lt;p&gt;This is what I really want from an AI-native email client: the ability to automate mundane work so that I can spend less time doing email&amp;lt;sup&amp;gt;2&amp;lt;/sup&amp;gt;.&lt;/p&gt;
&lt;h2&gt;AI-native software&lt;/h2&gt;
&lt;p&gt;This is what AI&apos;s &quot;killer app&quot; will look like for many of us: teaching a computer how to do things that we don&apos;t like doing so that we can spend our time on things we do.&lt;/p&gt;
&lt;p&gt;One of the reasons I wanted to include working demos in this essay was to show that large language models are already good enough to do this kind of work on our behalf. In fact they&apos;re more than good enough in most cases. It&apos;s not a lack of AI smarts that is keeping us from the future I described in the previous section, it&apos;s app design.&lt;/p&gt;
&lt;p&gt;The Gmail team built a horseless carriage because they set out to add AI to the email client they already had, rather than ask what an email client would look like if it were designed from the ground up with AI. Their app is a little bit of AI jammed into an interface designed for mundane human labor rather than an interface designed for automating mundane labor.&lt;/p&gt;
&lt;p&gt;AI-native software should maximize a user&apos;s leverage in a specific domain. An AI-native email client should minimize the time I have to spend on email. AI-native accounting software should minimize the time an accountant spends keeping the books.&lt;/p&gt;
&lt;p&gt;This is what makes me so excited about a future with AI. It&apos;s a world where I don&apos;t have to spend time doing mundane work because agents do it for me. Where I&apos;ll focus only on things I think are important because agents handle everything else. Where I am more productive in the work I love doing because agents help me do it.&lt;/p&gt;
&lt;p&gt;I can&apos;t wait.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Thanks to all who read drafts of this essay, including my Dad, dang, wcl &amp;amp; cpl, and my colleagues at YC.&lt;/p&gt;
&lt;h3&gt;Footnotes:&lt;/h3&gt;
&lt;p&gt;1: I&apos;m leaving some details out and of course today&apos;s models can input and output sound and video, too. For our purposes we can ignore that.&lt;/p&gt;
&lt;p&gt;2: There are email clients out there that are already working on this, notably &lt;a href=&quot;https://superhuman.com/&quot;&gt;Superhuman&lt;/a&gt; and &lt;a href=&quot;https://0.email/&quot;&gt;Zero&lt;/a&gt;&lt;/p&gt;
</content:encoded><category>ai</category><category>product-design</category><author>Nick Khami</author></item></channel></rss>