<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="/feed.xsl"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Adebesin Tolulope (Lope) — Blog</title>
        <link>https://lope.adebesin.com/blog</link>
        <description>Notes on open source, engineering with LLMs, and projects I'm building.</description>
        <lastBuildDate>Tue, 18 Aug 2026 19:11:17 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <copyright>CC BY-NC-SA 4.0 — Adebesin Tolulope</copyright>
        <atom:link href="https://lope.adebesin.com/feed.xml" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[My Pastor Once Said]]></title>
            <link>https://lope.adebesin.com/blog/my-pastor-once-said</link>
            <guid isPermaLink="false">https://lope.adebesin.com/blog/my-pastor-once-said</guid>
            <pubDate>Sun, 09 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[The product-design story behind a BethelFlow campaign, a card generator for the lines our pastors never stopped saying. What we designed, the rough edges, the numbers, and where it landed.]]></description>
            <content:encoded><![CDATA[<h3 id="a-campaign-for-the-lines-that-stuck-what-i-designed-what-broke-what-the-numbers-said-and-where-it-landed">A campaign for the lines that stuck. What I designed, what broke, what the numbers said, and where it landed.</h3><p><img alt="The My Pastor Once Said landing page, a wall of quote cards fanning out around the words &quot;My Pastor Once Said…&quot; in BethelFlow blue." src="https://lope.adebesin.com/images/blog/my-pastor-once-said/hero.png"></p><p>Every church has a few lines it never forgot. The one your pastor said at the end of every service. The sentence you were half-listening to at seventeen that you're still carrying at twenty-seven. <em>Shalom.</em> <em>You are not a mistake.</em> <em>Shout hallelujah.</em> You know the ones.</p><p>We wanted to collect them. Not in a database, but on people's phones, in group chats, on statuses. So we built <a href="https://www.bethelflow.com/my-pastor-once-said" rel="nofollow"><strong>My Pastor Once Said</strong></a> for <a href="https://www.bethelflow.com/" rel="nofollow">BethelFlow</a>: type a line your pastor never stopped saying, add a photo, and walk out with a card built to pass on. This is the story of designing it. The idea, my part in it, the rough edges, and where it is now.</p><h2 id="borrowing-a-good-idea">Borrowing a good idea</h2><p>The seed was <a href="https://cowrywise.com/mothers-day" rel="nofollow">Cowrywise's Mother's Day page</a>, <em>"Mama once said…"</em>, a small, joyful thing that turned a feeling everyone already had into something you could make and send in under a minute. We wanted that shape, pointed at the people who shaped our faith.</p><p>The strategy underneath it is plain. The movement is the hook, and <em>"start a church on BethelFlow"</em> is the quiet conversion sitting under it. Top of funnel dressed as a keepsake. That framing mattered, because it set the bar for the whole thing: it had to be the kind of page a person shares for their own reasons, not because we asked.</p><p>My part was the experience. Make it fast to pick up, cheap to run, and good enough that sharing it never feels like an ad.</p><h2 id="designing-for-a-five-second-pickup">Designing for a five-second pickup</h2><p>The rule I set myself: a stranger should understand what this is and start making one before they've decided whether to. That meant the payoff couldn't live at the end of a form. It had to be <em>right there</em> while you typed.</p><p>So the create flow is a small wizard, the line, then the photo, then the card, with a live preview sitting next to the form the whole time. Cowrywise reveals the card after you finish. We diverged and let you watch it assemble as you go. You type a name, it appears on the card. You paste the quote, the card breathes. The work and the reward share a screen.</p><p><img alt="Step one of the create flow: a form asking &quot;What did they always say?&quot; beside a live preview of the card updating in real time." src="https://lope.adebesin.com/images/blog/my-pastor-once-said/compose-line.png"></p><p>The whole thing wears BethelFlow's blue with a single gold accent for the campaign. Close enough to the brand that it reads as <em>them</em>, distinct enough that it reads as an <em>occasion</em>. No purple, no sub-brand. Just BethelFlow in a good mood.</p><h2 id="the-photo-never-leaves-your-browser">The photo never leaves your browser</h2><p>Here's the decision I'm still happy about. Early on, I didn't want to store anyone's uploads. Partly cost, mostly trust. Asking someone to hand you a photo of their pastor on day one is a big ask, and "where does this go?" is a fair question.</p><p>So at first, nothing went anywhere. The draft, your words and your photo, lived in the browser's own IndexedDB, survived a reload, and rendered the card locally. Download straight from the page. <em>"We're not storing your images"</em> wasn't marketing. It was just literally where the code stood. The photo genuinely never left the device.</p><p><img alt="Step two: adding a photo, with the card preview now showing the portrait behind the quote." src="https://lope.adebesin.com/images/blog/my-pastor-once-said/compose-photo.png"></p><p>That worked beautifully right up until people wanted to do the one thing the whole campaign was about: send it to someone.</p><h2 id="the-url-is-not-a-database">The URL is not a database</h2><p>A couple of friends, <mention handle="codiejay"></mention> and <mention handle="Tolu-Mals"></mention>, are the reason this part of the story exists at all. They made their cards and immediately hit the wall the whole campaign was pointed at: they wanted to <em>send</em> them, and sharing wasn't really there yet. Their nudging is what turned "download and keep" into "share and pass on," and, a few detours later, what put photos on Vercel Blob.</p><p>Because sharing breaks the no-storage stance. A shared link needs two things I didn't have: a page that lives at a URL, and an image a link preview can actually show. My first instinct was to cling to the principle and smuggle the entire card <em>into the link</em>, encoding the name, quote, and year as a blob of base64 in the path, so the URL itself carried the card and I still stored nothing.</p><p>Two walls, fast. A phone photo is two to five megabytes; it cannot live in a URL, full stop, no matter how you encode it. And even the text-only version produced links about three hundred and twenty characters long, the kind of link that looks less like a keepsake and more like something a stranger DMs you before stealing your bank details.</p><p>I gave up gracefully. Photos moved to Vercel Blob, but on my terms: the upload only fires when you actually reach for <em>share</em>, it's deduped by a hash of the file so refreshing never piles up copies, and a weekly job sweeps anything older than ninety days. The promise softened honestly, <em>we don't store it until you choose to send it</em>, instead of breaking. And in return we got real shareable pages, copy-to-clipboard, and link previews with the actual card in them.</p><p><img alt="The result screen: &quot;Download and share,&quot; with the finished card and a row of share buttons." src="https://lope.adebesin.com/images/blog/my-pastor-once-said/share-light.png"></p><h2 id="the-preview-that-rendered-nothing">The preview that rendered nothing</h2><p>The links worked. The previews didn't. A shared card came up blank where the image should be. I went hunting in the usual places first: the upload, the cache, my own markup. It took a while before it clicked that the culprit wasn't the card at all. It was satori.</p><p>The link-preview image is generated with <code>next/og</code>, which is satori under the hood. My card had a scrim, the dark gradient that keeps white text legible over a bright photo, and I'd written it the way you write it in a browser: pinned to all four edges, no explicit size. Every browser understands that. Satori doesn't. It sized the scrim to zero by zero, painted nothing, and on any light-coloured photo the white quote simply dissolved into the background.</p><p>The fix was one of those humbling one-liners: give the overlay real dimensions. The renderer that isn't a browser needs to be told the things a browser would have inferred. Scrim painted, text back, previews readable.</p><h2 id="shorter-prettier-links">Shorter, prettier links</h2><p>The base64 links still bothered me. A keepsake shouldn't look like a ransom note. So the payload came out of the URL entirely. Each shared card became a tiny record with a random eight-character id, and the link just points at that. Roughly three hundred and twenty characters down to about fifty.</p><p>The part I cared about was not breaking anyone who'd already shared. The resolver just looks at the length and picks a path. A short id gets looked up, an old long token still decodes inline like before.</p><pre><code>return param.length &lt;= 16 ? loadShareCard(param) : decodeCard(param)
</code></pre><p>Nobody's link died. Old and new both resolve to the same card. Migrations you can skip are the best kind.</p><h2 id="the-first-day-and-the-dip">The first day, and the dip</h2><p>Launch day did better than I'd let myself hope. In a single day the page took <strong>168 views from 117 different people</strong>, and over the run it made <strong>72 cards from 42 of them</strong>. For a side campaign built around a feeling, that's a lot of people choosing to make something.</p><p>And then, as campaigns do, it breathed out. The day after launch it was 56 views, then 18 the day after, then single digits. A steady exhale over the week.</p><p>Some of that taper is just gravity. But some of it I did to myself. Midweek I shipped a security-headers change, closed my laptop, and went to bed pleased. The new Content-Security-Policy quietly broke image downloading for everyone: the browser refused to fetch an image the app had literally just made. PostHog is how I found out. <strong>42 failed shares</strong>, almost all of them from a <em>handful</em> of people tapping a dead button over and over, exactly the way you jab an elevator button that isn't wired to anything.</p><p>That bug got its own post, because it deserved one. <a href="https://lope.adebesin.com/blog/the-button-that-did-nothing">The button that did nothing</a> is the full autopsy. The short version: I'd been building on the one device where everything worked, and instrumenting failure so faintly that only luck told me it was happening.</p><h2 id="most-of-them-were-on-a-phone">Most of them were on a phone</h2><p>The same dashboard said something quieter and more important than any single bug. Most people arrived on a phone, and on a phone they finished at less than half the rate they did on a desktop. I had lovingly designed a three-step, side-by-side wizard for a screen most of my users weren't holding.</p><p>So the create flow got rebuilt for a thumb. The separate <em>line</em> and <em>photo</em> steps merged into one compose screen with the card preview pinned to the top, so the payoff is always in frame instead of two taps away. "Take a photo" wires straight to the camera. Desktop keeps its roomy stepped wizard. The layout branches on the viewport rather than pretending one shape fits both.</p><p>The rest was performance, because a beautiful page that stutters on a mid-range Android is not a beautiful page. The marquee, that scrolling wall of example cards, got a lighter animation that pauses on hover and stands still for anyone who's asked for reduced motion. The decorative images lazy-load so they stop fighting the first paint. And the card-capture engine changed under the hood after WebKit kept dropping the photo out of exports.</p><p><img alt="The scrolling wall of community cards, real lines people submitted." src="https://lope.adebesin.com/images/blog/my-pastor-once-said/marquee.png"></p><h2 id="one-card-two-themes">One card, two themes</h2><p>A small thing I'm fond of: the card and the whole page render in light and dark off the same live preview, so it looks intentional whichever way your phone is set. Same card, same words, different room.</p><p><img alt="The result screen in dark mode, the card glowing against a near-black page." src="https://lope.adebesin.com/images/blog/my-pastor-once-said/share-dark.png"></p><h2 id="where-it-landed">Where it landed</h2><p>Here's the whole shape of it, start to finish.</p><p><img alt="A showcase board of the finished product: the landing page, the three-step create flow, the light and dark cards, and the community wall." src="https://lope.adebesin.com/images/blog/my-pastor-once-said/showcase.png"></p><p>Looking back, the thread through every good decision was the same. I kept holding a principle, and users kept, gently, asking me to bend it. <em>Don't store the photo</em> met <em>let me send this to my sister.</em> <em>Keep it pure in the URL</em> met <em>a phone photo is five megabytes.</em> <em>Ship the elegant wizard</em> met <em>I'm on a bus, on a phone, using one thumb.</em> The design got better every time I let the principle bend without letting it break: storing photos only at the moment of sharing, shortening links without stranding old ones, rebuilding the flow for the device people actually held.</p><p>It's evergreen, and it's still up. If your pastor had a line you never forgot, the one you can still hear in their voice, go <a href="https://www.bethelflow.com/my-pastor-once-said" rel="nofollow">make a card</a>. Send it to someone who needs it today. We'd genuinely love to see the ones that stuck with you.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The button that did nothing]]></title>
            <link>https://lope.adebesin.com/blog/the-button-that-did-nothing</link>
            <guid isPermaLink="false">https://lope.adebesin.com/blog/the-button-that-did-nothing</guid>
            <pubDate>Fri, 07 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Most of my users were on a phone, tapping a download button that silently did nothing. My desktop never once felt it. What a session recording taught me about building where your users actually live.]]></description>
            <content:encoded><![CDATA[<h3 id="most-of-my-users-lived-on-a-phone-i-built-the-whole-thing-on-a-laptop-heres-what-that-cost-and-the-rule-i-keep-now">Most of my users lived on a phone. I built the whole thing on a laptop. Here's what that cost, and the rule I keep now.</h3><p><img alt="A download button pressed over and over, each tap rippling out into nothing, with a counter reading times twenty." src="https://lope.adebesin.com/images/blog/the-button-that-did-nothing/cover.svg"></p><p>You've met a dead button. The crosswalk one that isn't wired to anything. The elevator "close door" that's decoration. The one you press, and when nothing happens you press it again, harder, because surely it heard you the first time. There's a particular small humiliation in it, standing there jabbing a button while the world declines to respond.</p><p>I built one of those. Not for me. For most of the people who showed up.</p><h2 id="the-recording-i-couldnt-stop-watching">The recording I couldn't stop watching</h2><p>We shipped a little thing on <a href="https://www.bethelflow.com/" rel="nofollow">BethelFlow</a> called <em>My Pastor Once Said</em>. You type a line your pastor never stopped saying, add a photo, and it makes a shareable card. Simple, joyful, exactly the kind of thing that's supposed to just work. I wrote about <a href="https://lope.adebesin.com/blog/my-pastor-once-said">designing the whole campaign</a> separately. This post is about the part that broke.</p><p>Then I opened a session replay one morning. An iPhone. Mobile Safari. Someone had made their card. It was right there on the screen, finished, theirs. Then they tapped <strong>Download</strong>. Nothing. They tapped it again. Again. I counted later: twenty times over eight minutes. Then they tried WhatsApp twice, then they left. No card ever reached them.</p><p>I watched it the way you watch someone wave at a sliding door that isn't going to open. And the worst part is that on my machine, a desktop running Chrome, I had pressed that exact button a hundred times and it worked every single time. It worked in the only place I ever tested it.</p><h2 id="three-different-silences">Three different silences</h2><p>When I finally pulled the button apart, it wasn't broken in one way. It was quietly broken in three, and each one was invisible from where I sat.</p><p><strong>One: iOS doesn't honour the download trick.</strong> The pattern every tutorial teaches is simple: make an anchor, set <code>download</code>, click it in code. Safari on iOS simply ignores it. And my click landed <em>after</em> an <code>await</code> while the image rendered, so the user's tap was already spent by the time the download tried to fire.</p><pre><code>const dataUrl = await renderCardDataUrl(cardRef.current) // the tap is gone by here
saveDataUrl(dataUrl, filename)                           // iOS: politely does nothing
</code></pre><p>On desktop this saves a file. On a phone it's a dead button. Same code, opposite outcome, and I only ever stood on the side where it worked.</p><p><strong>Two: the failure underneath, that hit everyone.</strong> Our security headers set <code>connect-src 'self' https: wss:</code>. Reasonable. But building the image meant turning a data URL into a blob, and I'd done it the lazy way, <code>fetch(dataUrl)</code>. <code>fetch</code> answers to <code>connect-src</code>. So the browser blocked me from fetching <em>my own image</em>, the call threw, and the file was never built. Not just on iOS. Everywhere. iOS just failed more loudly because it had no fallback to limp to.</p><p>The fix was to stop asking the network for something I already held in my hand, and decode the base64 myself:</p><pre><code>// was: const blob = await fetch(dataUrl).then(r =&gt; r.blob())  // CSP blocks this
const [head, body] = dataUrl.split(",", 2)
const bytes = Uint8Array.from(atob(body), c =&gt; c.charCodeAt(0))
const blob = new Blob([bytes], { type: "image/png" })
</code></pre><p><strong>Three: the button, hammered.</strong> To feel snappy, I'd pre-warmed the share on hover. But on every failure it reset and tried again on the <em>next</em> pointer event. So a person poking a dead button didn't just get silence. They fired off eleven, fifteen failed requests into the dark, and not one of them made a sound I could hear.</p><h2 id="the-colour-that-never-came">The colour that never came</h2><p>There was a quieter one still. On this card, the photo <em>is</em> the colour. Without it you get a flat gradient. iPhones shoot photos as HEIC, and an <code>&lt;img&gt;</code> renders HEIC on Safari and almost nowhere else. So a card built and shared from a phone, opened by a friend on Chrome, lost its photo entirely. And the handler I'd written to be "safe" made sure no one ever knew:</p><pre><code>&lt;img src={imageUrl} onError={(e) =&gt; { e.currentTarget.style.display = "none" }} /&gt;
</code></pre><p>Hide the broken image. No error, no log, just a colourless card where a face should be. I've written before about <a href="https://lope.adebesin.com/blog/quietest-bug">silence wearing the costume of safety</a>. Here it was again, in a different disguise. The fix was to stop trusting whatever the phone handed me and re-encode every upload to a plain JPEG the moment it's picked. And if it genuinely can't be decoded, to <em>say so</em> instead of swallowing it.</p><h2 id="the-number-i-hadnt-looked-at">The number I hadn't looked at</h2><p>Fixing the button felt like the work. It wasn't. The work was the number I'd never bothered to open: <strong>who was actually here.</strong></p><p>On mobile, 148 people landed and 26 made a card. An 18% finish. On desktop, 42 landed and 17 made a card. Forty percent. Seventy-eight percent of everyone who came was on a phone, and on a phone they finished at less than half the rate they did on a desktop. I had poured the entire build into the 22% I could see. Not out of some decision, but because my machine happened to be one of them, and a laptop never once felt the dead button.</p><h2 id="building-where-your-users-live">Building where your users live</h2><p>So we rebuilt the create flow for a thumb, not a mouse. One screen instead of three. The card previewing live as you type, so the payoff arrives while you're still working instead of two taps later. "Take a photo" wired straight to the camera.</p><p><img alt="The rebuilt mobile create flow: one screen, the card preview pinned to the top updating live as you fill the fields below." src="https://lope.adebesin.com/images/blog/the-button-that-did-nothing/compose-mobile.png"></p><p>And the part I'd change first if I could do it all again: we stopped <em>pretending to be the platform</em>. Instead of hand-rolling a download the phone doesn't understand, we hand the finished card to the phone's own share sheet. The one button iOS actually knows how to press.</p><pre><code>if (navigator.canShare?.({ files: [file] })) {
  await navigator.share({ files: [file], title, text })
}
</code></pre><p>One line, and suddenly the button presses back. Save to Photos, WhatsApp, wherever. All the things the person was reaching for in the first place, through the door their phone already knew how to open.</p><p><img alt="The mobile result screen: the finished card with a full-width &quot;Share your card&quot; button that hands off to the phone's native share sheet." src="https://lope.adebesin.com/images/blog/the-button-that-did-nothing/share-mobile.png"></p><h2 id="make-the-silence-loud">Make the silence loud</h2><p>Then we did the unglamorous thing: we made failure audible. Every failed share now carries its reason with it, so the next time something goes quiet I don't have to get lucky with a replay to find out.</p><p>Because the real bug was never the anchor tag or the header. Those were an afternoon. The real bug was that a person stood there tapping twenty times and the only reason I ever knew was that I happened to scrub through a recording weeks later. A dead button is bad. A dead button you can't see is how you quietly lose three-quarters of the people who came.</p><h2 id="the-rule-i-keep">The rule I keep</h2><p>Build on the device most of your users are actually holding, not the one you happen to own. And instrument every failure so it can't hide. The gap between a bug and a disaster is almost always just whether the button can tell you it's dead.</p><p>The card generator works on a phone now. If you've got a line your pastor never stopped saying, go make one at <a href="https://www.bethelflow.com/my-pastor-once-said" rel="nofollow">My Pastor Once Said</a>. Press the button. It should press back.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Context is everything]]></title>
            <link>https://lope.adebesin.com/blog/context-is-everything</link>
            <guid isPermaLink="false">https://lope.adebesin.com/blog/context-is-everything</guid>
            <pubDate>Sat, 18 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[What years of building AI agents taught me about context engineering, and how much of it turned out to be the same thing as briefing another human well.]]></description>
            <content:encoded><![CDATA[<p><img alt="A conversation panel you've walked into halfway: the top lines cut off, the middle clear, the last line only half typed." src="https://lope.adebesin.com/images/blog/context/cover.svg"></p><p>There's a specific kind of lost you feel walking into a conversation halfway through. People are laughing at something. Someone says "yeah, but that's exactly what <em>he</em> did," and you nod along like you know who <em>he</em> is. You don't. You've got the words but none of the wiring underneath them, and if you're not careful you'll repeat the half you caught as if it were the whole thing. That's the dangerous part. Not knowing nothing. Knowing <em>half</em>.</p><p>We spend our whole lives quietly topping each other up. You mention a person and I already have years of them loaded. You say "the usual" and the waiter knows. Almost none of what makes a conversation work is <em>in</em> the conversation; it's the shared state both of you dragged in with you.</p><p>A language model has none of that. Every single call is a stranger walking in halfway through, and your entire job is to catch it up, in just the right number of words, before it says something confident and wrong.</p><p>For a good while I lived close to AI agents, a lot of it around a crypto Q&amp;A agent called <a href="https://iq.wiki" rel="nofollow">AIDEN</a>. I haven't been in the thick of it for a bit now, but the lesson it drilled into me hasn't left. I've watched a model answer a question with total confidence and be completely, embarrassingly wrong, for one reason: it had been handed half the picture, with no way of knowing the other half existed. That's the part that stuck. The model isn't the hard part. Deciding what it gets to see is.</p><h2 id="the-art-of-deciding-what-not-to-say">The art of deciding what <em>not</em> to say</h2><p>The early years of that codebase read like a diary of a fight against a tiny window. The model underneath had a context budget of about <strong>4,000 tokens</strong>. Four thousand. And the questions people asked were things like "what are the biggest gainers today," which means a crypto API payload with hundreds of coins and thousands of fields, enum lists containing every token id that has ever existed. None of it fit. Not close.</p><p>So what got built, long before big context windows made any of it look quaint, was an elaborate machine whose entire purpose was deciding what <strong>not</strong> to send the model:</p><ul><li>There were ~30 API endpoints, too many to describe to a 4k model, so the endpoint <em>descriptions</em> got embedded and vector-searched to pull the top 10 candidates.</li><li>Ten was still too much. There's a loop, still in the codebase today, that encodes the candidate function definitions and just pops them off the end one at a time until they fit:</li></ul><pre><code>while (encode(functionsString).length &gt; maxTokens &amp;&amp; reducedEntries.length &gt; 1) {
  reducedEntries.pop()
  functionsString = JSON.stringify(Object.fromEntries(reducedEntries))
}
</code></pre><ul><li>Parameters like <code>coin_ids</code> have thousands of valid values, so those couldn't be inlined either: a second vector table <em>of enum values</em>, searched against the query to return ~20 likely ones.</li><li>When an API response came back too big, it got filtered by <em>another model call</em>, asking one to write a <code>jq</code> expression that projected the giant blob down to only the fields the question needed, then retrying by feeding it its own error.</li><li>Documents got chopped into 150-token chunks; keep the top 5, glue them together.</li><li>Conversation history got truncated by hand: chop any assistant reply past 150 characters, counted by character, with the comment <em>"prevent information saturation."</em></li></ul><p>The commit history is full of the desperation. <code>MAX_TOKENS</code> changed <strong>five times in a single day</strong>: 4000, then 3500, then 4000, then 8000, then back to 4000. There's a one-liner that just <em>"increases max tokens to 3 times if it's not english,"</em> because Korean and Chinese tokenize worse and there was no better idea at the time. There's a commit called <strong>"avoid caching sorry answers,"</strong> because the system had been caching its own <em>failures</em>. The README describes one of these subsystems and then admits, out loud, <em>"This is a big system as well 😅."</em> Twice.</p><p>When I got close to all of it, it was humbling to sit with. It felt less like engineering intelligence and more like being a very anxious travel agent for a genius who forgets everything the second you stop talking. But every one of those hacks is the same move underneath: <em>don't dump, decide.</em> Nobody had a name for it yet. It just felt like janitorial work you did so the smart part could work at all.</p><h2 id="then-we-gave-it-a-team">Then we gave it a team</h2><p>The rebuilds came as the models got bigger and smarter. You'd think that would make the whole problem evaporate.</p><p>It didn't. It just changed shape. In one of the newer versions the very first real crisis, only weeks in, was adding a token counter and model-based data filtering in the same week, because the fresh, powerful model was <em>still</em> drowning in raw API payloads. Same bottleneck. New house.</p><p>But the answer this time wasn't a bigger pile of filters. It was decomposition. One overloaded API agent got split into five specialists (CoinGecko, DeBank, DeFiLlama, Etherscan, IQAI) with a top-level <strong>router</strong> that stopped answering anything itself and just classified each question and handed it to exactly one specialist. Single hop. Its instruction literally says <em>"Do NOT provide your own answer after transferring."</em></p><p>The point of that is context. A CoinGecko question now travels with <em>only</em> CoinGecko instructions and <em>only</em> CoinGecko tools. It never has to carry DeBank's entire world in its head. Each agent gets a narrow, purpose-built slice of reality instead of one prompt straining to be everything at once.</p><p>And we got ruthless about history. There's a step that, before every model call on a specialist, throws the thread away and hands it <em>only the current question</em>. The default number of past turns kept? <strong>One.</strong> A tiny separate model rewrites your follow-up ("what about <em>its</em> price?") into a standalone question first, so the specialist never needs the backstory; it gets a question that already resolved "it."</p><p>We even stopped dumping tool menus. Instead of loading dozens of endpoint schemas, an agent <em>searches</em> for the tool it needs, then writes code to call it. And the newest rebuilds lean into that hard: give a specialist a single tool, let the model write a little function that runs in a sandbox, do the fetch-filter-sort-slice <em>inside</em> that call, and hand back only the ten rows it asked for. The 250-row array it worked through never touches the model's context. There's a line in there I love:</p><blockquote><p>Too large to hand back safely, return a truncated string so it can't overflow the consuming model's context window.</p></blockquote><p>Years earlier the move was popping function definitions off a list until they fit. This is the grown-up version of the exact same instinct, and somewhere in the middle of watching all of it, I stopped seeing this as janitorial work and realized it <em>was</em> the job. The whole job.</p><h2 id="it-has-a-name-now">It has a name now</h2><p>Somewhere around the middle of 2025, while I was buried in this stuff, watching agents keep choking on context they'd been handed badly, the rest of the field named the thing.</p><p>In June 2025, Shopify's Tobi Lütke put it like this:</p><p><img alt="Tobi Lütke: “I really like the term ‘context engineering’ over prompt engineering… the art of providing all the context for the task to be plausibly solvable by the LLM.”" src="https://lope.adebesin.com/images/blog/context/tobi-context-engineering.png"><em>Tobi Lütke, June 19, 2025.</em></p><p>Days later, Andrej Karpathy amplified it into the definition everyone now quotes:</p><p><img alt="Andrej Karpathy: “context engineering is the delicate art and science of filling the context window with just the right information for the next step.”" src="https://lope.adebesin.com/images/blog/context/karpathy-context-engineering.png"><em>Andrej Karpathy, June 25, 2025. The line that renamed the discipline.</em></p><blockquote><p>context engineering is the delicate art and science of filling the context window with just the right information for the next step. <span>...</span> Too little or of the wrong form and the LLM doesn't have the right context for optimal performance. Too much or too irrelevant and the LLM costs might go up and performance might come down.</p></blockquote><p>That last sentence is the part nobody warns you about. I used to think the whole problem was <em>scarcity</em>, not enough room. So when the windows finally got huge, I assumed we'd won. The token budget in that codebase tells the real story: it went from around 4,000 to a full million, and then someone quietly dialed it back to 500,000, for performance. We'd been handed almost infinite room and <em>chose</em> to use less. Because more context isn't free context. Anthropic frames it as an <strong>attention budget</strong>: like us, a model has limited working memory, and the more you cram in, the <em>worse</em> its recall gets. They call it "context rot," where the answer is technically in the window but buried in the middle where nothing looks. The <a href="https://www.ai-engineer.me/context-is-everything/" rel="nofollow">ai-engineer.me essay that shares this post's title</a> puts it best: <em>"bottlenecks can't be eliminated, they can only be moved around."</em> Bigger windows didn't kill the bottleneck. They moved it from <em>"will it fit"</em> to <em>"of everything that now fits, what actually deserves to be there."</em></p><p>When I read <a href="https://www.langchain.com/blog/context-engineering-for-agents" rel="nofollow">LangChain's four moves</a>, I laughed, because they're a tidy map of everything I'd watched us stumble into by feel. <strong>Write</strong> context out to somewhere durable, that's saving what an agent found so a later step can use it. <strong>Select</strong> the right pieces back in, that's the vector search. <strong>Compress</strong> what you keep, that's the <code>jq</code> filter and "return a small summary." <strong>Isolate</strong> it across agents, that's the scoped specialists. There are now whole companies, like <a href="https://supermemory.ai/" rel="nofollow">supermemory</a>, which bills itself as "the memory layer for AI agents, a context engineering platform," selling the pillar we'd been hand-rolling since it was a 150-token chunk in a database table.</p><p>Karpathy ended his post with a line I think about a lot: <em>the term "ChatGPT wrapper" is tired and really, really wrong.</em> He's right. The model is one component. The thick, unglamorous layer around it, deciding what it sees, when, and in what shape, is most of the actual software. It's most of the actual <em>work.</em></p><h2 id="back-to-the-humans">Back to the humans</h2><p>Here's the thing that keeps getting me, though. We are <em>terrible</em> context engineers with each other, and we've had a lot longer to practise.</p><p>Think about how we brief people. We fire off the Slack message with none of the backstory and wonder why the reply misses. We say "you know what I mean" to someone who probably does not. We walk a new teammate up to a two-year-old system and drop them at the door. We do to each other exactly what a bad prompt does to a model: too little context, or the wrong shape, and then we're surprised by the confident wrong answer that comes back.</p><p>The uncomfortable mirror is that everything I learned bolting context onto a forgetful model is just... good communication, formalized. Rewrite the follow-up so it stands alone: that's not making someone reconstruct the whole thread from memory. Return a summary, not the raw arrays: that's respecting someone's attention budget instead of forwarding the 200-email chain. Give each specialist only their slice: that's not CC'ing forty people on something two of them own. Write it down somewhere durable: that's the doc that outlives the one person who remembers.</p><p>We get away with being sloppy because humans are absurdly good at filling gaps. You lob half a sentence and repeated patterns, shared history, a lifted eyebrow do the rest. The model has none of that grace. It exposes, ruthlessly, how much of what we call "communicating" was actually just <em>the other person quietly doing the context engineering for us.</em></p><h2 id="the-refactor">The refactor</h2><p>Context is expensive. Assembling it, curating it, keeping it fresh, that's real cost, in tokens and in effort. But missing context is more expensive. It's the confident wrong answer, the rework, the argument that was really just two people running on different halves of the same conversation.</p><p>For years I thought I was solving a machine problem: squeeze the data through the tiny hole, then rank it once the hole got big. I was really learning the oldest problem there is: how do you get what's in your head <em>plausibly, correctly</em> into someone else's, using only the words you can afford to spend.</p><p>The essay I keep quoting opens with a line that turned out to be the whole point: <em>"With LLMs, as with people, context is everything."</em></p><p>Every conversation with a model is a stranger walking in halfway through. Turns out most of my conversations with people are too. The real, defining skill, whether the other side is a language model or a person who deserves better than half the story, is catching them up before they nod along to something they only half understand.</p><p>So: say more than "Hi." Say enough.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The quietest kind of bug]]></title>
            <link>https://lope.adebesin.com/blog/quietest-bug</link>
            <guid isPermaLink="false">https://lope.adebesin.com/blog/quietest-bug</guid>
            <pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Optional chaining hides the failure you most need to see. A user found a bug in BethelFlow before I did because of it. My honest take, and the rule I keep.]]></description>
            <content:encoded><![CDATA[<h3 id="why-optional-chaining-hides-the-failure-you-most-need-to-see-and-the-rule-i-keep-for-it">Why optional chaining hides the failure you most need to see, and the rule I keep for it.</h3><quiet-cover></quiet-cover><p>You know the little light on your dashboard. The orange one.</p><p>It flicks on one morning and you tell yourself you'll deal with it later. The car still drives. Nothing feels different. So you keep going, day after day, the light glowing quietly in the corner of your eye until you stop seeing it at all. Then one evening, on the highway, late for something that actually matters, the engine gives out. The light had been trying to tell you for weeks. You'd just trained yourself not to listen.</p><p>We do this everywhere, don't we? The strange noise in the house you slowly tune out. The "we should talk" text you leave on read. The friend who says "I'm fine" so smoothly that you let yourself believe them. None of it is loud. All of it is waiting. The problem never actually left when the signal went quiet. It just picked its moment.</p><p>Code has an operator for exactly this. For years, it was my favourite one.</p><h2 id="the-code-that-never-speaks-up">The code that never speaks up</h2><p>Optional chaining. <code>?.</code>. You reach into an object, and if something along the way is missing, it politely returns <code>undefined</code> instead of throwing. No crash. No noise. It just keeps going.</p><p>That sounds like a gift until it costs you. Here is what it cost me.</p><p>On the church records page in <a href="https://www.bethelflow.com/" rel="nofollow">BethelFlow</a>, the names went blank. Not everywhere, and not all at once, but rows that should have read "The Adebayo family, Ushering department" were quietly showing a dash. <code>—</code>. As if the record had no family and no department at all. No error. Nothing in the console. The data was sitting right there in the API response the whole time. The screen just refused to show it.</p><p>A user found it before I did. That part still stings.</p><p>Here is the line that did it, and I'll be honest with you: I wrote it on autopilot.</p><pre><code>value={r.familyId ? (family?.familyName ?? "—") : null}
</code></pre><p>Read it slower than I did. <code>family</code> is a lookup object I fetched separately on the client. When it hadn't loaded yet, or the lookup simply missed, <code>family</code> was <code>undefined</code>. So <code>family?.familyName</code> short-circuited to <code>undefined</code>, <code>?? "—"</code> caught it, and the row cheerfully rendered a dash. Optional chaining did exactly what it promised. It did not throw. It also never once mentioned that the whole premise of the line, "I have this family object," was false.</p><p>And here's the part I have to own. I didn't reach for <code>?.</code> because I'd thought carefully about what should happen when a family is missing. I reached for it because the red squiggle went away. It felt like handling the case. It was the "yeah, all good" of code. It was silence wearing the costume of safety.</p><p>That's the take, and I'll say it plainly: <strong>optional chaining is a bug-swallowing machine.</strong> It takes a loud, early failure and turns it into a quiet <code>undefined</code> that either sits on the screen as a blank, or travels three functions downstream and finally explodes as "cannot read x of undefined" somewhere that has nothing to do with where it actually broke.</p><h2 id="guarding-harder-just-moves-the-silence">Guarding harder just moves the silence</h2><p>When the names went blank, my first instinct was the wrong one. Guard harder. Sprinkle a few more <code>?.</code> upstream so nothing could possibly throw.</p><p>But think about what that actually does. It doesn't fix the bug. It moves the silence. Every <code>?.</code> you add is one more place that will show nothing instead of telling you something broke. You can optional-chain an entire file and never once be told what's missing. That isn't a safe codebase. It's a codebase that has quietly agreed never to alarm you. 😅</p><p>That isn't a fix. It's a strip of tape over the warning light, so now the dashboard looks calm too. It's the friend who only learned to say "I'm fine" more convincingly.</p><h2 id="am-i-saying-never-use-it-no">Am I saying never use it? No</h2><p>I use optional chaining every single day. For data that is <em>genuinely</em> optional, reaching one level in is exactly what the operator is for:</p><pre><code>const avatar = user.profile?.avatarUrl ?? fallback
</code></pre><p>That's honest code. <code>profile</code> is allowed to be absent, and I decided right here, in one glance, what happens when it is. The absence is <em>expected</em>, and it's <em>handled</em>. That is the whole test.</p><p>My rule is a length limit. <strong>Three links is the ceiling.</strong> <code>a?.b?.c</code> and I can still hold in my head which of those can be null and why. <code>a?.b?.c?.d?.e</code> and I have stopped reasoning entirely. That chain isn't safety, it's a nervous tic, a quiet confession that I don't know the shape of my own data and I'd like the language to paper over it for me.</p><p>Granted, three is a heuristic, not a law. Some days it's two. The number matters less than the honesty it forces: if the chain is long enough that you can't say out loud which link is allowed to be missing, the answer is almost never one more <code>?.</code>. It's that you're reaching into the wrong object.</p><p>That was the real story in BethelFlow. The fix wasn't to guard the lookup harder. It was to stop chaining into a maybe-missing client lookup and put the name on the record itself, where it belonged:</p><pre><code>value={r.familyId ? (r.familyName ?? "—") : null}
</code></pre><p>No lookup. No chain. Nothing left to go quietly missing.</p><h2 id="early-returns-say-the-quiet-part-out-loud">Early returns say the quiet part out loud</h2><p>Before I reach for optional chaining now, I reach for a guard clause. I decide what the absence <em>means</em>, at the point where it happens, while I still have the context to decide it well.</p><p>So instead of threading a maybe-null value through a whole function and hoping:</p><pre><code>function greeting(user) {
  return `Hi, ${user?.profile?.contact?.firstName ?? "there"}`
}
</code></pre><p>I handle it at the door:</p><pre><code>function greeting(user) {
  if (!user.profile) return null
  return `Hi, ${user.profile.contact.firstName}`
}
</code></pre><p>Yes, it's more typing. That's the honest cost, and I'll pay it every time, because of what it buys. The null case becomes a decision I actually made instead of a default I fell into. The rest of the function runs on known-good data, so the happy path stays flat and easy to read. And the next person, who is usually a tired future version of me, can read the top of the function and know exactly what it needs to work.</p><p>That last one is the real prize. A guard clause is how a function says its preconditions out loud, and that shared understanding is what lets code compose without everyone holding the whole call graph in their head. Optional chaining hides the precondition. An early return speaks it.</p><h2 id="the-rule-i-keep">The rule I keep</h2><p>Reach for <code>?.</code> when the absence is expected and you've handled it, and keep the chain to about three. Reach for a guard when the absence would mean something is wrong, and handle it right there, while you still know why.</p><p>Because the kindest thing a piece of code can do is the same thing that little orange light was trying to do all along. When something is wrong, say so early, say it out loud, and say it close to where it happened, while listening is still cheap. Silence is not safety. Ask the user who spent a week staring at a dash where their family's name should have been.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Being Heard Isn't Just About Speaking — It's About Understanding]]></title>
            <link>https://iamlope.medium.com/being-heard-isnt-just-about-speaking-it-s-about-understanding-b700929c5743</link>
            <guid isPermaLink="false">https://iamlope.medium.com/being-heard-isnt-just-about-speaking-it-s-about-understanding-b700929c5743</guid>
            <pubDate>Mon, 26 May 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[A guide to internationalization (i18n) in Next.js using next-intl to build multilingual, user-aware web experiences.]]></description>
        </item>
        <item>
            <title><![CDATA[A Form, A Pen, and A Prayer]]></title>
            <link>https://iamlope.medium.com/a-form-a-pen-and-a-prayer-aed96e4b490c</link>
            <guid isPermaLink="false">https://iamlope.medium.com/a-form-a-pen-and-a-prayer-aed96e4b490c</guid>
            <pubDate>Mon, 28 Apr 2025 00:00:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Stop yapping, Lock in]]></title>
            <link>https://iamlope.medium.com/stop-yapping-lock-in-5e0a0673ab19</link>
            <guid isPermaLink="false">https://iamlope.medium.com/stop-yapping-lock-in-5e0a0673ab19</guid>
            <pubDate>Fri, 25 Apr 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[Securing Next.js server actions with next-safe-action — typed input validation, Zod schemas, and clean error handling.]]></description>
        </item>
        <item>
            <title><![CDATA[Nuqs: Because URLs Should Do More]]></title>
            <link>https://iamlope.medium.com/nuqs-because-urls-should-do-more-5d5d86e873c1</link>
            <guid isPermaLink="false">https://iamlope.medium.com/nuqs-because-urls-should-do-more-5d5d86e873c1</guid>
            <pubDate>Mon, 17 Feb 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[Type-safe URL search-param state management in Next.js with nuqs, so user context persists across refreshes and shared links.]]></description>
        </item>
        <item>
            <title><![CDATA[Love Language]]></title>
            <link>https://iamlope.medium.com/love-language-11cc15e6160c</link>
            <guid isPermaLink="false">https://iamlope.medium.com/love-language-11cc15e6160c</guid>
            <pubDate>Mon, 10 Feb 2025 00:00:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[The Lies Imposter Syndrome Tells Us]]></title>
            <link>https://iamlope.medium.com/the-lies-imposter-syndrome-tells-us-0dc6905b34f0</link>
            <guid isPermaLink="false">https://iamlope.medium.com/the-lies-imposter-syndrome-tells-us-0dc6905b34f0</guid>
            <pubDate>Sun, 02 Feb 2025 00:00:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Networking: Your Tool for Growth in Tech]]></title>
            <link>https://iamlope.medium.com/networking-your-tool-for-growth-in-tech-3f3f7dfb1f1b</link>
            <guid isPermaLink="false">https://iamlope.medium.com/networking-your-tool-for-growth-in-tech-3f3f7dfb1f1b</guid>
            <pubDate>Mon, 20 Jan 2025 00:00:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Chasing the Missing 'n': A Debugger's Odyssey]]></title>
            <link>https://iamlope.medium.com/chasing-the-missing-n-a-debugger-s-odyssey-5c6412dafdc2</link>
            <guid isPermaLink="false">https://iamlope.medium.com/chasing-the-missing-n-a-debugger-s-odyssey-5c6412dafdc2</guid>
            <pubDate>Thu, 16 Jan 2025 00:00:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Don't Dry Out]]></title>
            <link>https://iamlope.medium.com/dont-dry-out-6d988a723c3d</link>
            <guid isPermaLink="false">https://iamlope.medium.com/dont-dry-out-6d988a723c3d</guid>
            <pubDate>Sun, 22 Dec 2024 00:00:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[The Virtual Colony: Why Communication Matters in Remote Teams]]></title>
            <link>https://iamlope.medium.com/the-virtual-colony-why-communication-matters-in-remote-teams-3928c1c61d3d</link>
            <guid isPermaLink="false">https://iamlope.medium.com/the-virtual-colony-why-communication-matters-in-remote-teams-3928c1c61d3d</guid>
            <pubDate>Sun, 01 Dec 2024 00:00:00 GMT</pubDate>
        </item>
    </channel>
</rss>