Build.msBuild.ms exists to make technology accessible to everyone with lessons about engineering, product, design, marketing, business, and AI.https://build.ms/en-usSmall Steps For Agent-Friendly Codebaseshttps://build.ms/2026/3/11/small-steps-for-agent-friendly-codebases/https://build.ms/2026/3/11/small-steps-for-agent-friendly-codebases/Updating Recap taught me a valuable lesson for making your codebase agent-friendly — small steps can have a big payoff.Wed, 11 Mar 2026 12:00:00 GMT<p>I’ve just released <a href="https://github.com/mergesort/Recap" target="_blank" rel="noopener noreferrer">Recap 2.0</a> — my open source library for <a href="https://build.ms/2024/10/9/introducing-recap" target="_blank" rel="noopener noreferrer">creating beautiful What’s New screens from markdown</a>. The primary purpose for this update was interface improvements I need to include in <a href="https://plinky.app/mac" target="_blank" rel="noopener noreferrer">Plinky for Mac</a> (available very soon!), but the underlying story is more important.</p> <p>This is my first open source release where an agent wrote 100% of the code. I was deeply involved in prompting and guiding the work, which taught me an important lesson about working with agents.</p> <p><img loading="lazy" width="1610" height="1050" src="https://build.ms/_astro/whats-new.BICDbbXb_11kqpt.webp" /></p> <h3>Building For Agents</h3> <p>I write code with agents every day — probably 90-95% of my code is the result of an agentic workflow. When I started working on Recap that number was closer to 5 or 10%, so my working style has changed a lot more than the project itself. When I started updating Recap for 2.0, I quickly realized the codebase wasn’t set up well for agents to work with.</p> <p>Updating Recap felt a bit like stepping into a time machine because the project had no structure in place to make agentic development easier. The first thing I did was create an <a href="https://llmstxt.org/" target="_blank" rel="noopener noreferrer">llms.txt</a> file that defines the project’s structure and functionality. This small file makes a big difference, letting an agent be laser-focused rather than fumbling around brute forcing solutions.</p> <p>Then I built a Recap Integration <a href="https://build.ms/2025/10/17/your-first-claude-skill/" target="_blank" rel="noopener noreferrer">Skill</a> that handles the hard work of adding Recap to your project. Something I’ve learned as an open source maintainer is that people will find the most creative ways to misuse your library. I’ve always wanted a way to prevent questions from people who’ve misunderstood how my open source project works and gotten stuck trying to fix their mistakes.</p> <p>In the past I’ve solved this by writing lots of time-consuming documentation. The tradeoff is worthwhile for my projects like <a href="http://github.com/mergesort/boutique/" target="_blank" rel="noopener noreferrer">Boutique</a> which have thousands of users, but there isn’t as much payoff for smaller projects. Creating a skill solves this by teaching an agent how to write the code <em>you</em> want, and that’s what makes the <code>recap-integration</code> skill the real highlight of Recap 2.0.</p> <p>Updating a project for agentic workflows isn’t just for open source, it will help you in any codebase where you use tools like Claude Code, Codex, and Cursor.</p> <h3>Your Turn</h3> <p>If you want to get better at working with agents, start small. Some of the highest leverage work you can do is to make your codebase agent-friendly. Much like professional pianists still practice their scales, you can level up your fundamentals by practicing on a small project. It’s more difficult to begin with a large established codebase, but the lessons you learn in a small project will transfer over to codebases of all sizes.</p> <p>The work of making your codebase agent-friendly starts with one small question: What would make your codebase easier for an agent to work with? You can figure this out by <a href="https://huyenchip.com/2025/01/07/agents.html" target="_blank" rel="noopener noreferrer">reading up on agents</a>, <a href="https://build.ms/ai" target="_blank" rel="noopener noreferrer">taking a workshop</a>, or even trial and error — but that question alone will point you towards the changes you need to make.</p> <p>If you want to see what that looks like in practice, check out <a href="https://github.com/mergesort/Recap" target="_blank" rel="noopener noreferrer">Recap</a> on Github. It’s a concrete example of a small project restructured with agents in mind, and I’m always happy to answer any questions you may have about it.</p>TopAIEngineeringiOSSwiftOpen SourceJoe FabisevichThe Hidden Cost Of Communication (A Case Against Big Companies)https://build.ms/2026/2/27/the-hidden-cost-of-communication/https://build.ms/2026/2/27/the-hidden-cost-of-communication/Big companies are downsizing, not solely because of AI and corporate greed but because communication bottlenecks kill productivity and demoralize teams.Fri, 27 Feb 2026 12:00:00 GMT<blockquote> <p>I think Twitter is as guilty of hiring too fast as Google and most of these other companies, the problem of bloat is just way bigger at Google and has been for over a decade now.</p> </blockquote> <p>That’s what I said in 2020 while working at Twitter, and I still believe it. Now as I watch companies like Square lay off <a href="https://x.com/jack/status/2027129697092731343" target="_blank" rel="noopener noreferrer">40% of their workforce</a>, I’m certain now that this isn’t solely about corporate greed or AI replacing workers — this is a sectoral shift that’s been a decade in the making, and we’re finally seeing it accelerate.</p> <p>People who have never worked at a large tech company believe that these companies are overstaffed. But workers at these companies will tell you the opposite — convinced their firm is understaffed because they’re constantly fighting for resources. So how do we square this circle? This isn’t directly a symptom of mismanagement, mismanagement is what happens when you underrate the hidden costs of communication.</p> <h3>Size Matters</h3> <p>These moves are rational because large companies aren’t overstaffed or understaffed — they’re overstaffed <em>and</em> under-focused. That’s why everyone at tech companies with 50,000 employees are always complaining that despite 49,999 coworkers, everything is inefficient. The naive solution is to hire more, but the real answer is to reduce bandwidth.</p> <p>There’s a saying that the job of a CEO is to say the same thing 10 times until everyone hears it. The larger your company, the further information has to diffuse, the more people have to coordinate — and the harder it becomes to actually get anything done. Go ahead and try it out yourself.</p> <div><iframe title="The Cost of Communication" loading="lazy"></iframe></div> <p>Notice that when we go from 1 to 5 people we don’t just have 4 more people, we have 10 new connections between people. Going from 5 to 10 increases connections from 10 to 45. Going from 10 to 500 brings us to 124,750 connections, and 50,000 people takes us all the way to 1,249,975,000 connections. Building a product is a team sport, and it gets exponentially harder to play together the more people you add.</p> <h3>Communication Is Everything</h3> <p>Imagine you’re a CEO and need to make a fundamental shift in your product:</p> <ul> <li><strong>1 person:</strong> You shift the product instantly.</li> <li><strong>5 people:</strong> You get everyone in a room and decide in a few hours.</li> <li><strong>10 people:</strong> You do all that and hold a company meeting where you explain why you’re making this change.</li> <li><strong>50 people:</strong> You coordinate with your executive team on how to cascade this change through the organization.</li> <li><strong>500 people:</strong> You reshape the entire company around this change — layoffs, restructuring, and a redefined mission.</li> <li><strong>50,000 people:</strong> You execute this across every organization, team, and product, and make your case to public markets.</li> </ul> <p>This is why the goal of a company is not to grow, but to avoid headcount growth while maximizing revenue and profits. Because the overhead for communication is so high, every employee you hire only produces marginal returns — maybe 10 or 20% more throughput once you account for coordination costs.</p> <h3>AI</h3> <p>It’s hard to ignore the elephant in the room: AI. AI really can make people more efficient and allow them to do more with less. But as <a href="https://hbr.org/2026/02/ai-doesnt-reduce-work-it-intensifies-it" target="_blank" rel="noopener noreferrer">this HBR study shows</a>, AI doesn’t reduce work — it intensifies it.</p> <p>The bottleneck was never the amount of work a person could do. It was always the number of interconnected edges in a system — the communication overhead. AI makes individuals more productive, but it doesn’t make large organizations better at coordinating. The people don’t get better at communicating as fast as the models get better at doing the work. So you can do more, but you still can’t align 50,000 people any faster than you could before.</p> <p>This is why the layoffs we’re seeing aren’t just about replacing workers with AI. They’re about companies finally accepting what was always inevitable — you don’t need 50,000 people to do the work of 30,000. You never did, but AI has made this reality impossible to ignore.</p> <h3>Workers</h3> <p>Now don’t get me wrong, this essay isn’t meant to be some sort of pro-capital anti-labor screed. My heart goes out to all of the people who were laid off from Square yesterday, from Amazon before that, and from Microsoft, Google, and all the other big tech companies that have done layoffs recently. This all makes me very squirmy as a person who sometimes considers reentering the workforce.</p> <p>This post isn’t meant to be solace, but a cold hard look at our reality. For companies like Square, this is a reset to focus on what matters most — with fewer employees and more efficiency. I’ve been saying this for 6 years — long before ChatGPT — so I’m confident this isn’t just an AI story.</p> <p>In response, I always hear an optimistic argument — these smart people will start companies of their own and outcompete the big players. But there’s no evidence the market needs more companies selling the same products to fewer employed people. If 50 people get laid off from TikTok, there’s no natural law that says the market will suddenly clamor for more innovation in short-form video.</p> <p>Some of these people will certainly create new companies and new jobs. But there’s no guarantee those jobs will be recouped — and even if they are, it may take years. In the meantime, the people laid off today are looking for work in a market that’s contracting, not expanding.</p> <p>And that’s why my takeaway is that big companies are going to get smaller and smaller, and smaller companies may follow suit. I wish I had tangible advice to share, but all I can say is brace for change across the industry, because it’s a-comin’.</p>TopAIIndustryJoe FabisevichAutomating Codexhttps://build.ms/2026/2/12/automating-codex/https://build.ms/2026/2/12/automating-codex/You‘re one step away from turning Codex into a proactive assistant that tackles low-hanging fruit while you sleep.Thu, 12 Feb 2026 12:00:00 GMT<p>There’s an adage that says using AI is like having an intern at your disposal. The metaphor usually carries a negative connotation — a lowly helper without much skill. But it’s apt because of the style of work AI actually does.</p> <ol> <li>You set an intern off to go do some work</li> <li>They come back saying they’ve completed their assignment</li> <li>You show them where there are gaps, and nudge them in the right direction</li> <li>Repeat 5-10x until they get where you wanted them to go</li> </ol> <p>Last week OpenAI released an app for their coding agent <a href="https://developers.openai.com/codex/" target="_blank" rel="noopener noreferrer">Codex</a>. <sup><a href="https://build.ms/2026/2/12/automating-codex/#user-content-fn-1" target="_blank" rel="noopener noreferrer">1</a></sup> At first glance, it looks like it just makes their Codex CLI more accessible — much like <a href="https://build.ms/notes/2026/1/12/claude-cowork/" target="_blank" rel="noopener noreferrer">Claude Cowork</a> is an interface to Claude Code for people who don’t live in a terminal. But there’s a premiere feature that instantly provided value: automations.</p> <h3>Automations</h3> <p>An automation in Codex is as simple as it sounds. You configure a task to run on a regular basis, and it goes off and does some work for you. This work can be anything — and it’s safer than other approaches because it runs in whatever sandbox environment you’ve set up.</p> <p>Setting up an automation is easy. You give Codex a title, decide which projects it can access, and write a prompt to execute. Then you pick how often the automation will run — and it’ll execute whenever your computer is on. (In the future, automations will run in Codex Cloud, so you won’t need to keep your computer on.)</p> <p><img alt="An automation configured in Codex" loading="lazy" width="1166" height="1028" src="https://build.ms/_astro/codex-automation.DQBRlaXF_ZGgCkf.webp" /></p> <h3>Your Summer Intern</h3> <p>Let’s push the intern analogy a little further. On their first day, an intern knows nothing and your job is to teach them the ropes. They don’t know anyone, don’t know what they’ll be doing, and don’t even know how you take your coffee. You should expect little from a college kid who’s never held down a full-time job before.</p> <p>By summer’s end though, they’ll have experience under their belt. They know their projects, understand how to work with product and design, and you can leave them alone for longer stretches of time. If you’ve been working closely with them, they may even anticipate your next move.</p> <p>This anticipation is what automations do so well. If you’ve defined a task well, you suddenly have an intern who understands what you need. Not only can they do it, but they’re confident <em>and now</em> proactive about it. This is the agentic unlock that automations bring — what really separates an automation from using ChatGPT to solve a problem.</p> <h3>Real World Automations</h3> <p>My first automation was inspired by a tweet from Dominik Kundel of OpenAI’s SDK team:</p> <blockquote><p>💪 Have Codex automatically become better<br /><br />Add a daily task to read your past sessions in ~/.codex/sessions and update your <a href="https://t.co/DKdqY6VTPu" target="_blank" rel="noopener noreferrer">https://t.co/DKdqY6VTPu</a> files or update/create new skills for common tasks. <a href="https://t.co/8ME7LWu3ML" target="_blank" rel="noopener noreferrer">pic.twitter.com/8ME7LWu3ML</a></p>— dominik kundel (@dkundel) <a href="https://twitter.com/dkundel/status/2018455599391121851?ref_src=twsrc%5Etfw" target="_blank" rel="noopener noreferrer">February 2, 2026</a></blockquote> <p>That got me thinking: what else could I make Codex do while I slept? I wanted something small enough to easily review, but important enough to actually derive value from. Basically, I want someone to help me with all the low-hanging fruit on my roadmap — so that’s exactly the automation I set up.</p> <h3>Low Hanging Fruit</h3> <p>I built a simple automation that works like this:</p> <ol> <li>Codex reads <a href="https://plinky.app/" target="_blank" rel="noopener noreferrer">Plinky’s</a> internal roadmap, which I keep in a <a href="https://craft.do/" target="_blank" rel="noopener noreferrer">Craft doc</a>. This document is accessible via MCP, so Codex can read through my backlog of features and issues.</li> <li>Codex reads my codebase to understand how easy or hard each task will be.</li> <li>Then it builds a small feature, improves code quality, or fixes up some technical debt.</li> </ol> <p>I turned those steps into a prompt:</p> <blockquote> <p>We are looking to pick off one piece of low-hanging fruit based on the state of our roadmap and our codebase. (This will usually be main, but not always.) This will take the shape of a clean up task to improve code quality, a fix for some technical debt you encounter, or something small from our roadmap.</p> <p>The roadmap is accessible via our plinky_roadmap MCP server.</p> <p>I want you to read through our codebase so you can understand the current state of our app. Then you will read the roadmap and find an item that is not done, but you would estimate has a small level of effort but a high level of return. It’s important to note that the two may be slightly out of sync, but that the code is our source of truth. (If there’s a completed task on our roadmap that seems to not be marked as complete, you can include that in your response.)</p> <p>Then I want you to execute on a code quality improvement, a feature that you think would be valuable to Plinky, or even an improvement to our documentation at an important or tricky point in the code. Every task you do requires a code review so try to not add too much work onto our plate — and focus on high leverage actions.</p> </blockquote> <p>And the results were instantly useful! Codex cleaned up extraneous logging code from debugging Plinky’s browser extension, modernized SwiftUI call-sites from the deprecated <code>foregroundColor</code> API to <code>foregroundStyle</code>, and caught that my CSV exporter used <code>\n</code> instead of carriage returns.</p> <p>These are small paper cuts that accumulate over 5 years of work, but fixing them one by one creates a better home for your code. Better yet, Codex went to my roadmap and found a hastily scrawled note that said “Add tests to LinksListFilterStateTests that validate ordering for pinned links.” I’d promptly forgotten about this after writing it down — but a few seconds later I had tests.</p> <p>I re-ran the automation a few more times and the results got even better. Codex fixed a bug in how I parse webhook payloads, extracted repetitive code into a private helper function, and found a subtle performance improvement by pre-sorting an array I was re-sorting on every View recalculation. All I did was write a prompt and I had a bug fix, improved code, and a small scrolling hitch had been eliminated.</p> <h3>The Future Is Automation</h3> <p>It’s not hard to see where this is going — any task that fits the shape of software is a task that can be automated. An indie like me is thrilled to have extra hands, but tools like these work for teams small and large.</p> <p>I started with a small scope because code review is still my bottleneck, but I’ve continued tweaking my prompts and adding more responsibility. Your intern is only as good as your prompts. I’ve <a href="https://build.ms/2025/12/24/ai-without-the-hype-ns-spain-2025/" target="_blank" rel="noopener noreferrer">spoken at length</a> about how your ability to prompt and build context for an agentic system is paramount, but I’ve also set my coding environment up with <a href="https://build.ms/2025/10/17/your-first-claude-skill/" target="_blank" rel="noopener noreferrer">Codex Skills</a> to make Codex write code the way I like it written.</p> <hr /> <p>Automations are new, so I haven’t implemented all of these yet, but here are a few ideas you can try:</p> <p><strong>Nightly issue triage:</strong> Scan your issue tracker (Sentry, GitHub Issues, Linear) to assign priorities, identify duplicates, and suggest labels.</p> Prompt <div> <p>Triage open issues for this project.</p> <p>Review open issues from the issue tracker and recent commits for context.</p> <p>For each issue, determine category, relative priority (high/medium/low), and whether it appears to be a duplicate.</p> <p>Produce:</p> <ol> <li>A short list of high-priority issues with justification.</li> <li>Suspected duplicates and why they overlap.</li> <li>Suggested labels or metadata.</li> </ol> <p>Do not close issues or make destructive changes.</p> </div> <p><strong>Automated architectural docs:</strong> Parse code changes and update or infer architectural diagrams and high-level readmes.</p> Prompt <div> <p>Keep architectural documentation aligned with the codebase.</p> <p>Review recent commits, existing README and architecture docs, and overall project structure.</p> <p>Identify meaningful changes to components, data flow, boundaries, or integrations.</p> <p>Propose concise updates to existing documentation, or specific additions where gaps exist.</p> <p>Focus on preserving intent and helping new contributors understand the system. Keep changes minimal.</p> </div> <p><strong>Automated test health monitor:</strong> Evaluate your test suite to find flaky tests, suggest missing edge cases, and propose new tests for untested logic.</p> Prompt <div> <p>Evaluate the overall health of the test suite.</p> <p>Review test files, recent failures or flakes, and recently changed code paths.</p> <p>Identify:</p> <ul> <li>Flaky or brittle tests</li> <li>Important logic paths lacking coverage</li> <li>Redundant or low-signal tests</li> </ul> <p>Produce:</p> <ol> <li>A short list of test risks.</li> <li>One or two high-leverage test improvements.</li> </ol> <p>Avoid broad rewrites. Optimize for confidence per line of test code.</p> </div> <p><strong>Product surface area audit:</strong> Periodically list features users can see, can’t discover, and misunderstand. Then suggest one small affordance — a copy tweak, default, or UI nudge to reduce that gap.</p> Prompt <div> <p>I want you to audit the current product surface area of this project.</p> <p>First, read through the codebase, UI components, feature flags, onboarding flows, and any relevant product documentation to understand what the product can do today.</p> <p>Then produce three short lists:</p> <ol> <li>Features that are clearly visible and discoverable to users.</li> <li>Features that exist but are difficult to discover without prior knowledge.</li> <li>Features that users are likely to misunderstand based on naming, defaults, or UI affordances.</li> </ol> <p>For each item in lists 2 and 3, propose one small, low-risk improvement (copy change, default tweak, UI hint, reordering, or documentation note).</p> <p>Do not propose large redesigns or new features. Optimize for leverage and simplicity.</p> </div> <p><strong>Solicit more ideas:</strong> If you had one uninterrupted hour today, what’s the single change that would most improve the product?</p> Prompt <div> <p>Determine the single highest-leverage improvement I could make if I had one uninterrupted hour to work on this project today.</p> <p>Review recent commits, open issues, roadmap items, and relevant documentation.</p> <p>Then answer:</p> <ul> <li>What is the one change that would most improve the product, developer experience, or user experience?</li> <li>Why this is high leverage right now.</li> </ul> <p>Constraints:</p> <ul> <li>Propose only one action.</li> <li>It must plausibly fit within one focused hour.</li> <li>Prefer actions that reduce future work or unlock momentum.</li> </ul> <p>Do not include alternatives.</p> </div> <hr /> <p>And if you think of any good automations, please <a href="https://bsky.app/profile/mergesort.me" target="_blank" rel="noopener noreferrer">reach out</a> and tell me what you’ve come up with!</p> <p>The models keep getting better, and as they become more trustworthy, I’ll continue delegating more to them. This is a glimpse into the agentic coding future we’ve been promised — and there’s still so much more to do.</p> <section><h2>Footnotes</h2> <ol> <li> <p>The app is currently available for macOS, but Windows and Linux are coming soon. <a href="https://build.ms/2026/2/12/automating-codex/#user-content-fnref-1" target="_blank" rel="noopener noreferrer">↩</a></p> </li> </ol> </section>TopAIEngineeringProductivityJoe FabisevichRead The Code (Sometimes)https://build.ms/notes/2026/2/9/read-the-code-sometimes/https://build.ms/notes/2026/2/9/read-the-code-sometimes/Should you read the code agentic systems write, or it all assembler in a world of AI-assisted coding?Mon, 09 Feb 2026 12:00:00 GMT<p>This weekend there was once again a debate about the future of programming, stemming from this comment by a longtime developer <a href="https://mastodon.social/@stroughtonsmith" target="_blank" rel="noopener noreferrer">Steven Troughton-Smith</a>. Steven has just gotten into using AI for programming, and as a result he is discovering just how powerful it is.</p> <p><img alt="Much as you don&amp;#x27;t generally go auditing the bytecode or intermediate representation generated by your compiler, I think the idea of manually reviewing LLM-written code will fall by the wayside too. Like it or not, these agents are the new compilers, and prompting them is the new programming. Regardless of what happens with any Al bubble, this is just how things will be from now on; we&amp;#x27;ve experienced a permanent, irreversible increase to the level of abstraction. We are all assembly programmers" loading="lazy" width="960" height="878" src="https://build.ms/_astro/stroughton-smith-mastodon-post.Uyfy0CA5_Z2kII4K.webp" /></p> <p>- <a href="https://mastodon.social/@stroughtonsmith/116030136026775832" target="_blank" rel="noopener noreferrer">Steven Troughton-Smith on Mastodon</a></p> <p>There are a lot of people who haven’t invested much in using agentic systems to write code, for many perfectly good reasons. And often, they haven’t updated their understanding of how LLMs work, still calling them “random word predictors” or “stochastic parrots” — without considering how much more complex agentic systems like Claude Code or Codex have become. As a result, there’s often a lot of debate (particularly on Mastodon) built on false premises or misunderstandings about how LLMs work.</p> <p>Then there are sensible people like Manton Reece who are not just looking to prove their worldview is right. Manton wrote a blog post titled <a href="https://www.manton.org/2026/02/07/read-the-code.html" target="_blank" rel="noopener noreferrer">Read The Code</a> that I mostly approve of, but also subtly disagree with. The premise is that you still need to read the code that agentic coding tools output. I follow this rule when it comes to the <a href="https://build.ms/notes/2026/2/9/read-the-code-sometimes/plinky.app" target="_blank" rel="noopener noreferrer">Plinky</a> — a piece of software I derive my livelihood from — but I want to share a story that directionally agrees with Steven. As programming becomes more abstracted, what matters most is not whether you can read the code, but whether you understand the problem you’re solving. Domain expertise — not code literacy — is becoming the real differentiator.</p> <h3>Building A Financial Model</h3> <p>Last night, I saw this play out firsthand. My wife Colleen and I built an app to create a visual representation for when we can retire. We spent 20 minutes writing a prompt that codified our goals and circumstances, and Claude generated a tool that let us visualize our financial options. I wanted to see what our future would look like if I stayed indie, or if I went back to work full-time for 2, 5, 7, or 10 years. We added variables like cost of living, the percentage of real GDP growth, whether we’d have a family, and a few other factors relevant to our personal life.</p> <p>We asked Claude to ask us some more questions to fill in any gaps for the things we didn’t consider, and spent about 10 minutes answering those follow ups. We went through a few iterations because we had to tweak scenarios and variables — all done through prompting. At some point Colleen noticed we could never retire earlier than 2040 no matter what we changed — even if we simulated ourselves winning the lottery. Many programmers would say “this is proof an LLM can’t do math,” but it’s just code under the hood. The real problem is that most people would have accepted the incorrect output and made important life decisions based on flawed software.</p> <p>But Colleen doesn’t know how to code, and instead she suggested we ask the model in plain English what assumptions it had baked in. It listed 15 assumptions, and one was “I assumed you wouldn’t want to retire before 2040.” We both exclaimed “wait, why?” — and Colleen joked that the model wants us to keep working so we can afford more tokens.</p> <p>Rather than scrapping the project, we cleaned up the assumptions and kept testing until the results matched our intuitions. It was more than intuition though — we had a few known outcomes — unit tests by another name. You know what we never did though? Look at the code. The more important factor was working through this with someone who has financial modeling experience, because we could validate our assumptions and outputs in other ways.</p> <h3>Not Quite Assembly, But Close</h3> <p>It was effectively assembly to us. With no programming experience, Colleen couldn’t have understood what it was doing, so it definitely was assembly to her. But she didn’t really need to. What mattered most was that she could validate the outputs, ask the right questions, and catch the flawed assumptions baked into our financial model. In this case — and in many cases — having domain expertise is more powerful than being able to write code.</p> <p>This is why I think Steven is more directionally correct than Manton, even though I still will often read the code. Not because all software will be built this way — I certainly don’t want to fly in a plane powered by software built with intuition. At the same time a reasonable amount of software will be built this way, with little or no detriment to users. Software is not just random code — it’s intuition and experience codified into syntax.</p> <p>When I work with non-technical students at <a href="https://pursuit.org/" target="_blank" rel="noopener noreferrer">Pursuit</a> or <a href="https://build.ms/notes/2026/2/9/read-the-code-sometimes/build.ms/ai" target="_blank" rel="noopener noreferrer">in my workshops</a> I’ll often get questions about what they can do without knowing how to code. Some have huge ambitions so I tell them that vibe coding won’t help them build the next Google, but it will let them build software that solves real problems they have. And if you have the right foundation of knowledge, you won’t even need to look at the code.</p>AIIndustryProgrammingJoe FabisevichSupporting Markdown Search For LLMshttps://build.ms/2026/2/2/supporting-markdown-search-for-llms/https://build.ms/2026/2/2/supporting-markdown-search-for-llms/Learn a simple but effective technique that makes your blog more easily searchable for LLMs.Mon, 02 Feb 2026 12:00:00 GMT<blockquote> <p>This post shows you how to support markdown search for your blog, making it token-efficient to search through your content. It also has a lesson about how context windows work and markdown work. If you only want the implementation steps, you can skip all that and go straight to <a href="https://build.ms/2026/2/2/supporting-markdown-search-for-llms/#so-what-are-we-doing" target="_blank" rel="noopener noreferrer">“So What Are We Doing?”</a>.</p> </blockquote> <p>If there’s one thing LLMs struggle with, it’s efficiency. I’m not talking about <a href="https://andymasley.substack.com/p/a-cheat-sheet-for-conversations-about" target="_blank" rel="noopener noreferrer">environmental impact</a> — I’m talking about context windows and tokens.</p> <p>LLMs know a lot of things, but they don’t know everything. When they recognize they don’t know something, they may fall back to searching the web to learn more. This is great because it means they can access the latest information available to solve the problem.</p> <p>But when LLMs retrieve information from the web, they need to store it somewhere they can reference while working. You can’t keep every word of every book you’ve ever read in your head — and neither can LLMs. Instead, they store retrieved information in a small amount of quickly accessible memory called a context window.</p> <h3>A Primer On Context Windows</h3> <p>An LLM’s context window can’t be infinitely large because the data we put in it needs to be stored somewhere, and it has to be accessed quickly. Having to access it quickly rules out traditional long-term storage mechanisms like databases, because they’re too slow. In effect, we have two constraints: we want to keep only the most important information, and we need to make it as compact as possible.</p> <p>Before we move on, we need to go over a few facts about context windows:</p> <ul> <li>The text (or images) you put into an LLM are converted into an LLM-friendly format called tokens.</li> <li>A token is a chunk of data that makes it easier for an LLM to process information.</li> <li><a href="https://platform.openai.com/tokenizer" target="_blank" rel="noopener noreferrer">Counting tokens</a> isn’t as straightforward as you’d expect, but here are some rough guidelines: about <strong>3-4 characters of text = 1 token</strong>, or roughly <strong>1 word = 1.33 tokens</strong>.</li> <li>The average Harry Potter book is 135,000 words long, or about 180,000 tokens.</li> <li>The latest models at the time of this writing are Claude Opus 4.5 and GPT-5.2 — which have context windows of 200K and 400K tokens respectively.</li> <li>This is a pretty limited space, so once you’ve reached the end of your token window, LLMs will do their best to summarize the conversation. Summarization, though, can lose important details.</li> </ul> <p>This means you can safely fit 1-2 Harry Potter books in a context window — which is far smaller than the entirety of the internet. In practice, that’s approximately 10-15 of my blog posts, or roughly 2-3 hours of video on YouTube.</p> <p>And that’s why we need to be selective when we ask LLMs to search the web for information. When Claude goes off to search the web, it may retrieve far more data than its context window can hold!</p> <h3>And That’s Why We Need Markdown</h3> <p>The good news is that my blog posts are written in <a href="https://en.wikipedia.org/wiki/Markdown" target="_blank" rel="noopener noreferrer">markdown</a> — a lightweight markup language created by <a href="https://build.ms/2026/2/2/supporting-markdown-search-for-llms/daringfireball.net" target="_blank" rel="noopener noreferrer">John Gruber</a>. If you’re reading this, you’ve likely used markdown to bold or italicize text by adding <code>_</code> or <code>**</code> around words or sentences.</p> <p>Markdown is very lightweight compared to HTML because it’s plain-text without any markup. (Hence the name markdown — which I only just realized as I was writing this sentence. 😆) Sharing the markdown version of a blog post will save precious tokens — which lets LLMs store and access more information in their context window.</p> <blockquote><p>In the next version of Claude Code, Claude’s WebFetch tool automatically adds Accept: “text/markdown, *” to requests which helps docs sites provide token-efficient docs <a href="https://t.co/CZWdH0OZsO" target="_blank" rel="noopener noreferrer">https://t.co/CZWdH0OZsO</a> <a href="https://t.co/uYthd1m9RP" target="_blank" rel="noopener noreferrer">pic.twitter.com/uYthd1m9RP</a></p>— Boris Cherny (@bcherny) <a href="https://twitter.com/bcherny/status/1988860326306087102?ref_src=twsrc%5Etfw" target="_blank" rel="noopener noreferrer">November 13, 2025</a></blockquote> <p>This approach has become standard practice — Claude Code now automatically searches for markdown versions of websites, and other tools like Codex have followed suit. Yet despite its importance, few websites have implemented this technique. Here’s how to be one of them.</p> <h3>So What Are We Doing?</h3> <p>We’re going to do two things.</p> <ol> <li>Add support for serving markdown versions of our blog posts.</li> <li>Direct LLMs to browse the markdown version instead of our HTML-rendered blog posts.</li> </ol> <p>Or more technically:</p> <ol> <li>When someone adds <code>.md</code> to a blog post URL, we’ll serve the markdown version of that post. (Try it yourself with this post: <a href="https://build.ms/2026/2/2/supporting-markdown-search-for-llms.md" target="_blank" rel="noopener noreferrer">build.ms/2026/2/2/supporting-markdown-search-for-llms.md</a>)</li> <li>We’ll build a small middleware layer that redirects LLMs to the markdown version when they request a page with an <code>Accept</code> header of <code>text/markdown</code>.</li> </ol> <h3>Adding Markdown Support To Your Website</h3> <p>Build.ms uses the <a href="https://astro.build/" target="_blank" rel="noopener noreferrer">Astro</a> static site generator, but this technique works for any static site generator. To give you an idea of what we want to do, here’s the prompt I gave to Codex to build this feature:</p> <blockquote> <p>Is it possible for us to expose the original markdown of our posts whenever the user adds a .md extension to a URL? This has become a common pattern for letting AI tools crawl websites, providing a more direct format that they can consume easily.</p> </blockquote> <p>The result was a file called <code>[slug].md.ts</code>. This file convention creates a route that serves the markdown version of your blog post. Now when a person (or LLM) adds <code>.md</code> to any blog post URL on build.ms, they see the markdown version of our post.</p> Expand to see the code for [slug].md.ts <div> <pre><code><span><span>import</span><span> type</span><span> { APIRoute } </span><span>from</span><span> 'astro'</span><span>;</span></span> <span><span>import</span><span> { </span><span>type</span><span> CollectionEntry, getCollection } </span><span>from</span><span> 'astro:content'</span><span>;</span></span> <span><span>import</span><span> { readFile } </span><span>from</span><span> 'node:fs/promises'</span><span>;</span></span> <span><span>import</span><span> path </span><span>from</span><span> 'node:path'</span><span>;</span></span> <span></span> <span><span>interface</span><span> MarkdownProps</span><span> {</span></span> <span><span> filePath</span><span>:</span><span> string</span><span>;</span></span> <span><span> slug</span><span>:</span><span> string</span><span>;</span></span> <span><span> pubDate</span><span>:</span><span> string</span><span>;</span></span> <span><span>}</span></span> <span></span> <span><span>function</span><span> hasFilePath</span><span>(</span><span>post</span><span>:</span><span> CollectionEntry</span><span>&lt;</span><span>'blog'</span><span>&gt;)</span><span>:</span><span> post</span><span> is</span><span> CollectionEntry</span><span>&lt;</span><span>'blog'</span><span>&gt; </span><span>&amp;</span><span> { </span><span>filePath</span><span>:</span><span> string</span><span> } {</span></span> <span><span> return</span><span> typeof</span><span> post.filePath </span><span>===</span><span> 'string'</span><span>;</span></span> <span><span>}</span></span> <span></span> <span><span>function</span><span> buildParams</span><span>(</span><span>post</span><span>:</span><span> CollectionEntry</span><span>&lt;</span><span>'blog'</span><span>&gt;) {</span></span> <span><span> const</span><span> date</span><span> =</span><span> new</span><span> Date</span><span>(post.data.pubDate);</span></span> <span></span> <span><span> return</span><span> {</span></span> <span><span> year: date.</span><span>getFullYear</span><span>().</span><span>toString</span><span>(),</span></span> <span><span> month: (date.</span><span>getMonth</span><span>() </span><span>+</span><span> 1</span><span>).</span><span>toString</span><span>(),</span></span> <span><span> day: date.</span><span>getDate</span><span>().</span><span>toString</span><span>(),</span></span> <span><span> slug: post.id,</span></span> <span><span> };</span></span> <span><span>}</span></span> <span></span> <span><span>export</span><span> async</span><span> function</span><span> getStaticPaths</span><span>() {</span></span> <span><span> const</span><span> posts</span><span> =</span><span> await</span><span> getCollection</span><span>(</span><span>'blog'</span><span>);</span></span> <span></span> <span><span> return</span><span> posts</span></span> <span><span> .</span><span>filter</span><span>(hasFilePath)</span></span> <span><span> .</span><span>map</span><span>((</span><span>post</span><span>) </span><span>=&gt;</span><span> ({</span></span> <span><span> params: </span><span>buildParams</span><span>(post),</span></span> <span><span> props: {</span></span> <span><span> filePath: post.filePath,</span></span> <span><span> slug: post.id,</span></span> <span><span> pubDate: </span><span>new</span><span> Date</span><span>(post.data.pubDate).</span><span>toISOString</span><span>(),</span></span> <span><span> },</span></span> <span><span> }));</span></span> <span><span>}</span></span> <span></span> <span><span>export</span><span> const</span><span> GET</span><span>:</span><span> APIRoute</span><span> =</span><span> async</span><span> ({ </span><span>params</span><span>, </span><span>props</span><span> }) </span><span>=&gt;</span><span> {</span></span> <span><span> const</span><span> { </span><span>filePath</span><span>, </span><span>slug</span><span>, </span><span>pubDate</span><span> } </span><span>=</span><span> props </span><span>as</span><span> MarkdownProps</span><span>;</span></span> <span></span> <span><span> if</span><span> (</span><span>!</span><span>filePath </span><span>||</span><span> !</span><span>slug </span><span>||</span><span> !</span><span>pubDate) {</span></span> <span><span> return</span><span> new</span><span> Response</span><span>(</span><span>'Not found'</span><span>, { status: </span><span>404</span><span> });</span></span> <span><span> }</span></span> <span></span> <span><span> const</span><span> parsedDate</span><span> =</span><span> new</span><span> Date</span><span>(pubDate);</span></span> <span><span> const</span><span> year</span><span> =</span><span> Number</span><span>(params.year);</span></span> <span><span> const</span><span> month</span><span> =</span><span> Number</span><span>(params.month);</span></span> <span><span> const</span><span> day</span><span> =</span><span> Number</span><span>(params.day);</span></span> <span></span> <span><span> if</span><span> (Number.</span><span>isNaN</span><span>(year) </span><span>||</span><span> Number.</span><span>isNaN</span><span>(month) </span><span>||</span><span> Number.</span><span>isNaN</span><span>(day)) {</span></span> <span><span> return</span><span> new</span><span> Response</span><span>(</span><span>'Not found'</span><span>, { status: </span><span>404</span><span> });</span></span> <span><span> }</span></span> <span></span> <span><span> if</span><span> (</span></span> <span><span> parsedDate.</span><span>getFullYear</span><span>() </span><span>!==</span><span> year </span><span>||</span></span> <span><span> parsedDate.</span><span>getMonth</span><span>() </span><span>+</span><span> 1</span><span> !==</span><span> month </span><span>||</span></span> <span><span> parsedDate.</span><span>getDate</span><span>() </span><span>!==</span><span> day </span><span>||</span></span> <span><span> slug </span><span>!==</span><span> params.slug</span></span> <span><span> ) {</span></span> <span><span> return</span><span> new</span><span> Response</span><span>(</span><span>'Not found'</span><span>, { status: </span><span>404</span><span> });</span></span> <span><span> }</span></span> <span></span> <span><span> try</span><span> {</span></span> <span><span> const</span><span> absolutePath</span><span> =</span><span> path.</span><span>resolve</span><span>(process.</span><span>cwd</span><span>(), filePath);</span></span> <span><span> const</span><span> markdown</span><span> =</span><span> await</span><span> readFile</span><span>(absolutePath, </span><span>'utf-8'</span><span>);</span></span> <span></span> <span><span> return</span><span> new</span><span> Response</span><span>(markdown, {</span></span> <span><span> status: </span><span>200</span><span>,</span></span> <span><span> headers: {</span></span> <span><span> 'Content-Type'</span><span>: </span><span>'text/markdown; charset=utf-8'</span><span>,</span></span> <span><span> 'Cache-Control'</span><span>: </span><span>'public, max-age=3600'</span><span>,</span></span> <span><span> },</span></span> <span><span> });</span></span> <span><span> } </span><span>catch</span><span> (error) {</span></span> <span><span> console.</span><span>error</span><span>(</span><span>'Failed to load markdown for'</span><span>, filePath, error);</span></span> <span><span> return</span><span> new</span><span> Response</span><span>(</span><span>'Not found'</span><span>, { status: </span><span>404</span><span> });</span></span> <span><span> }</span></span> <span><span>};</span></span></code></pre> </div> <p>We can now start dropping links with <code>.md</code> extensions in our prompts and watch our token count drop. But it would be better if this process was automatic. To do that, I wrote another prompt that explains our goals, so Codex can solve our problem.</p> <blockquote> <p>I just want to check something. The reason we built this was because I saw this post from the Claude Code team.</p> </blockquote> <blockquote> <p>&gt; In the next version of Claude Code, Claude’s WebFetch tool automatically adds Accept: “text/markdown, *” to requests which helps docs sites provide token-efficient docs</p> </blockquote> <blockquote> <p>The reason we recently added support for .md extensions rendering markdown docs was to support this feature, but are we handling that? If not, can we add that somehow?</p> </blockquote> Expand to see the code for our middleware <div> <pre><code><span><span>import</span><span> type</span><span> { MiddlewareHandler } </span><span>from</span><span> 'astro'</span><span>;</span></span> <span></span> <span><span>const</span><span> blogPathPattern</span><span> =</span><span> /</span><span>^</span><span>\/</span><span>\d</span><span>{4}</span><span>\/</span><span>\d</span><span>{1,2}</span><span>\/</span><span>\d</span><span>{1,2}</span><span>\/</span><span>[</span><span>^</span><span>/]</span><span>+</span><span>\/</span><span>?$</span><span>/</span><span>;</span></span> <span></span> <span><span>// Redirect markdown-preferring clients to the raw .md blog route (e.g. Claude WebFetch).</span></span> <span><span>export</span><span> const</span><span> onRequest</span><span>:</span><span> MiddlewareHandler</span><span> =</span><span> async</span><span> ({ </span><span>request</span><span> }, </span><span>next</span><span>) </span><span>=&gt;</span><span> {</span></span> <span><span> const</span><span> acceptHeader</span><span> =</span><span> request.headers.</span><span>get</span><span>(</span><span>'accept'</span><span>) </span><span>??</span><span> ''</span><span>;</span></span> <span></span> <span><span> if</span><span> (</span><span>!</span><span>acceptHeader.</span><span>toLowerCase</span><span>().</span><span>includes</span><span>(</span><span>'text/markdown'</span><span>)) {</span></span> <span><span> return</span><span> next</span><span>();</span></span> <span><span> }</span></span> <span></span> <span><span> const</span><span> url</span><span> =</span><span> new</span><span> URL</span><span>(request.url);</span></span> <span></span> <span><span> if</span><span> (</span><span>!</span><span>blogPathPattern.</span><span>test</span><span>(url.pathname) </span><span>||</span><span> url.pathname.</span><span>endsWith</span><span>(</span><span>'.md'</span><span>)) {</span></span> <span><span> return</span><span> next</span><span>();</span></span> <span><span> }</span></span> <span></span> <span><span> const</span><span> segments</span><span> =</span><span> url.pathname.</span><span>replace</span><span>(</span><span>/</span><span>\/</span><span>$</span><span>/</span><span>, </span><span>''</span><span>).</span><span>split</span><span>(</span><span>'/'</span><span>).</span><span>filter</span><span>(Boolean);</span></span> <span><span> const</span><span> [</span><span>year</span><span>, </span><span>month</span><span>, </span><span>day</span><span>, </span><span>slug</span><span>] </span><span>=</span><span> segments;</span></span> <span></span> <span><span> if</span><span> (</span><span>!</span><span>year </span><span>||</span><span> !</span><span>month </span><span>||</span><span> !</span><span>day </span><span>||</span><span> !</span><span>slug) {</span></span> <span><span> return</span><span> next</span><span>();</span></span> <span><span> }</span></span> <span></span> <span><span> const</span><span> markdownPath</span><span> =</span><span> `/${</span><span>Number</span><span>(</span><span>year</span><span>)</span><span>}/${</span><span>Number</span><span>(</span><span>month</span><span>)</span><span>}/${</span><span>Number</span><span>(</span><span>day</span><span>)</span><span>}/${</span><span>slug</span><span>}.md${</span><span>url</span><span>.</span><span>search</span><span>}`</span><span>;</span></span> <span><span> const</span><span> location</span><span> =</span><span> new</span><span> URL</span><span>(markdownPath, url);</span></span> <span></span> <span><span> return</span><span> new</span><span> Response</span><span>(</span><span>null</span><span>, {</span></span> <span><span> status: </span><span>307</span><span>,</span></span> <span><span> headers: {</span></span> <span><span> Location: location.</span><span>toString</span><span>(),</span></span> <span><span> Vary: </span><span>'Accept'</span><span>,</span></span> <span><span> },</span></span> <span><span> });</span></span> <span><span>};</span></span></code></pre> </div> <p>And just like that, Codex added a middleware layer that redirects any request with an <code>Accept</code> header of <code>text/markdown</code> to the <code>.md</code> version of our blog post. Now the two-step process is complete, and our token usage will automatically be optimized.</p> <h3>And That’s It?</h3> <p>As you can see adding support for the <code>text/markdown</code> Accept header is straightforward and easy! And it’s beneficial: LLMs get more accurate information, they use fewer tokens, and your website saves on bandwidth costs.</p> <p>You can implement this feature yourself, but rather than writing the code, let’s use the technique I shared in <a href="https://build.ms/2025/12/15/building-software-from-blog-posts/" target="_blank" rel="noopener noreferrer">Building Software From Blog Posts</a>. Point Codex or Claude Code to this blog post and let it handle the implementation.</p> <p>Here’s a prompt you can use — with all the <em>context</em> you’ll need.</p> <blockquote> <p>Please read this blog post (<a href="https://build.ms/2026/2/2/supporting-markdown-search-for-llms" target="_blank" rel="noopener noreferrer">https://build.ms/2026/2/2/supporting-markdown-search-for-llms</a>) that describes a process for adding support for the text/markdown Accept header — allowing LLMs to more easily and efficiently parse the contents of a statically generated blog. We want to implement the same technique for our static site generator — regardless of whether it’s Astro or not. If there are any concerns please raise them before we begin building this feature. If there are no concerns, please implement a solution that solves this problem for us in our static site.</p> </blockquote>TopAITutorialEngineeringJoe FabisevichUpdating Your Website For Generative Engine Optimizationhttps://build.ms/notes/2026/1/29/updating-your-website-for-generative-engine-optimization/https://build.ms/notes/2026/1/29/updating-your-website-for-generative-engine-optimization/Learn how to optimize your website for generative search engines like ChatGPT, Claude, and Gemini to improve your search engine rankings.Thu, 29 Jan 2026 12:00:00 GMT<p>Generative Engine Optimization — also called AI SEO, AEO, GEO, or LLMO — is the equivalent of SEO for AI search. Regardless of what acronym you prefer, it’s becoming increasingly important for agentic tools like ChatGPT, Claude, and Gemini to know about you and your website — whether you’re a developer, marketer, content creator, or business owner. I’ve never been particularly good at SEO, so I was very excited when I stumbled upon <a href="https://cassidoo.co/post/ai-llm-discoverability" target="_blank" rel="noopener noreferrer">this post</a> by <a href="https://bsky.app/profile/cassidoo.co" target="_blank" rel="noopener noreferrer">Cassidy Williams</a>.</p> <p>To quote Cassidy Williams (a brilliant developer, writer, and content creator), the most important things you can do are:</p> <blockquote> <ul> <li>Creating an <a href="https://llmstxt.org/" target="_blank" rel="noopener noreferrer">/llms.txt</a> file</li> <li>Adding “LLM-readable” structured pages (often called <a href="https://cassidoo.co/for-llms/" target="_blank" rel="noopener noreferrer">/for-llms</a>)</li> <li>Adding <a href="https://schema.org/" target="_blank" rel="noopener noreferrer">Schema.org</a> data to the pages of my website</li> <li>Being consistent with naming, phrases, and taglines</li> </ul> </blockquote> <p>Naturally, instead of doing all of this myself, I wrote this prompt in Codex and had it generate all of these files for me!</p> <blockquote> <p>I want to update my blog to be more generative AI search-friendly.</p> <ol> <li>Can you read this blog post to understand how we can do this: <a href="https://cassidoo.co/post/ai-llm-discoverability/" target="_blank" rel="noopener noreferrer">https://cassidoo.co/post/ai-llm-discoverability</a></li> <li>I want you to then read through my src/pages/about.astro page, knowing that a lot has changed about me since I wrote it. <strong>I want you to read all of the blog posts we have on this website and search the web and to find everything you can about Joe Fabisevich, then lightly update the page accordingly based on my goals and mission for the website.</strong> Keep the writing style the same though so we don’t lose my voice.</li> <li>As per the blog post then I want you to write a great llms.txt, for-llms page, and anything else that’s missing for AI tools to know about build.ms.</li> <li>I noticed we’re missing a robots.txt which helps AI crawlers know how to browse my site, so make sure to add one.</li> </ol> <p>Before we start, do you have any clarifying questions I can answer to make this process work better?</p> </blockquote> <p>I answered a few clarifying questions Codex had about my website and did some light editing to keep my voice intact — and that was it. Codex did 98-99% of the work, and while it was generating those files, I’d already started writing this blog post.</p> <hr /> <p>It’s more important than ever to be discoverable on the internet, and GEO is the next generation of discoverability. If you have a website and want to be found, you’ll need to make your website GEO-friendly. The good news is that agentic tools are great at generating GEO-friendly pages. All you need to do is copy the prompt above — change a few words — and let Codex do the rest. Can’t wait to find you on the internet!</p>TopAIMarketingDesignEngineeringBusinessSEOGEOJoe FabisevichAI Agents Are Starting To Eat SaaS (Really)https://build.ms/2026/1/26/ai-agents-are-starting-to-eat-saas-really/https://build.ms/2026/1/26/ai-agents-are-starting-to-eat-saas-really/AI is making it cheaper and easier for anyone to build custom software solutions. The changes from this shift will be drastic, especially for SaaS companies.Mon, 26 Jan 2026 12:00:00 GMT<p>A few weeks ago I read an essay titled <a href="https://martinalderson.com/posts/ai-agents-are-starting-to-eat-saas/" target="_blank" rel="noopener noreferrer">AI Agents Are Starting To Eat SaaS</a>. SaaS businesses have thrived by solving specific problems — outsourcing domain-specific knowledge so companies don’t have to build it themselves. A software company buys Workday instead of building HR software. A healthcare company uses Salesforce instead of maintaining a custom CRM. A piano teacher pays $9.99/month for Calendly instead of building their own booking system.</p> <p>The responses to that essay were full of skepticism — people saying they haven’t noticed any real decline in SaaS adoption. That may be true for them, but the most important word in the title is <strong>Starting</strong>. It’s too early to see a trend, but I’ve already heard dozens of stories where people are building internal solutions through prompting rather than turning to a SaaS tool. I’ve done this myself quite a few times since I’d rather spend 30 minutes building something personalized to my needs than pay $4.99 every month for a generic solution.</p> <hr /> <blockquote><p>I was wondering how long exactly it's been since I first articulated this exact scenario, and it turns out the answer is 33 months.<br /><br /><a href="https://bsky.app/profile/did:plc:b6eke66r3vbmnegg73qgprl6/post/3mc3hq477jk2n?ref_src=embed" target="_blank" rel="noopener noreferrer">[image or embed]</a></p>— Joe Fabisevich (<a href="https://bsky.app/profile/did:plc:b6eke66r3vbmnegg73qgprl6?ref_src=embed" target="_blank" rel="noopener noreferrer">@mergesort.me</a>) <a href="https://bsky.app/profile/did:plc:b6eke66r3vbmnegg73qgprl6/post/3mc3hq477jk2n?ref_src=embed" target="_blank" rel="noopener noreferrer">January 10, 2026 at 11:49 AM</a></blockquote> <p>I’ve been called the boy who cries wolf for three years, predicting that soon enough building your own app would become cheaper and easier than buying SaaS — and that custom solutions would be better because they’re tailored to your needs. The main pushback has always been that it requires skill to prompt effectively, and most people lack that know-how. But LLMs keep improving while AI tools keep getting easier to use. This is really important because these improvements compound, lowering the barrier to entry for millions of people.</p> <blockquote><p>As a software developer who spends his days building a link-saving app and his nights telling everyone that in the very near future people will be able to easily build their own software to solve their personal problems — this is my existential crisis coming to life. <br /><br /><a href="https://bsky.app/profile/did:plc:b6eke66r3vbmnegg73qgprl6/post/3mc3eq6pnks2u?ref_src=embed" target="_blank" rel="noopener noreferrer">[image or embed]</a></p>— Joe Fabisevich (<a href="https://bsky.app/profile/did:plc:b6eke66r3vbmnegg73qgprl6?ref_src=embed" target="_blank" rel="noopener noreferrer">@mergesort.me</a>) <a href="https://bsky.app/profile/did:plc:b6eke66r3vbmnegg73qgprl6/post/3mc3eq6pnks2u?ref_src=embed" target="_blank" rel="noopener noreferrer">January 10, 2026 at 10:55 AM</a></blockquote> <p>As the boy who cried wolf, I feel confident that a wolf has finally walked into a town full of software developers building SaaS products. The person who wrote the thread I’m quoting isn’t a software developer — it’s Kevin Roose, a reporter for The NY Times. He missed <a href="https://build.ms/2026/1/26/ai-agents-are-starting-to-eat-saas-really/pocket.org" target="_blank" rel="noopener noreferrer">Pocket</a> after it shut down, so he tried every link-saving app available. None met his needs, so over the Christmas break he built his own replacement in 12 prompts. It worked perfectly for him — so well that people begged him to <a href="https://kevinroose.com/stash" target="_blank" rel="noopener noreferrer">open-source it</a>. As the developer of <a href="https://plinky.app/" target="_blank" rel="noopener noreferrer">Plinky</a> — another link-saving app — I’m watching this moment like a dinosaur looking up at the sky wondering about the impending meteor.</p> <blockquote> <a href="https://www.threads.com/@crumbler/post/DTRX5P7ie3w" target="_blank" rel="noopener noreferrer"> <div><div> </div><div> View on Threads</div></div></a></blockquote> <p>Now I know that not <em>everyone</em> will build their own app. My mother-in-law certainly won’t build her own Plinky-replacement and will happily keep supporting her son-in-law’s work. But for companies like Squarespace, reporters vibe-coding their own websites in just a few hours rather than paying $200/year ought to be a red-alert moment. This won’t affect every SaaS product — enterprises rely on SaaS tools for stability and dependability that you may not want to in-source — but AI will drastically chip away at many businesses.</p> <hr /> <p>I’ve written several posts about how <a href="https://build.ms/2025/12/1/scribblenauts-for-software/" target="_blank" rel="noopener noreferrer">on-demand software is already here</a> and that <a href="https://build.ms/2025/10/17/your-first-claude-skill/" target="_blank" rel="noopener noreferrer">people are already capable of building their own solutions</a> even if not everyone knows it yet. <a href="https://build.ms/2026/1/19/the-claude-code-moment/" target="_blank" rel="noopener noreferrer">More and more people are catching on</a>, and tools like Claude Code are evolving into new modalities like <a href="https://build.ms/notes/2026/1/12/claude-cowork/" target="_blank" rel="noopener noreferrer">Claude Cowork</a> that let any knowledge worker access the power developers have had for most of 2025.</p> <p>The changes from this shift will be drastic and will disrupt many professions and industries. How this plays out deserves deeper exploration, but one thing is clear: it will upend many SaaS businesses. It’s no longer a question of if, but when. Especially for companies that don’t adapt to this new reality.</p>TopAIIndustryBusinessAI EconomicsJoe FabisevichThe One Skill Siri Needshttps://build.ms/notes/2026/1/23/the-one-skill-siri-needs/https://build.ms/notes/2026/1/23/the-one-skill-siri-needs/Siri doesn't just need a makeover, it needs a new vision for automation. And that's a job Skills can help with.Fri, 23 Jan 2026 12:00:00 GMT<p>There are once again rumors of <a href="https://www.bloomberg.com/news/articles/2026-01-21/ios-27-apple-to-revamp-siri-as-built-in-iphone-mac-chatbot-to-fend-off-openai" target="_blank" rel="noopener noreferrer">another Siri revamp</a> — something I’m strongly in favor of. In a world of ChatGPT, Claude, and Gemini, the current Siri experience feels like a fourth grader trying to defend a PhD dissertation.</p> <p>I use AI for many things — <a href="https://build.ms/2025/12/1/scribblenauts-for-software/" target="_blank" rel="noopener noreferrer">as</a> <a href="https://build.ms/2025/1/1/how-i-used-chatgpt/" target="_blank" rel="noopener noreferrer">this</a> <a href="https://build.ms/2025/12/15/building-software-from-blog-posts/" target="_blank" rel="noopener noreferrer">blog</a> <a href="https://build.ms/2026/1/19/the-claude-code-moment/" target="_blank" rel="noopener noreferrer">is</a> <a href="https://build.ms/2024/11/14/prompting-large-lanuage-models/" target="_blank" rel="noopener noreferrer">witness</a> <a href="https://build.ms/2025/12/22/codex-vs-claude-code-today/" target="_blank" rel="noopener noreferrer">to</a>. I write code with AI, research vacation plans, and automate menial tasks. Siri can’t do any of these — and I desperately want it to be a better tool for automation.</p> <p><img alt="Three images of Siri side by side" loading="lazy" width="4050" height="2760" src="https://build.ms/_astro/siri.BqYIAd68_2qqncI.webp" /></p> <p>If you double tap your iPhone’s home bar, you’ll see a few quick actions that are dynamically displayed based on — <em>waves hands</em> — whatever Siri thinks you want to do. (For the record, I have never played a podcast in Apple’s Podcast app.) Sometimes there aren’t even three options because Siri can’t think of three ways to help you.</p> <p>All I want is control over this experience. Let me write <a href="https://build.ms/2025/10/17/your-first-claude-skill/" target="_blank" rel="noopener noreferrer">Skills</a> that solve real problems I have. I want a Fix Spelling and Grammar skill that quickly cleans up my emails. I want a skill that answers questions about what I see on screen. I want a skill that summarizes podcasts to decide if they’re worth listening to. I want to do a lot of things — and many of them are hyper-specific to me. Since Apple loves AppIntents, maybe we can even throw these Skills into the framework and let developers expose them as Shortcuts.</p> <p>Everyone will have a different set of skills they want to use — and that’s great! People should have flexibility and autonomy in how they interact with AI systems. It frustrates me that the only way to interact with AI at a system level on iOS and macOS is so tied to Siri — and Apple’s current lackluster vision for AI. It shouldn’t be so hard to automate a menial task on the most important computing device in my life.</p>iOSJoe FabisevichThe Democratization of AIhttps://build.ms/2026/1/21/the-democratization-of-ai/https://build.ms/2026/1/21/the-democratization-of-ai/The barrier to building with AI has dropped thousands-fold. What happens when you give everyone the tools to implement their ideas?Wed, 21 Jan 2026 12:00:00 GMT<blockquote><p>I feel like we don’t talk enough about how five years ago the only people doing interesting things with “AI” were researchers and big tech employees optimizing algorithms, and now everybody has powerful AI systems available to them on demand via an API or in practically every tool.</p>— Joe Fabisevich (<a href="https://bsky.app/profile/did:plc:b6eke66r3vbmnegg73qgprl6?ref_src=embed" target="_blank" rel="noopener noreferrer">@mergesort.me</a>) <a href="https://bsky.app/profile/did:plc:b6eke66r3vbmnegg73qgprl6/post/3mcij2xhtgc2x?ref_src=embed" target="_blank" rel="noopener noreferrer">January 15, 2026 at 4:17 PM</a></blockquote> <hr /> <p>When I worked at Twitter, I worked across many teams and was involved in features across the platform. There was one thing I could never wrap my head around though — the AI<sup><a href="https://build.ms/2026/1/21/the-democratization-of-ai/#user-content-fn-1" target="_blank" rel="noopener noreferrer">1</a></sup> that powered the Home timeline. I could feel when the quality of my feed dropped or things felt worse, but it was all qualitative and I couldn’t explain why. I simply didn’t have the expertise to explain why, nor the skills to do anything about it.</p> <p>Today you don’t have to understand neural nets and tensors to build something with AI. With just an API key, everyone has access to OpenAI and Anthropic’s models and can do amazing things with AI in a way that simply wasn’t possible five years ago. Heck, the barrier to entry is even lower if you consider more no-code interfaces like n8n, Make, and Zapier. Now you have to understand how to work <em>with</em> AI, but previously AI was this mystical domain that was accessible to practically nobody.</p> <p>As I discussed in <a href="https://build.ms/2026/1/19/the-claude-code-moment/" target="_blank" rel="noopener noreferrer">The Claude Code Moment</a>, you don’t have to be an AI researcher anymore — you can play, experiment, and prompt your way to making something interesting. It no longer takes weeks of training a model to try something new, all you need is curiosity. You don’t have to tweak parameters or understand neural networks — you just update to the latest version of Claude or GPT-5 and see your results improve. Judging by how many people are building things with AI today versus a few years ago, the barrier to entry has dropped thousands-fold.</p> <p>This shift from AI being accessible to a few people in labs to anyone on the planet is extraordinary. You no longer need a PhD or a job at Meta or Google to use powerful AI systems to build something valuable, unique, and meaningful. All you need is an internet connection, plus some curiosity, creativity, and critical thinking skills.</p> <p>I have a core belief that people are ingenious. Given the tools to implement their ideas, there’s no telling what they’ll create. Democratizing access to AI isn’t just about lowering the barriers — it’s about unleashing human creativity at scale — and I find that beautiful and exciting.</p> <p>We’re living through a historical moment, but we may not even recognize it as such because we’re in the middle of it. Five years ago, practically nobody would have predicted how exponentially AI would penetrate every corner of our lives — and it’s just as hard to predict what the landscape will look like five years from now. People talk about <a href="https://www.nytimes.com/2023/03/21/opinion/artificial-intelligence-chatgpt.html?unlocked_article_code=1.FFA.GUm_.HDNLkG6DlvAs&amp;smid=url-share" target="_blank" rel="noopener noreferrer">the ChatGPT moment</a> or the more recent <a href="https://www.theatlantic.com/technology/2026/01/claude-code-ai-hype/685617" target="_blank" rel="noopener noreferrer">Claude Code moment</a> as turning points — but only in hindsight because you must live through a moment to truly understand its impact. Right now, we’re living through a moment where AI has become accessible to everyone, and we’re only beginning to see what becomes possible.</p> <section><h2>Footnotes</h2> <ol> <li> <p>Admittedly back then we called it machine learning or deep learning, but it was the bleeding edge of AI. <a href="https://build.ms/2026/1/21/the-democratization-of-ai/#user-content-fnref-1" target="_blank" rel="noopener noreferrer">↩</a></p> </li> </ol> </section>TopAIIndustryAI EconomicsJoe FabisevichThe Claude Code Momenthttps://build.ms/2026/1/19/the-claude-code-moment/https://build.ms/2026/1/19/the-claude-code-moment/The sudden adoption of Claude Code by non-technical people represents an important moment in time — and one that will have a lasting impact on the way we work.Mon, 19 Jan 2026 12:00:00 GMT<p>For the last year I’ve been teaching people in Pursuit’s AI-Native. As I described in <a href="https://build.ms/2026/1/6/making-technology-accessible-to-everyone" target="_blank" rel="noopener noreferrer">Build.ms: Making Technology Accessible To Everyone</a></p> <blockquote> <p>I’ve been volunteering at <a href="https://pursuit.org/" target="_blank" rel="noopener noreferrer">Pursuit</a> for 12 years — helping people from below the poverty line, with no prior background in tech, become software developers. 80% of them land jobs in tech making an average salary of $85,000/year. Former Uber drivers, janitors, and kindergarten teachers have learned technology skills to completely reshape their lives. I’ve had the great pleasure of watching hundreds of lives change right in front of my eyes, and it’s truly moving.</p> </blockquote> <blockquote> <p>Given the poor job market for junior developers, the program shifted to an AI-focused approach last year. People who’ve never heard of AI are building real software and learning how to build real products in just 9 months. They put in hundreds of hours learning to build with AI, develop products, market them, and everything else you’d need to build a small business. They do this with guidance from dedicated teachers and volunteers who transform people into builders incredibly quickly.</p> </blockquote> <p>These are incredibly dedicated people learning everything they need to thrive in an AI-centric world. They saw the opportunity a year ago and decided to jump in before most people had even heard of Claude Code.</p> <p>As I wrote in <a href="https://build.ms/notes/2026/1/12/claude-cowork" target="_blank" rel="noopener noreferrer">Claude Cowork</a></p> <blockquote> <p>Since the release of Claude Code, software developers have been living in the future. As Simon Willison puts it, developers have had access to general purpose agents for the last 11 months.</p> </blockquote> <p>But Claude Code is finally having its moment with non-technical people who are realizing they don’t need to be technical to use it to write code. Many had assumed it would be intimidating or require a coding background, but are now discovering that AI can guide them through the parts where they’d normally get stuck.</p> <hr /> <p>The Hard Fork podcast asked listeners to share how they’re using Claude Code — specifically regular people, not developers. (It starts at 36:07 in <a href="https://castro.fm/episode/5Tsdq2#36:07" target="_blank" rel="noopener noreferrer">the episode</a> if you want to listen.)</p> <p>There were stories like:</p> <ul> <li>A woman who loves to read built a service where people can sign up for personal book recommendations — just for fun!</li> <li>A person whose business is selling wallpaper to interior decorators built her own wallpaper calculator. Decorators can now submit their wall sizes and get exactly how much wallpaper they need to purchase, plus a layout diagram. In her words: this was always a job I used to do by hand in Adobe Photoshop or InDesign, but now clients can do it themselves and save time for us both.</li> <li>A man who runs a CNC milling business built agents that generate leads for jobs in his local area, and even created his own MCP server to interface with his CAD software.</li> </ul> <p>He also wrote a personal message to the podcast hosts:</p> <blockquote> <p>I really want people in my demographic to understand that this isn’t “I asked ChatGPT to write an email or to generate an image that has the correct spelling.” This is different to me, I built my own business infrastructure from scratch with an AI pair programmer, despite having zero formal training and a high school education.</p> </blockquote> <p>This is exactly what I’ve been seeing at Pursuit for the last year. People who learn to use agentic tools like Claude Code become more empowered, self-sufficient, and gain newfound autonomy to make what they need — without the technical background that was previously required.</p> <p><strong>The skill of commanding an agentic system to build something meaningful has little to do with writing code — all it requires is clarity of thought, creativity, and critical thinking skills.</strong></p> <p>This is wonderful for non-technical people — the power to create software has become dramatically more accessible. And for developers, this is worth paying attention to. People are crossing into technical domains without waiting for permission or credentials now that they can build solutions to their own problems. If you’ve considered writing code your moat, it’s worth reconsidering how quickly that’s going to change across a wide swath of domains.</p> <hr /> <p>When I was starting my career, it was common to see people list Microsoft Word, Excel, and PowerPoint on their resume. Today, everyone assumes you can use those (or their Google equivalents) if you’re applying for a knowledge work job.</p> <p>The ability to use AI well will become the same — something your employer assumes you know how to do. Not everyone will be equally skilled at it, the same way some people are wizards with a spreadsheet while others use it as a glorified table.</p> <p>That’s why <a href="https://build.ms/ai" target="_blank" rel="noopener noreferrer">I teach AI workshops</a> — because I genuinely believe this is a key skill for the future of work — and I’ve helped unlock something in <a href="https://build.ms/ai#testimonials" target="_blank" rel="noopener noreferrer">hundreds of people</a>. And that’s why if you haven’t tried Claude Code yet — or its less technical sibling <a href="https://build.ms/notes/2026/1/12/claude-cowork" target="_blank" rel="noopener noreferrer">Claude Cowork</a> — this is your moment.</p>TopAIIndustrySocietyAI EconomicsJoe FabisevichGemini × Foundation Modelshttps://build.ms/notes/2026/1/12/apple-google-gemini-foundation-models/https://build.ms/notes/2026/1/12/apple-google-gemini-foundation-models/What a partnership between Apple and Google could mean for AI on Apple‘s platforms.Mon, 12 Jan 2026 12:00:00 GMT<blockquote> <p>After careful evaluation, we determined that Google’s technology provides the most capable foundation for Apple Foundation Models and we’re excited about the innovative new experiences it will unlock for our users.</p> </blockquote> <p>— <a href="https://www.cnbc.com/2026/01/12/apple-google-ai-siri-gemini.html" target="_blank" rel="noopener noreferrer">Apple picks Google’s Gemini to run AI-powered Siri coming this year</a></p> <p>If this reported news is true, it seems like an unambiguous upgrade for Siri users on iOS, macOS, and anywhere else Siri runs. I have a <a href="https://store.bigme.vip/products/bigme-hibreak-pro-color-6-e-ink-eye-friendly-smartphone-with-4g-5g-connection?variant=44992853147827" target="_blank" rel="noopener noreferrer">BigMe HiBreak Pro</a> Android e-ink tablet, and even on relatively constrained hardware the experience of using <a href="https://gemini.google/overview/gemini-live/" target="_blank" rel="noopener noreferrer">Gemini and Gemini Live</a> is drastically better than Siri.</p> <p>This announcement provides more questions than answers though — questions that matter a lot to me.</p> <p>If this model runs purely on device, then that’s great news. Gemini’s local models are terrific, and better than whatever Apple is doing today with Qwen models in their FoundationModels framework today.</p> <p>If this runs in the cloud, then I have more questions. I care deeply about my privacy, so I abstain from using Gemini for anything private. By that I mean I won’t ask Gemini any health questions, discuss anything personally identifiable, and if I do use it for code it’s only for my open source projects.</p> <p>I’ve drawn the line there because Google unlike OpenAI and Anthropic provides no way for consumers to opt out of having their data used for model training purposes. I simply do not want that, and will not use a Gemini-powered Siri if those promises are not there.</p> <p>Also, will Apple continue to train their own models in the cloud? If so, does this give them time to catch up? Or is it just a wasteful effort because as their current capital expenditure shows they put very little effort into that effort.</p> <p>Either way I’m curious to see how this affects Apple’s FoundationModels frameworks. The framework provides a level of abstraction so developers have to care very little about the underlying models. The results obviously matter, and there are particulars that matter like whether a model is local or remote — but ultimately this is a generic set of APIs that you could easily slot a new model into.</p> <p>These are just the first questions I had, and it all depends on the details. My real hope is that Apple respects their stated values in a world of AI, and I expect to have more answers soon — and more questions stemming from those answers.</p>AIIndustryAppleGoogleJoe FabisevichClaude Coworkhttps://build.ms/notes/2026/1/12/claude-cowork/https://build.ms/notes/2026/1/12/claude-cowork/Anthropic‘s latest product brings Claude Code to everyone else — not just developers.Mon, 12 Jan 2026 12:00:00 GMT<p>Since the release of Claude Code, software developers have been living in the future. As Simon Willison puts it, developers have had access to general purpose agents for the last 11 months.</p> <blockquote> <p>Claude Code is, with hindsight, poorly named. It’s not purely a coding tool: it’s a tool for general computer automation. <em>Anything</em> you can achieve by typing commands into a computer is something that can now be automated by Claude Code. It’s best described as a <strong>general agent</strong>.</p> </blockquote> <p>— <a href="https://simonwillison.net/2025/Oct/16/claude-skills/#claude-as-a-general-agent" target="_blank" rel="noopener noreferrer">Simon Willison</a></p> <p>That brings us to today, with Anthropic announcing a new product called Claude Cowork.</p> <blockquote> <p>When we released Claude Code, we expected developers to use it for coding. They did—and then quickly began using it for <a href="https://x.com/claudeai/status/2009666254815269313" target="_blank" rel="noopener noreferrer">almost everything else</a>. This prompted us to build Cowork: a simpler way for anyone—<a href="https://www.lennysnewsletter.com/p/everyone-should-be-using-claude-code" target="_blank" rel="noopener noreferrer">not just developers</a>—to work with Claude in the very same way. Cowork is available today as a research preview for Claude Max subscribers on our <a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">macOS app</a>, and we will improve it rapidly from here.</p> </blockquote> <p>— <a href="https://claude.com/blog/cowork-research-preview" target="_blank" rel="noopener noreferrer">Claude Cowork Research Preview</a></p> <hr /> <p>To me this is so clearly the immediate<sup><a href="https://build.ms/notes/2026/1/12/claude-cowork/#user-content-fn-1" target="_blank" rel="noopener noreferrer">1</a></sup> future of how knowledge work will be done. I live close to the bleeding edge of AI, and it becomes apparent every time I talk to someone who isn’t immersed in the latest AI developments. As Arvind Narayanan and Sayash Kapoor explain “AI has not yet diffused through society”.</p> <blockquote> <p>Thus, the speed of diffusion is inherently limited by the speed at which not only individuals, but also organizations and institutions, can adapt to technology. This is a trend that we have also seen for past general-purpose technologies: Diffusion occurs over decades, not years.</p> </blockquote> <p>— <a href="https://www.normaltech.ai/p/ai-as-normal-technology" target="_blank" rel="noopener noreferrer">AI As A Normal Technology</a></p> <p>Developers like myself are fretting about <a href="https://bsky.app/profile/mergesort.me/post/3mc3eq6pnks2u" target="_blank" rel="noopener noreferrer">how our entire way of working</a> is shifting from right under us — <a href="https://bsky.app/profile/mergesort.me/post/3mc3hq477jk2n" target="_blank" rel="noopener noreferrer">as we type less code and do more</a>. But fields like product management, design, and marketing are not far behind.</p> <p>Claude Cowork is Claude Code for everything else — and for everyone else. And it’s far from the last tool to shake the ground beneath us.</p> <section><h2>Footnotes</h2> <ol> <li> <p>I say immediate because things are changing so fast that who knows what work looks like in 10 years. <a href="https://build.ms/notes/2026/1/12/claude-cowork/#user-content-fnref-1" target="_blank" rel="noopener noreferrer">↩</a></p> </li> </ol> </section>AIIndustryProgrammingJoe FabisevichThe Economics of AI (Part I)https://build.ms/notes/2026/1/7/economics-of-ai-part-i/https://build.ms/notes/2026/1/7/economics-of-ai-part-i/Stray thoughts about the economic contract we grew up with breaking down because of AI.Wed, 07 Jan 2026 12:00:00 GMT<blockquote><p>I’ve mostly been in the camp of AI is cool and great for big companies but we’re a way’s away from people losing jobs because of AI. I’m a lot less confident about that after playing with ChatGPT for two days. The most insane part is the pace of innovation over the last year.</p>— ✨ Joe Fabisevich ✨ (@mergesort) <a href="https://twitter.com/mergesort/status/1599487643120721922?ref_src=twsrc%5Etfw" target="_blank" rel="noopener noreferrer">December 4, 2022</a></blockquote> <p>This is a tweet of mine from five days after ChatGPT was released. It’s now been three years, and I remain confident that AI will touch and reshape everything about our digital world. But in 2026, our digital world is tightly bound to our reality — it’s not some separate space we can cordon off.</p> <hr /> <p>While economists debate the impact of AI on labor today, we can see cracks in the foundation forming. People cannot (or don’t want to) fathom the social contract of labor and capital changing, but it’s already starting. There’s no promise it will follow the same rules of capitalism we’re used to — the fundamentals will change over the next few years. I don’t know how much, but I know the system will not make the same promises.</p> <p>I have many thoughts on this, so I’ve titled this “The Economics of AI (Part I)”. Consider it an introductory post for a subject I’ll be revisiting often as this change transforms around us.</p> <p>Today, I want to show you an example of how AI is beginning to divorce value from capital.</p> <hr /> <p>This is a comment from the maintainer of Tailwind CSS, one of the most popular libraries in web development. Tailwind is what makes websites look good — and what AI systems like Claude and ChatGPT almost always turn to Tailwind by default. Until recently, more popularity translated to more value, and more value meant more opportunity to acquire capital. But people creating value are screaming out to say that this is no longer true.</p> <blockquote> <p>But the reality is that 75% of the people on our engineering team lost their jobs here yesterday because of the brutal impact AI has had on our business. And every second I spend trying to do fun free things for the community like this is a second I’m not spending trying to turn the business around and make sure the people who are still here are getting their paychecks every month.</p> <p>Traffic to our docs is down about 40% from early 2023 despite Tailwind being more popular than ever. The docs are the only way people find out about our commercial products, and without customers we can’t afford to maintain the framework. I really want to figure out a way to offer LLM-optimized docs that don’t make that situation even worse (again we literally had to lay off 75% of the team yesterday), but I can’t prioritize it right now unfortunately, and I’m nervous to offer them without solving that problem first.</p> <p>…</p> <p>This feature is so that people can build MORE things with Tailwind in a FASTER and more EFFICIENT capacity. @mtsears4 Tailwind is growing faster than it ever has and is bigger than it ever has been, and our revenue is down close to 80%. Right now there’s just no correlation between making Tailwind easier to use and making development of the framework more sustainable. I need to fix that before making Tailwind easier to use benefits anyone, because if I can’t fix that this project is going to become unmaintained abandonware when there is no one left employed to work on it. I appreciate the sentiment and agree in spirit, it’s just more complicated than that in reality right now.</p> </blockquote> <p>— <a href="https://github.com/tailwindlabs/tailwindcss.com/pull/2388#issuecomment-3717222957" target="_blank" rel="noopener noreferrer">Adam Wathan on GitHub</a></p> <p>To pull out the most important part.</p> <blockquote> <p><strong>Right now there’s just no correlation between making Tailwind easier to use and making development of the framework more sustainable.</strong></p> </blockquote> <p>This is the contract breaking. We don’t yet know what shape it will take, but we can see it happening in real time. Not just in software development, but <a href="https://www.bloodinthemachine.com/p/i-was-forced-to-use-ai-until-the" target="_blank" rel="noopener noreferrer">across many industries</a>.</p>AIIndustryAI EconomicsJoe FabisevichBuild.ms: Making Technology Accessible To Everyonehttps://build.ms/2026/1/6/making-technology-accessible-to-everyone/https://build.ms/2026/1/6/making-technology-accessible-to-everyone/Learn how to think about technology in a fast-moving world — I‘ll show you what matters and filter out the rest.Tue, 06 Jan 2026 12:00:00 GMT<p><strong>Build.ms exists to make technology accessible to everyone.</strong></p> <p>The technological landscape is moving faster than ever. Even professional developers, designers, and product managers are struggling to keep up with every new advancement. (And I’m not just talking about AI.) All the noise makes it hard to filter out what’s important, and what you can ignore.</p> <p>And people have busy lives. There’s work to do, families to raise, friends to see, and hobbies to pursue. You shouldn’t have to spend all your time keeping up with the daily flood of news to understand how technology is reshaping the world around you.</p> <p>Build.ms has always been a space for technical writing to share lessons about engineering, product development, design, marketing, AI, and anything you need to build a modern business. But I’m reshaping it around a clearer purpose: <strong>making technology accessible to everyone.</strong></p> <hr /> <p>This isn’t a new passion — I’ve been living this mission for over a decade, but I now have the clarity to make it my primary goal.</p> <p>I’ve broken complicated subjects down into <a href="https://build.ms/2025/10/17/your-first-claude-skill/" target="_blank" rel="noopener noreferrer">simple step-by-step processes</a> so you can learn by doing. I’ve <a href="https://build.ms/2025/12/15/building-software-from-blog-posts" target="_blank" rel="noopener noreferrer">demonstrated new ways of working</a> that are emerging. I’ve <a href="https://build.ms/2025/12/24/ai-without-the-hype-ns-spain-2025/" target="_blank" rel="noopener noreferrer">given talks that teach new ways to think</a> about technology. Each of these is designed to bridge the gap: to give beginners a way in while offering experts new clarity.</p> <p>That’s what you’ll find here going forward: more frequent posts that break down complex subjects into something you can understand and use immediately.</p> <hr /> <p>I’ve been volunteering at <a href="https://pursuit.org/" target="_blank" rel="noopener noreferrer">Pursuit</a> for 12 years — helping people from below the poverty line, with no prior background in tech, become software developers. 80% of them land jobs in tech making an average salary of $85,000/year. Former Uber drivers, janitors, and kindergarten teachers have learned technology skills to completely reshape their lives. I’ve had the great pleasure of watching hundreds of lives change right in front of my eyes, and it’s truly moving.</p> <p>Given the poor job market for junior developers, the program shifted to an AI-focused approach last year. People who’ve never heard of AI are building real software and learning how to build real products in just 9 months. They put in hundreds of hours learning to build with AI, develop products, market them, and everything else you’d need to build a small business. They do this with guidance from dedicated teachers and volunteers who transform people into builders incredibly quickly.</p> <p>Volunteers like me spend hours every week filling in the gaps, and we hear how valuable it is. But right now, those lessons are only available when I share an insight <a href="https://bsky.app/profile/mergesort.me" target="_blank" rel="noopener noreferrer">on Bluesky</a>, answer a question in the communities I’m a part of, or teach a <a href="https://build.ms/ai" target="_blank" rel="noopener noreferrer">hands-on workshop</a>. The knowledge is there, but it’s not accessible to everyone who needs it.</p> <p><strong>That’s not enough. This information needs to be free and accessible — to everyone.</strong></p> <hr /> <p>Here’s what I’ve learned from a decade of teaching: <strong>The people who thrive aren’t the ones who know the most. The people who thrive are the ones who know how and what to learn, how to adapt to new information, and how to think critically and creatively.</strong> There are a million places to learn about a specific tool or technology, but without the right mental models — taught in an accessible way — most people never figure out how to use those tools effectively.</p> <p><strong>That’s my mission for Build.ms 2.0.</strong> It’s a subtle shift from giving you everything you need to build a modern business, to sharing what matters most in a fast-moving technical world.</p> <p>I want people to have the knowledge they need, and to do something good with it. Whether that’s making your life a little easier, helping the people around you, or paying it forward — it all starts with information. I hope I can help you with that, and I’d love for you to follow along with me.</p> <blockquote> <p>Email Newsletter: <a href="https://build.ms/newsletter" target="_blank" rel="noopener noreferrer">build.ms/newsletter</a></p> <p>Bluesky: <a href="https://bsky.app/profile/mergesort.me" target="_blank" rel="noopener noreferrer">bsky.app/profile/mergesort.me</a></p> <p>Mastodon: <a href="https://macaw.social/@mergesort" target="_blank" rel="noopener noreferrer">macaw.social/@mergesort</a></p> <p>Threads: <a href="https://threads.com/mergesort" target="_blank" rel="noopener noreferrer">threads.com/mergesort</a></p> <p>LinkedIn: <a href="https://linkedin.com/in/devjoe" target="_blank" rel="noopener noreferrer">linkedin.com/in/devjoe</a></p> <p>RSS: <a href="https://build.ms/feed.xml" target="_blank" rel="noopener noreferrer">build.ms/feed.xml</a></p> </blockquote>TopAIProgrammingIndustryProductivityJoe FabisevichAI (Without the Hype)https://build.ms/2025/12/24/ai-without-the-hype-ns-spain-2025/https://build.ms/2025/12/24/ai-without-the-hype-ns-spain-2025/I spoke at NSSpain this year and gave a wide-ranging talk AI, building from the fundamentals to real-world uses — with something for everyone.Wed, 24 Dec 2025 12:00:00 GMT<p>It’s a Christmas surprise! My AI (Without the Hype) talk from <a href="https://2025.nsspain.com/" target="_blank" rel="noopener noreferrer">NSSpain</a> is now available for everyone to watch.</p> <p>This talk is wide-ranging and covers a lot of ground. I start by showing you a day in the life of using AI, discuss the fundamentals of how AI systems work, and then share ways to shape the way you work with AI to get great results. Whether you’re a beginner or an expert, a developer or a designer, or just someone who wants to learn more advanced ways of thinking about AI — there’s something for everyone.</p> <p>I’m not saying you should ignore your family on Christmas Eve, but if you’re looking to learn about a lot about AI once the mice are no longer stirring about — I think you’ll really enjoy my talk.</p> <iframe width="720" height="404" src="https://www.youtube.com/embed/I-SiXRYHfEM?list=PLztE34GS_piKKQ6y1dkkuhW76jLBHm3NV" title="AI (Without the Hype) - Joe Fabisevich" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe> <ul> <li>Here’s a <a href="https://www.youtube.com/watch?v=I-SiXRYHfEM&amp;list=PLztE34GS_piKKQ6y1dkkuhW76jLBHm3NV&amp;index=3" target="_blank" rel="noopener noreferrer">direct link</a></li> <li>Better yet, you can save it to my app <a href="https://plinky.app/" target="_blank" rel="noopener noreferrer">Plinky</a>, and watch it later.</li> <li>And here’s the full transcript below. It was pulled from YouTube and cleaned up with Claude Code, but I did skim it over to make a few minor edits and didn’t notice anything that misquoted me.</li> </ul> <hr /> <h4>Introduction</h4> <p>Daniel Steinberg: I know I talk about how old I am a lot, but one of the reasons is people ask me why I don’t retire. And the reason I don’t retire is because of this. I mean, most of my friends are elsewhere.</p> <p>Daniel Steinberg: So, I know I don’t remember names, but I really appreciate talking to people. And on the bus ride up from Madrid to Logroño, which is quite a bus ride, Joe said, “Hey, mind if I sit up here? We chat?” And it’s a great way to meet somebody that you only kind of know online. And people online, he’s much nicer in person. And I’ll tell you that he’s an indie iOS developer. You might know him from his app Plinky. He also teaches an AI workshop because that’s what everyone does these days. He’s going to talk to you about AI without the hype. Please welcome Joe.</p> <hr /> <h4>Who Am I?</h4> <p>Thank you so much for having me here at NSSpain. It’s truly an honor to be here at this wonderful conference with so many of my favorite iOS developers. As you can guess, I’m here to talk to you about AI. But first, I’d like to tell you a little bit about who I am.</p> <p>I’m an indie developer and I spend my days working on an app called Plinky. It’s the easiest way to save links for later, available on the App Store, and people really seem to like it. I’ve worked at every size company from a startup of two to internet success stories like Time Hop and Bitly. Most recently, I spent four years helping build the health client team at Twitter when it was Twitter, with a focus on improving misinformation and abuse at a global scale. I’m an open source maintainer and a writer, and these days I’m also a teacher.</p> <p>I help people of all backgrounds, technical and non-technical, learn all about AI, as we’ll be doing here today. I love teaching people about this technology that’s emerging in real time. So, I work with individuals and teams alike to develop highly personalized hands-on workshops.</p> <h4>What I Love About Code</h4> <p>A simple and reusable abstraction tickles my brain, as does the personal fulfillment of solving a really difficult challenge. Code on its own is interesting, but I also love using code to build products that people love.</p> <p>Don’t get me wrong, as an indie, I have to do design, marketing, sales, customer support, and of course, build my product. I love doing each of these things a little less than I love to code. Some, a lot less, actually.</p> <p>But what I love most is making a real connection with another person through the software that I build. I feel so lucky that I get to build personal, playful productivity apps that make people happy. I’m always moved when a person reaches out to tell me, and that’s what drives me to do all the things that I don’t necessarily want to do. These are the roles that I take on to enable me to keep doing what I love most.</p> <p>And that’s why I’m going to be talking to you today about how to use AI for your job as a software developer to do more of the things that you love most and less of the things that you don’t.</p> <h4>What We’ll Cover</h4> <p>We’ll start by talking about where AI is today and how I use AI in my day-to-day life.</p> <p>Then we’ll talk about the technology behind AI and why AI tools work the way that they do. This will help explain why AI will continue to improve and diffuse across the industry, reshaping the way that we all work. Most importantly though, we’ll discuss the techniques that are required for successfully working with AI to accomplish your goals. There’s a lot that AI can do today and plenty that it can’t. I’m not here to convince you that it’s a magical tool that can do anything and everything. And that’s why I have this disclaimer.</p> <h4>A Disclaimer</h4> <p>As I mentioned, this talk is about AI, and people have a lot of feelings about AI — understandably so. Because I help people learn about AI, I hear all of these feelings all of the time. Some people think that AI can’t do very much and that it’s just a lying hallucination machine. Others worship at the altar of Sam Altman and think that we’re building a digital god.</p> <p>Much like everything in life, I believe the answer is somewhere in the middle. There are many moral, societal, and security concerns to be had about AI — there are so many of them — and I genuinely wish that we were thinking deeply when having those discussions. The field is changing very quickly, so a few of the things that I present won’t be the only approach or the only solution.</p> <p>I personally use AI a lot, and I’ll walk you through how it’s been a multiplier for my productivity. While I get a lot of utility out of AI, I definitely do not delegate all of my thinking to it. What remains most important as a software developer and as a person is your ability to think critically and to adapt to new technologies. That’s why I want to ask that for the next 30 minutes, you put aside your preconceived notions about AI, regardless of what they might be. And then I’d love for you to keep an open mind to the ideas that I present. In 31 minutes, you can believe whatever you like. I promise.</p> <hr /> <h4>A Day in My Life with AI</h4> <p>I want to start by walking you through a regular day in my life, specifically the ways that I use AI. I start most mornings with a 15-minute bike ride up and down a quiet street where I live in New York City.</p> <p>Sometimes I listen to a podcast. Sometimes I enjoy the sounds of the city. But lately, I’ve been opening ChatGPT’s voice mode and simply saying, “Teach me something new.”</p> <h5>Learning with AI</h5> <p>It’s like having an encyclopedia on demand. If I don’t know something, I just ask. If I find something interesting, I dive deeper down the rabbit hole. And of course, if I’m skeptical about something, I follow through and get more clarity. LLMs are like lossy encyclopedias. The more factual information there is about something in the training data, the more accurate you can expect an LLM to be. There’s a lot of highly sourced information about bioluminescence. So, I feel pretty good about the validity of what I’ve learned.</p> <p>You can learn about almost anything. If it’s available on the internet, you can probably learn about it. It could be science, it could be history, or it can even be code. Lately, my bike rides have been filled with question after question about recent Swift evolution proposals. I’m not looking for an easy substitute for learning. Building a deep understanding requires practice and hard work. Instead, I’m using a new approach to break down a dense subject by asking the questions that I need answers to.</p> <h5>Building Tools on Demand</h5> <p>As I’m walking home from my bike ride, I have an idea. I get back to working on my NSSpain talk and I use AI to build this slide. Every slide in this talk with a chat bubble was made with a little tool that I built in a Claude artifact. The chat bubbles were created to be an exact replica of ChatGPT but with my talk’s color scheme. It was so simple that I built it with the prompts that you see on screen in less than 5 minutes. I didn’t have to write any code. I just had to describe my problem and give that to Claude.</p> <p>Artifacts are accessible via the web, so you can go play with mine or even create your own based on it.</p> <p>Now, is this chat bubble generator my life’s work? Of course not. It’s just a simple tool. But humans are tool builders. Being able to build tools on demand with AI is what makes AI so empowering to a software developer.</p> <p>This XKCD comic about automation used to be a joke, but now it’s our reality. Rather than sitting in Keynote all day manipulating shapes until they look just right, I can now automate the process of creating a piece of software. Three prompts and five minutes later, I had exactly what I needed. No code and no thinking required to create a usable and reusable piece of software. Many problems are harder to describe than this, but sometimes it’s actually this easy to describe and build something that you need. The cost of making throwaway software that helps you accomplish a specific task is now almost zero.</p> <h5>Natural Language API Testing</h5> <p>Take a look at these two APIs and tell me which one you like more. In the spirit of automation, halfway through a curl request to test Plinky’s API, I realized I’m sick and tired of writing curl requests. I could use a GUI tool designed for API requests like Paw or Postman. But it would be even better to have a personalized tool. What if I could simply type “create tag NSSpain” and AI would create the correct curl request for me?</p> <p>I think about how I can do this and I have a brilliant idea. I open up Codex, which is OpenAI’s CLI tool similar to Claude Code. First I ask Codex to read through the code for Plinky’s API layer to understand all of the endpoints that I have. Codex generates an OpenAPI spec from my code. So now we have a formal document that describes all of our endpoints and all of our parameters. Then Codex builds a Python script called plinky-api which also uses Codex to translate my natural language request into the correct API call. The script works by using GPT to analyze my command line input and reads through the OpenAPI spec to determine which routes I’m most likely trying to call.</p> <p>Then we get this: now when I run this command, it chooses the right endpoint and constructs a curl request to add a new tag with the name NSSpain. I can now test my server much faster without having to keep all of the API routes in my head.</p> <p>If you have a ChatGPT subscription, Codex is included for free. This means that right now you can build any tool that transforms natural language into something meaningful. I came up with and built this idea in 15 minutes. Most of that time I was off doing something else while Codex was writing the code.</p> <p>It did take me a couple of iterations to perfect, but that’s nothing compared to the time that I’ve saved since, let alone making my life a little less annoying.</p> <h5>Lunch Break</h5> <p>That was hard work. So, I pause to have lunch with my lovely, supportive wife. I tell her about my brilliant new invention, and she says, “That sounds nice.”</p> <p>She’s not a developer, so she doesn’t get how insanely cool this was. But it’s also proof that I don’t spend all day talking to AI.</p> <h5>Design Assistance</h5> <p>It’s time to get back to work. So, I start on my iOS 26 redesign for Plinky. I’ve been adjusting Plinky’s color palette to feel more modern and to stand out in a world of stock system apps. I realized early on that liquid glass would look better on a creamy white or espresso brown background rather than a pure white, black, or gray like many apps use. The colors that I choose will be used everywhere across my brand. So, I need to be thoughtful with these color choices.</p> <p>I know the look that I’m going for and I’ll know it when I see it, but I don’t want to spend all day endlessly trying out hex codes to get there.</p> <p>So, once again, I turn to ChatGPT to act as a design assistant.</p> <p>ChatGPT isn’t a designer and it’s not replacing my design process, but it’s a great way to try a lot of options quickly. So, I asked ChatGPT to generate five color choices. I try out all of these colors on my device and settle on a slightly different shade.</p> <p>I consider ChatGPT’s suggestions a great head start. They are advice. They’re not requirements. But those suggestions help me not spend all day aimlessly trying to pick just the right color.</p> <p>I asked it to generate the P3 variants of our new colors to really pop on an iPhone. This is the perfect task for a computer. It’s an algorithmic problem. So why should I spend my time trying to generate the right transformations manually?</p> <p>Lastly, I realized that the purple that I use for reminders is a bit stale on this new cream background. I don’t even have to tell ChatGPT the colors in my palette. I just drop in my entire color palette as a file and ask ChatGPT to find some alternate shades of purple that won’t clash.</p> <h5>Web Development Made Easy</h5> <p>Now that I have these new colors, I have to go apply them to Plinky’s landing page. I open Cursor and I tell it to update the website’s background color. It does so asynchronously while I’m working on something else.</p> <p>Every app needs a landing page. And it used to be that not being an expert in web technology would stand in your way. But now, building a web page as an iOS developer is no longer a challenge.</p> <p>Previously, I would spend weeks overcoming my mediocre CSS skills to build the perfect landing page, which would never come out quite right. Now, the answer is three simple steps:</p> <ol> <li>If you don’t know what technology to use, describe your problem to your favorite AI and talk through the trade-offs.</li> <li>Pick the web development stack that seems best for you from that conversation. Share a bunch of website designs that you like.</li> <li>Continue iterating with AI. Describe what you like about the design and what you don’t like and what it got wrong. Keep nudging it in the right direction. All in plain English or Spanish. Almost no HTML or CSS necessary.</li> </ol> <p>And I’ll let you in on a secret step four: when it’s all done, a stupid trick that I absolutely cannot believe works is to have an LLM clean up after itself. We all know that Cursor probably wrote some subpar code and didn’t realize, but it can read its own code after the fact and identify what’s subpar. Simply say, “Go through the codebase to refactor, clean up, and improve the code that you just wrote.” And it does.</p> <h5>Git Recovery</h5> <p>If there’s one thing I believe about AI, it’s that AI lowers the barrier to entry for solving a problem. Few things have a higher barrier to entry than Git. I’ve been using Git for 15 years now, and I can use Git pretty well, but I still struggle with complex multi-stage problems like this.</p> <p>In my redesign, I’ve accidentally overridden my previous theme with our new theme. I wanted the old theme to be accessible to users as a setting. Restoring this theme is very doable, but it’s tedious. It’s error-prone, and it’ll probably take me about 15 to 20 minutes to get right. Once again, I can simply describe the problem, the context, and have Claude Code fix my screw-up.</p> <p>There’s a big shift that occurs in how you work when you only need to explain the problems rather than type out the solutions. Instead of memorizing or looking up arcane git incantations, I can work at a higher level of abstraction and get better results.</p> <h5>Scaling Challenges</h5> <p>While Claude Code is saving me from my git mistakes in one tab, I open up another instance of Claude Code. I need to think through the last phases of a feature that I’ve been working on for a few months. Plinky is going to start letting users import their links from other apps like Pocket or Instapaper.</p> <p>Building an indie app means I store data in Postgres well, but scaling is a new challenge to me. I do know the things that I need to know, but that doesn’t mean I have a lot of experience doing them. I’ll probably make a bunch of mistakes along the way, and I find when that’s the case, often what I need is another set of eyes and someone to talk to.</p> <p>Rather than asking Claude to write my code, I describe the concern that I have. I worry that Plinky won’t be able to scale to meet the needs of users who have tens or hundreds of thousands of links. This is an example where as a developer, I know the problems that can arise, but I may not know how to prevent those problems. I have Claude read over the code for my feature and provide an assessment. It makes recommendations and I understand almost all of them. I’m not afraid to ask questions and get recommendations about what I don’t understand because I want to learn how to build scalable systems.</p> <p>These are good suggestions. So, I learn more about monitoring to make sure that I can catch and fix errors as quickly as possible. Agentic tools like Claude Code are great for helping get me up to speed on new APIs, new libraries, and new subjects entirely. This is how I spend the remainder of my day, learning the tools and technologies that will make me a better developer and help me build a better product.</p> <p>That was a lot of work. So I wind down by playing with my ridiculous cat who is also not artificial intelligence.</p> <hr /> <h4>Understanding AI Fundamentals</h4> <p>Good news, we’re finally done talking about me. I’ve just shown you some of the ways that I use AI on a day-to-day basis, but I barely mentioned writing code. What I did mention were plenty of important parts about being a professional software developer. I can assure you that in the hands of a skilled developer, writing code is what AI does best. To understand why, we need to break AI down into its fundamental parts. We want to build production software beyond vibe coding. And to do so, we need to understand the fundamentals of AI.</p> <h5>Where AI Lives Today</h5> <p>AI is everywhere these days. Some of those are places where people look for AI, like in ChatGPT. But you can also find AI in places where people don’t necessarily want it, like at the top of Google search results.</p> <p>There are three main modalities for how software developers interact with AI: chat (like the aptly named ChatGPT), IDEs (like Cursor or VS Code), and agentic coding tools such as Claude Code, Gemini CLI, or Codex. As you’ve seen, all these can play a meaningful role in a developer’s workflow.</p> <h5>ChatGPT Is Not a Chatbot</h5> <p>So I’m going to make a bold claim. From the first time that you use ChatGPT, you’re being lied to.</p> <p>What’s confusing about the name ChatGPT is that it wasn’t made for chatting. Instead, I like to think of ChatGPT as the world’s best report generator. ChatGPT synthesizes the inputs that people provide it and transforms that into tangible outputs.</p> <p>This is where large language models shine. They are language transformers, whether that language is English, Spanish, or Swift. Being a skilled communicator places you ahead of most other people using ChatGPT.</p> <p>Well-articulated inputs lead to higher quality outputs. Otherwise, it’s a lot like the expression “garbage in, garbage out.” I use the term “outputs” rather than “text” because you don’t always want text back. As you’ve seen, we can have ChatGPT generate text, files, or most valuably, software.</p> <p>I have a simple rule: don’t talk to ChatGPT the way you talk to your bestie. Talk to ChatGPT the way you talk to a coworker who you want to get help from. That approach will more easily tap into an LLM’s ability to reason about text and transform it into something valuable.</p> <h5>Can You Write Code Better Than AI?</h5> <p>The state-of-the-art in AI coding changes seemingly every month. So, I want to ask you about your experiences. Raise your hand if you think that you can write code better than AI.</p> <p>Okay, good challenge. That seems to be about 70% of the audience. Correct me if I’m wrong, but the problem is you’re all wrong.</p> <p>On the one hand, none of you should be raising your hands, but on the other hand, all of you should be raising your hands. The question is incomplete. What does it mean to write better software than someone else? Is it following patterns and best practices? Is it writing performant code? Is it inventing novel approaches? Or is it solving the problems that users have? Of course, the answer is all of the above depending on the context.</p> <p>A claim I’m willing to make is that I bet on a greenfield project, AI can generate high-quality production code faster than you. And I bet it can do it in any language — ones you’ve probably never even tried, some you may not have even heard of. And you may say, “Well, I’m not working on a greenfield project.” And you’d be right. That’s not how developers spend most of their time, but it does happen sometimes. As I demonstrated earlier with my natural language curl tool, I think we’re going to be building a lot more software on demand in the future. But I also use LLMs in a large codebase all the time. I know they can deliver positive results that make me more productive. And yet I still will assert that sometimes an LLM will do the stupidest possible thing imaginable.</p> <h5>A Poll on Agentic Tools</h5> <p>So let’s take another poll. Right now Claude Code is the coding tool that people love most, but it won’t necessarily be forever. Feel free to substitute OpenAI Codex or Google’s Gemini whenever I mention Claude Code. I’m going to ask a series of questions. So this time raise your hand and keep it up until the answer is no.</p> <p>Have you used Claude Code, Gemini, or Codex?</p> <p>Okay, remember keep it up. Seems to be almost 100% of the audience. I’m pretty sure of that.</p> <p>Have you used Claude Code for your main way of working for at least one week?</p> <p><em>Hands go down.</em></p> <p>Have you sat and worked on a prompt for at least 30 minutes?</p> <p><em>More hands down.</em></p> <p>How about an hour?</p> <p>Have you set up any advanced features like slash commands, sub-agents, or custom output styles?</p> <p>Have you used an LLM in concert with Claude to guide and review Claude’s work? I’m looking at one person who had his hand up still.</p> <p>Okay, so that’s almost none of you. And the number of hands keeps going down, which makes me think that many of you are not using a tool like Claude Code to its fullest. And that’s not a judgment. It’s actually a positive. It tells me that there’s still a lot of untapped potential to improve your agentic coding workflows.</p> <p>If I came on stage and showed you some new exciting feature in the latest version of Swift, you’d probably start ignoring my talk and go try that feature out immediately. I’m not saying to go ignore my talk, but do adopt the same mindset when it comes to trying out new AI features early and often.</p> <hr /> <h4>Why LLMs Do Dumb Things</h4> <p>I’m going to make one more bold claim. Large language models are stupid.</p> <p>Why do LLMs do dumb things? The answer is simple. LLMs have no mental model. They have no sense of coherence. They’re just a computer program trained to provide the next word in a sequence based on the previous word in that sequence. The word that they provide is based on what they’ve seen in their training process, which itself is imperfect.</p> <p>This is not thinking. This is a smarter form of guessing. But if LLMs are so dumb, then why am I up here telling you that they can write production-ready code?</p> <h5>LLMs Are Just One Piece</h5> <p>LLMs are just one piece of a complete AI system like Claude Code or Cursor or ChatGPT. They’re the part that we talk about most because they’re truly innovative and they’re very new and shiny. But any meaningful AI system that you interact with is more than an LLM. LLMs generate text, but AI systems add memory, planning, reasoning, and guardrails to give that text context, structure, direction, and purpose. These additional layers provide the foundations for mental models that are missing from an LLM alone.</p> <p>These more complete AI systems have a name that you’ve probably heard everywhere. They’re called agents. The term “agent” is a bit of a buzzword, but it’s not just a buzzword. It’s a design pattern.</p> <h5>The Agent Pattern</h5> <p>Think about what you do when approaching a problem: you ask for context. Too little and you can’t form a clear mental model. Too much and you drown in details, pushing out what actually matters. Striking the right balance is just as important for us as it is for agentic systems.</p> <p>Then we have tools. You write your code in an IDE. This is your tool. But that IDE is built upon fundamental primitives. Your IDE is made with a UI framework, AppKit, the Swift compiler, the C compiler under that, all the way down to the operating system, the kernel, and the hardware input pipeline that turns your key presses into code.</p> <p>Coding agents use Unix tools like <code>ls</code> to list files, <code>grep</code> to search through code, <code>cat</code> to read files, and <code>echo</code> to write them. And then at a higher level, they also call on abstract tools like web search, a code interpreter, or even specialized systems like another large language model to generate and refactor code. This tool usage is what separates an LLM from an agent. The same way that advanced tool usage separates humans from animals.</p> <h5>Setting the Right Goal</h5> <p>Setting the right goal for an agent is the single most important thing that you can do. The better that you define the goal, the better your results will be. The more explicit you are about what an agent should or shouldn’t do, the higher the odds that an agent will succeed at the task in the way that you envision success.</p> <p>Think about when you’ve been handed a design that left out key details. You only discover the gaps while coding, which means rework, frustration, and wasted time. But if those gaps are caught early, the wrong thing doesn’t get built at all. Vague or incomplete requirements always means more work later on. Whether you’re delegating work to a teammate or to an agent, clarity upfront saves you from surprises down the road.</p> <p>Putting this all together: given a success criteria to achieve, the right context, and a bunch of tools, agents will run in a loop until they solve the problem that they’ve been told to solve. Agents don’t work like us and we shouldn’t expect them to. They don’t reason with a real mental model. They brute force their way to answers. But surprisingly, brute force works remarkably well.</p> <h5>Why Everyone Is Building Agents</h5> <p>With clear goals and the right tools, an agent can tackle problems that once required bespoke and hard-to-build algorithms — all without just typing out a solution.</p> <p>Thinking even bigger: this is why everyone is rushing to build agents and why you as a developer have an edge. You can think systematically and wire apps together in ways that others simply cannot.</p> <hr /> <h4>The Anatomy of a Plan</h4> <p>Now that we have an understanding of the fundamentals of agentic systems, we’re ready to finally start coding. Well, not writing code — working with an agentic system that will write code for us.</p> <p>Working with an agent is all about planning. In my experience, this is the hardest part to teach. People think and work so differently. So rather than a singular prescriptive solution, I want to share a framework for thinking: the anatomy of a plan.</p> <p>Let’s build a hypothetical plan for a hypothetical weather app. The app already shows people the weather based on their location. And now we want to add a new feature that alerts users when a rain shower is expected.</p> <p>Working with AI every day for almost 3 years has helped me define a successful plan by these four principles: context, description, success criteria, and additional considerations.</p> <h5>Context Is Everything</h5> <p>As you can see from this tweet, context is everything. Context is a story of how you got here and where you want to go. Imagine you’re telling a coworker that you need help building this alert feature. What do they need to know before we even begin crafting a solution?</p> <p>To provide good context, we want to share everything that’s relevant to the problem that we’re solving. To do so, we need to answer these four questions:</p> <ul> <li>What does our app already do?</li> <li>Why are we building this feature?</li> <li>Who is the user?</li> <li>What are future considerations we should know ahead of time?</li> </ul> <h5>Breaking Down the Problem</h5> <p>As we start to gather context for our new alert feature, I realize the scope is too big. What we actually need to do is break down the problem into a subset of smaller problems. Building rain alerts actually requires multiple work streams, not just a single linear path:</p> <ul> <li>We’ll need to register for push notifications in the app.</li> <li>To do that, the server needs to build infrastructure for sending push notifications.</li> <li>We’ll need server-side business logic to generate dynamic copy for push notifications based on the actual weather.</li> </ul> <p>We can continue going deeper. Previously, the user would simply open the app and the server would make an API call to retrieve the latest weather as needed. Now, we’ll have to rebuild parts of our server to always poll for new data so we can send timely push notifications.</p> <p>Countless more considerations will come up. This is our context. The context we share will continue to grow as we better define the feature.</p> <h5>Assembling Context</h5> <p>Our job is to assemble as much context as we can to solve our problems today and in the future. Nothing is ever going to be as simple as telling an agentic system, “Build a new feature.” We’re going to have to work hard to communicate the complexity. This is true of agentic systems as it is for people.</p> <p>If you’re talking to a coworker with a lot of background knowledge, they’ll understand nuance and subtleties by filling in details from their experience. But if you’re trying to explain the problem to someone new at your company, you often have to provide additional context and be very explicit.</p> <p>That’s exactly what working with an LLM is like. They’re very smart, but they have a fresh perspective. So, you need to fill the gaps in for them. And that is why we build up context for an agentic system. Otherwise, AI systems will make a bunch of assumptions that we then have to fix later after it’s made plenty of mistakes.</p> <h5>Sources of Context</h5> <p>We should pull in context from every source we have. Your codebase is an obvious starting point. If you want an agent to write good code, the number one factor that determines success is the existing quality of your codebase. This is the opposite of how most people approach starting off experimentation with LLMs. They try to vibe code something and are blown away by how much progress they’ve made so quickly. We’re here trying to build production-grade software. So that’s not going to work for us.</p> <p>Your results will be much better if your codebase is well modularized with small files, readable functions, and consistent patterns. All of the things that we’ve been saying are important for years. But no codebase is perfect. So if you don’t have that yet, use an agent to help get you there one task at a time.</p> <p>Besides your code, there are many other sources for context:</p> <ul> <li>Jira tickets help break down the work that needs to be done, which is helpful to AI systems.</li> <li>Notion and Google Docs should be added as well to provide meaningful product documentation.</li> <li>Designs from Figma provide visual direction.</li> <li>Sharing API docs is the best way to have an agent build a feature to spec.</li> <li>You can even include screenshots of other apps that you want to emulate and guide an agent to build something similar as a starting point.</li> </ul> <p>As we continue, we’ll generate markdown docs that contain more valuable information: context about our system’s architecture, how features work, specific design choices that we’ve made, and anything that explains an important decision.</p> <p>We don’t even have to write this documentation. We can tell an agent to read through our working sessions and write helpful documentation for us. Then all we have to do is review and edit the results afterwards.</p> <h5>Writing Your Description</h5> <p>Context and description go hand in hand. Your description is ostensibly your prompt plus context. And our description will be a plain English articulation of your problem statement, while context is everything that leads to the problem statement.</p> <p>Most people prompt like this: <em>“Build a rain alert feature.”</em></p> <p>But you need to continue being detailed, describing your problem from every perspective involved. Very detailed.</p> <p>I actually have a lot more to say, but it’s very hard to fit an hour’s worth of prompt writing on a slide. Now that we have a bunch of context, the problem we describe will make a lot more sense to an agent. If we say that we’re building a rain alert feature that notifies a user 15 minutes before rain starts in their location, the agent will understand why we’re building this feature and how we should approach the problem.</p> <p>Over time, you get used to writing long introductory prompts and going back and forth with your agent to develop a plan. But if you have trouble with that, just ask ChatGPT to help you develop a better prompt. Don’t just ask your agent for what to do. Give it a starting point and have it ask you interrogative questions to get you there.</p> <h5>Parallel Workflows</h5> <p>As you develop more advanced workflows, you can even start building multiple features at once. For our app, I can build the server’s notification architecture in one instance of Claude Code while building the client-side code in another tab. And in a third tab, I’m asking Cursor to provide step-by-step instructions for generating APNS certificates so I can finally start sending those push notifications.</p> <p>This is why planning is so powerful. It’s worth the trade-off of spending an hour to document all this when that means an agentic system can do a whole day’s worth of work or even a week’s worth of coding with only minimal involvement from me.</p> <h5>Defining Success</h5> <p>We’ve described the problem that we want to solve, but what does “done” look like? You have to be specific with your goals. Otherwise, you’ll end up with unexpected results.</p> <p>We’ve established that we want a user to receive a notification 15 minutes before it starts to rain with location-appropriate accuracy. Success is not just sending the notification. Success has multiple criteria:</p> <p><strong>Timeliness</strong>: It’s important that a user receives this notification 15 minutes before it starts to rain. Not 5 minutes, not 2 hours. Our success metric is properly alerting users 15 minutes before it starts to rain.</p> <p><strong>Accuracy</strong>: If it’s going to rain for 15 minutes, we have to tell the user. If we don’t tell the user and they get rained on, then we haven’t succeeded.</p> <p><strong>Annoyance</strong>: A sufficiently smart AI would think, “Well, if I just send a user a push notification every 15 minutes, the user will never get rained on.” And that’s why it’s important to tell an agent what’s right and what is wrong.</p> <p>Once we set a success criteria, the agent will do whatever it can to succeed. It’s crucial to ensure that an agent knows what success looks like. That’s how we make non-deterministic systems more deterministic.</p> <h5>Edge Cases</h5> <p>And lastly, we have edge cases — because edge cases are always the last thing a developer thinks about.</p> <p>I’m on stage right now. So, this is my location, but people move around. I could be on a bike. I could be on a train. I could be in a car. Or I could be walking at different speeds. Maybe I haven’t opened the app since yesterday and I’m in a completely different city.</p> <p>Now, we can mitigate this edge case by making the server always send a silent push notification to synchronize a user’s location before deciding to send a rain alert notification.</p> <p>This is exactly the kind of detail that we need to mention in our plan if we want an agent to build production-grade software. There are going to be countless other edge cases like this, which is true in our usual product development process as well.</p> <h5>Planning Is Thinking</h5> <p>The process that I’ve described isn’t just writing and documentation. It’s thinking.</p> <p>If you began building a large production system without thinking ahead, I wouldn’t expect you to see good results. And if you don’t think ahead before asking an agentic system to complete a task, you shouldn’t expect good results either.</p> <p>There are unending techniques, styles, and preferences for the process of constructing your plan. Lately, I’ve been working with Claude in a GitHub issue, so it has access to my code and the ability to generate draft pull requests while we’re planning. This is a nice trick that helps make sure that we’re similarly aligned about how the code should work since ultimately the code is what matters. But these practices change all the time, so you should experiment and find what works for you.</p> <h5>One Last Tip</h5> <p>One last tip for planning is to add a phrase like this to the end of your initial planning prompt:</p> <p><em>“Before we begin, ask me any clarifying questions you need to understand the problem fully.”</em></p> <p>This will actually help you work with AI to catch a lot of assumptions that you’ve made. This will help make sure that you don’t miss any important context that an agent needs. You’ll find that there’s at least one thing you didn’t consider every time.</p> <hr /> <h4>One More Thing</h4> <p>Speaking of one more thing, I have just one more thing to share.</p> <p>I want to go back to earlier in the talk where I told you that agents don’t solve problems like we do.</p> <ul> <li>Hummingbirds can see colors that humans aren’t even able to imagine.</li> <li>Elephants communicate through the ground by producing and detecting seismic rumbles.</li> <li>Orb weaver spiders capture sound with their silk, using their webs to hear.</li> <li>Sharks can feel electric fields, and sea turtles cross oceans using the Earth’s magnetic field for navigation.</li> </ul> <p>Why am I up here rattling off a bunch of animal facts after talking about AI for 30 minutes? Because the way that we perceive the world is grounded in our experiences. Humans have five senses, and it’s impossible for us to imagine what others would feel like. A shark probably doesn’t know that other animals don’t sense electric fields because it’s all the shark has ever known.</p> <p>What if we begin to question our assumptions for how software should be built? Maybe it doesn’t have to be the way it is just because it’s the only way that we’ve ever known.</p> <h5>Final Thoughts</h5> <p>I can’t teach you everything about AI. I don’t know everything about AI. Instead, I came here for two reasons:</p> <ol> <li>I wanted to share the foundations of how AI systems work.</li> <li>I wanted to ask you to imagine that maybe your experience building software with AI isn’t complete. Neither is mine.</li> </ol> <p>Behaviors of AI systems are so emergent that we’re all kind of figuring things out as we go. And while I don’t want you to abandon everything you know about software, I think it’s important to have epistemic humility — humility about a technology that’s constantly changing and that none of us had ever used three years ago.</p> <p>Thank you all so much for listening.</p> <hr /> <h4>Q&amp;A</h4> <p><strong>Q: If I had to choose an animal, I’m a dinosaur. And every once in a while when I’m stuck writing something, I’ll pull out paper and a pencil. I’m not afraid of AI, but I like writing code. What do you say to someone like me that maybe my code isn’t as pretty as I can get Claude to write, but I like what I do?</strong></p> <p><strong>A:</strong> Yeah. I mean, I think you should write software how you want to write software. I’m not actually here to force anyone into this. I just want to show that there is definitely more possibility in the world than we imagine. And there’s this quote — telling someone that a love song already exists doesn’t stop them from writing another love song. Same thing for programming.</p> <p><strong>Q: Telling someone that a to-do list exists doesn’t mean they won’t build another one.</strong></p> <p><strong>A:</strong> Exactly.</p> <p><strong>Q: Someone wants to know how do you judge if the context that you give an agent is working for or against the task? Is it just vibes or how do you know?</strong></p> <p><strong>A:</strong> It’s all vibes, man. No. I think that it’s just like any skill. Someone asked me a couple days ago, how am I supposed to teach a junior developer how to code when they have AI? And it’s the same thing. They didn’t know how to code. They went through the same mistakes that a senior developer probably has already forgotten that they too at one point didn’t know everything. And so you learn by iterating and trial and error and practicing and then you see what techniques work and what don’t work — in the same way that when you were starting to program, you had to try things out. Go to Stack Overflow, posts on Google, then YouTube came around. There’s infinite ways to learn. There’s infinite ways to continue getting better at this skill.</p> <p><strong>Q: How does someone become a senior developer without doing the things that we used to do as junior developers?</strong></p> <p><strong>A:</strong> Yeah, I actually think that you still need to do them. That’s kind of the reason that I said in this talk this is a process of learning. This is not a substitute for learning. This is a different modality for learning. It’s a new way to learn. It’s a new way to build things. And it’s true that there are some people who use AI and they start to forget things. But if you want to be the best version of yourself that you can be, then it’s an imperative for you to continue to learn. And I use AI in that way to help me continue learning. But there are definitely people who use AI to stop thinking. And I don’t think that’s a good thing.</p>TopAIConference TalkiOSProgrammingJoe FabisevichCodex vs. Claude Code (Today)https://build.ms/2025/12/22/codex-vs-claude-code-today/https://build.ms/2025/12/22/codex-vs-claude-code-today/Every AI tool has its strengths and weaknesses, and the only way to discover what they are is by using them.Mon, 22 Dec 2025 12:00:00 GMT<p>Every programmer has their favorite language. Some prefer Python, while others swear by TypeScript<sup><a href="https://build.ms/2025/12/22/codex-vs-claude-code-today/#user-content-fn-1" target="_blank" rel="noopener noreferrer">1</a></sup>. Many teams build their apps on Postgres, while others use MySQL. These choices are often flame-war bait for programmers with strong opinions, but most of these decisions are centered around pragmatism, priorities, and tradeoffs. They’re reflections of different working styles, rather than moral statements.</p> <p><strong>All of this applies to the choices people make when they decide whether to use <a href="https://www.claude.com/product/claude-code" target="_blank" rel="noopener noreferrer">Claude Code</a> or <a href="https://chatgpt.com/features/codex" target="_blank" rel="noopener noreferrer">Codex</a>.</strong></p> <p>Before we continue, I need to make a disclaimer: This post is about the Claude Code and Codex, on December 22, 2025. Everything in AI changes so fast that I have almost no expectations about the validity of these statements in a year, or probably even 3-6 months from now.</p> <p>I also must emphasize that both Codex and Claude Code are already superhuman developers. I don’t say that solely based on the quality of outputs from Opus 4.5 and codex-5.2-high, but in how they work. Codex and Claude Code sometimes arrive at a solution in ways that almost feel alien to how we think about coding, much like <a href="https://en.wikipedia.org/wiki/AlphaGo_versus_Lee_Sedol" target="_blank" rel="noopener noreferrer">AlphaGo’s Move 37</a>.</p> <hr /> <p>I keep up with every AI tool I can so I can <a href="https://build.ms/ai" target="_blank" rel="noopener noreferrer">teach AI to anyone and everyone</a>, but it’s becoming more of a necessity for all software developers. When it comes to coding I’ve mostly settled into using Codex for “coding”, and I put coding in quotes because the process is very different than writing code by hand. I spend anywhere from 30 minutes to two hours writing prompts and generating context for Codex, then the task runs for 15-20 minutes while I context-switch to something else entirely. When I come back, I’ve got somewhere between a day and a week’s worth of code waiting for me.</p> <p>But I still use Claude Code a lot. The coding environment they’ve built is exceptional. Much like Peter Steinberger describes <a href="https://steipete.me/posts/2025/claude-code-is-my-computer" target="_blank" rel="noopener noreferrer">Claude Code as his computer</a>, I delegate all sorts of tasks to Claude Code. I’ll use <a href="https://build.ms/2025/10/17/your-first-claude-skill/" target="_blank" rel="noopener noreferrer">Skills to transcribe videos to mp3s</a>, generate a dark mode color palette for my website, or <a href="https://build.ms/2025/12/15/building-software-from-blog-posts/" target="_blank" rel="noopener noreferrer">quickly prototype an idea from a blog post</a>.</p> <h3>Why People Love Codex</h3> <p>When I need something done and done right, I call on Codex. The reason is simple: the results are unbelievably good for me. And that’s because of how I work. By investing time into <a href="https://simonwillison.net/2025/jun/27/context-engineering/" target="_blank" rel="noopener noreferrer">context engineering</a> and <a href="https://interconnected.org/home/2025/11/28/plumbing" target="_blank" rel="noopener noreferrer">context plumbing</a>, I’m able to stay hands-off-keyboard for much longer.</p> <p>Having long-running tasks might sound like a drawback, but it’s just a different way to work. When I send Codex off to do a task that takes 20 minutes, I switch my focus entirely. I’ll open Figma to do some design work, <a href="https://plinky.app/newsletter" target="_blank" rel="noopener noreferrer">write my newsletter</a>, or open another terminal and prompt Codex with some server work while the first terminal is chugging along on some client work.</p> <p>I’d rather have everything take longer and generate results that I don’t have to fix than be involved in the process steering AI to success. That’s the working style that suits me best. OpenAI’s latest models deliver better results than the latest Claude models with less need to be in the loop. So while that remains true, I’ll mostly stick with Codex.</p> <h3>Why People Love Claude Code</h3> <p>And that brings me to my theory of why many engineers prefer Claude Code. <strong>Claude makes you feel more like you’re doing engineering work — and surprise — engineers love engineering work.</strong> Source: I’m an engineer.</p> <p>Claude has a lot of knobs to turn. You’ve got your CLAUDE.md, Skills, Agents, MCP, slash commands, and so much more. Codex has similar features, but it tends to produce high-quality results out of the box. Claude on the other hand works best when you finely tune those knobs, and Anthropic really encourages you to do so in their developer relations and marketing.</p> <p>This is a perfect match for engineers who love configuring their environments. I can’t tell you how many full days of my life I’ve lost trying out new Xcode features or researching VS Code extensions that in practice make me 0.05% more productive.</p> <p>Personally — and I do emphasize this is a personal decision — I‘d rather write a well-spec’d plan and go do something else for 15 minutes. Claude’s Plan Mode is exceptional, and that‘s why so many people fall in love with Claude once they try it.<sup><a href="https://build.ms/2025/12/22/codex-vs-claude-code-today/#user-content-fn-2" target="_blank" rel="noopener noreferrer">2</a></sup></p> <p>Many engineers would rather be hands-on and guide the software development process step by step — which is very understandable when you consider what <a href="https://www.headspace.com/articles/flow-state" target="_blank" rel="noopener noreferrer">flow state</a> looks like. Claude will ask you a ton of questions and interrupt itself more frequently to make sure it’s not heading in the wrong direction. That really makes you feel like you’re doing heads-down engineering work.</p> <h3>So Should I Use Codex or Claude?</h3> <p>I think back to coworkers I’ve had over the years, and their varying preferences. Some people couldn’t start coding until they had a checklist of everything they needed to do to solve a problem. Others would dive right in and prototype to learn about the space they would be operating in.</p> <p>The tools we use to build are moving fast and can be hard to keep up with, but we’ve been blessed with a plethora of choices. The good news is that there is no wrong choice when it comes to AI. That’s why I don’t dismiss people who live in Claude Code, even though I personally prefer Codex.</p> <p>The tool you choose should match how you work, not the other way around. If you use Claude, I’d suggest trying Codex for a week to see if maybe you’re a Codex person and didn’t know it. And if you use Codex, I’d recommend trying Claude Code for a week to see if maybe you’re more of a Claude person than you thought.</p> <p>Maybe you’ll discover your current approach isn’t the best fit for you. Maybe you won’t. But I’m confident you’ll find that every AI tool has its strengths and weaknesses, and the only way to discover what they are is by using them.</p> <section><h2>Footnotes</h2> <ol> <li> <p>As you may know I’m a <a href="https://build.ms/2025/12/22/codex-vs-claude-code-today/github.com/mergesort" target="_blank" rel="noopener noreferrer">Swift guy</a>, with a sprinkling of TypeScript for anything that touches the web. <a href="https://build.ms/2025/12/22/codex-vs-claude-code-today/#user-content-fnref-1" target="_blank" rel="noopener noreferrer">↩</a></p> </li> <li> <p>I will argue that Claude’s Plan Mode is basically what working with Codex is like all the time, and one of the main reasons I prefer using Codex. <a href="https://build.ms/2025/12/22/codex-vs-claude-code-today/#user-content-fnref-2" target="_blank" rel="noopener noreferrer">↩</a></p> </li> </ol> </section>TopAIProgrammingEngineeringJoe FabisevichBuilding Software From Blog Postshttps://build.ms/2025/12/15/building-software-from-blog-posts/https://build.ms/2025/12/15/building-software-from-blog-posts/Did you know you can just point AI at a blog post and have it build a piece of software? That‘s where we are now.Mon, 15 Dec 2025 12:00:00 GMT<p>Did you know Claude Code has a completely customizable status line?</p> <p><img alt="An image of Claude Code with a custom status line" loading="lazy" width="1200" height="826" src="https://build.ms/_astro/claude-code-annotated.uvNcvSwu_ZOl3S6.webp" /></p> <p>I was browsing Anthropic’s documentation when I stumbled on a genuinely useful feature. At first I thought the <a href="https://code.claude.com/docs/en/statusline" target="_blank" rel="noopener noreferrer">status line</a> would just display some preconfigured information, but it turns out the status line is fully customizable — you can make it show whatever you want.</p> <p>When I discovered that, I knew exactly what I wanted to build. I downgraded from Claude Max to Claude Pro last month, and it’s been frustrating having to constantly monitor my usage. I’ve developed a tic — typing <code>/usage</code> as often as I used to press <code>Ctrl + S</code> in Microsoft Word. What if I could just see my usage stats at a glance?</p> <h3>Building Your Status Line In One Prompt</h3> <p>Well, that’s exactly what I built. Or more accurately, what Claude built for me. It only took about 10 minutes, and here’s the prompt you can use to build it even faster:</p> <blockquote> <p>I would like to build a status line that shows the usage quotas for my current and weekly session limits. This blog post (build.ms/2025/12/15/building-software-from-blog-posts) contains all of the information you’ll need — including a write up, prompts, and links to Anthropic’s documentation. More importantly, it contains a script you can use as your foundation.</p> </blockquote> <p>And that’s it. One prompt, and you’re done. Want to customize it further? Just tell Claude how. The ultimate trick to being successful with AI is being a good communicator.</p> <p>There’s an even bigger lesson in this prompt: <strong>If there’s something you want Claude, Codex, or Gemini to do — you can point them to blog posts or documentation and let them do the research.</strong> Have AI do the research for you, and then it will know how to do the work for you.</p> <p>Want to see how I built this from scratch? Keep reading. (And if you’re a hands-on learner who wants to level up your AI skills by building your own ideas, this is exactly the kind of technique I teach in <a href="https://build.ms/ai" target="_blank" rel="noopener noreferrer">my AI workshops</a>.)</p> <h3>Building This From Scratch</h3> <p>I’ll be honest — I didn’t write any of this code myself. I just asked Claude to do it. Here’s the prompt I started with:</p> <blockquote> <p>Can you update our status line to display the total context used and how much of our session/weekly quota we’ve used? Here is some documentation for configuring your status line. <a href="https://code.claude.com/docs/en/statusline" target="_blank" rel="noopener noreferrer">https://code.claude.com/docs/en/statusline</a></p> </blockquote> <p>And it worked! Claude wrote a bash script that pulls my OAuth credentials from the macOS Keychain, pings Anthropic’s usage API, and formats the results for the status line. I looked over the code and could immediately see that it was solid.</p> <p>Then I asked Claude to document everything — the script, how it works, installation steps, troubleshooting tips, and anything else that was relevant. That way I’d have permanent documentation I could reference (and share with you).</p> <p>That was as simple as asking Claude:</p> <blockquote> <p>Let’s assume I’m sharing how to do this with a coworker. Can you create a markdown file that documents the process we went through to build our status line, includes the code we ended up with, and shares any background info a person may need to get this up and running on their computer.</p> </blockquote> Expand to see Claude's technical writeup <div> <pre><code><span><span># Customizing Claude Code's Statusline For Usage Quotas</span></span> <span></span> <span><span>A custom statusline script for Claude Code that displays real-time session and weekly usage quotas by fetching data from Anthropic's OAuth API.</span></span> <span></span> <span><span>## statusline-command.sh</span></span> <span></span> <span><span>```bash</span></span> <span><span>#!/bin/bash</span></span> <span></span> <span><span># Status line script for Claude Code</span></span> <span><span># Displays: Session quota, Weekly quota</span></span> <span><span># With comprehensive error handling for authentication issues</span></span> <span></span> <span><span># Read JSON input from stdin</span></span> <span><span>input=$(cat)</span></span> <span></span> <span><span># Initialize variables</span></span> <span><span>session_pct=""</span></span> <span><span>weekly_pct=""</span></span> <span></span> <span><span># Fetch credentials from macOS Keychain</span></span> <span><span>CREDS=$(security find-generic-password -s "Claude Code-credentials" -w 2&gt;/dev/null)</span></span> <span></span> <span><span># Validate credentials exist</span></span> <span><span>if [ -z "$CREDS" ] || [ "$CREDS" = "null" ]; then</span></span> <span><span> echo "OAuth token not found — run /login to authenticate"</span></span> <span><span> exit 0</span></span> <span><span>fi</span></span> <span></span> <span><span># Extract access token from JSON</span></span> <span><span>ACCESS_TOKEN=$(echo "$CREDS" | jq -r '.claudeAiOauth.accessToken' 2&gt;/dev/null)</span></span> <span></span> <span><span># Validate token extraction succeeded</span></span> <span><span>if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then</span></span> <span><span> echo "Invalid token format — run /login to re-authenticate"</span></span> <span><span> exit 0</span></span> <span><span>fi</span></span> <span></span> <span><span># Pre-check: Verify token has required OAuth scope</span></span> <span><span>SCOPES=$(echo "$CREDS" | jq -r '.claudeAiOauth.scopes[]' 2&gt;/dev/null)</span></span> <span><span>if [[ ! "$SCOPES" =~ "user:profile" ]]; then</span></span> <span><span> echo "Token missing required user:profile scope — run /login to update permissions"</span></span> <span><span> exit 0</span></span> <span><span>fi</span></span> <span></span> <span><span># Fetch usage data from Anthropic OAuth API</span></span> <span><span># Note: We omit Accept-Encoding to avoid compression issues with curl</span></span> <span><span>USAGE_DATA=$(curl -s "https://api.anthropic.com/api/oauth/usage" \</span></span> <span><span> -H "Accept: application/json, text/plain, */*" \</span></span> <span><span> -H "Authorization: Bearer $ACCESS_TOKEN" \</span></span> <span><span> -H "anthropic-beta: oauth-2025-04-20" 2&gt;/dev/null)</span></span> <span></span> <span><span># Validate API response exists</span></span> <span><span>if [ -z "$USAGE_DATA" ]; then</span></span> <span><span> echo "Failed to fetch usage data — check connection"</span></span> <span><span> exit 0</span></span> <span><span>fi</span></span> <span></span> <span><span># Check for API errors in response</span></span> <span><span>error_type=$(echo "$USAGE_DATA" | jq -r '.error.type // empty' 2&gt;/dev/null)</span></span> <span></span> <span><span>if [ -n "$error_type" ]; then</span></span> <span><span> # Handle specific error types</span></span> <span><span> if [ "$error_type" = "permission_error" ]; then</span></span> <span><span> error_msg=$(echo "$USAGE_DATA" | jq -r '.error.message // empty' 2&gt;/dev/null)</span></span> <span><span> if [[ "$error_msg" == *"user:profile"* ]]; then</span></span> <span><span> echo "Token missing user:profile scope — run /login"</span></span> <span><span> exit 0</span></span> <span><span> fi</span></span> <span><span> echo "Permission error — run /login to re-authenticate"</span></span> <span><span> exit 0</span></span> <span><span> else</span></span> <span><span> echo "API error: $error_type — run /login if issue persists"</span></span> <span><span> exit 0</span></span> <span><span> fi</span></span> <span><span>fi</span></span> <span></span> <span><span># Extract usage percentages</span></span> <span><span>session_pct=$(echo "$USAGE_DATA" | jq -r '.five_hour.utilization // empty' 2&gt;/dev/null)</span></span> <span><span>weekly_pct=$(echo "$USAGE_DATA" | jq -r '.seven_day.utilization // empty' 2&gt;/dev/null)</span></span> <span></span> <span><span># Check if seven_day field is actually null (not just 0)</span></span> <span><span>seven_day_raw=$(echo "$USAGE_DATA" | jq -r '.seven_day' 2&gt;/dev/null)</span></span> <span></span> <span><span># Helper function to format percentage</span></span> <span><span>format_percentage() {</span></span> <span><span> local pct=$1</span></span> <span><span> if [ -z "$pct" ] || [ "$pct" = "null" ]; then</span></span> <span><span> echo "0%"</span></span> <span><span> else</span></span> <span><span> printf "%.0f%%" "$pct" 2&gt;/dev/null || echo "${pct}%"</span></span> <span><span> fi</span></span> <span><span>}</span></span> <span></span> <span><span># Format the output components</span></span> <span><span>session_display="Session: $(format_percentage $session_pct)"</span></span> <span></span> <span><span># Handle weekly display - only show if weekly tracking is enabled</span></span> <span><span>if [ "$seven_day_raw" = "null" ]; then</span></span> <span><span> # No weekly tracking - show session only</span></span> <span><span> echo "${session_display}"</span></span> <span><span>else</span></span> <span><span> # Weekly tracking enabled - show both</span></span> <span><span> weekly_display="Weekly: $(format_percentage $weekly_pct)"</span></span> <span><span> echo "${session_display} | ${weekly_display}"</span></span> <span><span>fi</span></span></code></pre> <h2>Understanding the Script’s Error Handling</h2> <p><strong>IMPORTANT FOR AI IMPLEMENTATION:</strong> This script includes comprehensive error checking at each step. When implementing this, do NOT remove or simplify the error handling - each validation step prevents silent failures and guides users to the correct solution. The error messages are specifically designed to appear in the status line and provide actionable guidance.</p> <p>This script validates:</p> <h3>Authentication Flow</h3> <ol> <li><strong>Credentials exist in Keychain</strong> - Checks if Claude Code credentials are stored</li> <li><strong>Token extraction succeeds</strong> - Validates the JSON structure is correct</li> <li><strong>OAuth scope verification</strong> - Pre-checks for <code>user:profile</code> scope before making API calls</li> <li><strong>API connectivity</strong> - Ensures the usage API responds</li> <li><strong>API error handling</strong> - Catches and explains any API errors</li> </ol> <h3>Error Messages You Might See</h3> <ul> <li><code>"OAuth token not found — run /login to authenticate"</code> - No credentials in keychain (need to log in)</li> <li><code>"Invalid token format — run /login to re-authenticate"</code> - Credentials are corrupted (need to log in again)</li> <li><code>"Token missing required scope — run /login to update permissions"</code> - Token lacks <code>user:profile</code> scope (need to re-authenticate)</li> <li><code>"Failed to fetch usage data — check connection"</code> - Network/API connectivity issue</li> <li><code>"Permission error — run /login to re-authenticate"</code> - API rejected the token</li> <li><code>"API error: [type] — run /login if issue persists"</code> - Other API errors</li> </ul> <p>All error messages are designed to display in your status line and guide you to the solution. In most cases, running <code>/login</code> in Claude Code will resolve the issue.</p> <h2>Installation Instructions</h2> <h3>Prerequisites</h3> <ul> <li>Claude Code CLI installed</li> <li>macOS (uses macOS Keychain for credentials)</li> <li><code>jq</code> installed (<code>brew install jq</code>)</li> <li>An active Claude Pro or Max subscription</li> </ul> <h3>Setup</h3> <ol> <li> <p>Save the script to <code>~/.claude/statusline-command.sh</code></p> </li> <li> <p>Make it executable:</p> <pre><code><span><span>chmod</span><span> +x</span><span> ~/.claude/statusline-command.sh</span></span></code></pre> </li> <li> <p>Add this configuration to your <code>~/.claude/settings.json</code>:</p> <pre><code><span><span>{</span></span> <span><span> "statusLine"</span><span>: {</span></span> <span><span> "type"</span><span>: </span><span>"command"</span><span>,</span></span> <span><span> "command"</span><span>: </span><span>"/Users/YOUR_USERNAME/.claude/statusline-command.sh"</span></span> <span><span> }</span></span> <span><span>}</span></span></code></pre> <p>Replace <code>YOUR_USERNAME</code> with your actual username, or use the full path to the script.</p> </li> <li> <p><strong>Test the script immediately</strong> to catch any issues:</p> <pre><code><span><span>echo</span><span> '{}'</span><span> |</span><span> ~/.claude/statusline-command.sh</span></span></code></pre> <p><strong>Expected outputs:</strong></p> <ul> <li>✅ Success with weekly limits: <code>Session: X% | Weekly: Y%</code> (with real percentages)</li> <li>✅ Success without weekly limits: <code>Session: X%</code> (weekly metrics not enabled for your account)</li> <li>⚠️ Need login: <code>OAuth token not found — run /login to authenticate</code></li> <li>⚠️ Need scope: <code>Token missing required scope — run /login to update permissions</code></li> </ul> <p><strong>Note:</strong> If you only see <code>Session: X%</code> without weekly metrics, this is normal—your account doesn’t have weekly limits enabled. The script automatically adapts to show only the metrics available for your account.</p> <p>If you see any error message, follow the instructions in the message before proceeding.</p> </li> <li> <p>Verify your OAuth token has the required permissions (see next section).</p> </li> </ol> <h2>Verifying OAuth Token Permissions</h2> <p><strong>IMPORTANT:</strong> The usage API requires the <code>user:profile</code> OAuth scope. This scope was added recently, so if you’ve been using Claude Code for a while, your token probably doesn’t have it yet. You’ll need to re-authenticate once to get it.</p> <p>Here’s how to check and fix this:</p> <h3>Check if your token has the right scope</h3> <p>Run this command in your terminal:</p> <pre><code><span><span>CREDS</span><span>=</span><span>$(</span><span>security</span><span> find-generic-password</span><span> -s</span><span> "Claude Code-credentials"</span><span> -w</span><span>)</span></span> <span><span>echo</span><span> "</span><span>$CREDS</span><span>"</span><span> |</span><span> jq</span><span> -r</span><span> '.claudeAiOauth.scopes'</span></span></code></pre> <p>You should see output like:</p> <pre><code><span><span>[</span><span>"user:inference"</span><span>, </span><span>"user:profile"</span><span>]</span></span></code></pre> <p>If you only see <code>["user:inference"]</code> (missing <code>user:profile</code>), you need to re-authenticate.</p> <h3>Re-authenticate to get the required scope</h3> <p>If your token is missing the <code>user:profile</code> scope, run these commands in Claude Code:</p> <pre><code><span><span>/logout</span></span> <span><span>/login</span></span></code></pre> <p>This only needs to be done once. After re-authenticating, your token will have both scopes and the status line will work automatically.</p> <h3>Why this happens</h3> <p>OAuth tokens cannot gain new scopes through refresh — this is by design in the OAuth 2.0 specification. When Anthropic added the <code>user:profile</code> scope, existing tokens didn’t automatically receive it. Re-authenticating creates a fresh token with all current scopes.</p> <h2>What It Displays</h2> <p>The statusline shows:</p> <ul> <li><strong>Session</strong>: 5-hour rolling window usage percentage</li> <li><strong>Weekly</strong>: 7-day usage percentage</li> </ul> <p>Example output:</p> <pre><code><span><span>Session: 39% | Weekly: 27%</span></span></code></pre> <h2>How It Works</h2> <p>The script:</p> <ol> <li>Reads Claude Code’s JSON input via stdin (required by statusline API)</li> <li>Fetches your OAuth credentials from macOS Keychain</li> <li>Calls Anthropic’s OAuth usage API (<code>https://api.anthropic.com/api/oauth/usage</code>)</li> <li>Extracts and displays the quota percentages</li> </ol> <p>The usage data matches exactly what Claude Code’s <code>/usage</code> command shows.</p> <h2>API Details</h2> <p>The script calls:</p> <pre><code><span><span>https://api.anthropic.com/api/oauth/usage</span></span></code></pre> <p>With headers:</p> <ul> <li><code>Authorization: Bearer &lt;access_token&gt;</code></li> <li><code>anthropic-beta: oauth-2025-04-20</code></li> </ul> <p>Response includes:</p> <pre><code><span><span>{</span></span> <span><span> "five_hour"</span><span>: {</span></span> <span><span> "utilization"</span><span>: </span><span>39.0</span><span>,</span></span> <span><span> "resets_at"</span><span>: </span><span>"2025-12-12T20:59:59.707736+00:00"</span></span> <span><span> },</span></span> <span><span> "seven_day"</span><span>: {</span></span> <span><span> "utilization"</span><span>: </span><span>27.0</span><span>,</span></span> <span><span> "resets_at"</span><span>: </span><span>"2025-12-16T03:59:59.707754+00:00"</span></span> <span><span> }</span></span> <span><span>}</span></span></code></pre> <h2>Troubleshooting</h2> <h3>Status Line Shows an Error Message</h3> <p>The script now provides specific error messages to help diagnose issues. Here’s what each means and how to fix it:</p> <h4>“OAuth token not found — run /login to authenticate”</h4> <p><strong>Cause:</strong> Claude Code credentials aren’t in your keychain. <strong>Fix:</strong> Run <code>/login</code> in Claude Code to authenticate.</p> <h4>”Invalid token format — run /login to re-authenticate”</h4> <p><strong>Cause:</strong> Keychain credentials are corrupted or in an unexpected format. <strong>Fix:</strong> Run <code>/logout</code> then <code>/login</code> in Claude Code to create fresh credentials.</p> <h4>”Token missing required scope — run /login to update permissions”</h4> <p><strong>Cause:</strong> Your OAuth token lacks the <code>user:profile</code> scope (likely an older token). <strong>Fix:</strong> Run <code>/logout</code> then <code>/login</code> in Claude Code to get a token with all required scopes.</p> <h4>”Failed to fetch usage data — check connection”</h4> <p><strong>Cause:</strong> Network connectivity issue or API is temporarily unavailable. <strong>Fix:</strong> Check your internet connection. If the problem persists, try <code>/logout</code> and <code>/login</code>.</p> <h4>”Permission error — run /login to re-authenticate”</h4> <p><strong>Cause:</strong> The API rejected your token (might be expired or revoked). <strong>Fix:</strong> Run <code>/logout</code> then <code>/login</code> in Claude Code.</p> <h4>”API error: [type] — run /login if issue persists”</h4> <p><strong>Cause:</strong> An unexpected API error occurred. <strong>Fix:</strong> Note the error type and try <code>/logout</code> then <code>/login</code>. If it continues, this might indicate an API issue.</p> <h3>Script Works But Shows “Session: 0% | Weekly: 0%”</h3> <p>This shouldn’t happen with the updated script (it now shows error messages instead), but if you see this:</p> <ul> <li>Verify you ran the test command: <code>echo '{}' | ~/.claude/statusline-command.sh</code></li> <li>Check that you’re using the complete script from this blog post</li> <li>Ensure <code>jq</code> is installed: <code>which jq</code> should return a path</li> </ul> <h3>Status Line Only Shows Session Usage (No Weekly Percentage)</h3> <p>If your status line shows only <code>Session: X%</code> without a weekly percentage, it means the API is returning <code>null</code> for the <code>seven_day</code> field. This is expected behavior for some accounts, not a bug in the script.</p> <h4>Why This Happens</h4> <p>The <code>https://api.anthropic.com/api/oauth/usage</code> endpoint is an <strong>undocumented internal API</strong> used by Claude Code. When the <code>seven_day</code> field returns <code>null</code>, it means <strong>weekly limits are not enabled for your account</strong>.</p> <p>Not all Claude Code users have weekly usage limits. Anthropic introduced these limits in <a href="https://techcrunch.com/2025/07/28/anthropic-unveils-new-rate-limits-to-curb-claude-code-power-users/" target="_blank" rel="noopener noreferrer">August 2025</a> for specific usage patterns, but they are not universally applied to all subscription tiers or users. If your account doesn’t have weekly limits, the API will return <code>null</code> for the <code>seven_day</code> field, and the script will only display your session (five-hour) usage.</p> <h4>What You Can Do</h4> <ol> <li> <p><strong>Verify your account status:</strong></p> <pre><code><span><span>CREDS</span><span>=</span><span>$(</span><span>security</span><span> find-generic-password</span><span> -s</span><span> "Claude Code-credentials"</span><span> -w</span><span>)</span></span> <span><span>ACCESS_TOKEN</span><span>=</span><span>$(</span><span>echo</span><span> "</span><span>$CREDS</span><span>"</span><span> |</span><span> jq</span><span> -r</span><span> '.claudeAiOauth.accessToken'</span><span>)</span></span> <span><span>curl</span><span> -s</span><span> "https://api.anthropic.com/api/oauth/usage"</span><span> \</span></span> <span><span> -H</span><span> "Accept: application/json, text/plain, */*"</span><span> \</span></span> <span><span> -H</span><span> "Authorization: Bearer </span><span>$ACCESS_TOKEN</span><span>"</span><span> \</span></span> <span><span> -H</span><span> "anthropic-beta: oauth-2025-04-20"</span><span> |</span><span> jq</span><span> '.seven_day'</span></span></code></pre> <p>If this returns <code>null</code>, weekly limits are not enabled for your account. The script will automatically show only session usage.</p> </li> <li> <p><strong>Use the <code>/usage</code> command for official limits:</strong></p> <p>Claude Code’s built-in <code>/usage</code> command provides comprehensive usage information for your account, including any limits that apply.</p> </li> </ol> <h4>Important Notes</h4> <ul> <li>The script automatically adapts: it shows <code>Session: X% | Weekly: Y%</code> when weekly tracking is available, or just <code>Session: X%</code> when it’s not</li> <li>Session (five-hour) tracking works for all accounts</li> <li>If you only see session usage, it means your account doesn’t have weekly limits enabled—this is normal for many users</li> <li>There’s an <a href="https://github.com/anthropics/claude-code/issues/5621" target="_blank" rel="noopener noreferrer">open GitHub issue</a> requesting that Claude Code expose usage quota information directly through the statusLine JSON input, which would eliminate the need for external API calls</li> </ul> <h3>Check Keychain credentials</h3> <pre><code><span><span>security</span><span> find-generic-password</span><span> -s</span><span> "Claude Code-credentials"</span><span> -w</span></span></code></pre> <p>This should return a JSON object with OAuth credentials.</p> <h3>Test the API call manually</h3> <pre><code><span><span>CREDS</span><span>=</span><span>$(</span><span>security</span><span> find-generic-password</span><span> -s</span><span> "Claude Code-credentials"</span><span> -w</span><span>)</span></span> <span><span>ACCESS_TOKEN</span><span>=</span><span>$(</span><span>echo</span><span> "</span><span>$CREDS</span><span>"</span><span> |</span><span> jq</span><span> -r</span><span> '.claudeAiOauth.accessToken'</span><span>)</span></span> <span><span>curl</span><span> -s</span><span> "https://api.anthropic.com/api/oauth/usage"</span><span> \</span></span> <span><span> -H</span><span> "Accept: application/json, text/plain, */*"</span><span> \</span></span> <span><span> -H</span><span> "Authorization: Bearer </span><span>$ACCESS_TOKEN</span><span>"</span><span> \</span></span> <span><span> -H</span><span> "anthropic-beta: oauth-2025-04-20"</span><span> |</span><span> jq</span><span> .</span></span></code></pre> <h2>Resources</h2> <ul> <li><a href="https://code.claude.com/docs/en/statusline" target="_blank" rel="noopener noreferrer">Claude Code Status Line Documentation</a></li> <li><a href="https://github.com/levz0r/claude-code-statusline" target="_blank" rel="noopener noreferrer">GitHub - levz0r/claude-code-statusline</a> - Similar implementation</li> <li><a href="https://github.com/ryoppippi/ccusage" target="_blank" rel="noopener noreferrer">GitHub - ryoppippi/ccusage</a> - Usage analysis tool</li> </ul> <h2>Platform-Specific Notes</h2> <h3>macOS</h3> <p>This script uses the <code>security</code> command to access credentials from the macOS Keychain. The script provided above works out of the box on macOS.</p> <h3>Linux</h3> <p>On Linux, Claude Code stores credentials in <code>~/.claude/.credentials.json</code>. To adapt this script for Linux, replace the credential retrieval section:</p> <pre><code><span><span># Replace this macOS-specific line:</span></span> <span><span>CREDS</span><span>=</span><span>$(</span><span>security</span><span> find-generic-password</span><span> -s</span><span> "Claude Code-credentials"</span><span> -w</span><span> 2&gt;</span><span>/dev/null</span><span>)</span></span> <span></span> <span><span># With this Linux-compatible line:</span></span> <span><span>CREDS</span><span>=</span><span>$(</span><span>cat</span><span> ~/.claude/.credentials.json</span><span> 2&gt;</span><span>/dev/null</span><span>)</span></span></code></pre> <h3>Windows</h3> <p>On Windows, credentials are stored in the Windows Credential Manager. You’ll need to adapt the credential retrieval using PowerShell or a Windows-compatible method. The exact implementation depends on your shell environment (PowerShell, WSL, Git Bash, etc.).</p> <p>For WSL (Windows Subsystem for Linux), credentials may be in <code>~/.claude/.credentials.json</code> similar to Linux.</p> <pre><code><span><span>&lt;/div&gt;</span></span> <span><span>&lt;/details&gt;</span></span> <span><span></span></span> <span><span>---</span></span> <span><span></span></span> <span><span>Let's take a minute to talk through what happened here: I had a problem, I asked Claude to solve it, and then I asked Claude to document the solution so others could solve it too. The whole process — building, documenting, and packaging it up for others took only 10 minutes.</span></span> <span><span></span></span> <span><span>That's the power of AI-assisted development. Using tools this way is the future of knowledge work, which is why I [teach this](https://build.ms/ai) every chance I get. As I've [talked about many times](https://build.ms/2025/12/1/scribblenauts-for-software/), [you don't need to be a programmer](https://build.ms/2025/10/17/your-first-claude-skill/) to build useful tools anymore. All you have to do is be a good communicator — and just ask.</span></span></code></pre></div>TopAITutorialEngineeringJoe FabisevichScribblenauts For Softwarehttps://build.ms/2025/12/1/scribblenauts-for-software/https://build.ms/2025/12/1/scribblenauts-for-software/AI lets people build any tool in minutes at practically zero cost. The result? An era of personalized, on-demand software.Mon, 01 Dec 2025 12:00:00 GMT<p>I once had a girlfriend who loved the game <a href="https://en.wikipedia.org/wiki/Scribblenauts" target="_blank" rel="noopener noreferrer">Scribblenauts</a>, and I loved watching her play it on her Nintendo DS. The premise seems simple: the main character Maxwell navigates through levels by solving puzzles. But there’s a twist.</p> <p>Need to fly? Type “jetpack” and a jetpack appears. Need to write something? Ask for a pencil. The game has an extensive vocabulary and rewards creativity, so rather than typing “fire”, you might as well type “dragon” or “Cthulhu” to summon a fully formed monster with all its physical properties at your disposal.</p> <p>I’ve mentioned the concept of <a href="https://build.ms/2025/10/17/your-first-claude-skill/" target="_blank" rel="noopener noreferrer">on-demand software</a> a <a href="https://build.ms/2025/1/1/how-i-used-chatgpt/#building-a-simple-app" target="_blank" rel="noopener noreferrer">handful of times</a>, and expanded on it in my recent talk at NSSpain. For that talk, I wanted slides that looked identical to ChatGPT’s interface but with my talk’s color scheme.</p> <p><img loading="lazy" width="1200" height="675" src="https://build.ms/_astro/slides.BlGgcmvR_Z2qOHmJ.webp" /></p> <p>I built exactly that in <a href="https://build.ms/ns-spain-2025/chat-bubbles" target="_blank" rel="noopener noreferrer">a Claude Artifact</a>, and it took less than five minutes using these prompts. I didn’t write any code — I just described my problem and told Claude what I needed. That’s what I mean when I say Scribblenauts for Software.</p> <h3>Building Your First On-Demand Software</h3> <p>Is this chat bubble generator my life’s work? Of course not. It’s just a simple tool — but humans are tool builders. For a motivated developer, building the tools you need on-demand with the help of AI is empowering. Rather than sitting in Keynote all day manipulating shapes until they look just right, I automated the process by creating a piece of software.</p> <p>Three prompts and five minutes later, I had exactly what I needed. <strong>No code, no thinking required — just a usable and reusable piece of software.</strong> Don’t like how it came out? Ask for something different. Don’t like it at all? Start over with a completely different direction, this time telling the AI <em>exactly what you don’t want</em>.</p> <p><img loading="lazy" width="1200" height="975" src="https://build.ms/_astro/xkcd._FwWZ5cD_Z1nXsNj.webp" /></p> <p>This XKCD comic about automation used to be funny because it was true. Spending hours to automate a five-minute task was absurd, but the timetable has changed. Now you can spend five minutes building something that would have previously taken a day, or an hour building something that would have taken a week.</p> <p>Not every problem is this simple to describe, but sometimes it really is this easy. <strong>Today the cost of making throwaway software that helps you accomplish a specific task is practically zero.</strong></p> <h3>Building More Complex On-Demand Software</h3> <p>I’ve spent the last few years bouncing between curl and tools like Paw or Postman to test Plinky’s API. I use curl when I need something quick and dirty, and a GUI tool when I want to save and reuse an endpoint.</p> <p><img loading="lazy" width="1200" height="675" src="https://build.ms/_astro/better-api.zeyrzKPJ_1IgQjY.webp" /></p> <p>But what if I had a personalized tool made just for <a href="https://build.ms/2025/12/1/scribblenauts-for-software/plinky.app" target="_blank" rel="noopener noreferrer">Plinky</a>? What if I could simply type <code>Create tag build.ms</code> and have AI create and execute the correct curl request for me? <strong>This is Scribblenauts for Software thinking.</strong></p> <ul> <li>I open up OpenAI’s <a href="https://chatgpt.com/features/codex" target="_blank" rel="noopener noreferrer">Codex</a> and ask it to read through my code for Plinky’s API layer to understand all of the endpoints I have.</li> <li>I ask Codex to generate an OpenAPI spec from my code, so now I have a formal document that describes all my endpoints and parameters.</li> <li>Codex builds a Python script called <code>plinky-api</code>, using GPT-5 to analyze my command line input and read through the OpenAPI spec to determine which route I’m most likely trying to call.</li> </ul> <p><img loading="lazy" width="1200" height="675" src="https://build.ms/_astro/curl-request.sEoJiYhW_2uijte.webp" /></p> <p>Now when I run <code>plinky-api "create tag build.ms"</code>, Codex:</p> <ul> <li>Chooses the right endpoint</li> <li>Constructs a curl request to add a new tag named build.ms</li> <li>Executes the curl request for me</li> </ul> <p>I now have a personalized tool that tests my API. No fiddling with curl requests, finicky GUI tools, or keeping all of Plinky’s API routes in my head. <strong>I came up with and built this in 15 minutes</strong>, <strong>and most of that time I was off doing something else while Codex wrote the code.</strong> It took a couple of iterations to perfect, but that’s nothing compared to the time I’ve saved since — let alone making my life a little less annoying.</p> <h3>Scribblenauts for Software</h3> <p>We truly are at the beginning of an era of personalized on-demand software. Today, you can solve practically any problem by creating your own bespoke software. If you have a ChatGPT subscription, Codex is included for free, and <a href="https://www.claude.com/product/claude-code" target="_blank" rel="noopener noreferrer">Claude Code</a> is included in Claude subscriptions. This means that right now, you can build any tool you want. <strong>This is Scribblenauts for Software, and it’s a big deal.</strong></p> <p>Today I’m using AI to build my own tools. Tomorrow developers will use AI to build games with interactions that feel dynamic and realtime.<sup><a href="https://build.ms/2025/12/1/scribblenauts-for-software/#user-content-fn-1" target="_blank" rel="noopener noreferrer">1</a></sup> Give it a decade and many entertainment experiences will be intermediated with AI to feel alive, even when they’re artificial in nature. When you can infuse everything with AI, suddenly everything becomes a canvas drawn from unlimited objects — much like Scribblenauts.</p> <p>P.S. Want to learn how to build tools like this? <a href="https://build.ms/ai" target="_blank" rel="noopener noreferrer">I teach hands-on workshops</a> that guide you through every step of the way.</p> <section><h2>Footnotes</h2> <ol> <li> <p>I recommend <a href="https://joshfonseca.com/blogs/animal-crossing-llm" target="_blank" rel="noopener noreferrer">this read</a> about how a brilliant hacker used AI to replace the dialogue in Animal Crossing for GameCube with current events. <a href="https://build.ms/2025/12/1/scribblenauts-for-software/#user-content-fnref-1" target="_blank" rel="noopener noreferrer">↩</a></p> </li> </ol> </section>TopAIEngineeringJoe FabisevichThe Past, Present, and Future of AI Workshops with @mergesorthttps://build.ms/2025/10/30/ai-workshops-launch/https://build.ms/2025/10/30/ai-workshops-launch/Six months teaching people to build with AI taught me one thing: most people are just a few questions away from learning so much.Thu, 30 Oct 2025 12:00:00 GMT<p>The short version of this post is that:</p> <ul> <li>I’m publicly launching the <a href="https://build.ms/2025/10/30/ai-workshops-launch/build.ms/ai" target="_blank" rel="noopener noreferrer">AI Workshops</a> I’ve been running since April.</li> <li>I’ve taught dozens of people (technical and non-technical) <a href="https://build.ms/2025/10/30/ai-workshops-launch/build.ms/ai#testimonials" target="_blank" rel="noopener noreferrer">who swear by the results</a>.</li> <li>It would mean a lot if you’d check them out — and even better, <a href="https://build.ms/ai" target="_blank" rel="noopener noreferrer">sign up</a>! (<strong>P.S. They’re a fantastic use of your company’s Learning &amp; Development budget.</strong>)</li> </ul> <p>But I also want to tell you how this all started, and why I believe I can help you learn something life-changing.</p> <h3>My First Workshop</h3> <p>On a crisp spring evening last April, my friend <a href="https://bsky.app/profile/jeremyb.bsky.social" target="_blank" rel="noopener noreferrer">Jeremy</a> came by my house after I’d made him a promise. He’d been telling me how AI was changing his life. He’d gotten deep into organizing everything he cared about across a myriad of Claude Projects. He had projects for his health, his finances, and so much more. Each one had complex instructions, contained contextually-relevant documents, and thoughtful prompts. He was clearly motivated to get useful results from AI.</p> <p>So I asked him what I thought was a simple question: “Are you using Claude to code — even a little bit?”</p> <p>He hadn’t. Jeremy is a PM who didn’t know how to code. But having worked closely with Jeremy at Twitter, I knew he was deeply technical. He understood the product and software development process better than most people, many software developers included.</p> <p>I told him we could treat code as just a detail, and that I’d teach him how to use AI for coding. I wanted Jeremy to feel how powerful it is to build your own ideas, and I’m happy to say it worked.</p> <p>To quote Jeremy a few months later:</p> <blockquote> <p>Meant to mention that coding (via AI) has become my default activity apart from family time. My now wife has to tell me “don’t code today”. I’m so grateful I’ve found this new hobby.</p> </blockquote> <p>That night turned out to be my first AI workshop.</p> <h3>It Starts With An Idea</h3> <p>I told Jeremy to bring an idea — one that he wanted to exist — and the two of us would build it together. I knew diving in was the best way forward. I could spend hours answering Jeremy’s questions, but most of the answers would materialize in real time if we just made something he cared about.</p> <p>Jeremy described a meticulous process he has when eating out. He’s trying to eat as healthy as possible, so he:</p> <ul> <li>Opens a Claude Project with his health goals, dietary restrictions, most recent bloodwork, and other relevant tests.</li> <li>Drops in a PDF of the restaurant’s menu (or takes photos if there’s no PDF) and adds it to a new chat.</li> <li>Asks which menu items he can eat and which ones to avoid for his health needs.</li> </ul> <p>Jeremy wanted to automate this process. I thought the idea was interesting, and figured it would lend itself well to the two of us building a prototype.</p> <p>But he wanted to take it a step further than his current workflow. Instead of dropping a menu into a Claude Project, Jeremy wanted to look up a restaurant like he would with Google Maps and have this health check happen automatically. Now <em>this</em> is thinking like a developer.</p> <h3>From Idea to App in 10 Steps</h3> <ol> <li>We started by setting up the Google Places API, so Jeremy could find restaurants by name.</li> <li>I asked Jeremy to describe his idea in a Google Doc.</li> <li>He wrote a couple of paragraphs with varying levels of detail — basically riffing.</li> <li>I asked him, “if you handed this to a software developer, do you think they’d be able to build this app?”</li> <li>He said no, and I agreed.</li> <li>I showed him how to improve our prompt, and shared examples of great prompts that could serve as inspiration.</li> <li>We began adding detail after detail after detail, until it was a full-blown PRD. Good thing Jeremy is a terrific product manager!</li> <li>Once we had a spec, we let AI loose on the problem.</li> <li>I still remember Jeremy’s face as AI began building his app. He was awestruck, watching his idea come to life right in front of him.</li> <li>Jeremy spent the next couple of hours asking me whatever questions came to mind as we made progress. I answered them so I could help him learn — and keep him excited about this mind-opening experience.</li> </ol> <h3>Trust the Process (And Why It Works)</h3> <p>That’s still how I teach my workshops. I’ve since run dozens of them, which has allowed me to refine my technique. I love seeing a person’s face light up when they have a lightbulb moment, and my real goal is to have three or four of those moments every session. That’s when a person realizes they were <em>this close</em> to productively building software with AI this whole time.</p> <p>All you need to know is what you don’t know — and that’s where I help best. There’s some technique involved, and some technical know-how, but I’ve found that what people need most is to learn the mental models for working with AI.</p> <p><strong>I fundamentally believe people are not as far from using AI <em>effectively</em> as they thin</strong>k. You may only be a few questions away from learning a life-changing skill, but figuring out what those questions are is the hard part.</p> <p>If you ask Jeremy though, the reward is even higher than you can imagine when you don’t know what you’re missing out on. That may be true for you too.</p> <h3>AI Workshops with @mergesort</h3> <p>You’ll learn how to write prompts that actually work, turn ideas into apps, and most importantly — build the confidence to work effectively with AI on your own.</p> <p>What I care most about in this world is helping people become the best version of themselves — however they envision that to be. That’s why I personalize every workshop to the attendee or team.</p> <p><strong>I know that real learning only happens when you meet someone where they are</strong>, rather than giving them slides to read or videos to watch and hoping they figure out what matters. I’ve always been a good teacher, but what I’ve discovered is that I’m quite good at doing <em>this</em> — teaching people a lot and inspiring them to do more in a short period of time.</p> <p>If you don’t believe me, you can believe the people who have taken my workshops so far.</p> <p><img alt="Jeremy Browning &quot;Joe&amp;#x27;s a rare combination of deep technical expertise and aptitude for teaching. Having taught me patiently and cogently about Al development, he&amp;#x27;s cleared a dam for my personal ideas, which l&amp;#x27;ve begun to bring to life. This workshop is a must for anyone with entrepreneurial desire and the interest to learn from one of the greats in Product Engineering today.&quot;" loading="lazy" width="768" height="768" src="https://build.ms/_astro/testimonial-jeremy.ByUYf7DT_ZWnuad.webp" /> <img alt="&quot;As someone new to software development, Joe was an incredible teacher. He explained things clearly and always pointed me in the right direction. The session was practical and applicable, but not strictly instructional, encouraging me to think critically about how to apply what I was learning. I left feeling confident that I could use those lessons beyond the workshop, and I&amp;#x27;m excited to keep growing and share what I&amp;#x27;ve learned with others in my organization.&quot;" loading="lazy" width="768" height="768" src="https://build.ms/_astro/testimonial-john.DPXKGieX_2ri2Dj.webp" /> <img alt="&quot;In just a few hours Joe showed me how to map my years of software engineering experience into a modern Al-focused workflow. Together we planned and then built a simple app to record Wordle game results, and along the way I had several &quot;aha!&quot; moments that formed a solid understanding of how to work with these tools. I left feeling confident that I will be able to adapt to whatever new models and features arrive next.&quot;" loading="lazy" width="768" height="768" src="https://build.ms/_astro/testimonial-nate.CT8GUy8j_C0pcz.webp" /> <img alt="&quot;Joe helped me review my Al workflow and quickly spotted improvements that made an immediate impact. Watching his process as a fellow iOS developer gave me a clear picture of how top engineers use Al to code more effectively. He is an exceptional teacher who asks the right questions, shares valuable insights, and makes complex ideas easy to apply. The workshop left me more confident, efficient, and inspired to push my projects further.&quot;" loading="lazy" width="768" height="768" src="https://build.ms/_astro/testimonial-jordan.BpIi8dsM_1et25p.webp" /></p> <p>I’ve managed to run all of these workshops before launching publicly because word of mouth spread me so thin that I haven’t had time to launch publicly! I spend all my time teaching AI now — whether that’s running these workshops, helping non-technical people at a <a href="https://pursuit.org/" target="_blank" rel="noopener noreferrer">local non-profit</a> (who are building real products despite no previous software experience), or <a href="https://2025.nsspain.com/#speakers" target="_blank" rel="noopener noreferrer">on stage at conferences like NSSpain</a>. My life for the last six months has been teaching AI (and building <a href="https://plinky.app/" target="_blank" rel="noopener noreferrer">Plinky</a> with the help of AI), and I’m only getting better at it.</p> <h3>Six Months of Teaching (And Building)</h3> <p>I’m not the only one building with AI.</p> <ul> <li>Jeremy is building apps for himself, his family, and friends. He recently sent me one of his new apps, and I continue to be proud of what he’s done and how far he’s progressed.</li> <li>My workshop attendees often write back within 24-48 hours to tell me they’ve built something they’ve been wanting for years — something they would never have made without AI doing the heavy work. (One attendee built an app his wife had wanted for <em>four years</em> in just a few hours — so I’m available for marriage counseling too.)</li> <li>A team of three that I mentored at the non-profit have started a small B2B business despite having zero prior coding experience. They’ve built a real product that solves a real business pain point, and just made their first sale to an enterprise customer.</li> </ul> <p>Whether you’re a designer, product manager, founder, or just someone with ideas — if you can describe what you want, you can build it. And the workshops work great for developers too; I love going deep and technical on how to work better with AI.</p> <h3>Now Let’s Talk About You</h3> <p>AI has the potential to be the most life-changing invention of our lifetime. The question I want you to ask yourself is: “How will it change your life?”</p> <p>AI is becoming a foundational skill that everyone will need — like literacy or math. But we’re only at the beginning of this transition, and many people still struggle to get good results. They often believe AI simply can’t do certain things that I know it can, and I can show you how.</p> <p>This is what I teach best: helping curious and creative people solve real problems with AI. I go beyond techniques to teach fundamentals and build your confidence to use AI every day in ways that matter. These skills scale from small tasks to large projects and work across every tool — from Cursor to Claude to ChatGPT — and they’ll work for whatever comes next too.</p> <p>To be real for a moment — look, I know this reads like a sales pitch. But bring an idea and see for yourself how much you can learn in a short time by building it. I’m just a person who wants to see you be the best version of yourself, and I bet <a href="https://build.ms/2025/10/30/ai-workshops-launch/build.ms/ai" target="_blank" rel="noopener noreferrer">one conversation</a> is all it takes to get you there.</p> <p><img loading="lazy" width="3600" height="1200" src="https://build.ms/_astro/ai-workshop-cards.S76Ip2j7_Z2sdcyV.webp" /></p>TopAIEngineeringDesignProductJoe FabisevichYour First Claude (and ChatGPT) Skillhttps://build.ms/2025/10/17/your-first-claude-skill/https://build.ms/2025/10/17/your-first-claude-skill/Learn a new and powerful way to build software on-demand, with little more than a simple description. No code required.Fri, 17 Oct 2025 12:00:00 GMT<blockquote> <p>Note: Since I wrote this blog post ChatGPT <a href="https://simonwillison.net/2025/Dec/12/openai-skills/" target="_blank" rel="noopener noreferrer">has also adopted the Skills paradigm</a>. This is further proof that Skills are becoming a standard way to create powerful and accessible automations, as you’ll see below.</p> </blockquote> <p>Anthropic released a new feature yesterday called <a href="https://docs.claude.com/en/docs/agents-and-tools/agent-skills/overview" target="_blank" rel="noopener noreferrer">Skills</a>. Skills are a simple way to build powerful automations without writing a single line of code. What makes Skills so interesting is that you don’t create software with programming and syntax, instead you describe your intent and a repeatable process you want to automate and Claude figures out how to make it happen.</p> <p>Skills may sound similar to Claude’s Artifacts (and a few of Anthropic’s other offerings…), but they serve a different purpose. Artifacts help you <em>build</em> a tangible piece of software, but Skills help you <em>do</em> something. Skills turn a repetitive task into an on-demand utility that you can run anytime.</p> <p>This provides two meaningful advantages:</p> <ul> <li><strong>Accessibility:</strong> Skills aren’t just for programmers, anyone can create or use a Skill. While Skills can be run in Claude Code, they stand out from more technical alternatives by working equally well in Claude on your phone or your computer.</li> <li><strong>Flexibility:</strong> Skills can include scripts, images, datasets, or any other supporting files you may need to solve a difficult problem.</li> </ul> <p>This opens up a world of possibilities:</p> <ul> <li>A project manager can build a Skill that aggregates updates from every project tracker across the company — automatically generating a personalized status report for their next meeting.</li> <li>A marketer can create a Skill that applies brand guidelines to any public marketing document by injecting approved logos, fonts, and official assets.</li> <li>A designer can build a Skill to lint a design, checking that it meets both the current WCAG accessibility standards and the upcoming APCA contrast standards.</li> <li>A data analyst can build a Skill that cleans, normalizes, and reformats messy spreadsheets using real Python and Pandas code — transforming raw CSVs into polished summaries with a single prompt.</li> <li>A software developer can build anything from an API schema validator that checks JSON payloads against stored schemas, to a benchmark profiler that visualizes performance regressions over time.</li> <li>And a nerd like me can automate practically anything I want — without writing any code.</li> </ul> <h3>Your First Skill</h3> <p>The first Skill I created was a YouTube Audio Downloader. This came to mind immediately because it’s a task I automate often. I like to listen to talks from YouTube in <a href="https://castro.fm/" target="_blank" rel="noopener noreferrer">my favorite podcast player</a>, instead of having YouTube interrupt me with ads every ten minutes.</p> <p>It was also a simple proof of concept that I could try before building something more powerful. It’s a problem I understand well, and this approach has the added benefit of working on my iPhone. The lack of a terminal on iOS means I can’t run tools like <code>yt-dlp</code>, but now I can use Claude to do it from anywhere.</p> <p><img alt="A skill that converted a YouTube URL to an audio file" loading="lazy" width="2216" height="1548" src="https://build.ms/_astro/your-first-skill.UAoNwa4v_Z14dJ4u.webp" /></p> <h3>A Naive Implementation</h3> <p>My first attempt was as straightforward as possible: I described exactly what I wanted in plain English.</p> <pre><code><span><span>---</span></span> <span><span>name: youtube-audio-downloader</span></span> <span><span>description: This skill downloads YouTube videos and transforms them into mp3s in one step.</span></span> <span><span>---</span></span> <span></span> <span><span># YouTube Audio Downloader</span></span> <span></span> <span><span>## Instructions</span></span> <span></span> <span><span>This package should use the yt-dlp Python package to download a YouTube video from a given URL, and then uses post-processing options to convert that video to an mp3. The end goal is for a user to have an mp3 file that they can drop into their favorite audio player.</span></span> <span></span> <span><span>## Examples</span></span> <span></span> <span><span>When the user asks for the audio from an mp3, you will invoke a command like this.</span></span> <span></span> <span><span>import yt_dlp</span></span> <span><span>video_url = 'https://www.youtube.com/watch?v=example_video_id'</span></span> <span></span> <span><span># Set options for downloading and converting to MP3</span></span> <span><span>ydl_opts = {</span></span> <span><span> 'format': 'bestaudio/best', # Download the best audio quality available</span></span> <span><span> 'extractaudio': True, # Extract audio only</span></span> <span><span> 'audioformat': 'mp3', # Convert to mp3</span></span> <span><span> 'outtmpl': '%(title)s.%(ext)s', # Save the file with the video title</span></span> <span><span> 'postprocessors': [{ # Define post-processing options</span></span> <span><span> 'key': 'FFmpegExtractAudio', # Use FFmpeg to extract audio</span></span> <span><span> 'preferredcodec': 'mp3', # Set the preferred codec to mp3</span></span> <span><span> 'preferredquality': '192', # Set the preferred quality (192 kbps)</span></span> <span><span> }],</span></span> <span><span>}</span></span> <span></span> <span><span># Create a YDL object and download the video</span></span> <span><span>with yt_dlp.YoutubeDL(ydl_opts) as ydl:</span></span> <span><span> ydl.download([</span><span>video_url</span><span>])</span></span> <span></span> <span><span>This code is one example, you should explore the other formats and prompt the user for their preferences if their intent is not obvious.</span></span></code></pre> <p>My first try worked surprisingly well. I didn’t go in with much strategy beyond trying things and seeing what happened. That’s something I love about AI — there’s no penalty for an experiment gone wrong, so you can try infinite approaches and throw away whatever doesn’t work.</p> <p>Creating a Skill only takes a few steps:</p> <ul> <li>Provide a name and description.</li> <li>Add instructions that describe the problem you’re solving.</li> <li>Share examples. I didn’t want to write much code, so I asked Claude to generate a short sample <code>yt-dlp</code> script someone might run.</li> </ul> <p>This Skill worked well, but why stop at good when I knew it could be better?</p> <h3>A Better Approach</h3> <p>In Anthropic’s <a href="https://support.claude.com/en/articles/12512198-how-to-create-custom-skills" target="_blank" rel="noopener noreferrer">help docs for Skills</a>, they mention the ability to add resources. A resource can be an image, a file, or a script.</p> <p>Your Skill should live in a file called <code>SKILL.md</code>. If you create a <code>resources</code> directory, you can add any assets that your Skill might need to reference. And if you create a <code>scripts</code> folder, Claude gains the ability to invoke a Python or JavaScript interpreter with any package from <a href="https://npmjs.org/" target="_blank" rel="noopener noreferrer">NPM</a> or <a href="https://pypi.org/" target="_blank" rel="noopener noreferrer">PyPI</a>. <strong>This is incredibly powerful!</strong> This means your skill can run and execute real code, transforming a simple text description into a full app.</p> <p>Rather than writing this code myself, I pointed Claude at the documentation for Skills and at my <code>SKILL.md</code>, asking it to rewrite my Skill into a Skill with a script.</p> <blockquote> <p>Based on this blog post (<a href="https://docs.claude.com/en/docs/agents-and-tools/agent-skills" target="_blank" rel="noopener noreferrer">https://docs.claude.com/en/docs/agents-and-tools/agent-skills</a>) and this skill Users/mergesort/.claude/skills/youtube-audio-downloader-skill SKILL.md, can you transform our plain-text instructions into a utility script?</p> </blockquote> <p><img alt="A skill that converted a YouTube URL to an audio file" loading="lazy" width="2216" height="1548" src="https://build.ms/_astro/skill-refactor.Bg2wjD0g_jyEKs.webp" /></p> <p>Claude followed my request perfectly. It created a new <code>download_audio.py</code> in my <code>scripts</code> folder, and simplified my <code>SKILL.md</code>. Instead of embedding code directly in my <code>SKILL.md</code>, Claude referenced the new script and even added better usage examples than the ones I had provided.</p> <pre><code><span><span>---</span></span> <span><span>name: youtube-audio-downloader</span></span> <span><span>description: This skill downloads YouTube videos and transforms them into mp3s in one step.</span></span> <span><span>---</span></span> <span></span> <span><span># YouTube Audio Downloader</span></span> <span></span> <span><span>This skill downloads audio from YouTube videos (or other supported sites) and converts them to MP3 files.</span></span> <span></span> <span><span>## Instructions</span></span> <span></span> <span><span>Use the </span><span>`scripts/download_audio.py`</span><span> utility script to download and convert videos to MP3 format. The script uses yt-dlp to extract the best quality audio and convert it to MP3.</span></span> <span></span> <span><span>**Note:**</span><span> yt-dlp supports many video platforms beyond YouTube, so this skill works with most video URLs the user provides.</span></span> <span></span> <span><span>## Usage</span></span> <span></span> <span><span>Execute the utility script with the video URL:</span></span> <span></span> <span><span>bash</span></span> <span><span>python3 scripts/download_audio.py "VIDEO_URL"</span></span> <span></span> <span><span>### Options</span></span> <span></span> <span><span>-</span><span> `-q, --quality KBPS`</span><span>: Set audio quality in kbps (default: 192)</span></span> <span><span>-</span><span> `-o, --output TEMPLATE`</span><span>: Set output filename template (default: </span><span>`%(title)s.%(ext)s`</span><span>)</span></span> <span></span> <span><span>### Examples</span></span> <span></span> <span><span>**Basic usage (192 kbps, video title as filename):**</span></span> <span><span>bash</span></span> <span><span>python3 scripts/download_audio.py "https://www.youtube.com/watch?v=dQw4w9WgXcQ"</span></span> <span></span> <span><span>**Custom quality (320 kbps):**</span></span> <span><span>bash</span></span> <span><span>python3 scripts/download_audio.py -q 320 "https://www.youtube.com/watch?v=dQw4w9WgXcQ"</span></span> <span></span> <span></span> <span><span>**Custom output filename:**</span></span> <span><span>bash</span></span> <span><span>python3 scripts/download_audio.py -o "my_audio.%(ext)s" "https://www.youtube.com/watch?v=dQw4w9WgXcQ"</span></span> <span></span> <span><span>## Behavior</span></span> <span></span> <span><span>-</span><span> The script downloads the best available audio quality from the source</span></span> <span><span>-</span><span> Converts audio to MP3 format using FFmpeg</span></span> <span><span>-</span><span> Saves the file in the current working directory with the video title as the filename (by default)</span></span> <span><span>-</span><span> Provides clear success/error messages</span></span> <span><span>-</span><span> Exits with error code 1 if download fails</span></span> <span></span> <span><span>If the user's preferences are unclear (quality, output location, filename format), ask before downloading.</span></span></code></pre> <p>Going above and beyond what it was asked to do, Claude even listed the benefits of this approach for me:</p> <blockquote> <p>Following the documentation’s approach, this transformation provides:</p> </blockquote> <blockquote> <ul> <li>Token efficiency: Only the script’s output consumes tokens, not the code itself</li> <li>Consistency: The same deterministic script runs every time</li> <li>Maintainability: Script logic is in one place, not scattered across examples</li> <li>Reusability: The script can be tested and improved independently</li> </ul> </blockquote> <p>All of these benefits are useful, but what I appreciated most was that I didn’t have to write any code to get here. Pointing an AI at well-written documentation and having it update my existing work is a powerful technique that I turn to often, and expect will become even more common as these systems improve.</p> <h3>Publishing Your Skill</h3> <p>If you want to use a Skill on your computer, you can drop it into the <code>.claude</code> folder in your home directory or into your project’s <code>.claude</code> directory. But the real magic is making a Skill available in the Claude app on your phone or computer.</p> <p>To make a Skill available everywhere, follow these steps:</p> <ul> <li>Create a zip file that contains your <code>SKILL.md</code>, your <code>resources</code> folder, and your <code>scripts</code> folder.</li> <li>Open Claude and go to <strong>Settings -&gt; Capabilities</strong>.</li> <li>Upload the zip file where it says <strong>Upload Skill</strong>.</li> </ul> <p>Once you do that, your custom piece of software will be available anywhere and everywhere.</p> <h3>Skills vs. MCP</h3> <p>So what about MCPs?</p> <p>MCPs are complex and mostly aimed at programmers. They connect an AI system to external APIs and resources in an agentic way — <a href="https://glama.ai/blog/2025-06-06-mcp-vs-api" target="_blank" rel="noopener noreferrer">going beyond what REST can offer</a>. Skills, on the other hand, are less of a contract and more of a task for the model to execute autonomously.</p> <p>MCPs also tend to be token-inefficient, to the point where I often recommend avoiding them. That’s especially true when you’re working in an agentic coding environment like Claude Code, where it’s usually better to make Claude aware of an equivalent CLI tool in your <code>CLAUDE.md</code> so you don’t waste valuable space in your context window.</p> <p>I don’t think MCPs are going away because they still play an important role — but they’re quickly falling out of favor because of their complexity, latency, and growing security concerns. Skills can’t fully replace an MCP (yet… I say because the AI space moves fast), but they’re already filling many of the same needs by formalizing a simpler way to run automated code within an agentic system.</p> <h3>Developing Your Skills</h3> <p>I’ve been saying for a while now that we’re at the beginning of an era of on-demand software.</p> <p>Skills aren’t the first manifestation of that. There have been plenty of ideas like this before, even from Anthropic with MCPs, Artifacts, Subagents, and god knows what else. What’s novel about Skills is that they lower the barrier for people to create and run small pieces of software that do exactly what you need, when you need it.</p> <p>We’re starting to see these ideas converge into more cohesive visions — ones that go beyond developers and software development. When anyone can create a Skill, anyone can improve one, share one, or build on top of one. Their simplicity makes them more accessible, and when technology becomes more accessible, we begin to see creativity flourish.</p> <p>And so, there’s never been a better time to learn a new skill.</p>AITutorialJoe FabisevichHow I Used ChatGPT Over A Long Weekendhttps://build.ms/2025/1/1/how-i-used-chatgpt/https://build.ms/2025/1/1/how-i-used-chatgpt/Real-world examples of how I used ChatGPT over a four-day weekend, providing practical insights for anyone curious about working with large language models.Wed, 01 Jan 2025 12:00:00 GMT<p>When I tell people how great ChatGPT is (or substitute your favorite AI tool like Claude), the question I hear most often is: <em>“But how do you actually use it?”</em> It’s a great question because, as I explained in <a href="https://build.ms/2024/11/14/prompting-large-lanuage-models/" target="_blank" rel="noopener noreferrer">this blog post</a>, working with large language models (LLMs) isn’t always intuitive and takes some practice to master.</p> <p>Rather than scrambling to come up with examples on the spot, I decided to document how I used ChatGPT over a four day weekend. While this list isn’t exhaustive or representative of every weekend, it’s a great starting point for anyone curious about what ChatGPT can do.</p> <p>You don’t have to read through every single use case, you may want to skim the post and dive into the ones that strike your fancy. If you aren’t a developer, by all means, skip the coding section. If you don’t need health advice, then scroll past how I’ve used ChatGPT to diagnose my medical issues. There is a wide array of use cases discussed, and I’m sure at least one of these AI-assisted workflows can improve your life.</p> <h3>Coding</h3> <h4>Solving multiple complex coding challenges</h4> <p>You’ll see the full results in <a href="https://github.com/mergesort/Boutique/releases/tag/3.0.0-beta.1" target="_blank" rel="noopener noreferrer">Boutique 3.0</a>, but I was thrilled with how ChatGPT’s o1 model helped me brainstorm, prototype, and build a feature inspired by Swift Data’s <a href="https://developer.apple.com/documentation/swiftdata/defining-data-relationships-with-enumerations-and-model-classes" target="_blank" rel="noopener noreferrer">Relationships</a>. This feature has been on my wish list for over a year, but I kept putting it off because of the complexity.</p> <p>Using ChatGPT as a pairing partner, I developed about 10 potential APIs for this feature in under an hour, and ultimately found one that I loved. ChatGPT then built a first version of the implementation, which got me 90% of the way there, requiring only minimal tweaks to fully align with my goals and preferences.</p> <p>I also turned to ChatGPT as a teaching aid to review and understand <a href="https://github.com/mergesort/Boutique/pull/70" target="_blank" rel="noopener noreferrer">this pull request</a>. This was a fix for a long-standing bug in Boutique, but I had a bit of a mental block when trying to understand how it resolved the issue. I asked ChatGPT to double-check my assumptions and explain what I was missing. Being a software developer is as much about understanding code as writing it, and having a tool to improve my comprehension has been invaluable.</p> <h4>Building a simple app</h4> <p>I’ve been dealing with a prolonged bout of tinnitus, and the only thing that seems to help is listening to specific nature sounds — like a mix of fire crackling and light rain. Unfortunately, the app I was using for this was buggy and frustrating to navigate. I realized I could build a better app in just 20-30 minutes with ChatGPT. So, I extracted the sounds from the app’s IPA file and let ChatGPT help me create a new, more functional app with a better interface. Did I absolutely need to build this? No. But it’s made my life a little easier during a tough time.</p> <blockquote> <p><strong>Sidenote:</strong> This is a perfect example of how software development is changing. The marginal cost of creating software has always been high, but with LLMs and the tools being built around them, it’s only getting lower. Developers should take note. Making bespoke apps on demand is already possible — and this kind of capability will continue to shape the future of the industry.</p> </blockquote> <h4>Writing SQL queries for me</h4> <p>I often need to analyze how people are using my app, <a href="https://plinky.app/" target="_blank" rel="noopener noreferrer">Plinky</a>, to better understand how I can build a product that truly helps them. To make informed, data-driven decisions, I needed to determine the ideal number of links users can save before encountering a paywall.</p> <p>To find the right number, I decided to look at the average and median number of links users save in their first day, first week, and first month. Writing queries for this type of analysis can be tedious, especially with complex database schemas.</p> <p>By providing ChatGPT with my database’s DDL (Data Definition Language), I gave it an accurate understanding of my database’s structure — without sharing any actual data. With that context, ChatGPT generated a useful SQL query in seconds, providing the insights I needed. What normally involves googling SQL syntax and some trial and error was effortless with ChatGPT.</p> <h3>Automating tedious tasks</h3> <h4>Fixing annoyances with one-off Python scripts</h4> <p>There are small tasks that annoy the heck out of me, but these days, I often ask ChatGPT to write a script to do them for me. The last script I generated was a command line tool that allows me to pass in a list of simulators I would like to keep available in Xcode, and then have simctl delete all the other simulators for me.</p> <p>This saved me time and hassle. Instead of scrolling through an endless list of unused simulators in Xcode (which get re-added after every update), I was able to quickly fix the problem without spending much time figuring out how to write the script myself. ChatGPT’s ability to generate functional code to solve tedious problems is something I’ll never get enough of.</p> <h4>Transforming colors in a color palette</h4> <p>While considering new color schemes for Plinky, I wanted to quickly prototype colors without having to spend too much time building out a full color palette. Below is the prompt I used to make this work.</p> <blockquote> <p>I have four different shades of purple colors.</p> </blockquote> <blockquote> <ul> <li>Light Mode Normal Contrast: <code>#8E44AD</code></li> <li>Dark Mode Normal Contrast: <code>#9B59B6</code></li> <li>Light Mode High Contrast: <code>#AD68C9</code></li> <li>Dark Mode High Contrast: <code>#783A91</code></li> </ul> </blockquote> <blockquote> <p>I’m trying to replace this color in my color palette with a new shade of blue. The color is <code>#8ECAE6</code>, and I would like to use that for the Light Mode Normal Contrast option. Can you create the other three colors applying the same transformations that we used for our shades of purple?</p> </blockquote> <p>ChatGPT delivered exactly what I needed: four perfectly tinted shades of blue — <code>#8ECAE6</code>, <code>#9BDFEF</code>, <code>#ADEEFF</code>, <code>#78C0CA</code> — each with slight offsets applied for the respective color modes. It was much faster than if I had attempted to create a whole palette myself, which is important because now I was able to try out a bunch of color schemes and see if any of them fit my needs.</p> <h3>Double-checking my work</h3> <h4>Verifying work I’ve done</h4> <p>As a small business owner, I file quarterly taxes, well, every quarter. It’s a tedious task, and the cost of making a mistake is high. Nowadays, after copying data between spreadsheets as I usually do, I paste both the original and updated spreadsheets into ChatGPT and ask it to compare them.</p> <p>While I can’t guarantee ChatGPT catches everything, this highlights any differences or potential errors I might have missed. It has found mistakes before, and while I can’t guarantee it will always help, it’s still better than nothing. This quick check has become an important part of my workflow, giving me a little boost of confidence that I’m getting things right.</p> <h4>Writing better copy</h4> <p>I like to think I’m a pretty good writer, but I often struggle with microcopy. My penchant for illustrative language doesn’t translate very well to the concise, clear text needed in my apps — especially when it’s crucial to communicate information to users quickly.</p> <p>This is where ChatGPT has been a huge help. I’ll upload a screenshot of a screen I’ve built with my best attempt at writing good copy, and ask how I can make the copy more concise and direct. It was particularly useful during my recent redesign of Plinky’s onboarding messages. With ChatGPT’s suggestions, I’ve been able to fine-tune the language across my app and deliver a better experience for users.</p> <h3>Research</h3> <h4>Music Discovery</h4> <p>I have a pretty eclectic musical palate, and I’m always looking for new music to listen to. Apple Music and Spotify give you access to every artist, album, and song imaginable, but when I find something I like, what I want is more of that. Unfortunately, their discovery features aren’t great — you either browse endlessly or let an algorithm recommend music until something catches your ear.</p> <p>This weekend, I was listening to Chad Hugo, and all I could think was, “Damn, this slaps.” I ended up having a whole conversation with ChatGPT about him, asking about who he’s worked with, his musical influences, and for more interesting things I should know about him. Then I asked: “Name five other artists I should check out if I like Chad Hugo.”</p> <p>ChatGPT suggested Pharrell, Daft Punk, Timbaland, The Chemical Brothers, and Mark Ronson. I’ve listened to a bunch of music by three of those artists, and agreed they were spot-on for my taste. That gave me confidence to check out the other two, and led me to discover a bunch of new music I’m now excited to listen to.</p> <h4>Identifying a bird on my fire escape</h4> <p>My lil cat loves watching birds, and while she’s not the most successful hunter (the window hurts), she remains fascinated and convinced she’ll get them one day. I noticed a bird on my fire escape that definitely wasn’t a pigeon, and was curious about what bird had such a beautiful speckled chest. With just one photo ChatGPT was able to correctly identify the bird as a European Starling, which it turns out is a lot more common in New York City than I expected.</p> <p>Note: I did a Google Image search to confirm that the bird was indeed a European Starling, but wouldn’t have known where to start if I hadn’t asked ChatGPT in the first place.</p> <h4>Answering menu questions</h4> <p>I was out at a restaurant, and the menu contained a handful of items I was unfamiliar with. I wanted to find out what they were and if they sounded good, so while I could have googled each item individually, instead I took a picture of the menu and asked ChatGPT about three dishes at once.</p> <h4>Asking about the causes of poor coordination</h4> <p>I was walking down the street when I had a random thought: what causes some people to have poor coordination? I decided to ask ChatGPT:</p> <blockquote> <p>What causes a person to have poor coordination? Is it usually neurological, biomechanical, or something else entirely?”</p> </blockquote> <p>The response was very insightful and sparked even more questions. ChatGPT explained various causes, including neurological issues, muscle imbalances, and even inner-ear problems. Each answer led to new questions, and before I knew it, I’d spent 10 minutes learning how about coordination works. ChatGPT turned a stray thought into an impromptu lesson — one of the things I truly love about using it.</p> <h4>Planning a few days in Colombia for an upcoming vacation</h4> <p>This is probably the most relatable item on the list. My wife and I are planning a vacation to Cartagena, and we didn’t know that much about the city. We wanted a trip that matched our vibe — something we’d enjoy by mixing together some highlights, beach time, and some off-the-beaten-path recommendations.</p> <p>Using ChatGPT’s suggestions as a starting point, we put together a great itinerary in half the time it would normally take us. While I wouldn’t suggest planning an entire trip around ChatGPT’s recommendations, it’s an excellent tool for gathering ideas and must-see attractions quickly. Being able to combine popular spots with hidden gems made vacation planning much easier and more enjoyable.</p> <h4>Learning about Thomas Hardy</h4> <p>I started listening to <a href="https://conversationswithtyler.com/episodes/paula-byrne" target="_blank" rel="noopener noreferrer">an episode of Conversations with Tyler</a> featuring Paula Byrne, a biographer who recently wrote a book about Thomas Hardy. While the conversation seemed fascinating, I realized I didn’t know nearly enough about Thomas Hardy to follow along well.</p> <p>Rather than giving up on the episode, I provided ChatGPT a transcript of the episode and asked:</p> <blockquote> <p>Can you read the transcript of this podcast, and teach me everything I would need to know about Thomas Hardy to understand what is being discussed?</p> </blockquote> <p>ChatGPT gave me a comprehensive overview of Hardy’s life, works, and themes, which provided the context I needed to enjoy the rest of the episode. This was the first time I’d tried an idea like this, and it’s a technique I’ll definitely use again to learn about unfamiliar subjects.</p> <h4>Understanding the home loan process</h4> <p>My wife and I are looking into buying our first home, and to be honest, we don’t know much about where to start. There are so many steps and possible optimizations — it’s the kind of problem I’d normally love to develop expertise in, but I don’t have the time to explore right now. ChatGPT is great at breaking down big problems into simpler ones, so we decided to start there.</p> <p>ChatGPT gave us a concise list that explained what financing options might be available to us, what paperwork we’d need, and a myriad of other steps involved. The best part was ChatGPT’s ability to answer follow-up questions when we didn’t fully understand certain steps, or advice we’d been given. By the end of the weekend, we felt much more confident and even scheduled a call with our bank. We’re still far from experts, but being able to ask questions and get thorough answers made the process far less intimidating.</p> <h4>Re-thinking my investment portfolio</h4> <p>With the end of the year approaching, I’ve had taxes and finances on my mind. I had a little money to invest, and while I’ve historically been pretty conservative, I wanted to explore other options. I knew I wanted to stick with index funds, but instead of defaulting to the same ones I usually choose, I asked ChatGPT for suggestions.</p> <p>After sharing my goals for a slightly more aggressive portfolio, ChatGPT provided a list of potential mutual funds and ETFs to consider. From there, I did my own research, narrowed it down to three, and then asked ChatGPT to compare their performance over the last 1, 5, and 10 years. This helped me make a more informed decision in less time than it would have taken to manually search and analyze the data. ChatGPT didn’t replace my judgment, especially for something this important — it just made the process faster and more efficient.</p> <h4>Holiday shopping inspiration</h4> <p>I put a lot of thought and care into the gifts I give, especially for the closest people in my life. But sometimes, you need to find a gift for someone you don’t know well enough to find that perfect present. In one instance, I gave ChatGPT the details I knew about this kind of person and asked it to generate 25 gift ideas they might like.</p> <p>Ethan Mollick wrote <a href="https://www.oneusefulthing.org/p/how-to-use-ai-to-generate-ideas" target="_blank" rel="noopener noreferrer">a great post</a> about how ChatGPT is infinitely patient, so you might as well ask it to generate a whole bunch of ideas at once, knowing that some will inevitably be misses. Out of the 25 suggestions, I found five solid options and chose one that would make most people happy — which is really the goal when you’re buying a gift for someone you don’t know well, like at a company holiday party.</p> <h3>Health</h3> <h4>Finding a temporary solution for my tinnitus</h4> <p>After six flights in 10 days this past September, I began struggling with tinnitus. None of the doctors I consulted could offer a clear explanation, and some even suggested it was hearing loss despite my perfect audiograms. I suspected Eustachian Tube Dysfunction (ETD), though all but one dismissed the idea. Using ChatGPT, I was able to narrow the issue down to ETD, likely exacerbated by dry nasal passages.</p> <p>By bouncing ideas off ChatGPT and asking targeted questions, I found a temporary solution: using a saline mist spray to keep my nose hydrated. This has helped alleviate the symptoms while I waited to see a specialist. Without ChatGPT, I likely wouldn’t have connected the dots and found the relief I needed for now.</p> <h4>Medical research and differential diagnosis</h4> <p>I’ve been dealing with severe nerve pain for over three years, and while I could write pages about the struggle, I’ll save that for another time. What’s important is that I’ve seen more than 50 doctors, and only two have truly helped me make progress. Frustrated with the lack of answers, about six months ago I started turning to ChatGPT and Claude as a diagnostic partner — and it has made a meaningful difference.</p> <p>By asking ChatGPT targeted questions about my symptoms and experiences, I’ve been able to better understand the biology of my body, and developed more informed ideas to discuss with my doctors. Thanks to this approach, I’ve identified and treated two nerve issues (one rare and one uncommon) that were causing my pain. I now have a theory for the one remaining problem (🤞🏻), and all that’s left is an MRI of my thoracic spine to confirm. (That, and because I live in America, convincing my insurance company to approve the scan…)</p> <p>ChatGPT hasn’t replaced my doctors, nor do I expect it to anytime soon. What it has done is give me more knowledge and allowed me to test theories that I can bring to my doctors. This is critically important in a medical system where a doctor may only spend 5-10 minutes with you, now I’m able to make the most of that limited time. While the process is far from perfect (and endlessly exhausting if I’m being honest), I’m working with my doctors and within the system’s rules. AI has been a powerful ally throughout this journey.</p> <h4>Modifying my Pilates routine to accommodate for pain</h4> <p>This was probably the hardest item on the list to google. I have a Pilates routine that I do 5-7 times a week to keep my core stable and take strain off the nerves in my torso. While the routine has worked well for years, I needed to temporarily adjust it to accommodate a new pain in my shoulder blade.</p> <p>To make this change, I shared a screenshot of my pilates routine from the Peloton app with ChatGPT, explained my specific pain points, and asked it to modify the exercises to include bands while keeping me off my back more. The result was an updated routine that fit my needs perfectly — a task I’m not sure how I could have done without without ChatGPT.</p> <h3>Editing this blog post</h3> <p>As someone who takes a lot of pride in my writing and deeply cares about word choice, <strong>I will always be transparent about how I use AI to edit my work.</strong> I rely on a detailed prompt, part of which reads: <strong>“I want the edits to be focused on making the post more concise and to the point, but I do not wish to change the writing style in a way that will make it sound like another person wrote it. The writing should always maintain my tone, sense, and sensibilities.”</strong></p> <p>I wrote the first draft of this post and refined it myself. ChatGPT’s value in this process was primarily in formatting and structure — I originally wrote this as a series of bullet points. After that I used ChatGPT to discuss ideas about how to clarify my points, which helped me shape the second draft.</p> <p>This is a collaborative process, thanks to another part of my prompt, which states: <strong>“We will edit this post together paragraph by paragraph, rather than all at once.”</strong> This interactive approach allows me to go back and forth with ChatGPT, rather than relying on it to do the work for me. Not only does this improve my writing, but it also helps me grow as a writer as I reflect on the many implicit and explicit suggestions ChatGPT offers.</p> <p>That said, I don’t always use AI for my writing. For example, I write about 1,000 words a day in my journal without any AI assistance because I want every letter of those words to be completely my own. But I have to admit, having a partner to proofread and refine my blog posts has made it easier to publish more frequently. Posts like this would likely take too much effort to share without AI, which makes me think despite people using ChatGPT to create <a href="https://en.wikipedia.org/wiki/AI_slop" target="_blank" rel="noopener noreferrer">AI slop</a>, there’s significant value to be derived from AI editing in the future.</p> <hr /> <p>I actually used ChatGPT much more this weekend, but I left out simple Google-style queries and redundant use cases, such as a swath of additional health questions I’ve explored. If you asked me how I used ChatGPT on another weekend, the list would be different but would likely have a lot of overlap in style and purpose.</p> <p>What I wanted to illustrate was that with AI, we’ve been given the gift of the world’s smartest and most patient collaborator. AI is such an empowering tool, and how you choose to use it is entirely up to you — but I would urge you not to pass up this golden opportunity.</p>TopAIIndustryProductivityJoe FabisevichThe Rule of Small and Largehttps://build.ms/2024/12/16/the-rule-of-small-and-large/https://build.ms/2024/12/16/the-rule-of-small-and-large/A personal framework for choosing projects: focus on either small projects for quick learning or large projects for meaningful impact, while avoiding the medium-sized trap.Mon, 16 Dec 2024 12:00:00 GMT<p>When it comes to creative work, personal growth is rarely a straight path. Whether you’re a developer, designer, writer, or artist, the question of what to focus on can feel daunting. Over the years, I’ve developed a personal framework to help me stay on track: The Rule of Small and Large.</p> <p>The Rule of Small and Large is simple: Every project I take on is either small or large — I avoid medium-sized projects at all costs. Why? Small projects let you learn quickly, experiment freely, and explore new ideas with minimal risk. Large projects, on the other hand, challenge you to go deep, tackle complex problems, and build something truly meaningful. Medium projects often fall into a frustrating trap — they demand too much effort for too little reward.</p> <h3>Small</h3> <p>Small projects allow you to experiment quickly, see results, and learn with minimal effort. They’re perfect for testing ideas in a low-risk environment. For example, if I want to explore how a programming language solves a particular problem, I might build a quick prototype. The simpler the process, the more likely I am to start. If I had to download an IDE, compile the language from scratch, and read lengthy documentation, I’d probably abandon the idea altogether.</p> <p>The beauty of small projects is that they yield small but cumulative rewards. Each one builds on the last, expanding your skillset and perspective over time. Small projects also thrive on simplicity — lowering the barrier to entry makes it easier to start, which encourages creativity and experimentation. This principle doesn’t just help beginners, even experienced creators benefit from the freedom to explore without overcommitting. By keeping the stakes low, small projects let you test ideas, fail fast, and stay in a constant state of discovery.</p> <p>As an indie developer, I rely on small projects to stay self-sufficient. Whether I’m testing out a new design trend, experimenting with a marketing framework, or playing around with some unfamiliar code, these low-effort experiments validate ideas and help me grow. Small projects aren’t just about learning — they’re about exploring new territory and fueling the creative process.</p> <h3>Large</h3> <p>Large projects are for those big, ambitious ideas that go far beyond a quick prototype. They start with a vision — a real problem you’re determined to solve. But large projects are rarely straightforward. They demand that you solve countless smaller problems before you even get close to your end goal. It’s this process of breaking down complexity and overcoming challenges that makes large projects so rewarding.</p> <p>The deeper you go, the more you learn. Solving real problems introduces you to complexities you would never encounter in a small project. Let’s say you’re building the next big app for restaurant bookings. At a minimum, you’ll need to understand how time works. That might sound simple, but <a href="https://www.zainrizvi.io/blog/falsehoods-programmers-believe-about-time-zones/" target="_blank" rel="noopener noreferrer">time zones</a> aren’t so straightforward. Nor are <a href="https://www.mjt.me.uk/posts/falsehoods-programmers-believe-about-addresses/" target="_blank" rel="noopener noreferrer">addresses</a>, <a href="https://gist.github.com/rgs/6509585" target="_blank" rel="noopener noreferrer">prices</a>, or <a href="https://www.youtube.com/watch?v=m6ID-dv2f34" target="_blank" rel="noopener noreferrer">payments</a>. Large projects immerse you in real-world challenges that don’t fit neatly in a small project, sharpening your skills and teaching you lessons you wouldn’t gain otherwise.</p> <p>That’s the magic of large projects: they push you to go deep, master complexity, and create something valuable. If you have a goal, passion, and are willing to push your boundaries, the impact of a large project can be transformative.</p> <h3>Medium</h3> <p>If small projects provide you with breadth and large projects provide you with depth, what do medium-sized projects provide you? A trap.</p> <p>Medium-sized projects sit in an uncomfortable middle ground. They require more effort than small projects but don’t offer the depth or rewards of large projects. You might learn something, but it’s rarely enough to justify the energy spent. In my experience, medium-sized projects are the ones that most often leave me frustrated — or worse, burned out.</p> <p>This is why passion and purpose are so important. Large projects demand intrinsic motivation to push through their challenges, and if that motivation isn’t there, it’s often a sign your project isn’t worth scaling up. Medium projects tend to feel aimless — they’re not quite big enough to inspire you, but too involved to finish quickly. When you notice a lack of passion, it’s better to keep a project small, where the stakes are lower and the lessons come faster.</p> <p>Avoiding medium projects doesn’t mean abandoning ideas altogether — it means being intentional about the scope of your work. If something doesn’t excite you enough to warrant a large commitment, let it stay small. Forcing yourself to choose between small and large helps sharpen your focus and ensures that your energy will be spent where it matters most.</p> <h3>Small and Large</h3> <p>There’s little I love more than starting a new project. Each one feels like a space full of opportunity, a place for my creativity to blossom, and something I can put my heart and soul into. But as exciting as starting can be, I’ve learned that it’s important to know upfront where the project is going too.</p> <p>Small projects let me experiment and explore, offering quick lessons and opening my eyes to new ideas. Large projects challenge me to commit deeply, be intricate, and build something meaningful. Both provide a clear sense of purpose, which is why I’m happy to keep building projects small and large.</p> <p>The next time you start a project, pause and ask yourself: is this a small project or a large one? If the answer isn’t clear, you might be stepping into the trap of a medium project. By choosing to stay small or go big, you’ll ensure your energy is spent on work that inspires, challenges, and matters to you.</p>TopIndustryProductivityProgrammingJoe FabisevichEmpower Apps Podcast: Building Plinky as an Indie Developerhttps://build.ms/2024/11/20/empower-apps-podcast-building-plinky/https://build.ms/2024/11/20/empower-apps-podcast-building-plinky/Leo Dion and I discuss building Plinky from scratch, the technical decisions behind using SwiftUI and Vapor, creating delightful user experiences, and the journey from working at Twitter to becoming an indie developer.Wed, 20 Nov 2024 12:00:00 GMT<p>I joined <a href="https://twitter.com/leogdion" target="_blank" rel="noopener noreferrer">Leo Dion</a> on the Empower Apps podcast to discuss my journey building Plinky, a link-saving app that I created to solve my own organizational needs. We explored the technical stack behind Plinky, ranging from SwiftUI to Vapor to Postgres, as well as my custom offline persistence library, <a href="https://github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a>.</p> <p>During our conversation, we dove into the design philosophy behind Plinky, focusing on making it playful, colorful, and customizable. Leo and I discussed the challenges of indie development, from agonizing over every color and icon choice to building features that work for both casual and power users. We also talked about everything coming to Plinky, like Link Reminders, and a native Mac app, as well as AI features that will elevate Plinky as a platform.</p> <ul> <li> <p><a href="https://www.empowerapps.show/177" target="_blank" rel="noopener noreferrer">Empower Apps Episode 177: Building Plinky with Joe Fabisevich</a></p> </li> <li> <p><a href="https://www.youtube.com/watch?v=CdamqOAEztY" target="_blank" rel="noopener noreferrer">Watch on YouTube</a></p> </li> <li> <p><a href="https://www.dropbox.com/scl/fi/k3pj7grl8rx0uqqbwtp4m/Empower-Apps-Plinky.mp3?rlkey=nz5po8xe3757shfes9xbxrbkn&amp;st=sm1uib3a&amp;dl=0" target="_blank" rel="noopener noreferrer">Original recording</a></p> </li> </ul>IndustryiOSPodcastProgrammingSwiftPlinkySwiftUIJoe FabisevichA Mental Model For Prompting Large Language Modelshttps://build.ms/2024/11/14/prompting-large-lanuage-models/https://build.ms/2024/11/14/prompting-large-lanuage-models/A practical analogy for understanding and improving how you work with AI tools like ChatGPT and Claude, moving beyond tips and tricks to develop better judgment.Thu, 14 Nov 2024 12:00:00 GMT<p>People are often disappointed when they use a Large Language Model (LLM) like <a href="https://chatgpt.com/" target="_blank" rel="noopener noreferrer">ChatGPT</a> or <a href="https://claude.ai/" target="_blank" rel="noopener noreferrer">Claude</a> for the first time. They’ve heard about this magical tool that helps people write code, edit blog posts, or suggest delicious recipes, but then Google Gemini tells them to <a href="https://www.theverge.com/2024/6/11/24176490/mm-delicious-glue" target="_blank" rel="noopener noreferrer">add glue to their pizza</a>, and the magic vanishes.</p> <p>There are countless guides out there on how to improve your prompts or get better answers. But tips and tricks only go so far. Instead, I’d like to share an analogy that might help you come up with better prompts using your own judgment. There is no magic in computers, even when using a computer feels magical.</p> <h3>Using An LLM</h3> <p>LLMs are trained by scouring data from across the internet <sup><a href="https://build.ms/2024/11/14/prompting-large-lanuage-models/#user-content-fn-1" target="_blank" rel="noopener noreferrer">1</a></sup>, and “learning” that information. <sup><a href="https://build.ms/2024/11/14/prompting-large-lanuage-models/#user-content-fn-2" target="_blank" rel="noopener noreferrer">2</a></sup> Imagine a world map where all this knowledge is stored in different places: financial advice in America, programming knowledge scattered across Asia, writing skills in Ireland, and so on.</p> <ul> <li>You open ChatGPT and ask for help with your portfolio.</li> <li>ChatGPT doesn’t know whether you mean a writing portfolio or a financial portfolio.</li> <li>Instead of asking for clarification, it assumes you mean financial advice. It tries to remember that by muttering to itself that this is a financial question, so from now on we’re going to look for all of our information in “America”.</li> <li>Next, you hand it a writing assignment about the stock market, hoping it will improve the quality of your writing.</li> <li>But instead of focusing on writing quality, ChatGPT invents some information about stocks.</li> <li>ChatGPT has just <a href="https://en.wikipedia.org/wiki/Hallucination_(artificial_intelligence)" target="_blank" rel="noopener noreferrer">hallucinated</a>.</li> </ul> <h3>Why LLMs Make Mistakes</h3> <p>To simplify, LLMs don’t actually know anything. Instead they take best guesses word by word <sup><a href="https://build.ms/2024/11/14/prompting-large-lanuage-models/#user-content-fn-3" target="_blank" rel="noopener noreferrer">3</a></sup> to try and predict the best next word, until it completes an answer to your question. A hallucination is a model’s best guess gone awry, when the model doesn’t have a high quality answer or has misinterpreted some context. Instead of pausing to think — because models can’t think — the model will instead continue on with its answer. To humans this reads as incorrect information, bullshitting, or even lying. But the model doesn’t really know good from bad, it just makes a statistically reasonable guess. <sup><a href="https://build.ms/2024/11/14/prompting-large-lanuage-models/#user-content-fn-4" target="_blank" rel="noopener noreferrer">4</a></sup></p> <p>This is why people sometimes think LLMs are useless — and it’s hard to blame them! But I’d like to show you how to make them work better for you, so you can get more value out of them.</p> <h3>Polite People Are Prompt</h3> <p>Rule #1 of successful prompting is to be as specific as possible. The more hints you give the model about what you want to know and where to look, the better its answers will be. In our earlier example, the model struggled because it couldn’t tell if you meant a financial or a writing portfolio, so it looked in the wrong place. The frustrating part? The model had the information you needed but it didn’t know where to find it.</p> <p>Let’s extend our map analogy a bit to show why hints are useful. Imagine you’re solving a programming problem. As we discussed, programming knowledge is scattered across Asia. The model knows most JavaScript expertise resides in Japan, while Swift knowledge is mostly in Korea — though there are exceptions.</p> <p>When we tell the model we’re looking to solve a JavaScript programming problem, we’re helping it focus on information in Japan, not South Korea. This avoids the earlier mistake of the model searching for writing advice in America, which is good because <a href="https://en.wikipedia.org/wiki/List_of_countries_by_literacy_rate" target="_blank" rel="noopener noreferrer">America ranks 36th in the world for literacy</a>. We can narrow it down further by specifying we want to use <a href="https://d3js.org/" target="_blank" rel="noopener noreferrer">d3.js</a>, guiding the model to look in Tokyo — specifically, Shibuya, where d3.js information “lives.”</p> <h3>Using An LLM (Better)</h3> <p>Let’s say I have another programming problem I want to solve. I give ChatGPT a code snippet that draws a simple multi-colored square on the screen. Then I tell ChatGPT:</p> <blockquote> <p>I want to make this darker</p> </blockquote> <ul> <li>First of all, what is this? The model has no idea.</li> <li>What color do you want to make darker?</li> <li>What does “darker” even mean? Do you want the multi-colored box to be one solid color, like black? Because that’s as dark as anything can be.</li> <li>Follow-up questions: How much darker? Do you have a specific percentage in mind? Which method should it use — adjust hue, saturation, RGB, luminance, etc?</li> </ul> <p>If a human would have follow-up questions for you, a model probably will too. You can answer those questions in advance or even instruct the LLM to ask you questions if anything is too ambiguous or needs clarification.</p> <hr /> <p>Now that we’ve anticipated some potential issues, we can compose a clearer prompt to describe the problem we’re trying to solve.</p> <blockquote> <p>I have a JavaScript code snippet that draws a simple multi-colored square on the screen. The colors are currently red, blue, green, and orange. I want a new color palette that’s slightly darker, but still vibrant so the colors pop.</p> </blockquote> <blockquote> <ul> <li>Please rewrite the code without using third-party dependencies or frameworks.</li> <li>Generate 10 color palette options that I can try in my project.</li> <li>Once I review them, I’ll let you know which one I like best, and I’d like 10 more options based on that.</li> </ul> </blockquote> <blockquote> <p>If anything is unclear or if you have any follow up questions that may help resolve ambiguities, please ask them before writing any code.</p> </blockquote> <p>See how much clearer this prompt is? It’s more likely to get a good answer because we were very specific about the problem we’re trying to solve. Even as I wrote it, I realized there were multiple ways to approach this problem and details I hadn’t initially considered. The process is a bit like teaching someone who’s never seen the problem before — talking it through helps clarify what needs to be done, both for them and for you.</p> <hr /> <p>Writing good prompts is like any skill — the more you do it the better you’ll get. While this analogy is just a starting point, I hope it demonstrates why good prompting is important and how it can help you make the most of this new, almost magical technology. With this technique we’ve brought a little more determinism into a non-deterministic system.</p> <p>If you’re interested in understanding how LLMs interpret your prompts, you might enjoy <a href="https://www.oneusefulthing.org/p/thinking-like-an-ai" target="_blank" rel="noopener noreferrer">this article</a> by Ethan Mollick. For a deeper dive, Stephen Wolfram’s <a href="https://writings.stephenwolfram.com/2023/02/what-is-chatgpt-doing-and-why-does-it-work" target="_blank" rel="noopener noreferrer">fascinating article</a> provides an in-depth explanation of how LLMs function. Finally, for advanced prompting techniques, <a href="https://arxiv.org/pdf/2302.11382.pdf" target="_blank" rel="noopener noreferrer">this paper</a> is an excellent resource to elevate your skills with LLMs.</p> <section><h2>Footnotes</h2> <ol> <li> <p>For the purposes of this explanation, let’s not get hung up on the fact that this is a legal and ethical gray area. <a href="https://build.ms/2024/11/14/prompting-large-lanuage-models/#user-content-fnref-1" target="_blank" rel="noopener noreferrer">↩</a></p> </li> <li> <p>For the purposes of this explanation, let’s not get hung up on the fact that LLMs are statistical models, and aren’t learning anything. <a href="https://build.ms/2024/11/14/prompting-large-lanuage-models/#user-content-fnref-2" target="_blank" rel="noopener noreferrer">↩</a></p> </li> <li> <p>This in itself is an oversimplification. LLMs actually use <a href="https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them" target="_blank" rel="noopener noreferrer">tokens</a>, which are a way to chunk letters together, and usually amount to 3-4 characters per token. <a href="https://build.ms/2024/11/14/prompting-large-lanuage-models/#user-content-fnref-3" target="_blank" rel="noopener noreferrer">↩</a></p> </li> <li> <p>There are a lot of techniques to minimize hallucinations, and to provide better information. This is partially why AI has advanced so much, so quickly, as we’ve done a great job of preventing LLMs from going off the rails. <a href="https://build.ms/2024/11/14/prompting-large-lanuage-models/#user-content-fnref-4" target="_blank" rel="noopener noreferrer">↩</a></p> </li> </ol> </section>TopAIIndustryProductivityJoe FabisevichMonarch: Minimal, Manageable, Migrations 🦋https://build.ms/2024/10/23/minimal-manageable-migrations-with-monarch/https://build.ms/2024/10/23/minimal-manageable-migrations-with-monarch/Introducing Monarch, a simple yet flexible migrations library for Swift that helps manage user data changes across app updates with minimal hassle.Wed, 23 Oct 2024 12:00:00 GMT<blockquote> <p>Note: When I <a href="https://fabisevi.ch/2022/04/01/goodbye-fellow-tweeps/" target="_blank" rel="noopener noreferrer">left Twitter</a> to become an indie developer I committed to helping others with my work as much as I could. My goal has always been to make the lives of developers a little easier, so they can turn their ideas into, apps, products, and businesses. Every week this month I will be open sourcing something I’ve built for <a href="https://plinky.app/download" target="_blank" rel="noopener noreferrer">Plinky</a>, my very own personal <a href="https://www.threads.com/@mergesort/post/DA6N6Q_PDX9" target="_blank" rel="noopener noreferrer">Open Source October</a>. My career rests on the foundation that others have created, and I want to continue paying it forward by lowering the barrier for others to make what matters to them. Now, onto the post.</p> </blockquote> <p>You don’t always get it right the first time. That’s why pencils have erasers — and why apps have migrations. In over two years of building <a href="https://plinky.app/download" target="_blank" rel="noopener noreferrer">Plinky</a>, I’ve made a lot of mistakes changes. I’ve fixed bugs, rebuilt features completely, and overhauled the app’s data architecture with <a href="https://github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a>.</p> <p>All these changes required migrating user data to prevent a broken experience. Initially, I used a straightforward solution, running a handful of functions on app launch. But to simplify things over time, <a href="https://github.com/mergesort/Monarch" target="_blank" rel="noopener noreferrer">Monarch</a> — a simple yet flexible migrations library.</p> <p><img alt="Photo of many Monarch butterflies" loading="lazy" width="1598" height="999" src="https://build.ms/_astro/monarch.BU38TVY-_21l3k1.webp" /></p> <blockquote> <p>Fun fact alert!</p> <p>Monarch is named after the Monarch butterfly, the only butterfly that makes a bird-like, bidirectional migration to escape the cold. In that way, the Monarch butterfly and I are similar. As I get older I too dread surviving another New York City winter.</p> </blockquote> <h3>Migrations (of the programmatic variety)</h3> <p>Migrations need to be minimal, manageable, and mistake-free. The more frequently they need to be run, the more true this is. People trust me with their links in Plinky, so I’m very serious about data loss and data corruption. Monarch eliminates the patchwork and complexity, allowing you to focus on writing clean, simple Swift code. Let’s dive into why Monarch is a good choice for your running migrations in your app.</p> <ul> <li> <p><strong>Simple Migrations</strong>: Define independent migrations without worrying about state management, making it easy to evolve your app over time.</p> </li> <li> <p><strong>Built for Swift and SwiftUI</strong>: Monarch’s migrations feel right at home in any Swift app, with a clean SwiftUI API.</p> </li> <li> <p><strong>Dependency Injection</strong>: Whether your migration is simple or complex, Monarch’s easy-to-use dependency injection provides structure to prevent data-related issues.</p> </li> </ul> <p>Instead of just telling you how simple Monarch is, let me show you.</p> <p>Imagine this scenario: You’ve been storing user data for your app in UserDefaults. Your app is doing well, and now you’re ready to build a new feature which requires a share extension. It would be helpful to have this user data available to you in the share extension, which means migrating the data from UserDefaults to an AppGroup shared between your app and share extension.</p> <p>First, let’s define a migration.</p> <pre><code><span><span>struct</span><span> MigrateUserDataToAppGroup</span><span>: </span><span>Migration </span><span>{</span></span> <span><span> @MigrationDependency</span><span> private</span><span> var</span><span> userDefaultsAppState</span></span> <span><span> @MigrationDependency</span><span> private</span><span> var</span><span> sharedAppState</span></span> <span></span> <span><span> static</span><span> let</span><span> id: MigrationID </span><span>=</span><span> "MigrateUserDataToAppGroup"</span></span> <span></span> <span><span> func</span><span> run</span><span>() </span><span>async</span><span> throws</span><span> {</span></span> <span><span> // Migrate the user data from UserDefaults to an AppGroup, so you can share data across targets</span></span> <span><span> sharedAppState.userData </span><span>=</span><span> userDefaultsAppState.userData</span></span> <span></span> <span><span> userDefaultsAppState.userData </span><span>=</span><span> nil</span></span> <span><span> }</span></span> <span><span>}</span></span></code></pre> <p>Next, we’ll create a <code>MigrationGroup</code>. This group lets us inject any necessary dependencies from our app, making them accessible to each <code>Migration</code> within the group.</p> <pre><code><span><span>let</span><span> migrations </span><span>=</span><span> MigrationGroup</span><span> {</span></span> <span><span> MigrateUserDataToAppGroup</span><span>()</span></span> <span><span> // Add more migrations here</span></span> <span><span>}</span></span> <span><span>.</span><span>migrationDependency</span><span>(</span><span>self</span><span>.userDefaultsAppState)</span></span> <span><span>.</span><span>migrationDependency</span><span>(</span><span>self</span><span>.sharedAppState)</span></span></code></pre> <p>Finally, we run our migrations!</p> <pre><code><span><span>struct</span><span> ContentView</span><span>: </span><span>View </span><span>{</span></span> <span><span> var</span><span> body: </span><span>some</span><span> View {</span></span> <span><span> Text</span><span>(</span><span>"Hello, Monarch! 🦋"</span><span>)</span></span> <span><span> .</span><span>runMigrations</span><span> {</span></span> <span><span> migrations</span></span> <span><span> }</span></span> <span><span> }</span></span> <span><span>}</span></span></code></pre> <p>How easy was that? In just a few lines of code, we’ve moved our user data from UserDefaults to an AppGroup. While you could write this code and all of the boilerplate necessary yourself, Monarch makes it seamless to focus on what matters. As your migrations grow, this structure helps prevent bugs and race conditions.</p> <h3>Advanced Usage</h3> <p>While migrations often solve problems, occasionally they may introduce new ones. In such cases, developers need more control. Monarch offers a <code>MigrationRunner</code>, which lets you build custom and complex migration pipelines. In the small app below, we’ll explore how <code>MigrationRunner</code> can manage multiple migration tasks.</p> <pre><code><span><span>struct</span><span> ButterflyTrackerApp</span><span>: </span><span>App </span><span>{</span></span> <span><span> @State</span><span> var</span><span> appState </span><span>=</span><span> AppState</span><span>()</span></span> <span><span> @State</span><span> var</span><span> preferences </span><span>=</span><span> Preferences</span><span>()</span></span> <span><span> </span></span> <span><span> var</span><span> body: </span><span>some</span><span> Scene {</span></span> <span><span> WindowGroup</span><span> {</span></span> <span><span> ContentView</span><span>()</span></span> <span><span> .</span><span>task</span><span> { </span></span> <span><span> try</span><span> await</span><span> self</span><span>.</span><span>runMigrations</span><span>()</span></span> <span><span> }</span></span> <span><span> }</span></span> <span><span> }</span></span> <span><span> </span></span> <span><span> func</span><span> runMigrations</span><span>() </span><span>async</span><span> throws</span><span> {</span></span> <span><span> // 1. Remove all the migrations</span></span> <span><span> MigrationRunner.</span><span>removeAllMigrations</span><span>()</span></span> <span><span> </span></span> <span><span> // 2. Mark the ResetButterflyListMigration as completed</span></span> <span><span> MigrationRunner.</span><span>markMigrationAsCompleted</span><span>(</span><span>withID</span><span>: ResetButterflyListMigration.id)</span></span> <span></span> <span><span> // 3. Create a MigrationGroup, with two dependencies and two migrations to run.</span></span> <span><span> let</span><span> migrationGroup </span><span>=</span><span> MigrationGroup</span><span> {</span></span> <span><span> ProvideButterlyFansPremiumAccountAccessMigration</span><span>()</span></span> <span><span> RemoveAccidentallyAddedMothMigration</span><span>()</span></span> <span><span> }</span></span> <span><span> .</span><span>migrationDependency</span><span>(</span><span>self</span><span>.appState)</span></span> <span><span> .</span><span>migrationDependency</span><span>(</span><span>self</span><span>.preferences)</span></span> <span><span> </span></span> <span><span> // 4. We run our migrations</span></span> <span><span> try</span><span> await</span><span> MigrationRunner.</span><span>runMigrations</span><span>({ migrationGroup })</span></span> <span><span> }</span></span> <span><span>}</span></span></code></pre> <p>In the example above we:</p> <ol> <li> <p>Removed all previous migrations, giving us a clean slate. (Note: You can also remove individual migrations without clearing all of them.)</p> </li> <li> <p>Marked the <code>ResetButterflyListMigration</code> as completed without running it. This allows us to skip a migration, which can be useful on a per-user basis or when transitioning from another migration system.</p> </li> <li> <p>Created a <code>MigrationGroup</code> with two migrations, which will run in the specified order. We also added two dependencies, to ensure access to the necessary state in our migrations.</p> </li> <li> <p>Ran our migrations! After all, that’s the goal.</p> </li> </ol> <hr /> <p>When I open source a project, I’m usually excited to share what’s coming next. Monarch is a handy tool, but as the Brits say, it “does exactly what it says on the tin.” It’s been a go-to solution for me over the last few years across multiple apps, and I hope it helps solve similar problems in your apps too.</p> <p>I’m always open to feedback, so I’d love to hear any thoughts or suggestions you may have. You can find me on <a href="https://threads.com/@mergesort" target="_blank" rel="noopener noreferrer">Threads</a>, where I welcome any and all polite discussions.</p>DatabasesEngineeringiOSOpen SourceSwiftJoe FabisevichBuilding A Customizable Framer Componenthttps://build.ms/2024/10/14/building-a-customizable-framer-component/https://build.ms/2024/10/14/building-a-customizable-framer-component/A technical walkthrough of building a customizable Framer Code Component for Buttondown email signups, complete with Property Controls and open source code.Mon, 14 Oct 2024 12:00:00 GMT<blockquote> <p>Note: When I <a href="https://fabisevi.ch/2022/04/01/goodbye-fellow-tweeps/" target="_blank" rel="noopener noreferrer">left Twitter</a> to become an indie developer I committed to helping others with my work as much as I could. My goal has always been to make the lives of developers a little easier, so they can turn their ideas into, apps, products, and businesses. Every week this month I will be open sourcing something I’ve built for <a href="https://plinky.app/download" target="_blank" rel="noopener noreferrer">Plinky</a>, my very own personal <a href="https://www.threads.com/@mergesort/post/DA6N6Q_PDX9" target="_blank" rel="noopener noreferrer">Open Source October</a>. My career rests on the foundation that others have created, and I want to continue paying it forward by lowering the barrier for others to make what matters to them. Now, onto the post.</p> </blockquote> <p>If you came here for the Buttondown Framer Component I’ve open sourced, you can skip the post below and grab the code on <a href="https://github.com/buttondown/framer" target="_blank" rel="noopener noreferrer">Buttondown’s official repo</a>. But if you happen to be interested in the technical details of how to build a customizable Framer Component, keep reading for a walkthrough.</p> <hr /> <p>When I launched Plinky’s <a href="https://links.plinky.app/" target="_blank" rel="noopener noreferrer">Links You’ll Love newsletter</a>, I knew I needed a beautiful landing page to set the right expectation for quality. The problem? I’m sick and tired of writing HTML, CSS, and JavaScript, with zero interest in building a landing page from scratch.</p> <p><img alt="A frowny penguin" loading="lazy" width="446" height="360" src="https://build.ms/_astro/pingu.DZqrCMAw_naCUb.webp" /></p> <p>I’d heard a lot about <a href="https://www.framer.com/" target="_blank" rel="noopener noreferrer">Framer</a> from my designer friends, and how easy Framer made it to <a href="https://build.ms/2024/10/14/building-a-customizable-framer-component/baselinedesign.com" target="_blank" rel="noopener noreferrer">build</a> <a href="https://www.tella.tv/" target="_blank" rel="noopener noreferrer">beautiful</a> <a href="https://tellet.ai/" target="_blank" rel="noopener noreferrer">websites</a>. The canvas felt familiar, like a more interactive version of Figma. Within half an hour of playing with Framer, I had a slick landing page with fancy animations that took only minutes to create. Despite a few <a href="https://www.framer.community/c/support/how-to-provide-rss-service#comment_wrapper_18924336" target="_blank" rel="noopener noreferrer">missing features</a> and minor quirks, I fell in love immediately.</p> <h3>Code Components</h3> <p>What really sold me on Framer wasn’t just the features it has, but how it handles what it doesn’t have. Framer provides common elements for website layouts, like Stacks, Images, and Text, but sometimes you need something custom. That’s where <a href="https://www.framer.com/developers/components/introduction" target="_blank" rel="noopener noreferrer">Code Components</a> come in.</p> <p>At its simplest, a Code Component is a React component, which means you can create just about anything with a bit of JavaScript.<sup><a href="https://build.ms/2024/10/14/building-a-customizable-framer-component/#user-content-fn-1" target="_blank" rel="noopener noreferrer">1</a></sup> There are also <a href="https://framer.university/resources" target="_blank" rel="noopener noreferrer">many</a> <a href="https://framer.supply/" target="_blank" rel="noopener noreferrer">pre-made </a><a href="https://framerthings.com/library" target="_blank" rel="noopener noreferrer">components</a> available, and best of all, they’re fully customizable.</p> <p>The magic of Framer’s Code Components lies in <a href="https://www.framer.com/developers/components/property-controls" target="_blank" rel="noopener noreferrer">Property Controls</a>, which are essentially <a href="https://react.dev/learn/passing-props-to-a-component" target="_blank" rel="noopener noreferrer">React props</a>. By adding Property Controls, Code Components become as customizable as any other Framer element. I’ll explain how this works in just a moment.</p> <p><img alt="Custom Framer Component" loading="lazy" width="1976" height="1182" src="https://build.ms/_astro/framer-custom-component.DosWC9rT_Z18TGA0.webp" /></p> <h3>Framer x Buttondown</h3> <p>I use <a href="https://buttondown.com/" target="_blank" rel="noopener noreferrer">Buttondown</a>, a popular indie email service, to deliver my <a href="https://links.plinky.app/" target="_blank" rel="noopener noreferrer">newsletter</a>. Buttondown is run by Justin Duke, a <a href="https://jmduke.com/" target="_blank" rel="noopener noreferrer">prolific writer</a> who’s <a href="https://buttondown.com/blog/category/guides-and-tips" target="_blank" rel="noopener noreferrer">very helpful</a> and <a href="https://jmduke.com/posts/post/marketing-site/" target="_blank" rel="noopener noreferrer">shares a lot of valuable lessons</a> — really an all-around great guy. While the default look of Buttondown’s email form is basic, I wanted something more polished for my website. This meant writing a lot of CSS, or… I could create a Code Component. 🤔</p> <p>I dove into the Framer docs, and once I had an understanding for how they worked, I fed that documentation to <a href="https://claude.ai/" target="_blank" rel="noopener noreferrer">Claude</a> to outsource the work of writing a template for my Code Component. The real fun began when I realized I could open source this component for other Framer users, and leaned into my love for building reusable abstractions.</p> <h3>Building A Code Component</h3> <p>The structure of a Framer Code Component is similar to any other React component. You import React, write functions, and use features like <a href="https://react.dev/learn/updating-objects-in-state" target="_blank" rel="noopener noreferrer">State</a> and <a href="https://react.dev/learn/synchronizing-with-effects" target="_blank" rel="noopener noreferrer">Effects</a>. Most of the time you’re just writing standard React so I’ll skip over some details, but you can view the complete code for this component <a href="https://github.com/buttondown/framer" target="_blank" rel="noopener noreferrer">here</a>.</p> <pre><code><span><span>import</span><span> React, { useState, useEffect } </span><span>from</span><span> "react"</span></span> <span><span>import</span><span> { addPropertyControls, ControlType } </span><span>from</span><span> "framer"</span></span> <span></span> <span><span>export</span><span> default</span><span> function</span><span> ButtondownForm</span><span>(</span><span>props</span><span>) {</span></span> <span><span> const</span><span> {</span></span> <span><span> newsletterConfiguration</span><span>,</span></span> <span><span> text</span><span>,</span></span> <span><span> style</span><span>,</span></span> <span><span> textFieldFont</span><span>,</span></span> <span><span> buttonFont</span><span>,</span></span> <span><span> backgroundColor</span><span>,</span></span> <span><span> submitButtonColors</span><span>,</span></span> <span><span> margin</span><span>,</span></span> <span><span> cornerRadius</span><span>,</span></span> <span><span> borderColor</span><span>,</span></span> <span><span> borderWidth</span><span>,</span></span> <span><span> ...</span><span>rest</span></span> <span><span> } </span><span>=</span><span> props</span></span> <span><span> </span></span> <span><span> const</span><span> [</span><span>email</span><span>, </span><span>setEmail</span><span>] </span><span>=</span><span> useState</span><span>(</span><span>""</span><span>)</span></span> <span><span> const</span><span> [</span><span>formState</span><span>, </span><span>setFormState</span><span>] </span><span>=</span><span> useState</span><span>(</span><span>"idle"</span><span>) </span><span>// idle, invalid, submitting, success</span></span> <span></span> <span><span> // A *lot* more code here...</span></span> <span></span> <span><span> useEffect</span><span>(() </span><span>=&gt;</span><span> {</span></span> <span><span> if</span><span> (formState </span><span>===</span><span> "success"</span><span>) {</span></span> <span><span> const</span><span> timer</span><span> =</span><span> setTimeout</span><span>(() </span><span>=&gt;</span><span> {</span></span> <span><span> setFormState</span><span>(</span><span>"idle"</span><span>)</span></span> <span><span> setEmail</span><span>(</span><span>""</span><span>)</span></span> <span><span> }, </span><span>2000</span><span>)</span></span> <span><span> return</span><span> () </span><span>=&gt;</span><span> clearTimeout</span><span>(timer)</span></span> <span><span> }</span></span> <span><span> }, [formState])</span></span> <span></span> <span><span> const</span><span> handleSubmit</span><span> =</span><span> (</span><span>e</span><span>) </span><span>=&gt;</span><span> {</span></span> <span><span> // It's me, I'm handling submit!</span></span> <span><span> }</span></span> <span></span> <span><span> const</span><span> getButtonContent</span><span> =</span><span> () </span><span>=&gt;</span><span> {</span></span> <span><span> // It's me, I'm getting the button content!</span></span> <span><span> }</span></span> <span></span> <span><span> const</span><span> getButtonStyle</span><span> =</span><span> () </span><span>=&gt;</span><span> {</span></span> <span><span> // It's me, I'm getting the button style!</span></span> <span><span> }</span></span> <span></span> <span><span> return</span><span> (</span></span> <span><span> &lt;&gt;</span></span> <span><span> &lt;</span><span>style</span><span>&gt;{</span><span>`</span></span> <span><span> // Your neat CSS bits</span></span> <span><span> `</span><span>}&lt;/</span><span>style</span><span>&gt;</span></span> <span><span> &lt;</span><span>form</span></span> <span><span> className</span><span>=</span><span>"buttondown-form"</span></span> <span><span> action</span><span>=</span><span>{newsletterConfiguration.embedURL}</span></span> <span><span> method</span><span>=</span><span>"post"</span></span> <span><span> target</span><span>=</span><span>{</span></span> <span><span> newsletterConfiguration.onSubmit </span><span>&amp;&amp;</span></span> <span><span> newsletterConfiguration.onSubmit.</span><span>trim</span><span>() </span><span>!==</span><span> ""</span></span> <span><span> ?</span><span> "popupwindow"</span></span> <span><span> :</span><span> undefined</span></span> <span><span> }</span></span> <span><span> onSubmit</span><span>=</span><span>{handleSubmit}</span></span> <span><span> style</span><span>=</span><span>{{</span></span> <span><span> ...</span><span>newsletterConfiguration,</span></span> <span><span> ...</span><span>formStyle,</span></span> <span><span> ...</span><span>style,</span></span> <span><span> backgroundColor,</span></span> <span><span> borderRadius: cornerRadius.form,</span></span> <span><span> borderColor,</span></span> <span><span> borderWidth,</span></span> <span><span> borderStyle: borderWidth </span><span>&gt;</span><span> 0</span><span> ?</span><span> "solid"</span><span> :</span><span> "none"</span><span>,</span></span> <span><span> }}</span></span> <span><span> &gt;</span></span> <span><span> &lt;</span><span>input</span></span> <span><span> type</span><span>=</span><span>"email"</span></span> <span><span> name</span><span>=</span><span>"email"</span></span> <span><span> id</span><span>=</span><span>"bd-email"</span></span> <span><span> value</span><span>=</span><span>{email}</span></span> <span><span> onChange</span><span>=</span><span>{(</span><span>e</span><span>) </span><span>=&gt;</span><span> setEmail</span><span>(e.target.value)}</span></span> <span><span> placeholder</span><span>=</span><span>{text.placeholder}</span></span> <span><span> style</span><span>=</span><span>{{</span></span> <span><span> ...</span><span>inputStyle,</span></span> <span><span> ...</span><span>textFieldFont,</span></span> <span><span> borderRadius: cornerRadius.input,</span></span> <span><span> }}</span></span> <span><span> required</span></span> <span><span> /&gt;</span></span> <span><span> &lt;</span><span>button</span></span> <span><span> type</span><span>=</span><span>"submit"</span></span> <span><span> style</span><span>=</span><span>{</span><span>getButtonStyle</span><span>()}</span></span> <span><span> onMouseEnter</span><span>=</span><span>{() </span><span>=&gt;</span><span> setIsHovered</span><span>(</span><span>true</span><span>)}</span></span> <span><span> onMouseLeave</span><span>=</span><span>{() </span><span>=&gt;</span><span> setIsHovered</span><span>(</span><span>false</span><span>)}</span></span> <span><span> disabled</span><span>=</span><span>{formState </span><span>===</span><span> "submitting"</span><span>}</span></span> <span><span> &gt;</span></span> <span><span> {</span><span>getButtonContent</span><span>()}</span></span> <span><span> &lt;/</span><span>button</span><span>&gt;</span></span> <span><span> &lt;</span><span>label</span><span> htmlFor</span><span>=</span><span>"bd-email"</span><span> style</span><span>=</span><span>{srOnlyStyle}&gt;</span></span> <span><span> Enter your email</span></span> <span><span> &lt;/</span><span>label</span><span>&gt;</span></span> <span><span> &lt;/</span><span>form</span><span>&gt;</span></span> <span><span> &lt;/&gt;</span></span> <span><span> )</span></span> <span><span>}</span></span></code></pre> <p>This component has various customizable properties, like <code>newsletterConfiguration</code>, <code>formStyle</code>, <code>text</code>, making it adaptable to different design needs. But the real power of a Framer Code Component comes from the <code>addPropertyControls</code> function.</p> <p>By adding Property Controls, we can make this form as customizable as any other standard Framer element. This is what allows us to transform the Buttondown form I built for my website, and turn it into a form that anyone can personalize for their website.</p> <pre><code><span><span>addPropertyControls</span><span>(ButtondownForm, {</span></span> <span><span> newsletterConfiguration: {</span></span> <span><span> type: ControlType.Object,</span></span> <span><span> title: </span><span>"Newsletter"</span><span>,</span></span> <span><span> controls: {</span></span> <span><span> embedURL: {</span></span> <span><span> type: ControlType.String,</span></span> <span><span> title: </span><span>"Embed URL"</span><span>,</span></span> <span><span> },</span></span> <span><span> onSubmit: {</span></span> <span><span> type: ControlType.String,</span></span> <span><span> title: </span><span>"onSubmit"</span><span>,</span></span> <span><span> displayTextArea: </span><span>true</span><span>,</span></span> <span><span> },</span></span> <span><span> },</span></span> <span><span> },</span></span> <span><span> textFieldFont: {</span></span> <span><span> type: ControlType.Object,</span></span> <span><span> title: </span><span>"Input Font"</span><span>,</span></span> <span><span> controls: {</span></span> <span><span> fontFamily: { type: ControlType.String, title: </span><span>"Family"</span><span> },</span></span> <span><span> fontSize: { type: ControlType.String, title: </span><span>"Size"</span><span> },</span></span> <span><span> fontWeight: {</span></span> <span><span> type: ControlType.Enum,</span></span> <span><span> title: </span><span>"Weight"</span><span>,</span></span> <span><span> options: [</span></span> <span><span> "Normal"</span><span>,</span></span> <span><span> "Bold"</span><span>,</span></span> <span><span> "100"</span><span>,</span></span> <span><span> "200"</span><span>,</span></span> <span><span> "300"</span><span>,</span></span> <span><span> "400"</span><span>,</span></span> <span><span> "500"</span><span>,</span></span> <span><span> "600"</span><span>,</span></span> <span><span> "700"</span><span>,</span></span> <span><span> "800"</span><span>,</span></span> <span><span> "900"</span><span>,</span></span> <span><span> ],</span></span> <span><span> },</span></span> <span><span> },</span></span> <span><span> },</span></span> <span><span> backgroundColor: { type: ControlType.Color, title: </span><span>"Background Color"</span><span> },</span></span> <span><span> text: {</span></span> <span><span> type: ControlType.Object,</span></span> <span><span> title: </span><span>"Text"</span><span>,</span></span> <span><span> controls: {</span></span> <span><span> placeholder: {</span></span> <span><span> type: ControlType.String,</span></span> <span><span> title: </span><span>"Placeholder"</span><span>,</span></span> <span><span> },</span></span> <span><span> submitButton: {</span></span> <span><span> type: ControlType.String,</span></span> <span><span> title: </span><span>"Button Text"</span><span>,</span></span> <span><span> },</span></span> <span><span> },</span></span> <span><span> },</span></span> <span><span> borderWidth: {</span></span> <span><span> type: ControlType.Number,</span></span> <span><span> title: </span><span>"Border Width"</span><span>,</span></span> <span><span> min: </span><span>0</span><span>,</span></span> <span><span> max: </span><span>10</span><span>,</span></span> <span><span> step: </span><span>1</span><span>,</span></span> <span><span> },</span></span> <span><span>})</span></span></code></pre> <p>The <code>addPropertyControls</code> function allows us to define these customization options using Framer primitives like <code>ControlType.String</code>, <code>ControlType.Number</code>, <code>ControlType.Color</code>, and <code>ControlType.Object</code>. These controls turn into beautiful configuration panels in Framer, where users can tweak settings to match their design preferences.</p> <p><img alt="Custom Framer Component" loading="lazy" width="1976" height="1743" src="https://build.ms/_astro/framer-custom-component-customized.BG7Fe8C0_14soN9.webp" /></p> <p>Now, we have a fully customizable Framer Component, ready for any Buttondown newsletter, whether your favorite color is red, blue, green, or even my favorite color in <a href="https://plinky.app/download" target="_blank" rel="noopener noreferrer">Plinky</a>, pink.</p> <hr /> <p>Speaking of newsletters, if you enjoy thought-provoking and enjoyable articles delivered straight to your inbox, I’d love for you to see the form I made for <a href="https://links.plinky.app/" target="_blank" rel="noopener noreferrer">my Buttondown newsletter</a>, and perhaps even sign up..</p> <p>And if you have any thoughts or suggestions, I’m always available on <a href="https://threads.com/@mergesort" target="_blank" rel="noopener noreferrer">Threads</a>. I welcome any and all polite discussions.</p> <section><h2>Footnotes</h2> <ol> <li> <p>Although I started off this post by saying I didn’t want to write JavaScript (or TypeScript), I find writing this kind of code is a lot more enjoyable than laying out a responsive website that works across half a dozen browsers. <a href="https://build.ms/2024/10/14/building-a-customizable-framer-component/#user-content-fnref-1" target="_blank" rel="noopener noreferrer">↩</a></p> </li> </ol> </section>TopEngineeringOpen SourceTutorialWebJoe FabisevichIntroducing Recap: What's Old Is What's New Againhttps://build.ms/2024/10/9/introducing-recap/https://build.ms/2024/10/9/introducing-recap/Open sourcing Recap, a Swift library for creating beautiful What's New screens to highlight app updates and keep users informed about new features.Wed, 09 Oct 2024 12:00:00 GMT<blockquote> <p>Note: When I <a href="https://fabisevi.ch/2022/04/01/goodbye-fellow-tweeps/" target="_blank" rel="noopener noreferrer">left Twitter</a> to become an indie developer I committed to helping others with my work as much as I could. My goal has always been to make the lives of developers a little easier, so they can turn their ideas into, apps, products, and businesses. Every week this month I will be open sourcing something I’ve built for <a href="https://plinky.app/download" target="_blank" rel="noopener noreferrer">Plinky</a>, my very own personal <a href="https://www.threads.com/@mergesort/post/DA6N6Q_PDX9" target="_blank" rel="noopener noreferrer">Open Source October</a>. My career rests on the foundation that others have created, and I want to continue paying it forward by lowering the barrier for others to make what matters to them. Now, onto the post.</p> </blockquote> <p>What’s New? Nm, what’s new witchu?</p> <p>We’ve all experienced launching one of our favorite apps, and seeing it’s had a big update. When that happens you’ll be greeted by screen that says “What’s New”, even Apple’s apps do this. The What’s New Screen is a great way to use one of the few moments an app has a user’s attention, to let them know about the new value you’ve delivered.</p> <p><img alt="Apple What&amp;#x27;s New Screens" loading="lazy" width="4077" height="2736" src="https://build.ms/_astro/apple-screens.QARJ6Bon_Z1IFRcO.webp" /></p> <p>Since launching <a href="https://plinky.app/download" target="_blank" rel="noopener noreferrer">Plinky</a> I’ve released an update every month. With so many new features, I wanted to ensure that people know I’m adding a lot of value to Plinky. This led me to build my own version of a What’s New Screen, in a library I call <a href="https://github.com/mergesort/Recap" target="_blank" rel="noopener noreferrer">Recap</a>.</p> <p>I had a few key priorities in mind when building my version of a What’s New screen:</p> <ol> <li> <p>Comprehensive version history: I wanted to showcase all the value I’ve added over time, so the What’s New Screen can scroll back through every major update. This is especially useful for new users, who can easily discover Plinky’s full feature set with each update.</p> </li> <li> <p>Effortless updates: With monthly app releases, adding new features to the What’s New screen needed to be quick and straightforward. The release process is stressful enough, so I wanted to avoid adding more hurdles to it.</p> </li> <li> <p>On-brand design: Plinky is known for being colorful, playful, and customizable. I wanted my What’s New screen to embody these qualities, while building Recap to fit in with any app’s design language.</p> </li> </ol> <p>I built a prototype of a What’s New Screen in a few hours, and quickly decided to open source the project. My goal is to help developers focus on what they do best — creating value for their users. By <a href="https://github.com/mergesort" target="_blank" rel="noopener noreferrer">open sourcing code</a> that solves common problems, I hope to contribute to that mission.</p> <h3>Recap</h3> <p><a href="https://github.com/mergesort/Recap" target="_blank" rel="noopener noreferrer">Recap</a> is a What’s New Screen that requires just two lines of code to implement, but it offers much more. It delivers on all the priorities I mentioned earlier: it’s easy to integrate, highly customizable, and handles several common implementation challenges.</p> <h4>No Code Updates</h4> <p>Recap’s standout feature is its markdown-powered release list. Here’s how simple it is to implement:</p> <pre><code><span><span>// Initialize releases from a markdown file in your app's bundle</span></span> <span><span>let</span><span> releases </span><span>=</span><span> ReleasesParser</span><span>(</span><span>fileName</span><span>: </span><span>"Releases"</span><span>).releases</span></span> <span></span> <span><span>// Create and display the RecapScreen</span></span> <span><span>RecapScreen</span><span>(</span><span>releases</span><span>: releases)</span></span></code></pre> <p>Updating your What’s New Screen is as easy as editing a markdown file — no code changes required. The simple markdown spec lets you create new versions in minutes:</p> <pre><code><span><span># Version Number</span></span> <span><span>## Release Title</span></span> <span><span>### Change Type</span></span> <span></span> <span><span>-</span><span> title</span></span> <span><span>-</span><span> description</span></span> <span><span>-</span><span> symbol</span></span> <span><span>-</span><span> color</span></span></code></pre> <p>And here’s a concrete example of what a 1.0 release for Recap might look like:</p> <pre><code><span><span># 1.0</span></span> <span><span>## Introducing Recap 🥳</span></span> <span><span>### Major</span></span> <span></span> <span><span>-</span><span> title: Welcome to Recap</span></span> <span><span>-</span><span> description: It's live! 🥰</span></span> <span><span>-</span><span> symbol: party.popper</span></span> <span><span>-</span><span> color: </span><span>`#F72585`</span></span></code></pre> <h3>Customization</h3> <p><img alt="Plinky What&amp;#x27;s New Screens" loading="lazy" width="4077" height="2736" src="https://build.ms/_astro/plinky-screens.DEbofFiy_mnqc0.webp" /></p> <p>Now, let’s explore a more advanced example from Plinky. This version has icons, colors, gradients, and markdown support (including links). With just a bit of SwiftUI and markdown, we have a beautiful What’s New Screen. Each feature is defined by four elements: <code>title</code>, <code>description</code>, <code>symbol</code>, and <code>color</code>.</p> <pre><code><span><span># </span><span>3.0</span></span> <span><span>## New </span><span>in</span><span> Plinky </span><span>3.0</span></span> <span><span>### Major</span></span> <span></span> <span><span>-</span><span> title</span><span>:</span><span> Widgets, Widgets, Widgets </span></span> <span><span>-</span><span> description</span><span>:</span><span> Customize your Home Screen, Lock Screen, and Control Center with </span><span>27</span><span> new widgets to easily access your links.</span></span> <span><span>-</span><span> symbol</span><span>:</span><span> rectangle.on.</span><span>rectangle</span><span>.angled</span></span> <span><span>-</span><span> color</span><span>:</span><span> `</span><span>#F72585</span><span>`</span></span> <span></span> <span><span>-</span><span> title</span><span>:</span><span> Smarter Search </span></span> <span><span>-</span><span> description</span><span>:</span><span> Now, find all of your saved links from anywhere on your iPhone or iPad with our Spotlight integration.</span></span> <span><span>-</span><span> symbol</span><span>:</span><span> text.magnifyingglass</span></span> <span><span>-</span><span> color</span><span>:</span><span> `#</span><span>4895EF</span><span>`</span></span> <span></span> <span><span>-</span><span> title</span><span>:</span><span> Apple Intelligence Ready 🤖 </span></span> <span><span>-</span><span> description</span><span>:</span><span> Starting </span><span>in</span><span> iOS </span><span>18.1</span><span> the new and improved Siri will know about your saved links, making them available to apps like Calendar, Mail, Notes, and more.</span></span> <span><span>-</span><span> symbol</span><span>:</span><span> sparkles</span></span> <span><span>-</span><span> color</span><span>:</span><span> `#</span><span>8E44AD</span><span>`</span></span> <span></span> <span><span>-</span><span> title</span><span>:</span><span> Shiny New App Icons </span></span> <span><span>-</span><span> description</span><span>:</span><span> 33</span><span> new icon styles, including a special icon to match iOS </span><span>18</span><span>’s dark and tinted modes. [Pick your favorite](</span><span>https</span><span>:</span><span>//plinky.app/open/appIcons).</span></span> <span><span>-</span><span> symbol</span><span>:</span><span> app.badge</span></span> <span><span>-</span><span> color</span><span>:</span><span> `</span><span>#FFC933</span><span>`</span></span></code></pre> <p>Looking closely you’ll notice a subtle gradient on each icon. This is achieved using one of the many <code>recapScreen</code> modifiers, allowing you to fully align the What’s New Screen with your app’s design language. Here are a few examples to customize the icon fill mode, dismiss button style, and page indicators:</p> <pre><code><span><span>RecapScreen</span><span>(</span><span>releases</span><span>: releases)</span></span> <span><span> .</span><span>recapScreenIconFillMode</span><span>(.gradient)</span></span> <span><span> .</span><span>recapScreenDismissButtonStyle</span><span>(Color.pink, Color.white)</span></span> <span><span> .</span><span>recapScreenPageIndicatorColors</span><span>(</span><span>selected</span><span>: Color.pink, </span><span>deselected</span><span>: Color.gray)</span></span></code></pre> <p>Recap offers numerous customization options. You can adjust title colors, screen padding, backgrounds, and more. For a complete list of supported modifiers, I recommend checking out <a href="https://github.com/mergesort/Recap?tab=readme-ov-file#customize-your-release-screen" target="_blank" rel="noopener noreferrer">the documentation</a>.</p> <h4>Version History</h4> <p>You may have noticed that there are multiple pages of releases — a core feature that inspired Recap. This allows users to browse all your app’s features, simply by adding multiple releases to your markdown file.</p> <p>Recap also supports a <code>leadingView</code> and <code>trailingView</code>. In Plinky, I use these to showcase my roadmap on the first page and a support screen at the end. This is a nice way to keep users informed about upcoming features, and encourage their input.</p> <pre><code><span><span>RecapScreen</span><span>(</span></span> <span><span> releases</span><span>: releases</span></span> <span><span> leadingView</span><span>: {</span></span> <span><span> UpcomingRoadmapView</span><span>()</span></span> <span><span> }, </span><span>trailingView</span><span>: {</span></span> <span><span> SupportView</span><span>()</span></span> <span><span> }</span></span> <span><span>)</span></span></code></pre> <h4>Semantic Versioning</h4> <p>Determining when to show your What’s New Screen can be tricky, and worst of all, you only know if it worked after an app update. To address this, I’ve integrated a flexible semantic versioning helper into Recap.</p> <p>The SemanticVersion type converts string version numbers (like <code>Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString")</code>) into the familiar <a href="https://semver.org/" target="_blank" rel="noopener noreferrer">semver</a> format. The parser is forgiving, handling versions like 1.0.0, 2.0, or 3 with ease.</p> <p>Here’s an example of how to use semantic versioning to show the What’s New Screen only for major or minor version increases:</p> <pre><code><span><span>var</span><span> shouldDisplayRecapScreen: </span><span>Bool</span><span> {</span></span> <span><span> let</span><span> currentVersionNumberString </span><span>=</span><span> Bundle.main.</span><span>object</span><span>(</span><span>forInfoDictionaryKey</span><span>: </span><span>"CFBundleShortVersionString"</span><span>) </span><span>as?</span><span> String</span></span> <span><span> let</span><span> previousVersionNumberString </span><span>=</span><span> "1.0.0"</span><span> // You should decide the best way to store the user's last launched version number, UserDefaults is a useful option.</span></span> <span><span> </span></span> <span><span> guard</span><span> let</span><span> currentVersionString, previousVersionString </span><span>else</span><span> { </span><span>return</span><span> false</span><span> }</span></span> <span></span> <span><span> let</span><span> currentVersion </span><span>=</span><span> SemanticVersion</span><span>(</span><span>version</span><span>: currentVersionString)</span></span> <span><span> let</span><span> previousVersion </span><span>=</span><span> SemanticVersion</span><span>(</span><span>version</span><span>: previousVersionString)</span></span> <span></span> <span><span> return</span><span> currentVersion.major </span><span>&gt;</span><span> previousVersion.major </span><span>||</span><span> currentVersion.minor </span><span>&gt;</span><span> previousVersion.minor</span></span> <span><span>}</span></span></code></pre> <p>This will display the screen when updating from 1.0.0 to 1.1.0 or 2.0.0, but not for patch updates like 1.0.0 to 1.0.1.</p> <h4>What’s Next</h4> <p>We’ve covered the key features of Recap, but there’s more to explore. For a deeper dive, check out the code on <a href="https://github.com/mergesort/Recap" target="_blank" rel="noopener noreferrer">Github</a>. I believe Recap offers a simple yet powerful solution for What’s New Screens, adaptable to any app’s needs.</p> <p>I have plenty of new features in mind for Recap, and I’d love your input. If you have any thoughts or suggestions, I’m always available on <a href="https://threads.com/@mergesort" target="_blank" rel="noopener noreferrer">Threads</a>. I welcome any and all polite discussions.</p>EngineeringiOSOpen SourceSwiftSwiftUIJoe FabisevichIndie App Spotlight - Plinky Featurehttps://build.ms/2024/9/10/indie-app-spotlight-plinky-feature/https://build.ms/2024/9/10/indie-app-spotlight-plinky-feature/Plinky was featured in Indie App Spotlight Issue 20, where we discussed the app's origin story, design philosophy, technical implementation, and the journey of building a successful indie app.Tue, 10 Sep 2024 12:00:00 GMT<p>I was honored to have Plinky featured in <a href="https://indieappspotlight.com/p/issue-20-plinky" target="_blank" rel="noopener noreferrer">Indie App Spotlight Issue 20</a>. In this in-depth interview, I shared the complete story behind Plinky - from its origins as a simple solution for sharing links with my wife to becoming a powerful, customizable link-saving app used by thousands.</p> <p>This is an interview that I did with <a href="https://indieappspotlight.com/" target="_blank" rel="noopener noreferrer">Indie App Spotlight</a>, originally posted <a href="https://indieappspotlight.com/p/issue-20-plinky" target="_blank" rel="noopener noreferrer">on their blog</a>.</p> <h2>Issue 20 - Plinky</h2> <p>Hey everyone, In this issue we’re taking a look at Plinky created by Joe Fabisevich. Plinky is more than just a link-saving app; it also includes browser extensions and a lightweight macOS app. Its user interface (UI) stands out because it doesn’t rely on standard iOS UI elements. I particularly appreciate the ability to set up folders for various needs. Joe is certainly not taking it easy; just two months after launch, he released version 2.0, which offers even more customization options.</p> <h3>App Spotlight</h3> <p><a href="https://substackcdn.com/image/fetch/$s_!KWh0!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc863dc68-884d-409e-913b-f7a7340a88c0_1000x1000.jpeg" target="_blank" rel="noopener noreferrer"><img src="https://substackcdn.com/image/fetch/$s_!KWh0!,w_512,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc863dc68-884d-409e-913b-f7a7340a88c0_1000x1000.jpeg" alt="Image 1" /></a></p> <p>App Name : <a href="https://apps.apple.com/app/id1597187737" target="_blank" rel="noopener noreferrer">Plinky</a></p> <p>Developer : Joe Fabisevich - <a href="https://threads.net/@mergesort" target="_blank" rel="noopener noreferrer">Threads</a></p> <h3>What Is Plinky?</h3> <p>Plinky is an app that makes it really easy to save links to enjoy later. With Plinky’s share extensions on<a href="https://plinky.app/download" target="_blank" rel="noopener noreferrer">iPhone and iPad</a>, browser extensions for<a href="https://plinky.app/chrome" target="_blank" rel="noopener noreferrer">Chrome</a>,<a href="https://plinky.app/safari" target="_blank" rel="noopener noreferrer">Safari</a>, and<a href="https://plinky.app/firefox" target="_blank" rel="noopener noreferrer">Firefox</a>, a little menu bar app called<a href="https://plinky.app/mini" target="_blank" rel="noopener noreferrer">Plinky Mini,</a> and an open API, you can save links from anywhere with just one tap or click. When you’re ready to come back to your links, they’re all there in Plinky, complete with organizational tools like folders, tags, search, and more. There are plenty of apps that help you save links, but Plinky really shines in three ways. 1 - It’s quick and delightful to save a link in Plinky. I save a dozen links a day, and all of that time adds up. I made speed a priority in Plinky to save your links and get you back to what you were doing quickly. 2 - Plinky is incredibly customizable. After years of trying out every link-saving product, I was never able to find one that felt just right for me. I’ve gone to great lengths to make every aspect of Plinky customizable, so you can make it a home for your links. 3 - Plinky is highly extensible. There are some great read-it-later apps, but they’re not particularly good for the cute animal videos or funny tweets you want to show your partner. Some apps are great for archiving, but they’re not flexible enough to use as a todo list for your links. I focused on making Plinky work for all kinds of links, all kinds of workflows, and all kinds of people who want to save links for later.</p> <p><a href="https://substackcdn.com/image/fetch/$s_!cD0s!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2829cc17-158d-4777-849a-7ef4bc884906_5826x2796.png" target="_blank" rel="noopener noreferrer"><img src="https://substackcdn.com/image/fetch/$s_!cD0s!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2829cc17-158d-4777-849a-7ef4bc884906_5826x2796.png" alt="Image 2" /></a></p> <h3>Where did the idea for Plinky come from?</h3> <p>When I first started dating my now wife Colleen, I would send her links I saw throughout the day. I have to emphasize, it was a lot of links. Working at Twitter meant I came across so many interesting articles, recipes, and funny videos. While she loved that I was thinking of her, she also found it pretty distracting when her phone buzzed with all the links I’d send.</p> <p>She suggested saving the links for later so we could look at them together, at night once she was done working. I tried filling up the Notes app with links, but that quickly became a disorganized mess. I left dozens of browser tabs open, but I’d forget about them, only for more links to pile up. I tried adding links as todos in the Reminders app, but then I couldn’t actually use Reminders with the jumble of saved links. I just wanted one place that made it easy to find all my saved links, so one morning I woke up, told Colleen I had an idea for an app, and started working on Plinky.</p> <p>Within two weeks, I had a working app for the two of us. Then I started showing it to people, and they said they really wanted an app to help them organize their links. As an avid fan of read-it-later and archiving apps, I knew that solutions existed, but those apps didn’t really resonate with the people I showed Plinky to, at least not in the same way my little app did.</p> <p>I like to think of Plinky as my love letter to links, and to Colleen. Now that we have a place to save the links we come across throughout the day, and we set aside time every night to share them, what we call plink time.</p> <h3>What’s one stand out feature you’d like everyone to know about?</h3> <p>Plinky has always made it quick and easy to save a link, a value that I don’t want to compromise on. It’s also important for me to allow Plinky to grow more powerful, to match the workflows that people need from their link saving app.</p> <p>Plinky 2.0 solves this design challenge with a feature called Save Screen Actions. As more people began using Plinky, I noticed they had many different needs and ideas for what they wanted to do when saving a link. Some people wanted to add their link to a folder, many people have a more tag-oriented workflow and wanted to add tags to their link without opening the app, and others wanted lesser-used options like archive or pin to still be available when they needed them.</p> <p>When you save a link using Plinky’s share extension, the link saves quickly and lets you get back to what you were doing. But if you want to edit the link in any way, you do that by pressing any of the actions, and the timer that would have dismissed Plinky’s Save Screen pauses. After that, you’re free to add your link to a folder, pin it, copy the link, or perform any of the other available actions, and dismiss whenever you want.</p> <p>This is different from many other apps that treat the share extension either as a quick action or a full editing mode. I really wanted to bridge that divide and provide both. One of my favorite compliments about the feature so far was that “it’s something you have to try to get, but when you do it just makes sense”. In the spirit of showing rather than telling, I’ve got a little demo of how it works.</p> <h3>How do you promote Plinky?</h3> <p>I’m actually planning to write a blog post about this, but I’d say the #1 piece of advice I have for building a good marketing strategy is to marry an amazing product marketer. I’m incredibly grateful to Colleen for helping build a full go-to-market strategy for Plinky — I wouldn’t have had nearly as successful a launch without that. If you’re already married or aren’t romantically interested in any product marketers, then here’s what I use as my outline for launching an app:</p> <ul> <li> <p>Conduct user research. This may sound scary or buttoned-up but really it’s just taking 30 minutes to talk to a user. This will not only help you understand your product, but it will help you understand the value people are getting from it, which is crucial for crafting a great marketing message.</p> </li> <li> <p>Do competitor analysis. Download a bunch of apps in your space, sign up for their onboarding and marketing emails, then don’t touch anything to see how they try to reactivate you as a user. This will help you understand what features people are looking for, what your competitors offer, and where you have room to make your app stand out.</p> </li> <li> <p>Create a cohesive brand. Those first two bullet points will help you craft a message to share with the world. The hardest part for me is sticking to the main selling points, because I get really excited and love to talk about everything my app can do in the nerdiest ways.</p> </li> <li> <p>Collect emails early and often. This is highly underrated because no one really wants more email, but an email address is the one piece of connective glue we all have. In a world of scattered social networks and new apps popping up every week, email is the one place to reach people who have shown direct interest in hearing more about your product. Don’t be a jerk and don’t spam people with every little update, but reaching out occasionally to share the value you’ve added to your app is a great way to keep people in the loop about what you’re building.</p> </li> <li> <p>Tell Apple! Most people don’t know about <a href="https://appstore.com/promote" target="_blank" rel="noopener noreferrer">appstore.com/promote</a>, but you can literally tell Apple you want to be promoted. (This will be even easier in the fall, when Apple launches <a href="https://developer.apple.com/videos/play/wwdc2024/10063/" target="_blank" rel="noopener noreferrer">Nominations</a>.) I can’t say for sure because when Apple chooses to feature your app can be a bit of a mystery, but I’m confident that filling out the promotional form played a part in Plinky being <a href="https://plinky.app/app-store-feature" target="_blank" rel="noopener noreferrer">featured by Apple on the App Store</a> within two months of launch.</p> </li> </ul> <p>I did quite a few more things to launch Plinky, including leveraging my social media following, blogging, and PR, but I do need to save some tips for my own blog. 😉</p> <h3>How did you decide on the pricing?</h3> <p>Before shipping, I set aside time to do competitor analysis to make sure I understood the market I was entering. I’m really familiar with the product space — I’d probably tried more read-it-later or archiving apps than 99.9% of people on the planet before deciding to dedicate 2+ years of my life to building Plinky as an indie business.</p> <p>I settled on $3.99/month or $39.99/year for Plinky Pro for a few reasons:</p> <ol> <li> <p>The most expensive apps in this product category are about $60/year, and the cheapest decent quality apps are priced as low as a $10 one-time purchase. I’m building more than just an app — Plinky is a service with recurring costs, so I knew that a one-time purchase would be unsustainable and I’d have to go the subscription route. I didn’t yet feel I could match the $60/year apps feature for feature, but I knew from the beta that hundreds of people were using the app and finding it valuable.</p> </li> <li> <p>You can always change your price! It’s really easy to get caught up in finding the perfect price, but the only way you can figure it out is by testing the supply/demand curve of different prices and seeing what resonates with consumers. I chose $39.99 as a price that sat in the middle to high end of the category’s range, while still giving me flexibility to make adjustments up or down as needed.</p> </li> <li> <p>Speaking of price adjustments, I knew from the start that I wanted to leave room to run sales. The sale price needs to be high enough to support the costs of running the business, yet still offer a good deal. The $39.99/year price point is a sweet spot where I can afford to mark down Plinky Pro by ~50%, and still provide a high-quality product at just $1.60/month. My hope is that by the time their yearly subscription renews Plinky will have added a lot of value. I’m working on features like a Mac app, Link Reminders, Shared Folders, a dedicated reader mode, and much more, which will make the renewal price of $39.99 a great deal.</p> </li> </ol> <h3>How did you launch Plinky?</h3> <p>As I mentioned earlier, with Colleen’s help I had a great go-to-market plan. But I actually had three launches, intentionally so.</p> <ul> <li> <p>The first launch was just me, Colleen, and my friend <a href="https://mastodon.social/@soroush" target="_blank" rel="noopener noreferrer">Soroush</a>. Soroush helped put together Plinky’s payments backend two weeks before launch. Unfortunately, it’s pretty hard to test payments with an app that isn’t yet in the App Store, so I pushed the button to release Plinky and we tried out the payment flow. I’m glad we did this because we found two bugs and an edge case we hadn’t considered with redeeming Plinky Pro promo codes.</p> </li> <li> <p>I fixed those bugs over the next 24 hours, then flew out to Chicago for the <a href="https://deepdishswift.com/" target="_blank" rel="noopener noreferrer">Deep Dish Swift</a> conference. I soft-launched the app at the conference and offered personalized promo codes, netting me my first couple dozen subscribers. More importantly, I was at a conference with hundreds of iOS developers, people who love to share their opinions about apps. Even though I’d had hundreds of people using the app, watching completely new users go through the onboarding flow with no context was incredibly valuable. Many people were kind and diplomatic with their feedback, but I gently pressed them to tell me what worked and what didn’t. I could see where people were getting tripped up or didn’t understand what to do, so I asked them what they liked, what they didn’t get, and what they wanted the app to do, all of which helped me prioritize some quick fixes. I spent the next three days addressing everything in the app that wasn’t clear, including completely rebuilding the onboarding in a chaotic 8-hour haze of coding while conference talks happened around me.</p> </li> <li> <p>I officially launched the app a few days later, and it went beyond my wildest expectations. Not only was the initial reception from my community overwhelmingly positive, but having people who’d never heard of me start using the app was a sign that I was onto something. When I got my first subscriptions from countries like Greece, Brazil, and Bulgaria, I knew these had to be from people I didn’t personally know, and that I’d made an app that strangers liked enough to pay for. The best thing though was how many compliments I got about Plinky’s updated onboarding, given how tough it is to teach a non-technical person how to set up a share extension — that 8-hour coding haze fuelled by panic was well worth the result.</p> </li> </ul> <h3>How long did it take to get to the first version?</h3> <p>Way too long. I started working on Plinky while I was still working as an iOS developer at Twitter in 2021, as a small app for Colleen and myself. As I started showing Plinky to people, I kept hearing that they were looking for something like this. I left Twitter to pursue my dream of being a full-time indie developer in April 2022, and shipped a public beta in November. The beta grew to hundreds of users, which was amazing for getting feedback.</p> <p>Then ChatGPT happened. The afternoon OpenAI released their API, I spent about three hours building a small prototype to see if I could create an app that would let me use ChatGPT through Siri. I shared the prototype on Mastodon, and a bunch of people said they wanted this prototype as a full app. I reached out to my friend Soroush and asked if he wanted to help build a small Swift server to proxy API calls, because I couldn’t ship my OpenAI API keys in the app for fear someone would extract and use them.</p> <p>We built and shipped a complete ChatGPT app, <a href="https://shortcircuit.chat/" target="_blank" rel="noopener noreferrer">Short Circuit</a>, in just 19 days. We made about $1,000 in sales in the first weekend, which was more money than either of us had ever made from an indie app. A few days later, we noticed a ridiculous amount of sales coming through — our app had gotten featured on <a href="https://daringfireball.net/linked/2023/04/17/short-circuit" target="_blank" rel="noopener noreferrer">Daring Fireball</a>. This was a life-changing experience, not only because of the burst of money, but because it validated that I could build something valuable as an indie developer.</p> <p>We ended up spending about 3 months working on the app and built quite a few novel technologies and features. Despite that, I saw the writing on the wall when ChatGPT came out with their app. We debated the pros and cons of continuing to work on Short Circuit, but as much as we loved working together it made more sense for me to focus on finishing Plinky and building that into a sustainable business.</p> <p>It took another 8 months of work before Plinky shipped to the App Store, but I finally launched Plinky 1.0 in May 2024. From my first commit to launch, it was 920 days, 1,624 total commits, and 37,145 lines of code across the apps, server, and browser extensions — totalling over 1,500 hours of work. It was so much effort that I get tired just thinking about it, phew.</p> <p>Launching was a very emotional experience, especially because the entire time I was working on Plinky, I was dealing with an incredibly painful health matter. It drained a lot of my time and energy, but I’m really proud to have overcome all the obstacles in my way. The whole process taught me how strong I really am, that I have the ability to do pretty much anything I set my mind to.</p> <h3>Is there anything you’ve learnt that stands out?</h3> <p>While developing Plinky, I kept looking at my competitors and thinking, “If I want to compete with these apps, I need to have all their features — otherwise, who would choose my app over theirs?” But when I talked to users, I discovered that what I thought was important often differed from what they valued.</p> <p>I thought Plinky needed to launch with a reader mode because apps like Pocket or Instapaper offered them. I’d always used Pocket to read articles, but one user I spoke to was saving YouTube links, and she hated that Pocket tried to open videos in a reader mode. I’d never considered that a reader mode could be a negative feature, and ironically, the fact that Plinky was missing this feature was what drew her to the app.</p> <p>This insight led me to realize that I should build Plinky to be highly customizable, making it your home for links. If someone who loves to read could use Plinky for articles, while another person could customize it for watching YouTube videos, then I could expand beyond just the read-it-later market.</p> <p>Many similar light bulb moments have occurred when I’ve spoken to users of Plinky. I’m really glad that I genuinely enjoy listening to people and ask lots of questions to understand their needs. My favorite moment is when I build a feature and get to delight someone with an email telling them I’ve created what they asked for — and often hear back that I’ve exceeded their expectations.</p> <h3>Any tools or apps you would recommend others check out?</h3> <p>Have you heard of this great app named Plinky? Oh, you mean besides my app.</p> <p>If it wasn’t obvious already, I truly love apps. I could spend HOURS giving recommendations, so I’ll try to limit myself to just one.</p> <p>I’m a really big reader. I especially love articles, blog posts, and all of the amazing writing available across the internet. I use RSS to find much of that writing, and my go-to app for RSS is <a href="https://reederapp.com/" target="_blank" rel="noopener noreferrer">Reeder</a>. (Though I’d like to give an honorable mention to <a href="https://www.goldenhillsoftware.com/unread/" target="_blank" rel="noopener noreferrer">Unread</a>, which offers a beautiful reading experience, while being utterly charming and pleasant to use.)</p> <p>I’ve been using Reeder for over a decade, and it’s one of the few apps that has been great since day one. The app hasn’t changed dramatically, and yet every release is full of improvements, delight, and whimsy. It’s incredibly difficult to innovate without completely reinventing your product, but <a href="https://gloria.social/@rizzi" target="_blank" rel="noopener noreferrer">Silvio Rizzi</a> has achieved something truly remarkable with Reeder. He’s built an app that I’ve used daily for a decade with nothing but enjoyment.</p> <h3>What’s one tip you would give to someone starting out?</h3> <p>This is a tough question to answer because I’ve been building iOS apps for 15 years, and it’s hard to put myself in the shoes of someone just starting out. However, I do mentor many new developers, and I always give them one simple, yet powerful piece of advice: you won’t learn unless you make something.</p> <p>In today’s connected world, it’s never been easier to acquire knowledge. However, gaining wisdom requires experience. There are good ideas and bad ideas, and I almost don’t care what my mentees do, as long as they do something. Learning what’s good and bad takes experience, and when you look back you realize that the order you learned the lessons doesn’t really matter. To quote Steve Jobs, “You can’t connect the dots looking forward; you can only connect them looking backwards. So you have to trust that the dots will somehow connect in your future.” Many people fixate on potential failures, often overlooking the possibilities for success or growth, even from setbacks.</p> <p>To illustrate this point, I often share a very short <a href="https://typesense.org/blog/the-unreasonable-effectiveness-of-just-showing-up-everyday/" target="_blank" rel="noopener noreferrer">blog post</a> with people. You can be someone who tries to solve problems purely in their head, or you can put fingers to keyboard and make tangible progress towards your goals. The former path is full of infinite paths and unrealized possibilities, but the latter validates you to test and refine assumptions. This hands-on method not only helps you realize your vision but might even lead you to invent something entirely new — something you couldn’t have imagined just by thinking about it.</p> <h3>Where can everyone go to find out more?</h3> <p>You can find out more about pretty much anything on the internet! But if you’re asking specifically:</p> <ul> <li> <p>For Plinky, visit <a href="https://plinky.app/" target="_blank" rel="noopener noreferrer">plinky.app</a>. Better yet, <a href="https://plinky.app/download" target="_blank" rel="noopener noreferrer">download the app</a> and try it out — it’s something best experienced rather than explained.</p> </li> <li> <p>To keep up with Plinky updates, follow the <a href="https://www.threads.net/@plinky.app" target="_blank" rel="noopener noreferrer">Plinky</a> account on Threads. That’s where I share my work-in-progress, app updates, and (of course) pictures of red pandas.</p> </li> <li> <p>As for me, I’m probably too online for my own good, so you can find me posting frequently on <a href="https://threads.net/@mergesort" target="_blank" rel="noopener noreferrer">Threads</a>.</p> </li> </ul>iOSPlinkyDesignSwiftInterviewJoe FabisevichCompile Swift Podcast: Being Featured on the App Storehttps://build.ms/2024/8/19/compile-swift-podcast-season-7-episode-9/https://build.ms/2024/8/19/compile-swift-podcast-season-7-episode-9/Peter Witham and I discuss the incredible experience of having Plinky featured on the App Store, the journey of indie app development, and how doing good work creates opportunities.Mon, 19 Aug 2024 12:00:00 GMT<p>I joined <a href="https://twitter.com/compileswift" target="_blank" rel="noopener noreferrer">Peter Witham</a> on the Compile Swift podcast to share the story of Plinky being featured on the App Store. It’s such a rare recognition, one that’s rarer than getting into Harvard, and provided a lot of validation early after Plinky’s release.</p> <p>We also dove into the realities of indie app development, the importance of intrinsic motivation, solving genuine problems, and creating delightful user experiences. While technical excellence matters, it doesn’t outweigh setting achievable goals, the value of customer feedback, and a strong focus on marketing and product positioning. And we ended up chatting a lot about philosophy, which is not at all what I expected.</p> <ul> <li> <p><a href="https://compileswift.transistor.fm/s7/9" target="_blank" rel="noopener noreferrer">Compile Swift Podcast: Season 7, Episode 9 - Building Better iOS Apps</a></p> </li> <li> <p><a href="https://www.dropbox.com/scl/fi/k9qmhkvp6xq1k6is34tcx/Compile-Swift-Podcast-Plinky-and-being-featured-on-the-AppStore.mp3?rlkey=cpk6jtw71x27aeotx4v24dnyk&amp;st=rylmegrq&amp;dl=0" target="_blank" rel="noopener noreferrer">Original Recording</a></p> </li> </ul>iOSInterviewPodcastProgrammingSwiftJoe FabisevichIntroducing Plinky: My Love Letter To Linkshttps://build.ms/2024/5/9/introducing-plinky/https://build.ms/2024/5/9/introducing-plinky/The story behind Plinky, my personal app for saving and organizing links that makes it incredibly easy to save links for later.Thu, 09 May 2024 12:00:00 GMT<blockquote> <p>The post below was written by me, originally featured on the <a href="https://blog.plinky.app/2024/05/introducing-plinky-my-love-letter-to-links" target="_blank" rel="noopener noreferrer">Plinky blog</a>.</p> </blockquote> <blockquote> <p>To celebrate the launch of Plinky you can get 50% off of a yearly subscription by redeeming this offer: <a href="https://plinky.app/offer/REDPANDA" target="_blank" rel="noopener noreferrer">plinky.app/offer/REDPANDA</a></p> </blockquote> <p>There are few words I’ve ever said more excitedly than these: I want to tell you about my latest app, <a href="https://plinky.app/" target="_blank" rel="noopener noreferrer">Plinky</a>.</p> <p><img alt="Plinky Screenshots" loading="lazy" width="3000" height="1067" src="https://build.ms/_astro/plinky-screenshots.CbXexy5Q_Z1z5fXN.webp" /></p> <p>Plinky makes it incredibly easy to do something we do every day, save links for later. You may already have a way to save links, I know I’ve tried every method under the sun, to the point where I decided to build my own app. That app is Plinky, and today it’s <strong><a href="https://plinky.app/download" target="_blank" rel="noopener noreferrer">available to download on the App Store</a>.</strong> Over the last 18 months people have been loving Plinky, because it fixes the same problems I ran into when I’ve tried to save links in the past.</p> <ul> <li>I tried filling up Apple Notes with links to come back to later, but that proved to be a disorganized mess.</li> <li>I tried leaving browser tabs open with links I wanted to look at later, but the links I needed later got lost amongst the dozens of tabs I had open for right now.</li> <li>I even tried spending hundreds of hours using apps dedicated for reading articles later, but not all links are for reading.</li> </ul> <p>All of that led me to build Plinky, a home for your links.</p> <h3>Why I Built An App To Save Your Links</h3> <p>I love links. I love reading links, I love it when people send me links to funny videos, I love collecting links, and I love sharing links. Something else I love is my fiancée Colleen. Shortly after meeting Colleen discovered how much I love links, and I’m very grateful that she found that to be an endearing trait. While she loved that I was thinking of her and sharing interesting articles, recipes to cook together, or cute animals I knew she’d like, she quickly found it distracting to have her phone buzzing with all the links I’d share while she was working.</p> <p>She suggested saving the links for later, so we could look at them together at night. That’s when I started working on a small project, a home for these links, an app that would become Plinky. I started to show people the app I’d made for the two of us, and they loved it. They were all saving links they needed but were unhappy with one thing or another. They kept telling me that they wanted an app like this for themselves. A few months later when I left my job at Twitter to start <a href="https://redpanda.club/" target="_blank" rel="noopener noreferrer">my own company</a> building personal, playful, productivity apps, I decided that the app I needed to build for all of these people was Plinky.</p> <p>Plinky became more than an app to Colleen and I, it became an essential tool in our lives and a ritual. Sometimes Colleen would save a cute picture of a red panda for me, sometimes I would save an activity we could do next weekend, but every night the two of us would come together and share the links that we’d saved for each other. To this day we still have plink time, where we lay in bed and show each other the links we’ve for each other.</p> <hr /> <p>Links come in all forms. An article just isn’t the same as a Tweet or Instagram post you want to show a friend. A YouTube video or TikTok won’t even open in an app dedicated to reading. Many apps like LinkedIn or Facebook have their own bookmarking systems, but I’ve wasted hours bouncing from app to app trying to remember where that link I saw a month ago but need now is.</p> <p>As I’ve built Plinky I’ve heard people tell me about the myriad of techniques they have for saving and storing links. There are an unending amount of organizational methods people use to reference links they might need later, but none of those perfectly fit into one app.</p> <p>The experience of listening to Plinky’s beta testers led me to three realizations:</p> <ol> <li><strong>It needs to be easy to save a link</strong>, the simpler the better. Saving a link shouldn’t take work, it needs to be one tap, so you can easily save a link.</li> <li><strong>People’s workflows benefit from having a universal inbox for their links.</strong> It should be easy to organize and find links you’ve saved for later, otherwise why are you even saving them?</li> <li><strong>People aren’t always looking for an app dedicated to reading articles.</strong> The diversity of links people save means many people are looking for a tool that helps them do anything with the links they’ve saved, a Swiss army knife for their links.</li> </ol> <p>This is where Plinky comes in.</p> <h3>Plinky: Saving Links Made Effortless</h3> <p>Plinky makes it easy to save a link for later. You can save links from anywhere with just one tap. Whether you’re on the <a href="https://plinky.app/download" target="_blank" rel="noopener noreferrer">iPhone, iPad, or Mac</a>, in your favorite browser like <a href="https://plinky.app/integrations/chrome" target="_blank" rel="noopener noreferrer">Chrome</a>, <a href="https://plinky.app/integrations/firefox" target="_blank" rel="noopener noreferrer">Firefox</a>, and <a href="https://plinky.app/integrations/safari" target="_blank" rel="noopener noreferrer">Safari</a>, or want to build personalized workflows using integrations like <a href="https://plinky.app/integrations/zapier" target="_blank" rel="noopener noreferrer">Zapier</a>, <a href="https://plinky.app/integrations/unread" target="_blank" rel="noopener noreferrer">Unread</a>, <a href="https://support.apple.com/guide/shortcuts/welcome/ios" target="_blank" rel="noopener noreferrer">Shortcuts</a>, or <a href="https://plinky.app/docs/integrations-api" target="_blank" rel="noopener noreferrer">Plinky’s API</a>, saving a link for later couldn’t be easier.</p> <p>Plinky is highly customizable. You can make the app look, feel, and behave however you like. The links you save are very personal, as is what you want to do with them after they’re saved. People have all sorts of workflows, so it’s a core goal of mine to make Plinky easy enough for my mom to use (she really is a big user), yet something you can infinitely tweak to make Plinky a perfect place for <em>your</em> links.</p> <p>Plinky has organizational features like Folders, Tags, Search, and Pinned Links. Each of these provides an opportunity to mold Plinky to your needs, and makes it very easy to find your saved links later.</p> <h3>What’s Next For Plinky</h3> <p>I’m continuing to build upon these three pillars: ease, customization, and organization.</p> <p>Over the coming months Plinky will have the ability to:</p> <ul> <li><strong>Add timed reminders</strong> for your links, for the purpose of reviewing a link in a certain time or context.</li> <li><strong>Import your links</strong> from services like GoodLinks, Raindrop, Pocket, and other places you may already have your links saved.</li> <li><strong>Create Secure Folders</strong>, giving you a space for more sensitive links protected by TouchID and FaceID.</li> <li>Indulge in <strong>an elegant reading experience</strong> that lets you customize fonts, sizes, and background color for the ideal read it later experience.</li> <li>Enjoy a <strong>native Mac app</strong>, one that takes the beautiful design Plinky already provides on iPhone and iPad and makes it feels right at home on the Mac.</li> <li>Have <strong>more ways to build personalized workflows</strong> around your links. It’s easy to tag a link, but it should always be getting easier. Pinning links helps you prioritize some links over others, but the best experience will be even quicker. Plinky’s search is powerful but it can get even more helpful by searching every word of a webpage you save.</li> </ul> <h3>A Favor, If I May</h3> <p>If you’ve made it this far I want to say thank you to you for caring so much about the app I’ve made. Plinky is the result of more than 1,500 hours of work. If that doesn’t emphasize how much of my heart, sweat, and tears are in Plinky, I’m not sure what will. I’m truly excited about the idea of spending another 1,500 hours making Plinky better for you, then 1,500 more hours after that.</p> <p>If I may ask:</p> <ul> <li>Please <strong><a href="https://plinky.app/download" target="_blank" rel="noopener noreferrer">download Plinky</a> and try the app.</strong> I would love to know what you love about Plinky, what can be improved, or what I can do to make Plinky an integral part of your life.</li> <li>If you like the app, <strong>I would greatly appreciate a <a href="https://plinky.app/rate" target="_blank" rel="noopener noreferrer">rating or review</a>.</strong> Ratings decide the success or failure of apps the App Store, and as much as I hate to ask, I would like to succeed because that means I get to continue building an app that strives to make your links better.</li> <li><strong>Tell your friends.</strong> I hope it’s not too gauche to ask, but I genuinely believe that Plinky can help improve many people’s lives. Every person who tries Plinky may be a person who’s now using an app that makes their life a little better, and nothing in this world makes me happier getting a chance to help others.</li> </ul> <h3>Thank You</h3> <p>While you can say that I built this app, nothing in this world happens alone. I’ve had a lot of help along the way, and want to make sure anyone who’s helped even in the smallest way is honored in Plinky. Plinky has a Thank You section on the About Red Panda Club screen where I’ve assembled everyone who’s helped me while I’ve been building Plinky.</p> <p><img alt="Thank You Screen" loading="lazy" width="300" height="596" src="https://build.ms/_astro/thank-you-screen.D5n1JeTB_Z25r4PL.webp" /></p> <p><strong>The rest of this post was written for me and my loved ones, but you’re free to read it as well.</strong></p> <p>I would like to first mention my wonderful fiancée and soon to be wife Colleen. Thank you for supporting me with your love, your advice, your daily feedback, and the hours of hands on help you provided over the last few months in creating and solidifying Plinky’s launch plan. I love you, and because of you my dream is now a reality.</p> <p>Thank you to my family for cheering me on this whole time, without you I wouldn’t be the person I am today. Plinky is a personal expression of who I am and the way I want to help make the world a slightly easier to navigate place. If it wasn’t for you I wouldn’t have that perspective, so thank you for instilling those values in me.</p> <p>Thank you to Joey Banks for being a great friend, for our weekly calls, and for your help in shaping Plinky’s App Store screenshots and website. Because of you I was able to show the world what I’ve built in the way that reflects what I wanted people to see. While I’m here bragging about my friend, if you’re looking for the world’s foremost <a href="https://baselinedesign.io/" target="_blank" rel="noopener noreferrer">expert in Figma</a>, you should reach out to Joey.</p> <p>Thank you to all of the beta testers who have provided endless ideas, feedback, and guidance over the last 18 months. Plinky wouldn’t look or work the way it does today without your amazing ideas, and of course your extremely helpful bug reports. If you’ve provided a suggestion during Plinky’s beta then odds are it’s already been implemented, or is on the Plinky roadmap I’ve constructed for the next year and beyond.</p> <p>Thank you to anyone who’s helped me over the last few years, whether or not that involves Plinky. The last few years haven’t been easy, and while I can’t always be strong, I can’t always be brave, what I can always be is grateful and appreciative.</p> <p>Now if I may leave you with a few words: Be good to others, help those in need, and <a href="https://plinky.app/download" target="_blank" rel="noopener noreferrer">save a few links</a>.</p>TopAppsiOSJoe FabisevichThe Reasoning Computerhttps://build.ms/2023/11/10/the-reasoning-computer/https://build.ms/2023/11/10/the-reasoning-computer/How will we solve problems in a new era of computation, where the very foundations of how a computer is expected to work are no longer deterministic?Fri, 10 Nov 2023 12:00:00 GMT<p>The Turing test is dead, and we killed it. The <a href="https://en.wikipedia.org/wiki/Turing_test" target="_blank" rel="noopener noreferrer">Turing test</a> is a test of a machine’s ability to exhibit intelligent behavior equivalent to, or indistinguishable from, that of a human. From the 1940s <sup><a href="https://build.ms/2023/11/10/the-reasoning-computer/#user-content-fn-1" target="_blank" rel="noopener noreferrer">1</a></sup> to the 2010s people programmed computers, and computers could only do what they were programmed to do in a rules-based deterministic manner. Sometimes a person would program the computer and it would do something unexpected, but 100 out of 100 times the computer was doing what it was programmed to do whether the person liked it or not. While there has been experimentation with what today we call AI since the 1950s, those machines were a long ways away from passing the Turing test.</p> <p>Why does using <a href="https://chat.openai.com/" target="_blank" rel="noopener noreferrer">ChatGPT</a> feel more like a conversation with the smartest person you know than a computer? It’s because ChatGPT doesn’t solve problems deterministically the way a programmed computer does, it solves them probabilistically. <sup><a href="https://build.ms/2023/11/10/the-reasoning-computer/#user-content-fn-2" target="_blank" rel="noopener noreferrer">2</a></sup> ChatGPT demonstrates the ability to think about something in a logical, sensible way, the definition of <a href="https://www.oxfordlearnersdictionaries.com/us/definition/english/reasoning" target="_blank" rel="noopener noreferrer">reasoning</a>. <sup><a href="https://build.ms/2023/11/10/the-reasoning-computer/#user-content-fn-3" target="_blank" rel="noopener noreferrer">3</a></sup></p> <p>We’ve created something completely new here, <strong>a reasoning computer.</strong> <sup><a href="https://build.ms/2023/11/10/the-reasoning-computer/#user-content-fn-4" target="_blank" rel="noopener noreferrer">4</a></sup></p> <h3>Working With A Reasoning Computer</h3> <p>There are so many political, societal, economic, and ethical implications of Large Language Models (LLMs), 5,000 words wouldn’t be enough to cover all those thoughts. (Trust me, there’s a much longer post sitting in my drafts.) But what’s really captivated me is why a reasoning computer really is different than anything we’ve used before, a conclusion I could only arrive at through experience.</p> <p>ChatGPT has been an essential tool for me over the last month, especially over the last week as I’ve been building <a href="https://plinky.app/" target="_blank" rel="noopener noreferrer">Plinky’s</a> browser extension. I’m a very experienced iOS developer but have little experience with web development. I know enough TypeScript and React to cobble together something with lots of help and guidance, but it will take me much longer than someone who knows what they’re doing.</p> <p>A browser extension is important for Plinky to be successful though, which presents a unique challenge: I know what I want, I know how to describe it, I don’t quite know how to get it, but I will know when ChatGPT gives me the wrong answer so with some nudging I can get what I’m looking for. Here’s why the process of pairing with ChatGPT works, and how it helped me build a fully functional browser extension that lives up to my standards in less than a week. (With far less frustration than if you took away the tool and gave me a whole month.)</p> <ol> <li>A simple browser extension to save links to Plinky’s database is a much smaller problem than building a whole app. The problem is self-contained, which makes it quick and easy to test ChatGPT’s results and see if the output matches my expectations. In fields like mathematics or computer science it’s generally easier to verify a solution’s correctness than come up with a solution in the first place.</li> <li>I may be a novice web developer but I’m a great programmer. Even in a domain where I’m not comfortable I can describe the problem I’m trying to solve, assess whether a solution is good, do some research (on my own or with the aid of <a href="https://perplexity.ai/" target="_blank" rel="noopener noreferrer">Perplexity</a> and ChatGPT), and nudge the reasoning computer in the right direction.</li> <li>This isn’t a process where I ask for something and am given exactly what I want, but I can promise you it’s much easier than becoming a good enough TypeScript developer to build the high quality browser extension I want.</li> <li>Little by little the browser extension looks and works more and more how I want it to be, until it does exactly what I want it to do.</li> <li>The whole process is interactive so I’m learning about how to get to the right solution. Not only do I have what I want, but this iteration made me a better web developer, I started off only knowing what the wrong output looks like but now I also know how the correct solution should look.</li> </ol> <hr /> <p>This is just one example of how I was able to accomplish something I previously wouldn’t have been able to do thanks to an LLM, the number of tasks I turn to LLMs for is growing every day. The same way that GPS becoming ever-present means I haven’t opened a map in almost two decades, I find myself turning to ChatGPT or Perplexity rather than opening Google and clicking a bunch of links to find answers. I used to do my own research, I used to be the reasoning machine, but now I’m offloading more and more of that work to Large Language Models.</p> <h3>How Can A Reasoning Computer Even Work?</h3> <p>People will say that ChatGPT can’t do math, and that’s true in the most literal sense. A Large Language Model may not know what addition and subtraction mean to a human, but it can use tools and synthesize the correct results to add and subtract numbers better than a person. Similarly people point out that ChatGPT can’t read, because it’s just a <a href="https://dl.acm.org/doi/10.1145/3442188.3445922" target="_blank" rel="noopener noreferrer">stochastic parrot</a> that means it can’t provide intelligible output. It’s true that LLMs are complex statistical models, yet despite ChatGPT not knowing English from Urdu the way people do it’s still capable of translating from English to Urdu to Russian to French in a way that I never would be able to. The fact that Github Copilot <sup><a href="https://build.ms/2023/11/10/the-reasoning-computer/#user-content-fn-5" target="_blank" rel="noopener noreferrer">5</a></sup> doesn’t actually know the difference between JavaScript and Swift hasn’t stopped it from <a href="https://resources.github.com/copilot-for-business/" target="_blank" rel="noopener noreferrer">making programmers 55% faster at coding</a>.</p> <p>Large Language Models use a different form of problem solving that starts with inputs and extrapolates technique. That’s the reverse of how humans believe they develop their skills, if you study hard, read a lot, and put in enough hours as a writer you too can become the next Faulkner or Shakespeare. But think about the way you first learned your native language, you listened and watched the world around you for 1-2 years, then reverse-engineered how the technique works. We’re reasoning machines too, the difference is that the entirety of the internet wasn’t preloaded into our brains the way it was into an LLM. (For the best, I don’t know if you know but there’s some bad shit on the internet.)</p> <p>When we say ChatGPT can’t do this or ChatGPT can’t do that what we’re doing is anthropomorphizing flaws onto the system, derived from our own experiences of solving problems successfully. The problem solving process may be difficult for people to understand because this is the first computer that doesn’t do exactly what you tell it to do. Our intuitions may view this as a flaw, but OpenAI loading the whole internet into ChatGPT and creating <a href="https://writings.stephenwolfram.com/2023/02/what-is-chatgpt-doing-and-why-does-it-work/" target="_blank" rel="noopener noreferrer">a simple model for how to think</a> rather than directly programming the machine is the reason this computer is incredibly useful in new and previously unexplored ways.</p> <hr /> <p><a href="https://fedi.simonwillison.net/@simon" target="_blank" rel="noopener noreferrer">Simon Willison</a> says that these tools <a href="https://simonwillison.net/2023/Mar/27/ai-enhanced-development/" target="_blank" rel="noopener noreferrer">make you more ambitious with what you can accomplish</a>, and I’d like to build upon his axiom. When you have a reasoning computer you only have to know what the wrong result looks like, not how to get the right result, and that alone has the power to change how society solves problems.</p> <section><h2>Footnotes</h2> <ol> <li> <p><a href="https://en.wikipedia.org/wiki/Ada_Lovelace" target="_blank" rel="noopener noreferrer">Ada Lovelace</a> deserves credit for writing the world’s first computer program 100 years before <a href="https://en.wikipedia.org/wiki/ENIAC" target="_blank" rel="noopener noreferrer">ENIAC</a>, but in this context I’m using the timeframe of the 1940s to focus the post on generally programmable computers. <a href="https://build.ms/2023/11/10/the-reasoning-computer/#user-content-fnref-1" target="_blank" rel="noopener noreferrer">↩</a></p> </li> <li> <p>It’s perfectly fair to debate whether this is how the inner-machinations of ChatGPT work, but I feel very strongly that at a minimum you can say this about the output ChatGPT provides. <a href="https://build.ms/2023/11/10/the-reasoning-computer/#user-content-fnref-2" target="_blank" rel="noopener noreferrer">↩</a></p> </li> <li> <p>This isn’t because ChatGPT is sentient, but in all likelihood because it was trained on a corpus of human-generated data. It’s difficult to define “thinking” in this context, my personal view is that there is no thinking without sentience, but in this context what I call thinking isn’t the low-level internal machinations of ChatGPT, but one level higher — the step by step token output process that people using ChatGPT see in the process of getting their result. <a href="https://build.ms/2023/11/10/the-reasoning-computer/#user-content-fnref-3" target="_blank" rel="noopener noreferrer">↩</a></p> </li> <li> <p>I’d like to co-credit <a href="https://www.linkedin.com/in/joseph-ugowe" target="_blank" rel="noopener noreferrer">Joe Ugowe</a> with coining this term, it stemmed from a wide-reaching discussion we had last night about our experiences with ChatGPT and Large Language Models. <a href="https://build.ms/2023/11/10/the-reasoning-computer/#user-content-fnref-4" target="_blank" rel="noopener noreferrer">↩</a></p> </li> <li> <p>Github Copilot is a Large Language Model product like ChatGPT, but trained with a coding-specific focus, which allows it to be integrated into a whole suite of Microsoft’s programming-related tools and platforms. <a href="https://build.ms/2023/11/10/the-reasoning-computer/#user-content-fnref-5" target="_blank" rel="noopener noreferrer">↩</a></p> </li> </ol> </section>TopAIIndustryProgrammingJoe FabisevichTwitter Space: Making Data Storage Magical with Boutiquehttps://build.ms/2022/9/26/github-dev-rel-interview/https://build.ms/2022/9/26/github-dev-rel-interview/I joined the Github DevRel team to discuss building Boutique, my "magical" library for data persistence. We also dive into my journey from Twitter to indie development, and the harsher realities of open source maintainership.Mon, 26 Sep 2022 12:00:00 GMT<p>I joined <a href="https://twitter.com/blackgirlbytes" target="_blank" rel="noopener noreferrer">Rizel Scarlett</a> and <a href="https://twitter.com/film_girl" target="_blank" rel="noopener noreferrer">Christina Warren</a> of GitHub on GitHub’s Open Source Friday Twitter Space. We had a wide-ranging conversation about my journey from working at Twitter on societal health issues to becoming an indie developer, and how my story involves open source at it’s heart. I built Boutique to be a “magical” persistence library, along with its companion Bodega, which led me to discover the harsher reality of being an open source maintainer, and working full time on open source wasn’t my dream despite my love of open source.</p> <ul> <li> <p><a href="https://www.youtube.com/watch?v=zfMzU9lbc-A" target="_blank" rel="noopener noreferrer">Watch on YouTube: iOS Development and Open Source - Making Data Storage Magical</a></p> </li> <li> <p><a href="https://www.dropbox.com/scl/fi/hbj9rsh1wp0lr6vq9vi0d/Twitter-Space-Open-Source-Maintainership.mp3?rlkey=fdltndqkli22b00erpc2apod9&amp;st=2avw93u3&amp;dl=0" target="_blank" rel="noopener noreferrer">Original Recording</a></p> </li> </ul>iOSInterviewOpen SourcePodcastProgrammingSwiftJoe FabisevichReflections On An Open Source Projecthttps://build.ms/2022/9/6/reflections-on-an-open-source-project/https://build.ms/2022/9/6/reflections-on-an-open-source-project/Personal reflections on building Boutique and Bodega open source projects, covering the ups and downs of creating something valuable for the Swift community.Tue, 06 Sep 2022 12:00:00 GMT<p>I’ve been working on <a href="https://github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a> and <a href="https://github.com/mergesort/Bodega" target="_blank" rel="noopener noreferrer">Bodega</a> for much of the last three months, officially celebrating the launch of Boutique and Bodega <a href="https://github.com/mergesort/Boutique/releases/tag/2.0" target="_blank" rel="noopener noreferrer">v2</a> today. If you’d like to learn more about what’s new you can read about it in <a href="https://twitter.com/mergesort/status/1567909556704972802" target="_blank" rel="noopener noreferrer">this tweet storm</a>, or read a quick rundown below <sup><a href="https://build.ms/2022/9/6/reflections-on-an-open-source-project/#user-content-fn-1" target="_blank" rel="noopener noreferrer">1</a></sup>. But this post isn’t about Boutique and Bodega, it’s about the process of building Boutique and Bodega.</p> <p>If you’d asked me in April if I’d spend three months this year working on an open source project, I would have said no way. I left my job at Twitter to be an indie developer building apps that represent my vision of personal productivity, but life is funny sometimes. Life teaches the same lesson over and over again — you can’t predict what’ll be important to you at any point in time. I built something to answer my own question, “what does a good SwiftUI architecture would look like?” for my own apps, and that’s what came to be Boutique. But Boutique has blossomed into much more than just a new architecture, it’s become a whole new way to build persistence layers, SwiftUI apps, and for me its created a community of people who find my work valuable. Boutique has quickly become a key part of my present and future app development plans, while remaining simple and approachable through many iterations. I’ve already integrated Boutique into more than half a dozen of my own apps, notably without many issues or much work, and every day I hear from people about how they’re using it as well.</p> <p>I can’t say it’s all been ups though, there have been plenty of downs as well. I’m writing this post not with the intention of convincing you to build an open source project, nor am I trying to dissuade you from participating in open source. I’m aiming to provide a balanced perspective because I would like nothing more than to see you build something that’s important to you, to leave your dent in the universe. If that dent takes the shape of open source project then it would be an honor to have provided a head start based on what I’ve learned, knowing better what to expect than I did.</p> <blockquote><p>"Running a successful open source project is just Good Will Hunting in reverse, where you start out as a respected genius and end up being a janitor who gets into fights."<br /><br />Quote attributed to <a href="https://twitter.com/cra?ref_src=twsrc%5Etfw" target="_blank" rel="noopener noreferrer">@cra</a>, and I don't think I've ever seen anything more true posted.</p>— Jens Axboe (@axboe) <a href="https://twitter.com/axboe/status/1558140503266365444?ref_src=twsrc%5Etfw" target="_blank" rel="noopener noreferrer">August 12, 2022</a></blockquote> <h3>#1. When you open source something you plan to support, double your estimates.</h3> <p>As Big Daddy Kane would say, open source ain’t easy. The rapper was ahead of his time, and while it’s hard to believe, he was actually talking about me and my project planning-related difficulties.</p> <p>When I started building Boutique it was a library just for me, something meant to provide a solution in the nascent and ambiguous space of SwiftUI app architecture. As I started to show Boutique to people in a private beta I was greeted with great ideas and improvements. I was able to implement their suggestions rather quickly, but nothing could prepare me for the amount of work that it takes to make something that works for the myriad of use cases that a general-purpose data storage library needs to support.</p> <p>Boutique’s public launch was greeted with a lot of praise, and even more ideas. Boutique is intentionally not prescriptive, but it does have a rigid and powerful API. It’s meant to be an architectural piece of the puzzle, not something you interact with all the time, but that wasn’t always clear to many people who checked it out. People are used to solutions matching their previous mental models, and so they would find clever ways to misuse an API I thought couldn’t be misused.</p> <p>Boutique’s goal is to be the only persistence library your app needs, something I emphasized heavily in every piece of documentation. Everything you need should be provided out the box, but I still saw people adding additional metadata to UserDefaults or their current database rather than building a model to represent additional information and storing it in Boutique. They weren’t wrong but I definitely wouldn’t say they were right, and I came to realize that a part of this confusion emerged from people leaning on old patterns and intuitions. I knew I had to fix that.</p> <p>I believe that when someone doesn’t understand something you’re trying to teach them it means you haven’t yet brought them to where they need to be. I resolved to make the v2 API even clearer, sanding down rough edges and making it easier to compose functions, that way you would have less to learn and less to misuse. On top of that I wanted to provide a lot of documentation so people could develop a good mental model, have a lot of help getting started, and even developed step by step tutorials for common problems.</p> <p>The quality of v2 is much higher, the library is more thought out, but this all took a lot of time and a lot of energy. What had started off as a six week project ballooned into three months of my life. We’ll discuss why working on Boutique became emotionally overwhelming in <a href="https://build.ms/2022/9/6/reflections-on-an-open-source-project/#3-open-source-can-be-quite-lonely" target="_blank" rel="noopener noreferrer">Lesson 3</a>, but it’s worth noting that open source adds layers of stress. When you’re trying to make something that has wide reach you have to account for answering support requests, writing documentation you don’t personally need but will lower support volume, building features you won’t use, and more. It will help you become a better developer as we discuss in <a href="https://build.ms/2022/9/6/reflections-on-an-open-source-project/#4-you-become-a-better-developer-by-thinking-open-source" target="_blank" rel="noopener noreferrer">Lesson 4</a>, but it will absolutely require a lot more time than your intuition tells you it will.</p> <p>I still have some open questions that I don’t have answers for, but they’re worth considering if you want to start your own open source project.</p> <h4>Project Planning</h4> <ul> <li>When starting a project without a defined endpoint how do you make good estimates?</li> <li>Given the often amorphous nature of an open source project how do you measure progress?</li> </ul> <h4>Energy</h4> <ul> <li>Knowing your energy and focus are finite resources, how do you choose what to work on?</li> <li>What’s an ideal pace for you, how can you sustain your energy levels, and how do disconnect and recharge?</li> </ul> <h4>Focus</h4> <ul> <li>How can you delegate tasks you don’t want to do, the ones that may lead you to become a bottleneck?</li> <li>How do you get people’s help so you can delegate those tasks?</li> <li>How do you lean into your strengths to stay satisfied to avoid burn out?</li> </ul> <h3>#2. Nail the marketing, and keep pushing</h3> <blockquote><p>I’ve been hinting at this for weeks, but the day’s finally arrived. Today I’m announcing not one, but five things. Model View Controller Store, Boutique, Bodega, a blog post, and a demo app that help you learn about all of these. Let’s thread this up! 🧵</p>— ✨ Joe Fabisevich ✨ (@mergesort) <a href="https://twitter.com/mergesort/status/1539644834394374145?ref_src=twsrc%5Etfw" target="_blank" rel="noopener noreferrer">June 22, 2022</a></blockquote> <p>I launched Boutique, Bodega, and a <a href="https://build.ms/2022/06/22/model-view-controller-store/" target="_blank" rel="noopener noreferrer">blog post</a> explaining the idea of a Model View Controller Store architecture together with a <a href="https://twitter.com/mergesort/status/1539644834394374145" target="_blank" rel="noopener noreferrer">tweet storm</a> that went semi-viral. I’d expected to get in front of a few hundred people, but thanks to utility of my projects, the quality of my writing, and creating a compelling thread that showed people the value of my work, a lot of what I had created had resonated with the iOS development community. With the help of friends and community members who’d watched me working tirelessly for weeks the project ended up reaching beyond my orbit, showing up in popular <a href="https://iosdevweekly.com/issues/564?i=mPfbu7Y#mPfbu7Y" target="_blank" rel="noopener noreferrer">community newsletters</a>, <a href="https://www.youtube.com/watch?v=ylZRmxKmTXc" target="_blank" rel="noopener noreferrer">YouTube videos</a>, and the GitHub team even reached out to me to setup a Twitter Space interview to discuss my open source work. (Scheduled for <a href="https://twitter.com/i/spaces/1jMKgLgmPDPGL" target="_blank" rel="noopener noreferrer">later this week</a>).</p> <p>Many things are launched on the internet never to be seen again, but I was committed to pushing Boutique forward knowing that this idea had the potential to change the way people build iOS and Mac apps. Over the coming weeks I leveraged my <a href="https://twitter.com/mergesort" target="_blank" rel="noopener noreferrer">moderately sized Twitter</a> following to keep people up to date with how v2 development was going. I would tweet about what developing an open source project looks like in real time, the good and the bad. I shared novel use cases for Boutique, people’s positive testimonials about using the library, and even leveraged the opportunity to show people cool Swift features that I’d been working with that could help them in their day to day work. This is very aligned with my vision for building my business with a silly name (<a href="https://redpanda.club/" target="_blank" rel="noopener noreferrer">Red Panda Club</a>). I want to build apps, sell products I genuinely believe will help people be better versions of themselves, and teach people along the way to show them how they too can build amazing things. This is a core belief of mine and a style of content marketing that suits and fulfills me, it’s the reason I’m writing posts like this on <a href="https://build.ms/" target="_blank" rel="noopener noreferrer">build.ms</a>.</p> <p>I can say that the launch was incredibly positive, and I only have one regret. I created my <a href="https://github.com/sponsors/mergesort" target="_blank" rel="noopener noreferrer">GitHub Sponsors account</a> about a week <em>after</em> the project went live, and that delay taught me that timing is everything. Boutique created a lot of value for people as evidenced by all of the people now using it in their apps, but I only managed to generate $17/month in recurring donations for all the work I’d done. While making money wasn’t my intention (and I honestly never expected random people on the internet to give me money), I wonder if I had pitched my GitHub Sponsors account at launch could I have ridden the wave of virality to build another revenue stream?</p> <h3>#3. Open source can be quite lonely</h3> <p>Working on Boutique felt very isolating, even with friends, family, and community members following along. I spent almost all of my waking hours working on a project by myself, and even when people asked me about my project there was a level of disconnect in terms of passion. That of course makes sense, this project was everything to me in the moment, and their only connection to Boutique is that they care about me. When you work at a company it really is all about the people. There’s a shared sense of camaraderie, or at worst a shared sense of misery. While I would never want to be miserable with others, the only thing worse is being miserable by yourself.</p> <blockquote> <p><strong>Some personal context</strong></p> <p>I happened to be going through a lot at the time.</p> <ul> <li> <p>I was seeing doctors multiple times a week for an ongoing all-consuming neurological issue, and fighting with the insane American medical system to get the proper care I needed.</p> </li> <li> <p>I <a href="https://fabisevi.ch/2022/04/01/goodbye-fellow-tweeps/" target="_blank" rel="noopener noreferrer">left my job at Twitter</a> a few months ago to work for myself as an indie developer to both focus on my health and happiness. While I’ve <a href="https://twitter.com/mergesort/status/1510976569858269185" target="_blank" rel="noopener noreferrer">been very happy with the decision</a>, there have had a lot of adjustments in my life because of that.</p> </li> <li> <p>My girlfriend and I were looking for a place to move into together (cheers, we did it, I’m so excited. 🎉).</p> </li> <li> <p>Life events that have made my friends less available, and it was hitting me quite hard emotionally.</p> </li> </ul> <p>To be completely transparent there were days where I was crying from the physical pain and the loneliness of not having time, energy, or emotional space for the people I care so much about. Thank you to my amazing partner for all the support, you are truly a one of a kind person, my one of a kind person.</p> </blockquote> <p>I don’t list all of those experiences because it’s what you should expect when you work on an open source project, but you will no doubt have some amount of other responsibilities. And another personal aside, if I’m being completely honest, I’m still surprised by the fact that I was able to build a successful open source project despite all of those obstacles.</p> <p>When I left my job, I expected to lean on community to fill the void of the meaningful goals and shared experience I used to get from my work. I’m lucky enough to be a member of many supportive communities, locally and online, but the reason I felt so lonely when I was working on Boutique was that community fulfills a different need than friendship, family, or a workplace where I felt very accepted. There aren’t many feedback mechanisms in open source, in fact the feedback loop you have isn’t very tight and the interactions you have can often feel impersonal in part because they have a transactional nature to them. This is true any time you work by yourself, but an open source project where you’re the only major contributor with no cross-functional counterparts seemed to amplify this feeling even more. I left my employed life so I could foster more connections with people who matter to me, and it stung over and over again to feel more disconnected from people while I worked on something those very same people were using.</p> <p>Open source isn’t designed to foster connection. For example, every time I received a GitHub Issue I felt like I was letting someone down. Without fail every time I responded to someone’s questions or bug reports the interaction was incredibly positive, even complimentary of my response. But getting to a solution often required a lot of work, and there was no real tangible reward as the human brain is trained to expect. When you consider the vulnerable act of putting your work out into the world, adding a dash of anxious personality, with a hint of feeling low due to external factors, that’s a recipe for negative feelings.</p> <p>The community did throw me a life preserver when I needed it though. Boutique has been a solid investment into my business, but it also feels really special to hear how my code is being used in people’s apps. Every few days someone tells me they’ve started to use Boutique, tells me about about a novel use case that was made easy thanks to Boutique, but one person left me feeling rather emotional. An iOS developer reached out to me about integrating Boutique into one of his apps. He was working with a physical therapist who’s building an app for people dealing with nerve-related issues in their neck, incredibly similar to the neurological issues I’ve been dealing with. When I told my girlfriend she reminded me that having this kind of personal impact on people is rare, and the next time I’m feeling low I should remember that my code is helping people, sometimes even people who are going through the same struggles I am.</p> <p>As always she was completely right (please don’t use this against me in the future), and for every person who messages me there are others who I’ll never hear from. The next time I’m beating myself up over a bug or a deadline I’ll remember that there are people who are grateful for my work, and for me.</p> <h3>#4. You become a better developer by thinking open source</h3> <p>I believe that you shouldn’t write code for the specific purpose of being testable, but that testable code often ends up having a better API surface due to the mindset of thinking about how some code can be tested. I have the same feelings about open source, you shouldn’t write code with the intention of open sourcing it, but the practice of writing code as if it could be open sourced leads to higher quality code*** <sup><a href="https://build.ms/2022/9/6/reflections-on-an-open-source-project/#user-content-fn-2" target="_blank" rel="noopener noreferrer">2</a></sup>. Even if you never intend to open source a piece of code, writing it with the possibility in mind will lead to a well thought out API and a more modular structure. You will be forced to clean up your code, handle errors, and consider use cases that someone may want to layer atop your code.</p> <p>Beyond the code, there’s a lot of work that goes into maintaining a successful open source project. The project management aspect comes to mind, but there are also many technical skills to learn. I’d never used Apple’s <a href="https://developer.apple.com/documentation/docc" target="_blank" rel="noopener noreferrer">docc</a> documentation library, but I learned the ins and outs to provide <a href="https://build.ms/boutique/docs" target="_blank" rel="noopener noreferrer">high quality documentation</a> for Boutique v2. I automated the documentation updates by wiring up GitHub Actions, spending a couple of days to learn how to use the tool effectively. I even solidified my understanding of <a href="https://semver.org/" target="_blank" rel="noopener noreferrer">semantic versioning</a> now that I had a wide-reaching project that people depended on, as compared to the devil may care attitude I had before.</p> <p>There was also a lot to learn based on what Boutique’s users were asking for. The first version of my library used files on the file system as a persistence layer, but that proved to be difficult to scale beyond storing a few thousand objects. That drove me to build a version of Boutique that was backed by <a href="https://sqlite.org/" target="_blank" rel="noopener noreferrer">SQLite</a>, building a fully working prototype in only a few hours. I read everything I could find about SQLite over the next couple of days to optimize my solution, playing into my strength of curiosity and thirst for learning. This would have never arisen organically, and what I learned led to me building the <code>StorageEngine</code> abstraction that underpins all of Boutique and Bodega now. With this I was able to build a pathway for people who are using databases like Core Data or Realm to use Boutique, a cornerstone of the v2 release. The library is much stronger because of the external feedback I received, including code reviews and contributions. All of this is something that never would have happened if I hadn’t open sourced my code, code that is now essential infrastructure for my apps going forward.</p> <hr /> <p>I learned a lot over my three months of being an open source developer, and after going through all of that, the truth is I don’t think I want to be an open source developer. I’ll continue to open source code, but with much less of the commitment that I took upon myself for this project. Boutique and Bodega will continue to be supported, but I won’t be adding new features at the same pace. They don’t need a revolutionary amount of new work, so I’ll mostly focus on my use cases rather than the needs of others going forward. So much more than code goes into building a successful open source project, and while I enjoyed many parts of the project, I was ultimately left unfulfilled in the ways that I want to feel fulfilled as a person.</p> <p>My love for solving problems and teaching is why I thought open source would be a great fit for me. I learned that there’s more to being an open source maintainer than sharing code and helping people, and unfortunately those other parts don’t motivate me. But that won’t deter me from continuing to open source code because open source is built upon the idea that putting something out in the world can help people in ways you never would have expected. If I come up with a novel idea in the process of building my apps or if I learn something new that I think will be valuable to others, you can be sure I’ll open source it.</p> <p>For now I’ll go back to my true passion, building my beloved apps and products like <a href="https://plinky.app/" target="_blank" rel="noopener noreferrer">Plinky</a> — stay tuned for more on that.</p> <section><h2>Footnotes</h2> <ol> <li> <p><em>The new version of Boutique</em> <em>brings</em></p> <ul> <li>UIKit and AppKit support!</li> <li>Two new property wrappers (<code>@StoredValue</code> and <code>@AsyncStoredValue</code>) to help you store individual values</li> <li>The ability to chain <code>.add()</code> and <code>.remove()</code> operations, not only providing a simpler and clearer API but notable performance improvements.</li> <li>Even more performance gains thanks to Boutique now defaulting to storing items using SQLite. Boutique now has the ability to BYOD, bring your own database. You can now use whatever persistence layer you like, simply by conforming to Bodega’s new <code>StorageEngine</code> protocol. Whether you’re using Core Data, Realm, or even remote storage solutions like CloudKit you can quickly build a <code>StorageEngine</code> that matches your schema and start using Boutique today.</li> </ul> <a href="https://build.ms/2022/9/6/reflections-on-an-open-source-project/#user-content-fnref-1" target="_blank" rel="noopener noreferrer">↩</a> </li> <li> <p>A GIANT asterisk here, this is a completely subjective statement. <a href="https://build.ms/2022/9/6/reflections-on-an-open-source-project/#user-content-fnref-2" target="_blank" rel="noopener noreferrer">↩</a></p> </li> </ol> </section>EngineeringiOSOpen SourceSwiftJoe FabisevichModel View Controller Store: Reinventing MVC for SwiftUI with Boutiquehttps://build.ms/2022/6/22/model-view-controller-store/https://build.ms/2022/6/22/model-view-controller-store/Introducing Boutique, a new architecture that reimagines MVC for SwiftUI by adding a Store layer to handle state management and data flow in a familiar, approachable way.Wed, 22 Jun 2022 12:00:00 GMT<p><em>This <a href="https://twitter.com/mergesort/status/1539644834394374145" target="_blank" rel="noopener noreferrer">Twitter thread</a> offers a concise high level 13-tweet summary of this post’s announcements, but you miss out on a lot of important detail that I highly recommend reading if you plan to start using the libraries I’ve developed and introduce in this post, or if you’d like to read an interesting technical walkthrough.</em></p> <p><em>This post was updated on August 22, 2022 to reflect the v2 release of Boutique and related API changes.</em></p> <hr /> <p>Apple has never provided a blessed architecture for SwiftUI, and many developers have spent thousands of hours filling the gaps with their own ideas. A familiar approach for many developers is to take the <a href="https://www.objc.io/issues/13-architecture/mvvm/" target="_blank" rel="noopener noreferrer">MVVM</a> pattern many people adopted in their UIKit/AppKit apps and translate it to the needs of SwiftUI. That can work well enough, but you begin to see some cracks in the architecture when you need to manage <a href="https://developer.apple.com/documentation/swiftui/state-and-data-flow" target="_blank" rel="noopener noreferrer">state and data flow</a> due to how heavily SwiftUI leans on having a single source of truth. Others have taken the path of integrating powerful libraries such as <a href="https://github.com/pointfreeco/swift-composable-architecture" target="_blank" rel="noopener noreferrer">The Composable Architecture</a> which provide you with the tools to reason about your entire application. TCA takes inspiration from <a href="https://redux.js.org/" target="_blank" rel="noopener noreferrer">redux</a>, more specifically <a href="https://guide.elm-lang.org/architecture/" target="_blank" rel="noopener noreferrer">The Elm Architecture</a>, two patterns that are rather incredible in how they allow you to define your entire application as a tree of state. But TCA’s great power comes with great responsibility a very high learning curve, which can make it difficult to learn personally. TCA’s goals are much more of a fit for solving problems with a very high level of complexity, which may not be necessary for every app.</p> <p>When it comes to developer ergonomics and program provability MVVM and TCA live on opposite ends of the spectrum. MVVM is lenient and depends on convention while TCA apps are built with rigidity in mind to guarantee correctness. This post isn’t meant to be a tour of architectures though, I’ve mentioned those two patterns to show that there’s a spectrum of how we develop software, and I believe there’s room for something in between.</p> <hr /> <p>If SwiftUI shows us anything it’s that declarative programming is the future of software development, and we need an approach to software development that leans into the strengths of the paradigm without being overly constrictive. What’s needed isn’t a whole new architecture, but to bring together tried and true patterns with a new concept, the Store. The Store is a <em>very minimal yet powerful</em> layer, which means the rest of your application’s code can remain almost entirely unchanged. Your apps are yours, they are your expression of creativity, and you shouldn’t have to change how you write code to turn your ideas into software.</p> <p>The Store will be the heart of your app’s data, and thanks to the declarative nature of SwiftUI an app’s user interface is driven by its data. You can think the Store as the storage for your model objects, in SwiftUI this would be your <a href="https://www.raywenderlich.com/11781349-understanding-data-flow-in-swiftui" target="_blank" rel="noopener noreferrer">single source of truth</a>. If you model your data correctly then your user interface will always do what you expect it to do. That relationship between data and user interface is why Views having a single source of truth is so important.</p> <p>I’ve built a batteries-included Store that comes with everything you’ll need out of the box called <a href="https://www.github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a> to be the foundation for that data. <a href="https://www.github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a> does <em>no behind the scenes magic</em> and doesn’t resort to shenanigans like runtime hacking to achieve a great developer experience. Our <code>Store</code> is extremely simple and has no complicated abstractions, you’ll be surprised how little there is to learn. There entire API surface is only one public property, an array of <code>items</code>, and only three functions, <code>add()</code>, <code>remove()</code>, and <code>removeAll()</code>. Once you experience <a href="https://www.github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a> it becomes a must-have feature for any state-driven app you build. We’ll dive much deeper into how the <code>Store</code> works under the hood to make this possible <a href="https://build.ms/2022/6/22/model-view-controller-store/#wandering-around-the-boutique" target="_blank" rel="noopener noreferrer">later in this post</a>, <a href="https://build.ms/2022/6/22/model-view-controller-store/#lets-build-an-app" target="_blank" rel="noopener noreferrer">build a Model View Controller Store</a> app to see how it all comes together.</p> <p>Boutique’s <code>Store</code> is a dual-layered memory and disk cache which <strong>lets you build apps that update in real time with full offline storage and an incredibly simple API in only a few lines of code</strong>. That may sound a bit fancy, but all it means is that when you save an item into the <code>Store</code>, it also saves that item to disk. This persistence is powered under the hood by <a href="https://github.com/mergesort/Bodega" target="_blank" rel="noopener noreferrer">Bodega</a>, an actor-based library I’ve developed for saving data or Codable objects to disk. In this post we’ll use <a href="https://www.github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a> to show how the <code>Store</code> integrates into your apps.</p> <p>The pattern and concepts are familiar and straightforward whether you’ve been a programmer for a few months or have been writing code for decades. After integrating this architecture into multiple apps without a hitch over the last few months I feel confident sharing it, and open sourcing the libraries that make it easy to build realtime offline-ready apps with just a few lines of code.</p> <h3>Model View Controller Store</h3> <p>Model View Controller Store is a rethinking of an architecture familiar to iOS developers <a href="https://developer.apple.com/library/archive/documentation/General/Conceptual/DevPedia-CocoaCore/MVC.html" target="_blank" rel="noopener noreferrer">MVC</a>, for SwiftUI. While this post discusses SwiftUI, all of these techniques are applicable for building a UIKit or AppKit app. You can build apps across many platforms using this pattern, in fact the C from MVCS is inspired by <a href="https://guides.rubyonrails.org/getting_started.html" target="_blank" rel="noopener noreferrer">Data Controllers</a> rather than <a href="https://developer.apple.com/documentation/uikit/view_controllers" target="_blank" rel="noopener noreferrer">ViewControllers</a>, but in this post we’ll focus on SwiftUI. Your app’s Models and Views will require no changes to your mental model when you think about a Model or View in a SwiftUI app, but we’ll be adding the Store to our apps. The combination of MVC and a Store bound together by a simple API allows a developer to give their app a straightforward and well-defined data architecture to create an app that’s incredibly easy to reason about.</p> <p>The best way to explain Model View Controller Store is to show you what it is. The idea is so small that you may even be an expert before this post is over, there’s actually very little to learn. Model View Controller Store doesn’t require you to change your apps, and I’m going to use <a href="https://www.github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a> for the Store in our app.</p> <p>You may prefer use <a href="https://www.github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a> on its own without Controllers, Model View Store is a perfectly acceptable architecture that some people will prefer. While I prefer having a data controller to mediate data and interactions between different parts of the app, especially as apps get more complex, it’s not a prerequisite to using <a href="https://www.github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a>’s <code>Store</code> in your app. We’ll touch on using <a href="https://www.github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a> to create a Model View Store architecture in the <a href="https://build.ms/2022/6/22/model-view-controller-store/#a-model-view-store-interlude" target="_blank" rel="noopener noreferrer">Store</a> section of the post, and discuss what the Store can bring to any app.</p> <hr /> <h3>Let’s Build An App</h3> <p>To demonstrate Model View Controller Store in practice we’ll walk through a simple app that’s representative of most apps people work on. You can find the full source code available on <a href="https://github.com/mergesort/Boutique/tree/main/Demo" target="_blank" rel="noopener noreferrer">Github</a>, but we’ll also walk through a condensed version below.</p> <video controls> <source src="./app-demo.mov" type="video/mp4"></source> </video> <br /> <p>This app will allow us to</p> <ol> <li>Query an API for a red panda image.</li> <li>Favorite the red panda the API sent us, saving the image and associated metadata to our Store.</li> <li>See that when we save the red panda to our Store, the state changes are rendered across two separate views.</li> <li>Remove a red panda, deleting the image and associated metadata from our Store.</li> <li>See that when we remove the red panda to our Store the state change is rendered due to the state change.</li> <li>If we relaunch the app all of our favorited red pandas will be there because the Store not only saves data in-memory but also cached on disk.</li> </ol> <hr /> <h3>Model</h3> <p>You can assume our app has a <code>RemoteImage</code> model to represent a remote image resource, we’ll use it when we fetch and save our red panda images.</p> <pre><code><span><span>struct</span><span> RemoteImage</span><span>: </span><span>Codable</span><span>, </span><span>Equatable</span><span>, </span><span>Identifiable </span><span>{</span></span> <span><span> let</span><span> createdAt: Date</span></span> <span><span> let</span><span> url: URL</span></span> <span><span> let</span><span> width: </span><span>Float</span></span> <span><span> let</span><span> height: </span><span>Float</span></span> <span><span> let</span><span> dataRepresentation: Data</span></span> <span></span> <span><span> var</span><span> id: </span><span>String</span><span> {</span></span> <span><span> url.absoluteString</span></span> <span><span> }</span></span> <span><span>}</span></span></code></pre> <br /> <hr /> <h3>Store</h3> <p>We’ll create our Store, using the Store provided by <a href="https://www.github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a>, though if you’d like to build your own it’s possible too.<sup><a href="https://build.ms/2022/6/22/model-view-controller-store/#user-content-fn-1" target="_blank" rel="noopener noreferrer">1</a></sup> The <code>Store</code> has a readonly array of <code>items</code> and only three operations to <code>add()</code>, <code>remove()</code>, and <code>removeAll()</code>. What that means is modeling our data is easy, and our views will always render what we expect due to that data being well-modeled. The <code>Store</code>’s <code>items</code> property is a <code>@Published</code> property, and because of that our Views update in realtime <em>without having to build any observable behaviors</em>, it’s almost magical to watch.</p> <p>To create a <code>Store</code> all you need is a <code>storage</code> and a <code>cacheIdentifier</code>.</p> <ul> <li>The <code>storage</code> parameter is of the type <code>StorageEngine</code>, a data storage mechanism provided out of the box by Bodega. A <code>StorageEngine</code> can be a database, but it doesn’t have to be. Bodega provides two built-in <code>StorageEngine</code> options, <code>DiskStorageEngine</code> and <code>SQLiteStorageEngine</code>. <code>DiskStorageEngine</code> works by saving your data as files to the file system, and <code>SQLiteStorageEngine</code> creates an SQLite database as the underlying storage mechanism. If you use either of these you’ll never have to think about how the data is being stored, but Boutique v2 introduces the concept of BYOD (Bring Your Own Database), allowing you to build your own <code>StorageEngine</code>. You can create a <code>CoreDataStorageEngine</code>, a <code>RealmStorageEngine</code>, or even a <code>CloudKitStorageEngine</code> that matches your app’s backend schema, the possibilities are endless. Bodega’s <a href="https://mergesort.github.io/Bodega/documentation/bodega/using-storageengines" target="_blank" rel="noopener noreferrer">documentation</a> provides much more context, but for the purposes of our app we’ll use the suggested default, <code>SQLiteStorageEngine</code>.</li> </ul> <ul> <li>The <code>cacheIdentifier</code> is a <code>KeyPath&lt;Model, String&gt;</code> that your model must provide. That may seem unconventional at first, so let’s break it down. Much like how protocols enforce a contract, the KeyPath is doing the same for our model. To be added to our <code>Store</code> and saved to disk our models must conform to <code>Codable &amp; Equatable</code>, both of which are reasonable constraints given the data has to be serializable and searchable. But what we’re trying to avoid is making our models have to conform to a specialized caching protocol, we want to be able to save any ol’ object you already have in your app. Instead of creating a protocol like <code>Storable</code>, we instead ask the model to tell us how we can derive a unique string which will be used as a key when storing the item.</li> </ul> <p>In this case we’ll generate the <code>cacheIdentifier</code> by using the <code>absoluteString</code> of <code>RemoteImage.url</code>. If you try to save an item with the same <code>cacheIdentifier</code> as an item that already exists in the <code>Store</code> the newer item will override the older saved item. Because of that it’s important that your <code>cacheIdentifier</code> is unique, but it also means that our <code>Store</code> will only need an <code>add()</code> method and no <code>update()</code>, with no checks necessary to see if an item is in the <code>Store</code> before adding a new item. <sup><a href="https://build.ms/2022/6/22/model-view-controller-store/#user-content-fn-2" target="_blank" rel="noopener noreferrer">2</a></sup></p> <pre><code><span><span>// You can create a Store anywhere but for a globally available Stores like an image cache</span></span> <span><span>// I like to do it in an extension on Store so you can later reference it as `.imagesStore`.</span></span> <span><span>extension</span><span> Store</span><span> where</span><span> Item </span><span>==</span><span> RemoteImage {</span></span> <span></span> <span><span> static</span><span> let</span><span> imagesStore </span><span>=</span><span> Store</span><span>&lt;</span><span>RemoteImage</span><span>&gt;</span><span>(</span></span> <span><span> storage</span><span>: SQLiteStorageEngine.</span><span>default</span><span>(</span><span>appendingPath</span><span>: </span><span>"Items"</span><span>),</span></span> <span><span> cacheIdentifier</span><span>: \.id</span></span> <span><span> )</span></span> <span></span> <span><span>}</span></span></code></pre> <ul> <li>For models that conform to <code>Identifiable</code> where the <code>id</code> is a <code>String</code> we’ve we can infer the <code>cacheIdentifier</code>. That leads us to a nice ergonomic improvement where we skip the <code>Store</code>’s <code>cacheIdentifier</code> parameter, instead writing:</li> </ul> <pre><code><span><span>static</span><span> let</span><span> imagesStore </span><span>=</span><span> Store</span><span>&lt;</span><span>RemoteImage</span><span>&gt;</span><span>(</span></span> <span><span> storage</span><span>: SQLiteStorageEngine.</span><span>default</span><span>(</span><span>appendingPath</span><span>: </span><span>"Items"</span><span>)</span></span> <span><span>)</span></span></code></pre> <p>You can initialize your <code>Store</code> anywhere you like, but I chose to initialize the <code>imagesStore</code> in an extension on <code>Store</code>. Caching images is often a task that you want to have available globally, and by choosing to initialize it on <code>Store</code> the we can drop the <code>Store.</code> prefix when we initialize it, leaving us with this aesthetically pleasing <code>@Stored(in: .imagesStore)</code> syntax.</p> <p>You may be asking yourself “can I put all my data in one store” and the answer is sure, but it’s not recommended. I highly recommend making many small stores, especially for a SwiftUI app. Any SwiftUI app, not just an app powered by Boutique’s <code>Store</code>, will often have to re-render state changes if the source of truth is too centralized. To work within the frameworks’ constraints, what I recommend is using a few smaller stores scoped to a certain model, or usage domain.</p> <h3>A Model View Store Interlude</h3> <p>Before we get to the Controller I’d like to talk about Model View Store. If you were to pursue the Model View Store approach then much of the Controller functionality would move to the View. If you’re building an app that focuses on rendering local data you can find success using the Model View Store pattern. But as your app grows in scope, especially if it’s interfacing with other data sources such as APIs or external storage services such as CloudKit then the Controller layer will become invaluable. That’s because each View will have to duplicate the related logic, and a Controller will be able to not only centralize and prevent you from duplicating that logic, but provide you a domain-specific object for handling those operations.</p> <p>If you’re prototyping or building something simple Model View Store may be enough for you, but you may hit some growing pains as your app scales in complexity, and at that point I recommend incorporating a layer of abstraction for handling the business logic and interactions in your app.</p> <hr /> <h3>Controller</h3> <p>That’s all the setup we needed to create the Store in Model View Controller Store, only one line of code. There are many opinions about the correct shape for a <a href="https://dev.to/kputra/rails-skinny-controller-skinny-model-5f2k" target="_blank" rel="noopener noreferrer">Controller</a> to take on, and that’s why Controllers come in many flavors. If you ask 10 programmers how they would design their Controller you’ll get 12 answers. Some people prefer Thin Controllers, others lean towards Thick Controllers, and there’s also Data controllers or Model Controllers. Some people don’t even like to use the Controller nomenclature, they name these intermediate objects <code>XYZManager</code>, <code>ABCFeature</code>, or an object name specific to their app. As I said that your apps are yours and the way I shape them is your choice, and we don’t need to impose an overly specific Controller paradigm to build a Model View Controller Store app.</p> <p>Below we’ll create something based on my preference of using Data Controllers. Our Controller will act as a centralized pass-through layer for operations such as hitting API to fetch images, saving those images to our Store, and handling all of the interactions related to images. We’ve included other <a href="https://en.wikipedia.org/wiki/Create,_read,_update_and_delete" target="_blank" rel="noopener noreferrer">CRUD operations</a> such as delete or delete all, as Data Controllers often do.</p> <pre><code><span><span>/// A controller that allows you to fetch images remotely, and save or delete them from a `Store`.</span></span> <span><span>final</span><span> class</span><span> ImagesController</span><span>: </span><span>ObservableObject </span><span>{</span></span> <span></span> <span><span> /// The `Store` that we'll be using to save images.</span></span> <span><span> @Stored</span><span>(</span><span>in:</span><span> .imagesStore) </span><span>var</span><span> images</span></span> <span></span> <span><span> /// Fetches `RemoteImage` from the API, providing the user with a red panda if the request succeeds.</span></span> <span><span> /// - Returns: The `RemoteImage` requested.</span></span> <span><span> func</span><span> fetchImage</span><span>() </span><span>async</span><span> throws</span><span> -&gt;</span><span> RemoteImage {</span></span> <span><span> // Hit the API that provides you a random image's metadata</span></span> <span><span> let</span><span> imageURL </span><span>=</span><span> URL</span><span>(</span><span>string</span><span>: </span><span>"https://image.redpanda.club/random/json"</span><span>)</span><span>!</span></span> <span><span> let</span><span> randomImageRequest </span><span>=</span><span> URLRequest</span><span>(</span><span>url</span><span>: imageURL)</span></span> <span><span> let</span><span> (imageResponse, </span><span>_</span><span>) </span><span>=</span><span> try</span><span> await</span><span> URLSession.shared.</span><span>data</span><span>(</span><span>for</span><span>: randomImageRequest)</span></span> <span></span> <span><span> return</span><span> RemoteImage</span><span>(</span><span>createdAt</span><span>: .now, </span><span>url</span><span>: imageResponse.</span><span>url</span><span>, </span><span>width</span><span>: imageResponse.width, </span><span>height</span><span>: imageResponse.height, </span><span>imageData</span><span>: imageResponse.imageData)</span></span> <span><span> }</span></span> <span></span> <span><span> /// Saves an image to the `Store` in memory and on disk.</span></span> <span><span> func</span><span> saveImage</span><span>(</span><span>image</span><span>: RemoteImage) </span><span>async</span><span> throws</span><span> {</span></span> <span><span> try</span><span> await</span><span> self</span><span>.$images.</span><span>add</span><span>(image)</span></span> <span><span> }</span></span> <span></span> <span><span> /// Removes one image from the `Store` in memory and on disk.</span></span> <span><span> func</span><span> removeImage</span><span>(</span><span>image</span><span>: RemoteImage) </span><span>async</span><span> throws</span><span> {</span></span> <span><span> try</span><span> await</span><span> self</span><span>.$images.</span><span>remove</span><span>(image)</span></span> <span><span> }</span></span> <span></span> <span><span> /// Removes all of the images from the `Store` in memory and on disk.</span></span> <span><span> func</span><span> clearAllImages</span><span>() </span><span>async</span><span> throws</span><span> {</span></span> <span><span> try</span><span> await</span><span> self</span><span>.$images.</span><span>removeAll</span><span>()</span></span> <span><span> }</span></span> <span></span> <span><span>}</span></span></code></pre> <p>Here we’ve referenced <code>.imagesStore</code> inside of <code>ImagesController</code>, but you may prefer to decouple your Store from the Controller it’s used in. I would personally recommend taking this approach, and it’s easily doable, only requiring you to change three lines of code.</p> <pre><code><span><span>final</span><span> class</span><span> ImagesController</span><span>: </span><span>ObservableObject </span><span>{</span></span> <span></span> <span><span> // This line of code goes away</span></span> <span><span> // @Stored(in: .imagesStore) var images</span></span> <span></span> <span><span> // Instead we create an uninitialized property and a new initializer</span></span> <span><span> @Stored</span><span> var</span><span> images: [RemoteImage]</span></span> <span></span> <span><span> init</span><span>(</span><span>store</span><span>: Store&lt;RemoteImage&gt;) {</span></span> <span><span> self</span><span>._images </span><span>=</span><span> Stored</span><span>(</span><span>in</span><span>: store)</span></span> <span><span> }</span></span> <span><span> </span></span> <span><span> // The rest of the controller looks exactly the same</span></span> <span><span>}</span></span></code></pre> <p>Injecting a Store into your Controller means that your Controller’s actions now act independently from their state, and as a result become more testable. You can even create test Stores that are injected for the purposes of unit testing.<sup><a href="https://build.ms/2022/6/22/model-view-controller-store/#user-content-fn-3" target="_blank" rel="noopener noreferrer">3</a></sup> Testability is usually the primary selling point for using MVVM, but we’ve obviated the need for a ViewModel. Our data manipulation and business logic resides in our Controller, and our Views will interface directly with the Controller. This allows us to remove one layer of abstraction from each View, no longer needing to pair a ViewModel with every View. And speaking of the View…</p> <hr /> <h3>View</h3> <p>Now we have a <code>Store</code> that stores our <code>RemoteImage</code>s, and an <code>ImagesController</code> that can fetch images for us. All we need is to render them so our users can look at some red pandas, and that’s a job for the View. To remind you our view will look like this. This code is a little different from the code on GitHub but it’s fundamentally the same, mainly some styling modifiers and additional state related to managing the view’s user experience were removed for brevity’s sake.</p> <p><img alt="App Demo Card View" loading="lazy" width="370" height="508" src="https://build.ms/_astro/app-demo-card-view.BFFyHzR7_ZAfEf8.webp" /></p> <p>We’ll build a card view that looks like this, with three interactions.</p> <ol> <li>When the view appears, we call <code>imagesController. fetchImage()</code> to set the current displayed image.</li> <li>When the user taps Fetch, we call the same function to fetch a new random red panda.</li> <li>When the user taps Favorite, we call <code>imagesController.saveImage (image: self.currentImage)</code> to save the current image to the <code>Store</code>.<sup><a href="https://build.ms/2022/6/22/model-view-controller-store/#user-content-fn-4" target="_blank" rel="noopener noreferrer">4</a></sup></li> </ol> <pre><code><span><span>struct</span><span> RedPandaCardView</span><span>: </span><span>View </span><span>{</span></span> <span></span> <span><span> @StateObject</span><span> private</span><span> var</span><span> imagesController </span><span>=</span><span> ImagesController</span><span>()</span></span> <span></span> <span><span> @State</span><span> private</span><span> var</span><span> currentImage: RemoteImage</span></span> <span></span> <span><span> var</span><span> body: </span><span>some</span><span> View {</span></span> <span><span> VStack</span><span> {</span></span> <span><span> RemoteImageView</span><span>(</span><span>image</span><span>: currentImage)</span></span> <span><span> .</span><span>aspectRatio</span><span>(</span><span>CGFloat</span><span>(currentImage.height </span><span>/</span><span> currentImage.width), </span><span>contentMode</span><span>: .fit)</span></span> <span><span> .</span><span>primaryBorder</span><span>()</span></span> <span><span> .</span><span>cornerRadius</span><span>(</span><span>8.0</span><span>)</span></span> <span></span> <span><span> Spacer</span><span>()</span></span> <span></span> <span><span> Button</span><span>(</span><span>action</span><span>: {</span></span> <span><span> Task</span><span> {</span></span> <span><span> try</span><span> await</span><span> self</span><span>.</span><span>setCurrentImage</span><span>()</span></span> <span><span> }</span></span> <span><span> }, </span><span>label</span><span>: {</span></span> <span><span> Label</span><span>(</span><span>"Fetch"</span><span>, </span><span>systemImage</span><span>: </span><span>"arrow.clockwise.circle"</span><span>)</span></span> <span><span> .</span><span>font</span><span>(.title)</span></span> <span><span> .</span><span>frame</span><span>(</span><span>height</span><span>: </span><span>52.0</span><span>)</span></span> <span><span> .</span><span>background</span><span>(Color.palette.primary)</span></span> <span><span> })</span></span> <span></span> <span><span> Button</span><span>(</span><span>action</span><span>: {</span></span> <span><span> Task</span><span> {</span></span> <span><span> try</span><span> await</span><span> self</span><span>.imagesController.</span><span>saveImage</span><span>(</span><span>image</span><span>: </span><span>self</span><span>.currentImage)</span></span> <span><span> try</span><span> await</span><span> self</span><span>.</span><span>setCurrentImage</span><span>()</span></span> <span><span> }</span></span> <span><span> }, </span><span>label</span><span>: {</span></span> <span><span> Label</span><span>(</span><span>"Favorite"</span><span>, </span><span>systemImage</span><span>: </span><span>"star.circle"</span><span>)</span></span> <span><span> .</span><span>font</span><span>(.title)</span></span> <span><span> .</span><span>frame</span><span>(</span><span>height</span><span>: </span><span>52.0</span><span>)</span></span> <span><span> .</span><span>background</span><span>(Color.palette.secondary)</span></span> <span><span> })</span></span> <span><span> }</span></span> <span><span> .</span><span>task</span><span>({</span></span> <span><span> try?</span><span> await</span><span> self</span><span>.</span><span>setCurrentImage</span><span>()</span></span> <span><span> })</span></span> <span><span> }</span></span> <span><span> </span></span> <span><span> private</span><span> func</span><span> setCurrentImage</span><span>() </span><span>async</span><span> throws</span><span> {</span></span> <span><span> self</span><span>.currentImage </span><span>=</span><span> try</span><span> await</span><span> self</span><span>.imagesController.</span><span>fetchImage</span><span>()</span></span> <span><span> }</span></span> <span></span> <span><span>}</span></span></code></pre> <br /> <hr /> <p>In the <code>RedPandaCardView</code> section I wrote there were three interactions.</p> <blockquote> <p>3. When the user taps Favorite, we call <code>imagesController. saveImage (image: self.currentImage)</code> to save the current image to the <code>Store</code>.</p> </blockquote> <p>But technically there’s a step 4.</p> <ol> <li>When the user taps Favorite the changes will propagate to anyone who’s using that <code>Store</code> to power their view. We’re going to build a <code>FavoritesCarouselView</code> that has a reference to the same <code>Store</code>, that way we maintain one source of truth. Even though our two Views have two different instances of <code>ImagesController</code>, those <code>ImagesController</code> instances share the same underlying <code>Store</code>. This means that when the <code>RedPandaCardView</code> sends an action to save the red panda image to our <code>Store</code>, the <code>FavoritesCarouselView</code> will automatically update with the newly favorited image in real time!</li> </ol> <p><img alt="App Demo Card View" loading="lazy" width="370" height="235" src="https://build.ms/_astro/app-demo-carousel-view.DwBw6MoU_gsvfL.webp" /></p> <pre><code><span></span> <span><span>struct</span><span> FavoritesCarouselView</span><span>: </span><span>View </span><span>{</span></span> <span></span> <span><span> @StateObject</span><span> private</span><span> var</span><span> imagesController </span><span>=</span><span> ImagesController</span><span>()</span></span> <span></span> <span><span> var</span><span> body: </span><span>some</span><span> View {</span></span> <span><span> VStack</span><span> {</span></span> <span><span> HStack</span><span> {</span></span> <span><span> Text</span><span>(</span><span>"Favorites"</span><span>)</span></span> <span><span> .</span><span>bold</span><span>()</span></span> <span></span> <span><span> Spacer</span><span>()</span></span> <span></span> <span><span> Button</span><span>(</span><span>action</span><span>: {</span></span> <span><span> Task</span><span> {</span></span> <span><span> try</span><span> await</span><span> imagesController.</span><span>clearAllImages</span><span>()</span></span> <span><span> }</span></span> <span><span> }, </span><span>label</span><span>: {</span></span> <span><span> Image</span><span>(</span><span>systemName</span><span>: </span><span>"xmark.circle.fill"</span><span>)</span></span> <span><span> .</span><span>foregroundColor</span><span>(.red)</span></span> <span><span> })</span></span> <span><span> }</span></span> <span></span> <span><span> HStack</span><span> {</span></span> <span><span> CarouselView</span><span>(</span></span> <span><span> items</span><span>: </span><span>self</span><span>.imagesController.images.</span><span>sorted</span><span>(</span><span>by</span><span>: { </span><span>$0</span><span>.createdAt </span><span>&gt;</span><span> $1</span><span>.createdAt}),</span></span> <span><span> contentView</span><span>: { image </span><span>in</span></span> <span><span> RemoteImageView</span><span>(</span><span>image</span><span>: image)</span></span> <span><span> .</span><span>primaryBorder</span><span>()</span></span> <span><span> .</span><span>centerCroppedCardStyle</span><span>()</span></span> <span><span> }</span></span> <span><span> )</span></span> <span><span> .</span><span>transition</span><span>(.</span><span>move</span><span>(</span><span>edge</span><span>: .trailing))</span></span> <span><span> .</span><span>animation</span><span>(.default, </span><span>value</span><span>: </span><span>self</span><span>.imagesController.images)</span></span> <span><span> }</span></span> <span><span> }</span></span> <span><span> }</span></span> <span></span> <span><span>}</span></span></code></pre> <p>You’re probably thinking “this looks pretty much exactly like any SwiftUI app”, and reader, you are right. That’s the point. As linguists know one word can make a big difference, and in this case that one word is <code>@Stored</code>.</p> <hr /> <p><strong>Editor’s note: From this point on the blog post is going to get more technical and it is not required reading for using Model View Controller Store. If you’re not interested in the underpinnings of the <code>Store</code> and <code>@Stored</code> you can skip to the <a href="https://build.ms/2022/6/22/model-view-controller-store/#conclusion" target="_blank" rel="noopener noreferrer">conclusion</a>.</strong></p> <hr /> <h3>Wandering Around The Boutique</h3> <p>So what is that <code>@Stored</code>? <code>@Stored</code> is a property wrapper that makes interacting with the <code>Store</code> go from straightforward to downright magical. But before we can get to the property wrapper, let’s walk through how the <code>Store</code> is built, that way we can see how <code>@Stored</code> layers on top of it.</p> <p>Let’s break down how the <code>Store</code> is built.</p> <pre><code><span><span>public</span><span> final</span><span> class</span><span> Store</span><span>&lt;</span><span>Item</span><span>: </span><span>Codable </span><span>&amp;</span><span> Equatable</span><span>&gt;: </span><span>ObservableObject </span><span>{</span></span> <span></span> <span><span> // More code here…</span></span> <span></span> <span><span> private</span><span> let</span><span> storageEngine: StorageEngine</span></span> <span><span> private</span><span> let</span><span> cacheIdentifier: KeyPath&lt;Item, </span><span>String</span><span>&gt;</span></span> <span></span> <span><span> @MainActor</span><span> @Published</span><span> public</span><span> private</span><span>(</span><span>set</span><span>) </span><span>var</span><span> items: [Item] </span><span>=</span><span> []</span></span> <span></span> <span><span> public</span><span> init</span><span>(</span><span>storage</span><span>: StorageEngine, </span><span>cacheIdentifier</span><span>: KeyPath&lt;Item, </span><span>String</span><span>&gt;) { </span><span>…</span><span> }</span></span> <span></span> <span><span> public</span><span> func</span><span> add</span><span>(</span><span>_</span><span> item: Item) </span><span>async</span><span> throws</span><span> { </span><span>…</span><span> }</span></span> <span><span> public</span><span> func</span><span> add</span><span>(</span><span>_</span><span> items: [Item]) </span><span>async</span><span> throws</span><span> { </span><span>…</span><span> }</span></span> <span><span> public</span><span> func</span><span> remove</span><span>(</span><span>_</span><span> item: Item) </span><span>async</span><span> throws</span><span> { </span><span>…</span><span> }</span></span> <span><span> public</span><span> func</span><span> remove</span><span>(</span><span>_</span><span> items: [Item]) </span><span>async</span><span> throws</span><span> { </span><span>…</span><span> }</span></span> <span><span> public</span><span> func</span><span> removeAll</span><span>() </span><span>async</span><span> throws</span><span> { </span><span>…</span><span> }</span></span> <span></span> <span><span> // More code here…</span></span> <span></span> <span><span>}</span></span></code></pre> <p>This is an abridged version of the <code>Store</code>, removing some details to make it easier to touch on the most important parts.</p> <ol> <li><code>Store</code> is an <code>ObservableObject</code>, which means it can publish any changes that occur to the <code>Store</code>’s <code>@Published</code> properties.</li> <li><code>Store</code> contains one <code>@Published</code> property, <code>items</code>, the array representing the items we’re storing.</li> <li>All <code>@Published</code> property changes must dispatch on the main actor, so we provide <code>items</code> with a <code>@MainActor</code> annotation.</li> <li>The access control of <code>items</code> is <code>public private(set)</code>, that’s to ensure that all mutations occur by using <code>add()</code>, <code>remove()</code>, and <code>removeAll()</code>.</li> <li>Since <code>items</code> is <code>public private(set)</code> that means we can read and access <code>items</code> without any additional methods. The simplest API is one that doesn’t require additional abstraction, so there’s no reason to add superfluous methods for read operations, we can treat <code>items</code> just like any other array.</li> <li>You can even use the <code>$items</code> Publisher to subscribe changes, and this is what makes it possible to build SwiftUI apps that automatically update everywhere from a single source of truth.</li> </ol> <p>A few additional details that are relevant, but not as important to explore.</p> <ol> <li>The <code>storageEngine</code> property is an on-disk cache provided by <a href="https://github.com/mergesort/Bodega" target="_blank" rel="noopener noreferrer">Bodega</a>. This is what’s responsible for writing <code>items</code> to disk, all of which happens for you automatically. Because <code>storageEngine</code> is <code>private</code> you’ll never have to directly interact with it, but you can use it separately from the <code>Store</code> if you only want to save files to disk.</li> </ol> <pre><code><span><span>// Don't do this</span></span> <span><span>try</span><span> await</span><span> self</span><span>.$images.</span><span>removeAll</span><span>()</span></span> <span><span>try</span><span> await</span><span> self</span><span>.$images.</span><span>add</span><span>(images)</span></span> <span></span> <span><span>// Do this!</span></span> <span><span>try</span><span> await</span><span> self</span><span>.$images</span></span> <span><span> .</span><span>removeAll</span><span>()</span></span> <span><span> .</span><span>add</span><span>(images)</span></span> <span><span> .</span><span>run</span><span>()</span></span></code></pre> <ol> <li><a href="https://www.github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a> provides a fluent syntax for chaining functions that modify <code>items</code> together. This provides performance benefits as each function call will modify <code>items</code>, and since <code>items</code> is annotated with <code>@MainActor</code> any modification will trigger observing SwiftUI Views to re-render. By using <code>.run()</code> to we’re able to tell the <code>Store</code> to use a different variant of <code>removeAll()</code> and <code>add()</code> that will run all of the chained commands together. This ensures a seamless user experience with the View batching together the modifications of <code>items</code>, redrawing the contents of your View only once.</li> </ol> <p>That’s all you need to know about the <code>Store</code>, and when you break it down there’s nothing too complicated happening here. As a consumer of this API you never even have to deal with or understand any complexity, all you have to do is use the <code>add()</code> and <code>remove()</code> operations and you get the power of <code>Store</code>.</p> <p>To recap</p> <blockquote> <p>A single source of truth, full offline support, and real time state updates automatically. These are not three separate libraries, they’re one library and we’re calling it <a href="https://www.github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a>.</p> <p>— Steve Jobs</p> </blockquote> <hr /> <p>We’ve finally reached the point where I explain <code>@Stored</code>, and here’s where we encounter some real complexity. It’s very important to state that <strong>you absolutely do not need to know this to use <a href="https://www.github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a> or to understand Model View Controller Store</strong>, I just found this to be an interesting technical challenge. 98% of the credit for this code goes to <a href="https://twitter.com/harshil" target="_blank" rel="noopener noreferrer">@harshil</a> and <a href="https://twitter.com/IanKay" target="_blank" rel="noopener noreferrer">@iankeen</a>. Harshil provided the idea and strongly guided me to the initial implementation for the <code>@Stored</code> property wrapper, and Ian came up with the brilliant subscript that allows the property wrapper’s enclosing object to republish the underlying <code>Store</code>’s changes without adding five lines of boilerplate for a developer to write whenever creating a <code>Store</code>. A huge and heartfelt thank you to both of you.</p> <pre><code><span><span>@propertyWrapper</span></span> <span><span>public</span><span> struct</span><span> Stored</span><span>&lt;</span><span>Item</span><span>: </span><span>Codable </span><span>&amp;</span><span> Equatable</span><span>&gt; {</span></span> <span></span> <span><span> private</span><span> let</span><span> box: Box</span></span> <span></span> <span><span> public</span><span> init</span><span>(</span><span>in</span><span> store: Store&lt;Item&gt;) {</span></span> <span><span> self</span><span>.box </span><span>=</span><span> Box</span><span>(store)</span></span> <span><span> }</span></span> <span></span> <span><span> @MainActor</span></span> <span><span> public</span><span> var</span><span> wrappedValue: [Item] {</span></span> <span><span> box.store.items</span></span> <span><span> }</span></span> <span></span> <span><span> public</span><span> var</span><span> projectedValue: Store&lt;Item&gt; {</span></span> <span><span> box.store</span></span> <span><span> }</span></span> <span></span> <span><span> @MainActor</span><span> public</span><span> static</span><span> subscript</span><span>&lt;</span><span>Instance</span><span>: </span><span>ObservableObject</span><span>&gt;(</span></span> <span><span> _enclosingInstance</span><span> instance: Instance,</span></span> <span><span> wrapped</span><span> wrappedKeyPath: KeyPath&lt;Instance, [Item]&gt;,</span></span> <span><span> storage</span><span> storageKeyPath: KeyPath&lt;Instance, Stored&gt;</span></span> <span><span> ) </span><span>-&gt;</span><span> [Item] </span><span>where</span><span> Instance.ObjectWillChangePublisher </span><span>==</span><span> ObservableObjectPublisher {</span></span> <span><span> let</span><span> wrapper </span><span>=</span><span> instance[</span><span>keyPath</span><span>: storageKeyPath]</span></span> <span></span> <span><span> if</span><span> wrapper.box.cancellable </span><span>==</span><span> nil</span><span> {</span></span> <span><span> wrapper.box.cancellable </span><span>=</span><span> wrapper.projectedValue</span></span> <span><span> .objectWillChange</span></span> <span><span> .</span><span>sink</span><span>(</span><span>receiveValue</span><span>: { [objectWillChange </span><span>=</span><span> instance.objectWillChange] </span><span>in</span></span> <span><span> objectWillChange.</span><span>send</span><span>()</span></span> <span><span> })</span></span> <span><span> }</span></span> <span></span> <span><span> return</span><span> wrapper.wrappedValue</span></span> <span><span> }</span></span> <span><span>}</span></span></code></pre> <p>As a reminder, this is what our usage of <code>@Stored</code> looks like.</p> <pre><code><span><span>final</span><span> class</span><span> ImagesController</span><span>: </span><span>ObservableObject </span><span>{</span></span> <span></span> <span><span> @Stored</span><span>(</span><span>in:</span><span> .imagesStore) </span><span>var</span><span> images</span></span> <span></span> <span><span> // More code here…</span></span> <span></span> <span><span>}</span></span></code></pre> <ol> <li>The <code>wrappedValue</code> is an array of the items in the <code>Store</code>, in our case <code>images</code>. This allows our <code>Store</code>’s <code>items</code> to be exposed to callers as <code>images</code>, providing a slick experience now that reading <code>images</code> is just like reading any other array.</li> <li>The <code>projectedValue</code> is a representation of the <code>Store</code> which allows us to call <code>self.$images.add(image)</code> in a manner that doesn’t expose any of the underlying details.</li> <li>The subscript is a trick I learned from <a href="https://twitter.com/johnsundell" target="_blank" rel="noopener noreferrer">@johnsundell</a>’s <a href="https://www.swiftbysundell.com/articles/accessing-a-swift-property-wrappers-enclosing-instance/" target="_blank" rel="noopener noreferrer">post</a>, and was able to implement with Ian’s help. The code looks rather complex but is simpler when you break it down into plain English. It’s important to know that if you have an <code>ObservableObject</code> inside of an <code>ObservableObject</code>, the outer <code>ObservableObject</code> has to manually republish the inner <code>ObservableObject</code>s changes for them to be reflected in your app. I honestly don’t know why this is the case, but it’s been <a href="https://rhonabwy.com/2021/02/13/nested-observable-objects-in-swiftui/" target="_blank" rel="noopener noreferrer">well-documented</a> as the way SwiftUI works.</li> <li>In our <code>subscript</code> the first parameter is the <code>_enclosingInstance</code>, which is the object that holds a reference to this property wrapper. In our case that’s <code>ImagesController</code>, so when this subscript is invoked (transparently on any <code>objectWillChange</code> changes), <code>ImagesController</code> will republish changes from our <code>Store</code>. This technique is useful on it’s own, and I will be incorporating it into a <code>@Republished</code> property wrapper.<sup><a href="https://build.ms/2022/6/22/model-view-controller-store/#user-content-fn-5" target="_blank" rel="noopener noreferrer">5</a></sup></li> </ol> <hr /> <h3>Conclusion</h3> <p>Phew, that was a bit of a deep dive, so let’s come up for air and discuss at a high level what we’ve done. We talked about the tradeoffs of modern architectures used to build SwiftUI apps, and showed that there’s space for <a href="https://build.ms/2022/6/22/model-view-controller-store/#model-view-controller-store" target="_blank" rel="noopener noreferrer">Model View Controller Store</a> in between MVVM and TCA. We introduced a new concept, the Store, and a batteries-included implementation with everything you’ll need called <a href="https://www.github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a> that lets you build realtime offline-ready apps with just a few lines of code. We reviewed a condensed version of a full <a href="https://build.ms/2022/6/22/model-view-controller-store/#lets-build-an-app" target="_blank" rel="noopener noreferrer">Model View Controller Store app</a>, available on <a href="https://github.com/mergesort/Boutique/tree/main/Demo" target="_blank" rel="noopener noreferrer">Github</a>, and broke down its constituent parts, the <a href="https://build.ms/2022/6/22/model-view-controller-store/#model" target="_blank" rel="noopener noreferrer">Model</a>, <a href="https://build.ms/2022/6/22/model-view-controller-store/#view" target="_blank" rel="noopener noreferrer">View</a>, <a href="https://build.ms/2022/6/22/model-view-controller-store/#controller" target="_blank" rel="noopener noreferrer">Controller</a>, and <a href="https://build.ms/2022/6/22/model-view-controller-store/#store" target="_blank" rel="noopener noreferrer">Store</a>. And then we did a technical breakdown of <a href="https://build.ms/2022/6/22/model-view-controller-store/#wandering-around-the-boutique" target="_blank" rel="noopener noreferrer">Boutique</a>. It may look like there’s a lot here, but it takes a lot of work to make something simple to use.</p> <p>As a developer you’ll get to take advantage of all this infrastructure to build SwiftUI apps in the same straightforward and familiar manner you’re used to, without making any changes or compromises. Model View Controller Store is so familiar that I’m almost hesitant to even label this a new architecture. Model View Controller Store is more of a modern day twist on an old architecture that’s been proven to work over decades. Please let me know if you’re using Boutique or Model View Controller Store in your apps, I’ve opened up Discussions on GitHub for <a href="https://github.com/mergesort/Boutique/discussions/5" target="_blank" rel="noopener noreferrer">Boutique</a>. And if you have any thoughts, any ways to improve the what I’ve developed, I’m always available on <a href="https://twitter.com/mergesort" target="_blank" rel="noopener noreferrer">Twitter</a>. I welcome any and all polite discussions.</p> <p>Thank you so much for reading all of this, and a special thank you to <a href="https://twitter.com/conradstoll" target="_blank" rel="noopener noreferrer">Conrad Stoll</a> for the multiple editing passes, and <a href="https://twitter.com/palleas" target="_blank" rel="noopener noreferrer">Romain Pouclet</a> for this post’s amazing header artwork. Most importantly, happy hacking! I can’t wait to see what you all build. 🧑🏿‍💻🧑🏼‍💻👩🏽‍💻🧑🏻‍💻👩🏼‍💻</p> <section><h2>Footnotes</h2> <ol> <li> <p>It’s possible to create a Store backed by any database or storage system, or even no persistent storage system. <a href="https://www.github.com/mergesort/Boutique" target="_blank" rel="noopener noreferrer">Boutique</a>’s <a href="https://github.com/mergesort/Boutique/blob/main/Sources/Boutique/Store.swift" target="_blank" rel="noopener noreferrer"><code>Store</code></a> and <a href="https://github.com/mergesort/Boutique/blob/main/Sources/Boutique/Stored.swift" target="_blank" rel="noopener noreferrer"><code>@Stored</code></a> can serve as a good reference implementations for you to create a similar construct. Rather than creating a <code>Store</code> protocol I’ll give you a blueprint for defining your own Store in plain English, or you can read <a href="https://build.ms/2022/6/22/model-view-controller-store/#wandering-around-the-boutique" target="_blank" rel="noopener noreferrer">this section</a> for a much more in depth explanation..</p> <ol> <li>Define one property, <code>@MainActor @Published public private(set) var items</code>, to expose the underlying stored data.</li> <li>Add five functions <code>add(_ item: Item)</code>, <code>add(_ items: [Item])</code>, <code>remove(_ item: Item)</code>, <code>remove(_ items: [Item])</code>, and <code>removeAll()</code> for the purposes of mutating the Store.</li> <li>The <code>@Stored</code> property wrapper should be identical, though you can add your functionality based on the needs of your <code>Store</code> if you choose.</li> </ol> <a href="https://build.ms/2022/6/22/model-view-controller-store/#user-content-fnref-1" target="_blank" rel="noopener noreferrer">↩</a> </li> <li> <p>Saving an item with the same <code>cacheIdentifier</code> as an item that already exists in the <code>Store</code> the newer item will override the older saved item. The same applies to saving two items at once with the same <code>cacheIdentifier</code>. <a href="https://github.com/mergesort/Bodega" target="_blank" rel="noopener noreferrer">Bodega</a> is the library which powers Boutique’s SQLite storage, and it provides a <code>CacheKey</code> type that takes a URL or String, hashes it, and saves it to disk in a file-system safe manner. Since URLs can be 4096 characters and files on disk can only be 256 characters I recommend using this whenever using a <code>URL</code> as your <code>cacheIdentifier</code>, but using <code>CacheKey</code> shouldn’t be necessary otherwise unless your identifiers are <code>String</code>s that are long strings, longer than 256 characters, or contain characters that aren’t file system safe. A shorter version of that, it’s almost always the smart move to use <code>CacheKey</code>. <a href="https://build.ms/2022/6/22/model-view-controller-store/#user-content-fnref-2" target="_blank" rel="noopener noreferrer">↩</a></p> </li> <li> <p>A nice tip for unit testing is to create a <code>Store</code> using the <code>.temporaryDirectory</code>, that way your data won’t be persisted past the tests’ execution. <a href="https://build.ms/2022/6/22/model-view-controller-store/#user-content-fnref-3" target="_blank" rel="noopener noreferrer">↩</a></p> </li> <li> <p>If you had a server responsible for syncing favorites this would be a good place to call <code>API.save(image: image)</code> as well. Since that’ll also be asynchronous you may take one of two strategies, both of which are valid.</p> <ol> <li>Wait until you get a response from the API telling you an image has been saved to the server, this is especially easy when using async/await.</li> <li>Add an <code>isSynced</code> boolean to your models and optimistically save your image to the Store before calling the API. When the API responds you would change <code>isSynced</code> from <code>false</code> to <code>true</code>, and show users in the user interface that their model has been synced to the server.</li> </ol> <a href="https://build.ms/2022/6/22/model-view-controller-store/#user-content-fnref-4" target="_blank" rel="noopener noreferrer">↩</a> </li> <li> <p><a href="https://twitter.com/adam_zethraeus/status/1540285892736143360" target="_blank" rel="noopener noreferrer">@adam_zethraeus</a> reached out to me and let me know that he’d built an <code>@Republished</code> property wrapper, and it is exactly what I would have extracted from this implementation so I thought <a href="https://github.com/adam-zethraeus/Republished/blob/2ec1421719d8e1a69003b9cbddad4d96bf930bb3/RepublishTestApp.swiftpm/App/PropertyWrappers/Republished.swift" target="_blank" rel="noopener noreferrer">it’d be useful share</a>. <a href="https://build.ms/2022/6/22/model-view-controller-store/#user-content-fnref-5" target="_blank" rel="noopener noreferrer">↩</a></p> </li> </ol> </section>DatabasesEngineeringiOSOpen SourceSwiftSwiftUIJoe FabisevichMaximum Design For Minimal Efforthttps://build.ms/2022/6/15/maximum-design-for-minimal-effort/https://build.ms/2022/6/15/maximum-design-for-minimal-effort/A 15-minute live coding talk covering design fundamentals - color, spacing, typography, and animation - to transform basic UI into beautiful interfaces.Wed, 15 Jun 2022 12:00:00 GMT<p>Whether you’re starting your design journey or if you’ve been doing design for years, nothing’s more important than the fundamentals. The fundamentals are what help you get from 0 to 60, and when it comes to design it turns out that that 60% may be all you need. You can create something really beautiful by understanding color, spacing, typography, and animation, which is what we’ll cover in this video. I gave this talk at <a href="https://pursuit.org/" target="_blank" rel="noopener noreferrer">Pursuit</a>, while live coding, showing you how to take an app from stock UIKit to something beautiful in only 15 minutes. I’ve been told quite a few times by people I’ve shared this talk with that it was invaluable for them, so if you’ve ever opened up Figma and thought “I don’t know where to start”, you should take 15 minutes to watch.</p> <p>And if you need inspiration, here’s a <a href="https://build.ms/talks/maximum-design-minimal-effort/notes/" target="_blank" rel="noopener noreferrer">repository of resources</a> I’ve put together to help you along your design journey.</p> <iframe src="https://player.vimeo.com/video/430176931?h=a87dc2f994" width="640" height="360" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>DesignEngineeringProductivityTalkJoe FabisevichCoding As Creative Expressionhttps://build.ms/2022/5/21/coding-as-creative-expression/https://build.ms/2022/5/21/coding-as-creative-expression/Exploring whether coding is art or science, and how programming serves as a creative medium for solving problems and expressing ideas through code.Sat, 21 May 2022 12:00:00 GMT<blockquote><p>Is coding a science or an art?</p>— Matthew Garlington (@dejadu13) <a href="https://twitter.com/dejadu13/status/1528015327866540032?ref_src=twsrc%5Etfw" target="_blank" rel="noopener noreferrer">May 21, 2022</a></blockquote> <p>I’ve seen many versions of this question posed over the years, and to Matthew’s credit it’s a very good question. As you can see in the replies people translate their lived experience writing code and answer art or science based on however they conceptualize and practice programming. A few years ago MIT conducted <a href="https://news.mit.edu/2020/brain-reading-computer-code-1215" target="_blank" rel="noopener noreferrer">a study</a> that concluded “reading computer code is not the same as reading language”, answering the question of whether coding is art or science with a rigorously documented “both”. While I’m hard-pressed to argue with science, I’d like to provide a different answer, one that’s a little more conceptual.</p> <p>The instinct to qualify coding as art comes from the practice of programming. While writing a program software developers make many choices, ones that rarely have hard and fast rules. As long as your program compiles and runs as expected, you can make any choice you want. It’s all human input, if you ask 100 developers to write a complex program they’ll write it 100 different ways. Heck, if you ask one developer 100 times they may write it 100 different ways. Code can create something beautiful, enable an amazing experience, and people will even call a piece of code elegant or exquisite. To someone in the arts this all sounds very familiar.</p> <p>The instinct to qualify coding as a science comes from the output of a program. Developers desire deterministic results for their programs, when you provide an input to a program you expect to receive the same output every time. The practice of programming itself can be imprecise, and the same way that science operates in unknown space, building a program can often feel the same. Developers will try to minimize the ambiguity by using industry-tested practices, much the same that a scientist uses a standardized beaker rather than throwing some chemicals in an unclean cup and seeing what happens. When put together that truly sounds like the practice of science.</p> <p>What we’ve found ourselves asking is whether code is a paint brush or a calculator, and frustratingly the answer appears to be both. Despite the fact that programming looks like art and looks like science, I still think there’s a concept that better fits the practice of coding, creative expression.</p> <p>We can look to writing as a point of comparison. Sometimes it’s hard to believe that the most beautiful poem uses the same medium as boring technical documentation because the artifacts look and feel so different, but we all know it’s true. The medium doesn’t impose limitations on how someone can choose to express themselves, it’s a tool for creative expression. With writing it’s impossible to separate the medium from the artifact created, and the same is true for code. Code can be artistic and create something new in the world, or it can simply exist to accomplish a task. Code helps people capture the meaningful moments of their lives, lets you carry the beauty of every song ever recorded in your pocket, but it’s also the boring spreadsheet that helps you run your business.</p> <p>So is coding an art, a science, or creative expression? I say coding isn’t science, it’s not art, it’s not quite a craft, but a malleable form of creative expression. When given an infinitely flexible and manipulable canvas people use their imagination to create wondrous things, ones not limited to simple categorization.</p>TopEngineeringProgrammingJoe FabisevichCreating Slick Color Palette APIshttps://build.ms/2021/8/24/creating-slick-color-palette-apis/https://build.ms/2021/8/24/creating-slick-color-palette-apis/Building maintainable, flexible color palette systems for iOS and macOS apps using asset catalogs, semantic naming, and SwiftUI integration.Tue, 24 Aug 2021 12:00:00 GMT<p>The work of writing maintainable code is an ongoing endeavor and some of my favorite problems to solve are ones that build maintainable systems. Maintainable systems are ones you can learn once, easily manipulate, and ideally take from project to project. My favorite part of building maintainable systems is that it minimizes the amount of work I need to do when starting a new project, and like it is for many programmers hitting ⌘ + ⇪ + N to start a new project is one of the most satisfying feelings in the world for me.</p> <p>A color palette is something every well-designed app needs, and it turns out there are a lot of ways to solve this problem. If you don’t yet have a good sense for how to construct a color palette, I highly recommend <a href="https://www.refactoringui.com/previews/building-your-color-palette" target="_blank" rel="noopener noreferrer">this post by Refactoring UI</a> that explains the fundamentals of a good color palette. Generating a good color palette can be tricky if you’re new to the practice and can require some trial and error, so if you’d like a shortcut a tool like <a href="https://coolors.co/" target="_blank" rel="noopener noreferrer">Coolors</a> is a great starting point.</p> <p>I’ve spent years iterating on approaches to codifying color palettes in my iOS and macOS apps, seeking to create one that’s flexible, scales well, and is easy to understand, landing on the version we’ll explore below. We’ll be able to leverage asset catalogs, create a clear sense of hierarchy, provide statically typed semantic colors, even take advantage of the built in features of SwiftUI. As a bonus, if you’re working with designers, your palette will be so straightforward to modify that a designer can submit a pull request to change colors without ever involving you.</p> <hr /> <p>Considering we’ll be leveraging asset catalogs, the first step should be pretty intuitive, we should create an asset catalog to hold our color palettes.</p> <p><img alt="An image displaying an asset catalog in a folder called Resources" loading="lazy" width="534" height="184" src="https://build.ms/_astro/color-palette-resources-folder.BkTw4KGD_2tilIa.webp" /></p> <p>As I was piecing together different instructions and ideas this second step confused me, so I’ll spare you the misery. You’re going to want to make a different folder for each color palette you create, and you can do that by right clicking in the asset catalog and selecting <em>New Folder</em>.</p> <p><img alt="An image displaying a New Folder option when you right click inside of an empty asset catalog" loading="lazy" width="300" height="535" src="https://build.ms/_astro/color-palette-right-click.ntKS98UN_Z1gecxp.webp" /></p> <p>Now it’s time to create our color palettes. I’m showing you the Night palette I created for my app, and below it are Spring, Summer, and Winter. Each palette has the same name but all of the colors are named the same, each palette will have colors named background-alt, primary, quaternary, etc.</p> <p><img alt="An image displaying four color palettes from my app" loading="lazy" width="2768" height="1590" src="https://build.ms/_astro/color-palette-asset-catalog.DC2uwX5F_ZoSBcT.webp" /></p> <p><strong>Do not miss this incredibly important step, guide your eyes towards the pink arrow on the right side of the image.</strong> You must select the folder you’re adding colors to and check the <strong>Provides Namespace checkbox</strong>. This is what will enable our code to have a clear and consistent naming structure, matching the folder’s name to our palette’s name.</p> <hr /> <p>Now that we’ve got our asset catalogs setup, we’re ready to write some code. We’ll start by constructing a new <code>Palette</code> struct, and populating it with some <code>Color</code>s that we’ll reference across our app.</p> <pre><code><span><span>extension</span><span> Color</span><span> {</span></span> <span></span> <span><span> struct</span><span> Palette</span><span> {</span></span> <span><span> let</span><span> name: </span><span>String</span></span> <span></span> <span><span> var</span><span> mainBackground: Color {</span></span> <span><span> Color</span><span>(</span><span>fromPalette</span><span>: </span><span>self</span><span>.name, </span><span>semanticName</span><span>: </span><span>"background-main"</span><span>)</span></span> <span><span> }</span></span> <span></span> <span><span> var</span><span> midBackground: Color {</span></span> <span><span> Color</span><span>(</span><span>fromPalette</span><span>: </span><span>self</span><span>.name, </span><span>semanticName</span><span>: </span><span>"background-mid"</span><span>)</span></span> <span><span> }</span></span> <span></span> <span><span> var</span><span> alternativeBackground: Color {</span></span> <span><span> Color</span><span>(</span><span>fromPalette</span><span>: </span><span>self</span><span>.name, </span><span>semanticName</span><span>: </span><span>"background-alt"</span><span>)</span></span> <span><span> }</span></span> <span></span> <span><span> var</span><span> primaryText: Color {</span></span> <span><span> Color</span><span>(</span><span>fromPalette</span><span>: </span><span>self</span><span>.name, </span><span>semanticName</span><span>: </span><span>"text-primary"</span><span>)</span></span> <span><span> }</span></span> <span></span> <span><span> var</span><span> alternativeText: Color {</span></span> <span><span> Color</span><span>(</span><span>fromPalette</span><span>: </span><span>self</span><span>.name, </span><span>semanticName</span><span>: </span><span>"text-alt"</span><span>)</span></span> <span><span> }</span></span> <span></span> <span><span> var</span><span> primary: Color {</span></span> <span><span> Color</span><span>(</span><span>fromPalette</span><span>: </span><span>self</span><span>.name, </span><span>semanticName</span><span>: </span><span>"primary"</span><span>)</span></span> <span><span> }</span></span> <span></span> <span><span> var</span><span> secondary: Color {</span></span> <span><span> Color</span><span>(</span><span>fromPalette</span><span>: </span><span>self</span><span>.name, </span><span>semanticName</span><span>: </span><span>"secondary"</span><span>)</span></span> <span><span> }</span></span> <span></span> <span><span> var</span><span> tertiary: Color {</span></span> <span><span> Color</span><span>(</span><span>fromPalette</span><span>: </span><span>self</span><span>.name, </span><span>semanticName</span><span>: </span><span>"tertiary"</span><span>)</span></span> <span><span> }</span></span> <span></span> <span><span> var</span><span> quaternary: Color {</span></span> <span><span> Color</span><span>(</span><span>fromPalette</span><span>: </span><span>self</span><span>.name, </span><span>semanticName</span><span>: </span><span>"quaternary"</span><span>)</span></span> <span><span> }</span></span> <span><span> }</span></span> <span></span> <span><span>}</span></span></code></pre> <p>This is a pretty standard palette and should cover most of the use cases you’ll encounter in building an app. You can modify it to your needs, but the important thing is that the colors you choose need to match the names of the colors you declared in your asset catalog. But what is this <code>Color(fromPalette:semanticName:)</code> initializer?</p> <pre><code><span><span>private</span><span> extension</span><span> Color</span><span> {</span></span> <span></span> <span><span> init</span><span>(</span><span>fromPalette</span><span> palette: </span><span>String</span><span>, </span><span>semanticName</span><span>: </span><span>String</span><span>) {</span></span> <span><span> #</span><span>if</span><span> os</span><span>(</span><span>macOS</span><span>)</span></span> <span><span> self</span><span>.</span><span>init</span><span>(</span><span>NSColor</span><span>(</span><span>named</span><span>: </span><span>"</span><span>\(palette)</span><span>/</span><span>\(semanticName)</span><span>"</span><span>)</span><span>!</span><span>)</span></span> <span><span> #</span><span>else</span></span> <span><span> self</span><span>.</span><span>init</span><span>(</span><span>UIColor</span><span>(</span><span>named</span><span>: </span><span>"</span><span>\(palette)</span><span>/</span><span>\(semanticName)</span><span>"</span><span>)</span><span>!</span><span>)</span></span> <span><span> #</span><span>endif</span></span> <span><span> }</span></span> <span></span> <span><span>}</span></span></code></pre> <p>You can just as easily use a private method in <code>Color.Palette</code>, something like <code>private func assetCatalogColor(semanticName: String)</code>. I happen to prefer the ergonomics of a custom initializer, and this whole post is about improving ergonomics, so let’s run with that.</p> <p>This initializer lives in <code>Color.Palette</code> and takes advantage of our namespaced folder structure to pull out colors from the asset catalog. <code>palette</code> unsurprisingly is the name of our color palette, and <code>semanticName</code> is the name of the color we’re pulling out of it, such as <code>primary</code>, <code>secondary</code>, or <code>background-main</code>. Combining the two with a <code>/</code>, we’ll get the <code>primary</code>, <code>secondary</code>, or <code>background-main</code> color from our current palette. All that’s left is to define the palettes we’ll be constructing.</p> <pre><code><span><span>extension</span><span> Color</span><span>.Palette {</span></span> <span><span> static</span><span> let</span><span> spring </span><span>=</span><span> Color.</span><span>Palette</span><span>(</span><span>name</span><span>: </span><span>"Spring"</span><span>)</span></span> <span><span> static</span><span> let</span><span> summer </span><span>=</span><span> Color.</span><span>Palette</span><span>(</span><span>name</span><span>: </span><span>"Summer"</span><span>)</span></span> <span><span> static</span><span> let</span><span> autumn </span><span>=</span><span> Color.</span><span>Palette</span><span>(</span><span>name</span><span>: </span><span>"Autumn"</span><span>)</span></span> <span><span> static</span><span> let</span><span> winter </span><span>=</span><span> Color.</span><span>Palette</span><span>(</span><span>name</span><span>: </span><span>"Winter"</span><span>)</span></span> <span><span> static</span><span> let</span><span> day </span><span>=</span><span> Color.</span><span>Palette</span><span>(</span><span>name</span><span>: </span><span>"Day"</span><span>)</span></span> <span><span> static</span><span> let</span><span> dusk </span><span>=</span><span> Color.</span><span>Palette</span><span>(</span><span>name</span><span>: </span><span>"Dusk"</span><span>)</span></span> <span><span> static</span><span> let</span><span> night </span><span>=</span><span> Color.</span><span>Palette</span><span>(</span><span>name</span><span>: </span><span>"Night"</span><span>)</span></span> <span><span>}</span></span></code></pre> <p>Constructing palettes is pretty easy. All you have to do is instantiate a <code>Color.Palette</code> object with a name. That name must match the folder name, so in my app the palette name for <code>Night</code> will be <code>Night</code> because the folder we chose for that palete was <code>"Night"</code>.</p> <hr /> <p>Now that we have a color palette created, we should probably start using it. If you’re using UIKit you’ll likely have built your palette atop <code>UIColor</code> instead of <code>Color</code> and used <code>.palette.primaryText</code> wherever a <code>UIColor</code> is expected.</p> <p>But if we’re building our app in SwiftUI, we can go the extra mile with just a few lines of code to leverage the <code>Environment</code>, making our palette easily accessible and SwiftUI-friendly.</p> <pre><code><span><span>private</span><span> struct</span><span> ColorPaletteKey</span><span>: </span><span>EnvironmentKey </span><span>{</span></span> <span><span> // We need to default to a palette so without any particular preference let's pick `day`</span></span> <span><span> static</span><span> let</span><span> defaultValue </span><span>=</span><span> Color.Palette.day</span></span> <span><span>}</span></span> <span></span> <span><span>extension</span><span> EnvironmentValues</span><span> {</span></span> <span><span> var</span><span> preferredColorPalette: Color.Palette {</span></span> <span><span> get</span><span> {</span></span> <span><span> return</span><span> self</span><span>[ColorPaletteKey.</span><span>self</span><span>]</span></span> <span><span> }</span></span> <span><span> set</span><span> {</span></span> <span><span> self</span><span>[ColorPaletteKey.</span><span>self</span><span>] </span><span>=</span><span> newValue</span></span> <span><span> }</span></span> <span><span> }</span></span> <span><span>}</span></span></code></pre> <p>This creates the <code>preferredColorPalette</code> property in our <code>Environment</code>. If you’re not sure how the <code>Environment</code> works there are a lot of great resources on the internet like <a href="https://useyourloaf.com/blog/swiftui-custom-environment-values/" target="_blank" rel="noopener noreferrer">this post by Keith Harrison</a>.</p> <pre><code><span><span>struct</span><span> HeaderView</span><span>: </span><span>View </span><span>{</span></span> <span></span> <span><span> @Environment</span><span>(\.preferredColorPalette) </span><span>private</span><span> var</span><span> palette</span></span> <span></span> <span><span> var</span><span> body: View {</span></span> <span><span> Text</span><span>(</span><span>"Hello World"</span><span>)</span></span> <span><span> .</span><span>foregroundColor</span><span>(palette.primaryText)</span></span> <span><span> }</span></span> <span></span> <span><span>}</span></span></code></pre> <p>Now accessing our colors is a nothing more than two lines of code, how slick is that?</p> <hr /> <p>Hope I didn’t wear you out, there’s a lot here, but when you throw it into Xcode you’ll see that it’s pretty easy to digest. After a few iterations I’m really happy with this solution and have been using it in my personal apps for a while now. It allows us to easily add or change colors, have a visual representation of our colors, with minimal code, and a pretty slick API.</p> <p>But no code is perfect (except for <a href="https://en.wikipedia.org/wiki/Merge_sort" target="_blank" rel="noopener noreferrer">mergesort</a>), so I’m always looking for improvements. I’m excited to hear your thoughts or <a href="https://twitter.com/mergesort" target="_blank" rel="noopener noreferrer">suggestions</a>, so don’t be shy about reaching out.</p>DesignEngineeringiOSSwiftSwiftUITutorialJoe FabisevichPutting the U in GraphQLhttps://build.ms/2021/3/12/putting-the-u-in-graphql/https://build.ms/2021/3/12/putting-the-u-in-graphql/Learning how to make GraphQL requests from scratch without libraries like Apollo, exploring the fundamentals of GraphQL client-side implementation in Swift.Fri, 12 Mar 2021 12:00:00 GMT<p>GraphQL has been on my list of technologies to learn for a few months now, and last week I came across <a href="https://swiftwithmajid.com/2021/02/24/graphql-in-swift/" target="_blank" rel="noopener noreferrer">Majid Jabrayilov’s post</a>, feeling pretty excited to tackle the subject. The post was very good, but it didn’t answer the one question I’ve had as I’ve gone through numerous exercises to understand GraphQL, how do I make GraphQL requests without a library?</p> <p>I’ve read about how to create a GraphQL query and how to integrate GraphQL on your server a dozen times, but one topic that’s highly under-covered is how to make a GraphQL request from the client. In the world of GraphQL it’s very common to reach for <a href="https://www.apollographql.com/" target="_blank" rel="noopener noreferrer">Apollo</a>, a library that handles turning GraphQL queries into functions, leveraging tooling to turn those functions into type-safe API requests the client can make.</p> <p>While this is a perfectly reasonable approach, and actually a pretty good developer experience, it still didn’t answer the questions I had as an ever-curious engineer, how would I do this on my own?</p> <p>I broke the problem I saw down into two smaller problems, request-generation and request-making. Generating a request, especially in Swift, it turns out is pretty easy. I really like the approach that <a href="https://github.com/NicholasBellucci/SociableWeaver" target="_blank" rel="noopener noreferrer">SociableWeaver</a> takes, leveraging Swift’s function builders to let you build a type-safe directly in Swift. The second problem was a bit fiddlier. I knew that I had to make a <code>POST</code> request, and I knew the endpoint that was being hit, and through some trial and error (and a friend’s help<sup><a href="https://build.ms/2021/3/12/putting-the-u-in-graphql/#user-content-fn-1" target="_blank" rel="noopener noreferrer">1</a></sup>), I was able to start making GraphQL requests without any external libraries needed.</p> <pre><code><span><span>extension</span><span> URLSession</span><span> {</span></span> <span></span> <span><span> func</span><span> graphQLRequest</span><span>(</span><span>url</span><span>: URL, </span><span>query</span><span>: </span><span>String</span><span>) </span><span>-&gt;</span><span> URLSession.DataTaskPublisher {</span></span> <span><span> var</span><span> request </span><span>=</span><span> URLRequest</span><span>(</span><span>url</span><span>: url)</span></span> <span><span> request.httpMethod </span><span>=</span><span> "POST"</span></span> <span><span> request.</span><span>addValue</span><span>(</span><span>"application/json"</span><span>, </span><span>forHTTPHeaderField</span><span>: </span><span>"Content-Type"</span><span>)</span></span> <span></span> <span><span> let</span><span> body </span><span>=</span></span> <span><span> """</span></span> <span><span> { "query": "</span><span>\(query)</span><span>" }</span></span> <span><span> """</span></span> <span><span> let</span><span> queryData </span><span>=</span><span> body.</span><span>data</span><span>(</span><span>using</span><span>: .</span><span>utf8</span><span>)</span></span> <span><span> request.httpBody </span><span>=</span><span> queryData</span></span> <span></span> <span><span> return</span><span> self</span><span>.</span><span>dataTaskPublisher</span><span>(</span><span>for</span><span>: request)</span></span> <span><span> }</span></span> <span></span> <span><span> // If using SociableWeaver or a similar GraphQL query generator, you can do it in a type-safe manner.</span></span> <span><span> func</span><span> graphQLRequest</span><span>(</span><span>url</span><span>: URL, </span><span>query</span><span>: Weave) </span><span>-&gt;</span><span> URLSession.DataTaskPublisher {</span></span> <span><span> return</span><span> self</span><span>.</span><span>executeGraphQLQuery</span><span>(</span><span>url</span><span>: url, </span><span>query</span><span>: query.</span><span>description</span><span>)</span></span> <span><span> }</span></span> <span></span> <span><span>}</span></span></code></pre> <p>After looking over the above code a few times I realized that the majority of it was handling the creation of a <code>URLRequest</code>. That served as a hint to me that we could refactor the code into a custom <code>URLRequest</code> initializer. This would be less prescriptive about how the <code>URLRequest</code> is used, since my first code snippet assumes you always want to return a <code>URLSession.DataTaskPublisher</code>.</p> <pre><code><span><span>extension</span><span> URLRequest</span><span> {</span></span> <span></span> <span><span> init</span><span>(</span><span>url</span><span>: URL, </span><span>graphQLQuery</span><span> query: </span><span>String</span><span>) {</span></span> <span><span> self</span><span>.</span><span>init</span><span>(</span><span>url</span><span>: url)</span></span> <span></span> <span><span> self</span><span>.httpMethod </span><span>=</span><span> "POST"</span></span> <span><span> self</span><span>.</span><span>addValue</span><span>(</span><span>"application/json"</span><span>, </span><span>forHTTPHeaderField</span><span>: </span><span>"Content-Type"</span><span>)</span></span> <span></span> <span><span> let</span><span> body </span><span>=</span></span> <span><span> """</span></span> <span><span> { "query": "</span><span>\(query)</span><span>" }</span></span> <span><span> """</span></span> <span><span> let</span><span> queryData </span><span>=</span><span> body.</span><span>data</span><span>(</span><span>using</span><span>: .</span><span>utf8</span><span>)</span></span> <span><span> self</span><span>.httpBody </span><span>=</span><span> queryData</span></span> <span><span> }</span></span> <span><span> </span></span> <span><span> // If we're going all in on SociableWeaver we can make a similar initializer that takes a `Weave` parameter instead of a `String`.</span></span> <span></span> <span><span>}</span></span></code></pre> <p>Now if you’d like to use <code>URLSession.DataTaskPublisher</code> you’re free to by creating a <code>URLRequest</code> from our new initializer and using it, but you can also return a <code>URLSession.DataTask</code> or any other reason mechanism that involves a <code>URLRequest</code>.</p> <pre><code><span><span>extension</span><span> URLSession</span><span> {</span></span> <span></span> <span><span> func</span><span> graphQLRequest</span><span>(</span><span>url</span><span>: URL, </span><span>query</span><span>: </span><span>String</span><span>) </span><span>-&gt;</span><span> URLSession.DataTaskPublisher {</span></span> <span><span> let</span><span> request </span><span>=</span><span> URLRequest</span><span>(</span><span>url</span><span>: url, </span><span>graphQLQuery</span><span>: query)</span></span> <span><span> return</span><span> self</span><span>.</span><span>dataTaskPublisher</span><span>(</span><span>for</span><span>: request)</span></span> <span><span> }</span></span> <span></span> <span><span> func</span><span> graphQLRequest</span><span>(</span><span>url</span><span>: URL, </span><span>query</span><span>: Weave) </span><span>-&gt;</span><span> URLSession.DataTaskPublisher {</span></span> <span><span> return</span><span> self</span><span>.</span><span>graphQLRequest</span><span>(</span><span>url</span><span>: url, </span><span>query</span><span>: query.</span><span>description</span><span>)</span></span> <span><span> }</span></span> <span></span> <span><span>}</span></span></code></pre> <p>That looks a lot cleaner, and our responsibilities seem a lot more well-divided.</p> <hr /> <p><img alt="A text message saying &quot;Take that multi-million dollar company, not gonna use your so called library.&quot;" loading="lazy" width="1054" height="206" src="https://build.ms/_astro/not-using-apollo.BUkRKc20_J7G7t.webp" /></p> <p>Is there room for tools like Apollo? Absolutely! I’m not going to pretend that my dozen lines of code replaces the value that a multimillion dollar company provides. (I’ll only make sick jokes about it.) But before importing a library like Apollo, any library really, it’s worth asking yourself whether you need a big solution for a small problem. Or maybe question the better question to ask before that is, have you really understood the problem you’re trying to solve?</p> <p>But we still haven’t really answered where exactly we should put the U in GraphQL. (I say after the Q since Q is almost always followed by U, but I’m <a href="https://twitter.com/mergesort" target="_blank" rel="noopener noreferrer">open to feedback</a> on that or the rest of this post.)</p> <section><h2>Footnotes</h2> <ol> <li> <p>Special thanks to <a href="https://twitter.com/davedelong" target="_blank" rel="noopener noreferrer">Dave DeLong</a> for his debugging prowess. <a href="https://build.ms/2021/3/12/putting-the-u-in-graphql/#user-content-fnref-1" target="_blank" rel="noopener noreferrer">↩</a></p> </li> </ol> </section>EngineeringiOSSwiftTutorialWebJoe FabisevichEmpower Apps Podcast: Large Scale Teamshttps://build.ms/2021/2/12/empower-apps-large-scale-teams/https://build.ms/2021/2/12/empower-apps-large-scale-teams/Leo Dion and I discuss scaling app development to thousands of people and millions of users at Twitter, communication, documentation, and the complexity of holding moral frameworks at a global level.Fri, 12 Feb 2021 12:00:00 GMT<p>I recorded an episode of the <a href="https://twitter.com/brightdigit/status/1360091529466023936" target="_blank" rel="noopener noreferrer">Empower Apps</a> podcast, where <a href="http://twitter.com/leogdion" target="_blank" rel="noopener noreferrer">Leo Dion</a> and I discussed a wide range of topics. We spoke about everything from how we scale app development to thousands of people and millions of users at Twitter, communication, documentation, people working together, and a lot about and the complexity of holding moral frameworks at a global level.</p> <ul> <li> <p><a href="https://share.transistor.fm/s/f6582b48" target="_blank" rel="noopener noreferrer">Empower Apps: Large Scale Teams with Joe Fabisevich</a></p> </li> <li> <p><a href="https://www.youtube.com/watch?v=Mgg9D4JggQI" target="_blank" rel="noopener noreferrer">Watch on YouTube</a></p> </li> <li> <p><a href="https://www.dropbox.com/scl/fi/p7rk6lt4s53slghzzziwi/Empower-Apps-Large-Scale-Teams.mp3?rlkey=6zda29w1ntv9h146iqaferzmh&amp;st=kbun2cnx&amp;dl=0" target="_blank" rel="noopener noreferrer">Original recording</a></p> </li> </ul>IndustryiOSPodcastProgrammingSwiftJoe FabisevichContext-Bound Typeshttps://build.ms/2020/9/21/context-bound-types/https://build.ms/2020/9/21/context-bound-types/Exploring API design patterns for balancing implementation exposure, using the example of creating view controllers with header views in four different approaches.Mon, 21 Sep 2020 12:00:00 GMT<p>I’ve been thinking about privacy lately. No, not <a href="https://www.fabisevi.ch/2018/01/16/the-future-will-be-signed/" target="_blank" rel="noopener noreferrer">online</a> <a href="https://www.fabisevi.ch/2019/01/01/pushing-the-boundaries-of-technology/" target="_blank" rel="noopener noreferrer">privacy</a>, but about how APIs can balance exposing the right amount of implementation details without revealing too much.</p> <p>I’ll walk through a task I find myself doing often when building iOS apps, creating a view controller with header view, and four different ways to go about it.</p> <hr /> <h3>Regular View Configured as a Header</h3> <p><em>SettingsViewController.swift</em></p> <pre><code><span><span>final</span><span> class</span><span> SettingsViewController</span><span>: </span><span>UIViewController </span><span>{</span></span> <span><span> private</span><span> let</span><span> headerView </span><span>=</span><span> UIView</span><span>()</span></span> <span><span> </span></span> <span><span> private</span><span> let</span><span> tableView </span><span>=</span><span> UITableView</span><span>()</span></span> <span><span> </span></span> <span><span> override</span><span> func</span><span> viewDidLoad</span><span>() {</span></span> <span><span> super</span><span>.</span><span>viewDidLoad</span><span>()</span></span> <span><span> </span></span> <span><span> self</span><span>.</span><span>view</span><span>.</span><span>addSubview</span><span>(</span><span>self</span><span>.tableView)</span></span> <span><span> self</span><span>.</span><span>setupTableView</span><span>()</span></span> <span><span> self</span><span>.</span><span>configureHeaderView</span><span>()</span></span> <span><span> }</span></span> <span><span> </span></span> <span><span> func</span><span> setupTableView</span><span>() {</span></span> <span><span> self</span><span>.tableView.translatesAutoresizingMaskIntoConstraints </span><span>=</span><span> false</span></span> <span><span> NSLayoutConstraint.</span><span>activate</span><span>([</span></span> <span><span> self</span><span>.tableView.leadingAnchor.</span><span>constraint</span><span>(</span><span>equalTo</span><span>: </span><span>self</span><span>.</span><span>view</span><span>.leadingAnchor),</span></span> <span><span> self</span><span>.tableView.trailingAnchor.</span><span>constraint</span><span>(</span><span>equalTo</span><span>: </span><span>self</span><span>.</span><span>view</span><span>.trailingAnchor),</span></span> <span><span> self</span><span>.tableView.topAnchor.</span><span>constraint</span><span>(</span><span>equalTo</span><span>: </span><span>self</span><span>.</span><span>view</span><span>.topAnchor),</span></span> <span><span> self</span><span>.tableView.bottomAnchor.</span><span>constraint</span><span>(</span><span>equalTo</span><span>: </span><span>self</span><span>.</span><span>view</span><span>.bottomAnchor),</span></span> <span><span> ])</span></span> <span><span> }</span></span> <span><span> </span></span> <span><span> func</span><span> configureHeaderView</span><span>() {</span></span> <span><span> // Some code configuring self.headerView</span></span> <span><span> // ...</span></span> <span><span> // ...</span></span> <span><span> self</span><span>.tableView.tableHeaderView </span><span>=</span><span> self</span><span>.headerView</span></span> <span><span> }</span></span> <span><span>}</span></span></code></pre> <p>For folks new to iOS development, this is a common approach I see when adding a header. It makes sense, you want to have a header, and a header is a view, so why not configure and style <code>UIView</code> to be the <code>UITableView</code> header. While this is a good first try, it lacks the encapsulation that makes your code easy to edit and reason about.</p> <hr /> <h3>Separate Class For The Header</h3> <p><em>SettingsViewController.swift</em></p> <pre><code><span><span>final</span><span> class</span><span> SettingsViewController</span><span>: </span><span>UIViewController </span><span>{</span></span> <span><span> private</span><span> let</span><span> headerView </span><span>=</span><span> SettingsTableHeaderView</span><span>()</span></span> <span><span> </span></span> <span><span> private</span><span> let</span><span> tableView </span><span>=</span><span> UITableView</span><span>()</span></span> <span><span> </span></span> <span><span> override</span><span> func</span><span> viewDidLoad</span><span>() {</span></span> <span><span> super</span><span>.</span><span>viewDidLoad</span><span>()</span></span> <span><span> </span></span> <span><span> self</span><span>.</span><span>view</span><span>.</span><span>addSubview</span><span>(</span><span>self</span><span>.tableView)</span></span> <span><span> self</span><span>.</span><span>setupTableView</span><span>()</span></span> <span><span> </span></span> <span><span> self</span><span>.tableView.tableHeaderView </span><span>=</span><span> self</span><span>.headerView</span></span> <span><span> }</span></span> <span><span>}</span></span></code></pre> <p><em>SettingsTableHeaderView.swift</em></p> <pre><code><span><span>final</span><span> class</span><span> SettingsTableHeaderView</span><span>: </span><span>UIView </span><span>{</span></span> <span><span> // Some code creating and configuring SettingsTableHeaderView</span></span> <span><span> // ...</span></span> <span><span> // ...</span></span> <span><span>}</span></span></code></pre> <p>A naive approach to improve our readability would have been to move our configuration code into a function, but an even nicer improvement is to move it into its own class. This looks a lot better, it’s easier to reason about and it’s well-encapsulated. But a new problem this introduces is adding <code>SettingsTableHeaderView</code> into our module’s namespace. Now I’ll admit this isn’t the world’s biggest problem, but as you start adding different view controllers with different headers, suddenly finding the right header view for a given view controller becomes difficult.</p> <hr /> <h3>Private Class for the Header</h3> <p><em>SettingsViewController.swift</em></p> <pre><code><span><span>final</span><span> class</span><span> SettingsViewController</span><span>: </span><span>UIViewController </span><span>{</span></span> <span><span> private</span><span> let</span><span> headerView </span><span>=</span><span> HeaderView</span><span>()</span></span> <span><span> </span></span> <span><span> private</span><span> let</span><span> tableView </span><span>=</span><span> UITableView</span><span>()</span></span> <span><span> </span></span> <span><span> override</span><span> func</span><span> viewDidLoad</span><span>() {</span></span> <span><span> super</span><span>.</span><span>viewDidLoad</span><span>()</span></span> <span><span> </span></span> <span><span> self</span><span>.</span><span>view</span><span>.</span><span>addSubview</span><span>(</span><span>self</span><span>.tableView)</span></span> <span><span> self</span><span>.</span><span>setupTableView</span><span>()</span></span> <span><span> </span></span> <span><span> self</span><span>.tableView.tableHeaderView </span><span>=</span><span> self</span><span>.headerView</span></span> <span><span> }</span></span> <span><span> </span></span> <span><span> private</span><span> final</span><span> class</span><span> HeaderView</span><span>: </span><span>UIView </span><span>{</span></span> <span><span> // Some code creating and configuring SettingsViewController.HeaderView</span></span> <span><span> // ...</span></span> <span><span> // ...</span></span> <span><span> }</span></span> <span><span>}</span></span></code></pre> <p>Now this is a solution that I’m really liking. We’ve moved <code>SettingsTableHeaderView</code> out of our module’s namespace and into one dependent on the context it’s in, <code>SettingsViewController</code>. When referring to <code>SettingsViewController.HeaderView</code> inside of this class we can plainly refer to it as <code>HeaderView</code>, which is not only less verbose, but emphasizes the pairing between <code>HeaderView</code> and <code>SettingsViewController</code>.</p> <p>There is a downside to this approach though, the more views we add to <code>SettingsViewController</code>, the harder this file becomes to parse. Now again this may not seem like a big problem, but if you have a well encapsulated view, you may have many subviews that belong to either <code>SettingsViewController</code> or <code>HeaderView</code>, and your file can get pretty large. (Trust me, I’ve seen written some pretty large files.)</p> <hr /> <h3>Two Files with Namespaced Internal Classes</h3> <p><em>SettingsViewController.swift</em></p> <pre><code><span><span>final</span><span> class</span><span> SettingsViewController</span><span>: </span><span>UIViewController </span><span>{</span></span> <span><span> private</span><span> let</span><span> headerView </span><span>=</span><span> HeaderView</span><span>()</span></span> <span><span> </span></span> <span><span> private</span><span> let</span><span> tableView </span><span>=</span><span> UITableView</span><span>()</span></span> <span><span> </span></span> <span><span> override</span><span> func</span><span> viewDidLoad</span><span>() {</span></span> <span><span> super</span><span>.</span><span>viewDidLoad</span><span>()</span></span> <span><span> </span></span> <span><span> self</span><span>.</span><span>view</span><span>.</span><span>addSubview</span><span>(</span><span>self</span><span>.tableView)</span></span> <span><span> self</span><span>.</span><span>setupTableView</span><span>()</span></span> <span><span> </span></span> <span><span> self</span><span>.tableView.tableHeaderView </span><span>=</span><span> self</span><span>.headerView</span></span> <span><span> }</span></span> <span><span>}</span></span></code></pre> <p><em>SettingsViewController.HeaderView.swift</em></p> <pre><code><span><span>extension</span><span> SettingsViewController</span><span> {</span></span> <span><span> final</span><span> class</span><span> HeaderView</span><span>: </span><span>UIView </span><span>{</span></span> <span><span> // Some code creating and configuring SettingsViewController.HeaderView</span></span> <span><span> // ...</span></span> <span><span> // ... </span></span> <span><span> }</span></span> <span><span>}</span></span></code></pre> <p>This is the approach I’ve settled on today. You’ll notice that <code>HeaderView</code> is no longer private, but it’s also not particularly easy to access publicly. You still end up with the benefits from namespacing the API, and this extension can go into its own file, unlike the earlier approach.</p> <p>If you were to accidentally misuse this API, it would be pretty clear. When calling <code>HeaderView</code> inside of <code>SettingsViewController</code> the call-site is clean and simple. But if someone were to attempt to use it from another class, they would have to reference the fully-qualified type, <code>SettingsViewController.HeaderView</code>.</p> <hr /> <p>While I’ve walked through one example with four approaches, binding a type to its context is something you can do throughout a codebase. In an ideal world Swift would have a <code>submodule</code> keyword to make types less ambiguous, but in the mean time this is a reasonable substitute that developers can take advantage of. While we don’t have a <code>submodule</code> keyword, we have a close approximation by using empty enums. One notable example is Combine’s usage of <a href="https://developer.apple.com/documentation/combine/publishers" target="_blank" rel="noopener noreferrer">Publishers</a> and <a href="https://developer.apple.com/documentation/combine/subscribers/" target="_blank" rel="noopener noreferrer">Subscribers</a> to help people have context and understanding for their subtypes.</p> <p>As always, I’d love to know what you think or if you’ve come up with better solutions, so please don’t be shy about <a href="https://twitter.com/mergesort" target="_blank" rel="noopener noreferrer">reaching out</a>.</p> <hr /> <p>Special shoutout to <a href="https://twitter.com/jasdev" target="_blank" rel="noopener noreferrer">Jasdev</a> for taking a <em>very rough</em> first draft and helping me turn it into something coherent.</p>EngineeringiOSSwiftJoe FabisevichBuilding Better Views (Part I)https://build.ms/2019/12/26/building-better-views-part-i/https://build.ms/2019/12/26/building-better-views-part-i/Introducing ViewData, a declarative architecture pattern for transforming models into views that offers a maintainable alternative to MVVM for iOS development.Thu, 26 Dec 2019 12:00:00 GMT<p>As iOS developers, a lot of our work involves taking models from a server, and transforming them to be displayed on an iPhone or iPad. This sounds like a job for some declarative architecture. 🤔</p> <blockquote><p>If you ask 3 programmers how to define MVVM, expect to get 7 different responses.</p>— ✨ Joe Fabisevich™ ✨ (@mergesort) <a href="https://twitter.com/mergesort/status/720706593982062592?ref_src=twsrc%5Etfw" target="_blank" rel="noopener noreferrer">April 14, 2016</a></blockquote> <p>Confession: I’ve never fully bought into MVVM. I don’t think it’s worse than MVC. I use View Models as a place to store state and actions for View Controllers, and preferably stateless functions for manipulating data. In my experience, things become harder to maintain when they start becoming a crutch, as a place to put your code if it doesn’t neatly fall into the Model, View, or Controller label.</p> <p>With this in mind, I realized we need an answer for configuring our views in a way that’s maintainable, and ultimately transforms one or multiple models into a view. This led me to the idea of <code>ViewData</code>. I started working on this with <a href="https://twitter.com/shengjundong" target="_blank" rel="noopener noreferrer">@shengjundong</a> at <a href="https://www.timehop.com/" target="_blank" rel="noopener noreferrer">Timehop</a>, and have been using it successfully across apps of varying sizes since.</p> <p>There are three parts to this approach:</p> <ol> <li> <p>A <code>UIView</code> instance. This is your standard view that you’ll be displaying in an app. It can be a regular class, or a custom subclass as you need.</p> </li> <li> <p>A <code>ViewData</code> protocol. This is what’s going to keep track of the data that needs to be displayed in your view. Most commonly this will be a slice of a model, used specifically for rendering the view.</p> </li> <li> <p>A <code>configure(viewData: ViewData)</code> function. This is what’s going to map your View to your ViewData.</p> </li> </ol> <p><img alt="A diagram explaining the interaction flow of ViewData" loading="lazy" width="1798" height="1798" src="https://build.ms/_astro/view_data_diagram.BayDElrl_LeO79.webp" /></p> <h5>An Example</h5> <p>Let’s start with an example, where we’re building a view to display a comment. It will have a few properties you’d expect from a comment view. A commenter, their avatar, some text, and a timestamp. To make it easier to visualize, let’s imagine it looks like this:</p> <p><img alt="A visual example of a commment box we&amp;#x27;re going to build in code" loading="lazy" width="710" height="434" src="https://build.ms/_astro/comment_example.8ey57poQ_2dEGRO.webp" /></p> <p>We start with a simple model. This is what we’ll be later manipulating for display purposes.</p> <pre><code><span><span>public</span><span> struct</span><span> Comment</span><span> {</span></span> <span><span> let</span><span> text: </span><span>String</span></span> <span><span> let</span><span> commenter: </span><span>String</span></span> <span><span> let</span><span> createdAt: Date</span></span> <span><span> let</span><span> avatarURL: URL</span><span>?</span></span> <span><span>}</span></span></code></pre> <p>A simple <code>UIView</code> subclass to display the comment.</p> <pre><code><span><span>public</span><span> final</span><span> class</span><span> CommentView</span><span>: </span><span>UIView </span><span>{</span></span> <span><span> let</span><span> titleLabel </span><span>=</span><span> UILabel</span><span>()</span></span> <span><span> let</span><span> subtitleLabel </span><span>=</span><span> UILabel</span><span>()</span></span> <span><span> let</span><span> statusLabel </span><span>=</span><span> UILabel</span><span>()</span></span> <span><span> let</span><span> replyButton </span><span>=</span><span> UIButton</span><span>(</span><span>type</span><span>: .custom)</span></span> <span><span> let</span><span> avatarImageView </span><span>=</span><span> UIImageView</span><span>()</span></span> <span><span>} </span></span></code></pre> <p>Now we get a little to the fun stuff.</p> <p>We’ll make our first <code>ViewData</code> protocol. This represents how we will render the data we’re trying to populate the <code>UIView</code> with.</p> <pre><code><span><span>protocol</span><span> CommentViewData</span><span> {</span></span> <span><span> var</span><span> title: </span><span>String</span><span> { </span><span>get</span><span> }</span></span> <span><span> var</span><span> subtitle: </span><span>String</span><span> { </span><span>get</span><span> }</span></span> <span><span> var</span><span> timestamp: </span><span>String</span><span> { </span><span>get</span><span> }</span></span> <span><span> var</span><span> replyText: </span><span>String</span><span> { </span><span>get</span><span> }</span></span> <span><span> var</span><span> avatarURL: URL</span><span>?</span><span> { </span><span>get</span><span> }</span></span> <span><span>}</span></span></code></pre> <p>Let’s conform our model to our <code>CommentViewData</code> protocol. This will be how we tell our <code>CommentView</code> how it should display our model whenever it comes across an instance of it.</p> <pre><code><span><span>// The original data source is made to conform to the protocol which we are using for display, CommentViewData</span></span> <span></span> <span><span>extension</span><span> Comment</span><span>: </span><span>CommentViewData </span><span>{</span></span> <span><span> </span></span> <span><span> var</span><span> title: </span><span>String</span><span> {</span></span> <span><span> return</span><span> self</span><span>.commenter</span></span> <span><span> }</span></span> <span><span> </span></span> <span><span> var</span><span> subtitle: </span><span>String</span><span> {</span></span> <span><span> return</span><span> self</span><span>.</span><span>text</span></span> <span><span> }</span></span> <span><span> </span></span> <span><span> var</span><span> replyText: </span><span>String</span><span> {</span></span> <span><span> return</span><span> NSLocalizedString</span><span>(</span><span>"Reply"</span><span>, </span><span>comment</span><span>: </span><span>"Text for replying to a comment"</span><span>)</span></span> <span><span> }</span></span> <span><span> </span></span> <span><span> var</span><span> replyImage: UIImage</span><span>?</span><span> {</span></span> <span><span> return</span><span> UIImage</span><span>(</span><span>named</span><span>: </span><span>"reply"</span><span>)</span></span> <span><span> }</span></span> <span><span> </span></span> <span><span> var</span><span> timestamp: </span><span>String</span><span> {</span></span> <span><span> return</span><span> self</span><span>.createdAt.timeAgoSinceNow</span></span> <span><span> }</span></span> <span></span> <span><span>}</span></span></code></pre> <p>One thing to note is that the <code>avatarURL</code> property automatically conforms to the <code>CommentViewData</code>! As long as we plan to use it directly, we don’t have to add it to our extension.</p> <p>Last but not least, we need to configure the <code>CommentView</code> with a <code>CommentViewData</code>.</p> <pre><code><span><span>extension</span><span> CommentView</span><span> {</span></span> <span></span> <span><span> func</span><span> configure</span><span>(</span><span>viewData</span><span>: CommentViewData) {</span></span> <span><span> self</span><span>.titleLabel.</span><span>text</span><span> =</span><span> viewData.title</span></span> <span><span> self</span><span>.subtitleLabel.</span><span>text</span><span> =</span><span> viewData.subtitle</span></span> <span><span> self</span><span>.statusLabel.</span><span>text</span><span> =</span><span> viewData.timestamp</span></span> <span><span> self</span><span>.replyButton.</span><span>setTitle</span><span>(viewData.replyText, </span><span>for</span><span>: .normal)</span></span> <span><span> self</span><span>.replyButton.</span><span>setImage</span><span>(viewData.replyImage, </span><span>for</span><span>: .normal)</span></span> <span><span> self</span><span>.avatarImageView.</span><span>setImage</span><span>(</span><span>from</span><span>: viewData.avatarURL)</span></span> <span><span> }</span></span> <span></span> <span><span>}</span></span></code></pre> <p>We’ve got everything configured in a nice declarative fashion, but how do we actually use this? This is in my opinion the best part. Let’s look at the call-site.</p> <pre><code><span><span> func</span><span> tableView</span><span>(</span><span>_</span><span> tableView: UITableView, </span><span>cellForRowAt</span><span> indexPath: IndexPath) </span><span>-&gt;</span><span> UITableViewCell {</span></span> <span><span> // My own homegrown solution, you're under no obligation to use it of course 😇</span></span> <span><span> let</span><span> cell </span><span>=</span><span> tableView.</span><span>dequeueReusableCell</span><span>(</span><span>forIndexPath</span><span>: indexPath) </span><span>as</span><span> GenericTableCell</span><span>&lt;</span><span>CommentView</span><span>&gt;</span></span> <span><span> </span></span> <span><span> // This is of type `Comment`</span></span> <span><span> let</span><span> currentComment </span><span>=</span><span> self</span><span>.comments[indexPath.row]</span></span> <span><span> </span></span> <span><span> // Comment conforms to `CommentViewData`, so we can use it directly!</span></span> <span><span> cell.customView.</span><span>configure</span><span>(</span><span>viewData</span><span>: currentComment)</span></span> <span><span> </span></span> <span><span> return</span><span> cell</span></span> <span><span> }</span></span></code></pre> <p>And that’s it! All you need to do is pass the original model object to the view, and as long as it conforms to the right protocol, you’ve got it working without any intermediate objects.</p> <hr /> <p>This may seem like a lot of boilerplate, and to be honest, it’s more than I would like. There are other languages with features such as <a href="https://github.com/purescript/documentation/blob/a15f4e6b40e0a8dc874285526afe13f3074b6d26/language/Types.md#row-polymorphism" target="_blank" rel="noopener noreferrer">row polymorphism</a> or <a href="https://medium.com/@ckoster22/advanced-types-in-elm-extensible-records-67e9d804030d" target="_blank" rel="noopener noreferrer">extensible records</a> which would make this easier. Until Swift supports these language features, or macros, or more powerful tooling that can fill the gaps, this is the best solution I’ve found to enforcing good practices and leveraging compile-time safety for view configuration.</p> <hr /> <p>Now you may also be thinking “sometimes my models don’t map to how they’re displayed one to one, how can I make that work?” Follow along with <a href="https://build.ms/2019/12/26/building-better-views-part-ii" target="_blank" rel="noopener noreferrer">part 2</a>, where we’ll cover that, and a few other questions you may have.</p> <p>As always, I’m excited to hear your thoughts, and am receptive to <a href="https://twitter.com/mergesort" target="_blank" rel="noopener noreferrer">feedback</a>!</p>EngineeringiOSSwiftSwiftUITutorialJoe FabisevichBuilding Better Views (Part II), Next Stepshttps://build.ms/2019/12/26/building-better-views-part-ii/https://build.ms/2019/12/26/building-better-views-part-ii/Continuing the ViewData architecture discussion, covering complex views, performance considerations, and practical implementation strategies.Thu, 26 Dec 2019 12:00:00 GMT<p>If you haven’t checked out <a href="https://build.ms/2019/12/26/building-better-views-part-i" target="_blank" rel="noopener noreferrer">Part I</a>, I recommend reading it because if you don’t, none of writing below will make sense!</p> <h2>Three Unanswered Questions</h2> <h4>1. What happens when the views you want to configure are more complex?</h4> <p>My recommended approach is to construct a one-time use struct, specifically for displaying in that one place. This type should only have the properties you need to render the view.</p> <pre><code><span><span>struct</span><span> HomeScreenCourseProgressViewDisplay</span><span> {</span></span> <span><span> let</span><span> course: Course</span></span> <span><span> let</span><span> enrollment: Enrollment</span></span> <span><span> let</span><span> customization: SchoolCustomization</span></span> <span><span>}</span></span></code></pre> <p>Creating the <code>ViewData</code> should look familiar. We’re going to do the exact same thing we did before.</p> <pre><code><span><span>extension</span><span> HomeScreenCourseProgressViewDisplay</span><span>: </span><span>CourseProgressViewData </span><span>{</span></span> <span></span> <span><span> var</span><span> titleLabelText: </span><span>String</span><span> {</span></span> <span><span> return</span><span> self</span><span>.course.name</span></span> <span><span> }</span></span> <span></span> <span><span> var</span><span> subtitleLabelText: </span><span>String</span><span> {</span></span> <span><span> return</span><span> self</span><span>.course.author.name</span></span> <span><span> }</span></span> <span><span> </span></span> <span><span> var</span><span> statusLabelText: </span><span>String</span><span> {</span></span> <span><span> return</span><span> String</span><span>.</span><span>localizedStringWithFormat</span><span>(</span><span>NSLocalizedString</span><span>(</span><span>"%@% complete"</span><span>, </span><span>comment</span><span>: </span><span>"The percentage a course is complete"</span><span>), </span><span>self</span><span>.enrollment.percentComplete)</span></span> <span><span> }</span></span> <span><span> </span></span> <span><span> var</span><span> progress: CGFloat {</span></span> <span><span> return</span><span> CGFloat</span><span>(</span><span>self</span><span>.enrollment.percentComplete) </span><span>/</span><span> 100</span></span> <span><span> }</span></span> <span><span> </span></span> <span><span> var</span><span> imageUrl: URL</span><span>?</span><span> {</span></span> <span><span> return</span><span> self</span><span>.course.imageUrl</span></span> <span><span> }</span></span> <span></span> <span><span>}</span></span></code></pre> <p>Using this <code>ViewData</code> object is just as simple as it was before. On our home screen, we now create the struct, and configure our custom view with it. Same as before, just leveraging how lightweight creating types in Swift is!</p> <pre><code><span><span>func</span><span> tableView</span><span>(</span><span>_</span><span> tableView: UITableView, </span><span>cellForRowAt</span><span> indexPath: IndexPath) </span><span>-&gt;</span><span> UITableViewCell {</span></span> <span><span> guard</span><span> let</span><span> currentUser </span><span>=</span><span> self</span><span>.</span><span>userAtIndexPath</span><span>(</span><span>indexPath</span><span>: indexPath), </span><span>self</span><span>.</span><span>hasCoursesAtIndexPath</span><span>(</span><span>indexPath</span><span>: indexPath) </span><span>else</span><span> { </span><span>fatalError</span><span>(</span><span>"Ruh roh"</span><span>!</span><span>) }</span></span> <span><span> </span></span> <span><span> let</span><span> currentCourse </span><span>=</span><span> currentUser.courses[indexPath.row]</span></span> <span><span> let</span><span> currentEnrollment </span><span>=</span><span> currentUser.enrollments[indexPath.row]</span></span> <span><span> let</span><span> schoolCustomization </span><span>=</span><span> currentUser.school.customization</span></span> <span><span> </span></span> <span><span> let</span><span> homeScreenDisplay </span><span>=</span><span> HomeScreenCourseProgressViewDisplay</span><span>(</span></span> <span><span> course</span><span>: currentCourse, </span></span> <span><span> enrollment</span><span>: currentEnrollment, </span></span> <span><span> customization</span><span>: schoolCustomization</span></span> <span><span> )</span></span> <span><span> </span></span> <span><span> cell.customView.</span><span>configure</span><span>(</span><span>viewData</span><span>: homeScreenDisplay)</span></span> <span><span> </span></span> <span><span> return</span><span> cell</span></span> <span><span>}</span></span></code></pre> <h4>2. How does the <code>ViewData</code> pattern deal with user interaction?</h4> <p>I advise keeping user actions in the <code>UIView</code> realm. You can continue using the delegate pattern, closures, or wherever your preferences may lie. If you’re looking to get a little more advanced, I’d consider reading Dave DeLong’s <a href="https://davedelong.com/blog/2017/11/06/a-better-mvc-part-1-the-problems/" target="_blank" rel="noopener noreferrer">A Better MVC</a> series.</p> <h4>3. Where does logic code reside, and what happens if you have more complex transformations?</h4> <p>The scenarios so far have worked great. The models you received from the server looked a lot like the way you plan to display them, but that’s not always the case. Sometimes you’re going to need business logic, and that’s ok.</p> <p>This is the question I had the most trouble coming up with one answer for. I realized the reason I couldn’t come up with one answer is because there isn’t only one answer.</p> <p>Looking back at our <code>Comment</code> model, we see that there is a <code>Date</code> object in there.</p> <pre><code><span><span>public</span><span> struct</span><span> Comment</span><span> {</span></span> <span><span> let</span><span> text: </span><span>String</span></span> <span><span> let</span><span> commenter: </span><span>String</span></span> <span><span> let</span><span> createdAt: Date</span></span> <span><span> let</span><span> imageUrl: URL</span><span>?</span></span> <span><span>}</span></span></code></pre> <p>In our first example we simply glossed over the fact that we were translating a <code>Date</code> into a <code>String</code>, by using a simple function that already exists in a third party library.</p> <pre><code><span><span>extension</span><span> Comment</span><span>: </span><span>CommentViewData </span><span>{</span></span> <span><span> var</span><span> timestamp: </span><span>String</span><span> {</span></span> <span><span> return</span><span> self</span><span>.createdAt.timeAgoSinceNow</span></span> <span><span> }</span></span> <span><span>}</span></span></code></pre> <p>But now let’s pretend we don’t have <code>timeAgoSinceNow</code> available to us. Where does that transformation code live? The answer is, it’s up to you!</p> <p>Some people prefer to make an object to handle business logic, to make their code more testable. If it makes you happy to keep it in the <code>ViewData</code> file, go right ahead. If not, then don’t. Who am I to tell people how to be happy?</p> <pre><code><span><span>extension</span><span> Comment</span><span>: </span><span>CommentViewData </span><span>{</span></span> <span></span> <span><span> var</span><span> timestamp: </span><span>String</span><span> {</span></span> <span><span> let</span><span> dateTransformer </span><span>=</span><span> DateTransformer</span><span>(</span><span>self</span><span>.createdAt)</span></span> <span><span> return</span><span> dateTransformer.</span><span>asString</span><span>()</span></span> <span><span> }</span></span> <span><span> </span></span> <span><span> private</span><span> static</span><span> func</span><span> transformDateToString</span><span>(</span><span>date</span><span>: Date) </span><span>-&gt;</span><span> String</span><span> {</span></span> <span><span> return</span><span> someMagicalWayToTransformDatesToStrings</span><span>()</span></span> <span><span> }</span></span> <span></span> <span><span>}</span></span> <span><span> </span></span> <span><span>struct</span><span> DateTransformer</span><span> {</span></span> <span><span> let</span><span> date: Date</span></span> <span></span> <span><span> func</span><span> asString</span><span>() </span><span>-&gt;</span><span> Date {</span></span> <span><span> return</span><span> someMagicalDateTransformer</span><span>()</span></span> <span><span> }</span></span> <span><span>}</span></span></code></pre> <p>My personal preference is to use private static functions, keeping in tune with the functional nature of this approach.</p> <pre><code><span><span>extension</span><span> Comment</span><span>: </span><span>CommentViewData </span><span>{</span></span> <span></span> <span><span> var</span><span> timestamp: </span><span>String</span><span> {</span></span> <span><span> return</span><span> transformDateToString</span><span>(</span><span>self</span><span>.createdAt)</span></span> <span><span> }</span></span> <span></span> <span><span>}</span></span> <span></span> <span><span>private</span><span> extension</span><span> Comment</span><span> {</span></span> <span></span> <span><span> static</span><span> func</span><span> transformDateToString</span><span>(</span><span>date</span><span>: Date) </span><span>-&gt;</span><span> String</span><span> {</span></span> <span><span> return</span><span> someMagicalDateTransformer</span><span>()</span></span> <span><span> }</span></span> <span></span> <span><span>}</span></span></code></pre> <p>The important thing to note is that when it comes to business logic, you have the agency to structure your codebase however you’d like. The <code>ViewData</code> pattern isn’t prohibitive or prescriptive, it’s just there to aid you in transforming models into views.</p> <hr /> <p>These are the big questions I’ve received while using this pattern over the last few years. I’m excited to hear your thoughts, and am always receptive to <a href="https://twitter.com/mergesort" target="_blank" rel="noopener noreferrer">feedback</a>!</p>EngineeringiOSSwiftSwiftUITutorialJoe FabisevichThe Learn Swift Podcast: Episode 28https://build.ms/2018/6/24/learn-swift-podcast-episode-28/https://build.ms/2018/6/24/learn-swift-podcast-episode-28/Join me on The Learn Swift Podcast where Steven Sherry and I discuss contracting, computing history, philosophy, Smalltalk, and even a little bit about Swift.Sun, 24 Jun 2018 12:00:00 GMT<p>I recorded an episode of <a href="https://twitter.com/learnswift_fm" target="_blank" rel="noopener noreferrer">@learnswift_fm</a> with <a href="https://twitter.com/steven_0351" target="_blank" rel="noopener noreferrer">Steven Sherry</a> a couple weeks back. We had a great chat about contracting, computing history, philosophy, Smalltalk, and even a little bit about Swift. I think you’ll really like it, and recommend giving it a listen.</p> <p><a href="https://learnswift.show/28" target="_blank" rel="noopener noreferrer">The Learn Swift Podcast: #28: Joe Fabisevich</a></p> <p><a href="https://www.dropbox.com/scl/fi/e95utkgrlpnne7d7xd3kg/Joe-Fabisevich-Learn-Swift.mp3?rlkey=rigg5acczbhjuhpjt5ku68f6k&amp;st=9os6gy1x&amp;dl=0" target="_blank" rel="noopener noreferrer">Original recording</a></p>IndustryiOSPodcastProgrammingSwiftJoe FabisevichDev Chats - Joe Fabisevichhttps://build.ms/2017/10/24/dev-chats-joe-fabisevich/https://build.ms/2017/10/24/dev-chats-joe-fabisevich/An interview with Sam Jarman covering my career journey, the NYC tech scene, consulting vs full-time work, and advice for building a successful software career.Tue, 24 Oct 2017 12:00:00 GMT<p>This is an interview that I did with Sam Jarman, originally posted <a href="https://www.samjarman.co.nz/blog/mergesort" target="_blank" rel="noopener noreferrer">on his blog</a>.</p> <h4>Introduce yourself! Who are you? Where do you work?</h4> <p>Hey Sam, thanks for this interview! I’ve been reading your other developer interviews and am humbled to be in the same company as them.</p> <p>I’m Joe Fabisevich, an iOS developer in New York City, with no specific ties to a company right now. I spent the first 5 or 6 years of my career working startups like Timehop, Betaworks, and Bitly. Last year I decided to take the things I’d learned and help other companies grow via consulting work.</p> <p>My job is to help companies get through whatever challenges they’re currently face. I enjoy working most with early stage startups, so a lot of my work focuses around getting startups to their 1.0. Often times they’re starting with nothing more than sketches and wireframes. Other times I help businesses figure out their mobile strategy, or work with iOS teams to grow their skills. This is especially interesting as iOS has recently moved to Swift, and there are few experts in the field so far. I wanted to add flexibility in my life, and now I’m able to tackle all different kinds of challenges. Not all of my work is even engineering related, which makes it fun to grow in many directions.</p> <h4>Who or what got you into programming?</h4> <p>It’s a bit cliché, but I got into programming somewhat by accident. In high school my grades were pretty poor, and I had to choose a major for my junior and senior years. My mom was a mainframe programmer and told me that if I took the computer science course, she would help me out. (Please don’t tell my 11th grade AP Java teacher Mr. Turner). After about two weeks, she declared that she was completely out of her element, and that I was on my own.</p> <p>I was never a great rote learner, but I was always good with understanding patterns and systems. Programming lends itself to hands on learning, which made me finally feel good about learning. After some initial success, I was pretty hooked. As I got better, I was able to start helping others, which was also something I’d never been able to do.</p> <p>In college I majored in computer science, but rather aimlessly. When the iPhone came out, I really wanted to make an app for it with a friend, and so I mustered up my strength and did. The code was awful, and the UI was hideous by today’s standards, but there were probably only 10,000 or so people building apps, so it was felt like quite an accomplishment. Since there was so little iOS talent out there, I was able to parlay that app into an internship. As I left school (after spending most of it building apps in class), I was able to turn that internship and subsequent apps into my first startup job.</p> <h4>What’s the tech scene like in NYC? Any major upsides and downsides?</h4> <p>The tech scene in New York City is quite a mixed bag, but I feel that way about most things in New York. Tech is not the top dog in this city, which has it’s pros and cons.</p> <p>The biggest pro is that as an industry, we stay mostly grounded. We use our technology to lift up other industries, as well as tech for tech’s sake. This helps us avoid a monoculture like you see in Silicon Valley.</p> <p>The biggest con is that as an industry, we stay mostly grounded. This means that we don’t attract as much talent (though there’s still plenty), or work on as many moonshot ideas as you’d see in Silicon Valley. Those moonshot ideas are the one’s that grow to be Facebook or Airbnb, and affect our lives in an outsized manner.</p> <p>As a person, it’s hard to say whether I would trade one for the other, but it’s always a fun thought experiment.</p> <h4>You’ve worked both for companies and for yourself – do you have a preference? What are the advantages and disadvantages?</h4> <p>Like everything in life, there’s pros and cons to everything. I don’t have a preference, and don’t think I’ll always be a consultant, but don’t think I’ll always work full time either.</p> <p>Being a consultant gives me work-life balance that’s hard to beat. It’s quite an opportunity to use the extra time I have to invest in my own learning. I can spend more time reading about product, design, management, or even things like metacognition, which help me grow in my career.</p> <p>On the other hand there are some skills you can learn at a company over the long term. I still work at and with companies, but being with a company for a while helps you develop different skills.</p> <p>I tend to think of it as growing in a breadth-first manner vs. a depth-first manner. Both will likely get you to the place you want to be, but the path will look different. Ultimately what works best for you is in your hands.</p> <h4>What has been your toughest lesson to learn in your software career so far?</h4> <p>I’d say the career matters. Developers often don’t want to think about their career, and instead think about programming. It makes sense because it takes a while to become comfortable enough to feel confident in what you’re doing, but as a result other people’s other skills suffer.</p> <p>I’ve always been interested in product, design, and programming, so choosing a linear path was difficult for me. Nowadays I’m able to leverage those skills as a consultant and former startup founder. On the other hand, I hadn’t spent much time thinking about management or a career track until recently, and realized that I’ve got little experience with that and now am playing catch up. Ultimately it may not matter because you can’t predict your future, but it is very important to be in touch with your goals to move forward as much as possible, without parallel diversions.</p> <h4>What would be your number one piece of advice for a successful software career?</h4> <p>Everyone starts at the beginning and there’s no way around it. Luckily, that’s ok. Over the first few years of my software career I read anything programming related in sight that sounded interesting. Even if it didn’t pertain to what I was currently working on, I would still read it. That ended up paying dividends as I started to expand my horizons. Even though I didn’t understand it all, I had all this knowledge tucked away in the back of my brain for a later day.</p> <p>When it was time for me to try my hand at Python, I didn’t need to start from scratch. When I wanted to build a website, it was as easy as recalling that tutorial I read a month ago. Better yet, I took the lessons I learned in other languages and frameworks, and applied them to what I was working on as I saw fit. This allowed me to grow in ways that my peers weren’t, and made a more confident programmer.</p> <p>While this technique worked for me, I can’t promise it will work for everyone. Everyone learns differently, and you have to find your path. What I will suggest though is trying it out and seeing if it works for you! Maybe it won’t be the only way you grow, but it can be another tool in your toolbelt.</p> <h4>What programming skill do you think is underrated?</h4> <p>There are two personality traits which go hand in hand. Empathy and modesty can take you from being a good programmer to being a good teammate. Being a good teammate can transform you into a great asset. If you apply these traits, you’ll more easily accept your flaws, and that will empower you to your co-workers and fellow community members.</p> <p>One underrated aspect of empathy and modesty is that over time you become more confident and humble. Confidence and humility allow you to turn anything into a learning opportunity. The more you can say “I don’t know, but I want to learn about it” either to yourself or to a peer, the more you’ll open yourself up to an opportunity to grow. Over time it will become an innate part of how you approach solving problems.</p> <h4>What books/resources would you recommend?</h4> <p>I’m going to be a little self-serving here, but I maintain a <a href="https://pinboard.in/u:mergesort/t:learn-programming" target="_blank" rel="noopener noreferrer">Pinboard tag</a> where I post articles. The articles are ones that I’ve come across in my career that taught me something, with information I wish I knew when I was just starting out as a programmer. Sometimes I go back and re-read the articles, and I’ll still pick up new things. As I’ve grown, the advice and stories take on new life and new meaning.</p> <p>I recommend going through the posts in reverse order. The first posts are more foundational and encourage bigger thinking. I’m really big on RSS, so I recommend subscribing to the <a href="https://feeds.pinboard.in/rss/u:mergesort/t:learn-programming/" target="_blank" rel="noopener noreferrer">feed</a>, so that way you can always get a new article right after it’s posted.</p> <h4>Finally, make your shoutout! What would you like the readers to go have a look at?</h4> <p>I think right now it’s important to not forget about others in need. There are people dealing with natural disasters that have entirely uprooted people’s lives. A cause that’s near and dear to my heart is the <a href="http://hispanicfederation.org/unidos/" target="_blank" rel="noopener noreferrer">Hispanic Federation</a>, where every dollar will go to aid on the ground in Puerto Rico. You can choose your own cause, but the important thing is to do something.</p> <p>Personally you can find me on <a href="http://twitter.com/mergesort" target="_blank" rel="noopener noreferrer">Twitter</a> where I am busy saying anything that comes into my head, so my apologies in advance.</p>IndustryInterviewiOSProductivityProgrammingJoe Fabisevich