<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://www.joshbeckman.org/feed/practicing.xml" rel="self" type="application/atom+xml" /><link href="https://www.joshbeckman.org/" rel="alternate" type="text/html" /><updated>2026-03-04T23:44:54+00:00</updated><id>https://www.joshbeckman.org/feed/practicing.xml</id><title type="html">Josh Beckman’s Organization | Practicing</title><subtitle>Building in the open</subtitle><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><entry><title type="html">ComEd Hourly Pricing as Calendar Events</title><link href="https://www.joshbeckman.org/blog/practicing/comed-hourly-pricing-as-calendar-events" rel="alternate" type="text/html" title="ComEd Hourly Pricing as Calendar Events" /><published>2026-03-01T01:15:00+00:00</published><updated>2026-03-01T01:15:00+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/comed-hourly-pricing-as-calendar-events</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/comed-hourly-pricing-as-calendar-events"><![CDATA[<p>ComEd electricity prices change every hour — sometimes swinging several cents between midnight and mid-afternoon. After <a href="/notes/2026-03-01-enrolling-in-hourly-pricing-for-comed-electricity">enrolling in hourly pricing</a>, I realized I didn’t want to check a dashboard to know when power is cheap. I wanted to see it on my calendar, right next to the rest of my day.</p>

<p>Same impulse that led me to build <a href="/blog/ical-feeds-for-a-jekyll-site">iCal feeds for my entire blog history</a>. A calendar is the tool I already use for planning around time. Price data <em>is</em> time data — it just happens to come from a utility instead of a CMS. If I can see that electricity drops to near-zero at 2am and spikes at 6pm, I can plan around it the same way I plan around meetings.</p>

<p>So I built a small <a href="https://www.val.town/x/joshbeckman/comed-hourly-pricing-calendar/code/README.md">Val.town server</a> that generates an iCal feed of price changes. It pulls the last 24 hours of 5-minute prices from ComEd’s <a href="https://hourlypricing.comed.com/hp-api/">public API</a>, averages them into hourly buckets, and grabs the next day’s prices from their (undocumented) day-ahead endpoint. Then it compares consecutive hours and emits a calendar event whenever the price shifts by more than a threshold:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>↑ 3.7c/kWh (+0.9c)
↓ 2.1c/kWh (-0.3c)
</code></pre></div></div>

<p>Stable hours produce no event — gaps in the calendar mean the price isn’t moving. The sensitivity, lookback window, and lookahead are all configurable via query parameters, so I (or you!) can tune it to only surface the swings I care about.</p>

<blockquote class="markdown-alert markdown-alert-note">
  <p><strong class="markdown-alert-title"><svg width="16" height="16" class="octicon octicon-info mr-2" aria-hidden="true" viewBox="0 0 16 16" version="1.1"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</strong></p>

  <p>Day-ahead prices aren’t always available — ComEd publishes them on their own schedule, typically in the evening for the following day — so the feed only includes forward-looking events when the data is there.</p>
</blockquote>
<p><img width="582" height="355" alt="The pricing changes display in my calendar app" src="/assets/images/7da27360-4fa4-4eb5-8fbb-9aa80f34e9b7.png" /></p>

<p>The next step is pairing this with batteries to buffer my high-draw appliances — grow lights, the computer desk — into cheap hours automatically. For now, just seeing the price rhythm on my calendar alongside everything else is enough to shift my habits. <a href="/blog/everywhere-a-calendar">Everywhere a calendar</a>.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="tools" /><category term="consumption" /><category term="time" /><category term="open-source" /><summary type="html"><![CDATA[I built an iCal feed of ComEd electricity price changes so I can plan around cheap hours from my calendar.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/7da27360-4fa4-4eb5-8fbb-9aa80f34e9b7.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/7da27360-4fa4-4eb5-8fbb-9aa80f34e9b7.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Displaying Letterboxd Like Counts on My Movie Reviews</title><link href="https://www.joshbeckman.org/blog/practicing/displaying-letterboxd-like-counts-on-my-movie-reviews" rel="alternate" type="text/html" title="Displaying Letterboxd Like Counts on My Movie Reviews" /><published>2026-02-02T18:27:13+00:00</published><updated>2026-02-02T18:27:13+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/displaying-letterboxd-like-counts-on-my-movie-reviews</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/displaying-letterboxd-like-counts-on-my-movie-reviews"><![CDATA[<p>I’ve been <a href="https://www.joshbeckman.org/blog/crossposting-from-letterboxd-to-jekyll">crossposting my Letterboxd reviews</a> to this site for a while now. The posts link back to the original review, but I wanted to show engagement metrics the same way I do for <a href="https://www.joshbeckman.org/blog/pesos-mastodon-to-jekyll">Mastodon</a>, Bluesky, and HackerNews posts. I run this static site on Jekyll so I want to load these in the visitor’s browser for efficiency and accuracy.</p>

<h2 id="the-approach">The Approach</h2>

<p>Letterboxd doesn’t have a public API (though they have a <a href="https://letterboxd.com/api-beta/">private beta API</a>), but the like count is embedded in the review page’s HTML. When you view a review, there’s an element with a <code class="language-plaintext highlighter-rouge">data-count</code> attribute holding the number:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>data-likes-page="/joshbeckman/film/the-french-connection/likes/"
data-format="count"
data-count="1"
data-owner="joshbeckman"
</code></pre></div></div>

<p>So the plan was simple: fetch the review page, parse out that <code class="language-plaintext highlighter-rouge">data-count</code> value, and update the link text.</p>

<h2 id="the-implementation">The Implementation</h2>

<p>I added a <code class="language-plaintext highlighter-rouge">loadLetterboxd()</code> function alongside my existing social platform loaders. These all fire when the comments section scrolls into view, using an <code class="language-plaintext highlighter-rouge">IntersectionObserver</code> to avoid unnecessary requests on page load.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nf">loadLetterboxd</span><span class="p">()</span> <span class="p">{</span>
    <span class="kd">var</span> <span class="nx">letterboxd</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">.social-letterboxd</span><span class="dl">'</span><span class="p">);</span>
    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">letterboxd</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="kd">var</span> <span class="nx">url</span> <span class="o">=</span> <span class="nx">letterboxd</span><span class="p">.</span><span class="nf">getAttribute</span><span class="p">(</span><span class="dl">'</span><span class="s1">href</span><span class="dl">'</span><span class="p">);</span>
    <span class="nf">fetch</span><span class="p">(</span><span class="nx">url</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">then</span><span class="p">(</span><span class="nx">response</span> <span class="o">=&gt;</span> <span class="nx">response</span><span class="p">.</span><span class="nf">text</span><span class="p">())</span>
        <span class="p">.</span><span class="nf">then</span><span class="p">(</span><span class="nx">html</span> <span class="o">=&gt;</span> <span class="p">{</span>
            <span class="kd">var</span> <span class="nx">match</span> <span class="o">=</span> <span class="nx">html</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="sr">/data-count="</span><span class="se">(\d</span><span class="sr">+</span><span class="se">)</span><span class="sr">"/</span><span class="p">);</span>
            <span class="k">if </span><span class="p">(</span><span class="nx">match</span><span class="p">)</span> <span class="p">{</span>
                <span class="kd">var</span> <span class="nx">likes</span> <span class="o">=</span> <span class="nf">parseInt</span><span class="p">(</span><span class="nx">match</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="mi">10</span><span class="p">);</span>
                <span class="k">if </span><span class="p">(</span><span class="nx">likes</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
                    <span class="nx">letterboxd</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">likes</span><span class="p">}</span><span class="s2"> </span><span class="p">${</span><span class="nf">pluralize</span><span class="p">(</span><span class="nx">likes</span><span class="p">,</span> <span class="dl">'</span><span class="s1">like</span><span class="dl">'</span><span class="p">)}</span><span class="s2"> on Letterboxd`</span><span class="p">;</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">})</span>
        <span class="p">.</span><span class="k">catch</span><span class="p">(</span><span class="nx">error</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Error fetching Letterboxd review:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">error</span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="the-cors-problem">The CORS Problem</h2>

<p>This didn’t work. The browser blocked the request:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Fetch API cannot load https://letterboxd.com/... due to access control checks.
</code></pre></div></div>

<p>Letterboxd doesn’t set CORS headers allowing cross-origin requests from browsers. This is the same problem you hit with any site that hasn’t explicitly opted into being fetched by client-side JavaScript from other domains.</p>

<h2 id="the-fix-a-cors-proxy">The Fix: A CORS Proxy</h2>

<p>The solution is to route the request through a proxy that adds the appropriate headers. I tried a few options:</p>

<ol>
  <li>
    <p><strong><a href="https://corsproxy.io">corsproxy.io</a></strong> - Worked locally but <a href="https://corsproxy.io/docs/faq/">blocks HTML content in production</a> due to phishing concerns. Only JSON, XML, and CSV are allowed.</p>
  </li>
  <li>
    <p><strong><a href="https://allorigins.win/">allorigins.win</a></strong> - Supports HTML, but was painfully slow in my testing.</p>
  </li>
  <li>
    <p><strong><a href="https://cors.lol/">cors.lol</a></strong> - Fast, supports HTML, and has a simple API. This is what I ended up using.</p>
  </li>
</ol>

<p>The <a href="https://gist.github.com/jimmywarting/ac1be6ea0297c16c477e17f8fbe51347">CORS Proxies gist</a> is a useful reference for finding working proxies, though the landscape changes frequently as services get abused and shut down.</p>

<p>The final implementation:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">var</span> <span class="nx">url</span> <span class="o">=</span> <span class="nx">letterboxd</span><span class="p">.</span><span class="nf">getAttribute</span><span class="p">(</span><span class="dl">'</span><span class="s1">href</span><span class="dl">'</span><span class="p">);</span>
<span class="nf">fetch</span><span class="p">(</span><span class="s2">`https://api.cors.lol/?url=</span><span class="p">${</span><span class="nf">encodeURIComponent</span><span class="p">(</span><span class="nx">url</span><span class="p">)}</span><span class="s2">`</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">then</span><span class="p">(</span><span class="nx">response</span> <span class="o">=&gt;</span> <span class="nx">response</span><span class="p">.</span><span class="nf">text</span><span class="p">())</span>
    <span class="c1">// ... rest unchanged</span>
</code></pre></div></div>

<p>This adds a dependency on a third-party service, which isn’t ideal. If cors.lol goes away or starts blocking HTML, I could set up a Cloudflare Worker to proxy these requests, or fetch the data at Jekyll build time instead of client-side. For now, the simplicity of a hosted proxy wins.</p>

<h2 id="the-result">The Result</h2>

<p><img width="816" height="372" alt="Display of Letterboxd likes on a review" src="/assets/images/21addc37-7230-4055-97e9-959509301ba3.png" /></p>

<p>Movie review posts now show like counts from Letterboxd right alongside the other social metrics. It’s a small addition, but it makes the social section feel more complete.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="personal-blog" /><category term="letterboxd" /><category term="jekyll" /><category term="language-javascript" /><summary type="html"><![CDATA[I’ve been crossposting my Letterboxd reviews to this site for a while now. The posts link back to the original review, but I wanted to show engagement metrics the same way I do for Mastodon, Bluesky, and HackerNews posts. I run this static site on Jekyll so I want to load these in the visitor’s browser for efficiency and accuracy.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/21addc37-7230-4055-97e9-959509301ba3.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/21addc37-7230-4055-97e9-959509301ba3.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Links That Survive the Printer</title><link href="https://www.joshbeckman.org/blog/practicing/links-that-survive-the-printer" rel="alternate" type="text/html" title="Links That Survive the Printer" /><published>2026-01-22T16:11:53+00:00</published><updated>2026-01-22T16:11:53+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/links-that-survive-the-printer</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/links-that-survive-the-printer"><![CDATA[<p><a href="https://philipithomas.com/">Philip I. Thomas</a> sent me a mailed copy of his <a href="https://www.contraption.co/introducing-the-print-edition/">“Print Edition” post</a>. I sat down in an armchair to read it, and found myself wanting to follow the links in the text. But I couldn’t - the blue underlined text was just text on paper.</p>

<p><img src="/assets/images/d3b4eef5-6d42-4ae9-87e0-3fd0df13af74.jpeg" alt="My notes on Philip's printed page" /></p>

<blockquote class="markdown-alert markdown-alert-note">
  <p><strong class="markdown-alert-title"><svg width="16" height="16" class="octicon octicon-info mr-2" aria-hidden="true" viewBox="0 0 16 16" version="1.1"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</strong></p>

  <p>Philip uses <a href="https://www.lob.com/">Lob</a> to print and mail his issues, which includes a single QR code on the header/mailing page to direct the reader to the single source article. <em>BUT</em> I didn’t grok that at first (it’s not labeled as anything and I assumed it was errata from Lob) and I think there should be a general solution to this outside of using Lob.</p>
</blockquote>
<p>This isn’t a new problem. Over a decade ago, I wrote about <a href="https://www.joshbeckman.org/blog/reading/the-great-discontent-issue-one#:~:text=I%20really%20enjoy,have%20on%20me.">how The Great Discontent handled links</a> in their first print issue - they used numbered footnotes with shortened URLs. Back then, I noted that QR codes felt “large and intrusive.” But they’ve become ubiquitous since, and at 64 pixels square they’re unobtrusive enough to include alongside each URL.</p>

<p>I wanted to make link-following possible on my site. When someone prints one of my posts, I want them to still be able to follow the links I included.</p>

<h2 id="the-solution-qr-code-footnotes">The Solution: QR Code Footnotes</h2>

<p>I wanted to take a similar approach to what Stripe Press does in its books (e.g. <a href="https://press.stripe.com/an-elegant-puzzle">An Elegant Puzzle</a>): footnotes with full links and QR codes.</p>

<p><img src="/assets/images/2a3b27e8-1dc5-414f-a914-7f1a38d222c7.jpeg" alt="The appendix footnotes in An Elegant Puzzle contain links and QR codes" /></p>

<p>When you print any page on this site now, every link gets:</p>
<ol>
  <li>A superscript footnote marker (like <code class="language-plaintext highlighter-rouge">L1</code>, <code class="language-plaintext highlighter-rouge">L2</code>, etc.)</li>
  <li>A corresponding entry in a “[L]inks” section at the end with the full URL and a QR code</li>
</ol>

<p>The <code class="language-plaintext highlighter-rouge">L</code> prefix distinguishes these from any regular footnotes on the page, and ties visually to the “[L]inks” header. Readers can either type the URL or scan the QR code with their phone to follow the link.</p>

<h2 id="how-it-works">How It Works</h2>

<p>The implementation uses the browser’s <code class="language-plaintext highlighter-rouge">beforeprint</code> event to generate footnotes just before printing. The <a href="https://github.com/davidshimjs/qrcodejs">qrcodejs</a> library is preloaded so it’s ready when needed:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://cdn.jsdelivr.net/gh/davidshimjs/qrcodejs/qrcode.min.js"</span> <span class="na">async</span><span class="nt">&gt;&lt;/script&gt;</span>
</code></pre></div></div>

<h3 id="building-the-link-list">Building the Link List</h3>

<p>When printing is triggered, it collects all unique links from the page content, seeding the list with the current page URL as item 0:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">var</span> <span class="nx">links</span> <span class="o">=</span> <span class="p">[{</span><span class="na">href</span><span class="p">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">location</span><span class="p">.</span><span class="nx">href</span><span class="p">,</span> <span class="na">anchor</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span> <span class="na">label</span><span class="p">:</span> <span class="dl">'</span><span class="s1">This page:</span><span class="dl">'</span><span class="p">}];</span>
<span class="kd">var</span> <span class="nx">seenUrls</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Set</span><span class="p">([</span><span class="nb">document</span><span class="p">.</span><span class="nx">location</span><span class="p">.</span><span class="nx">href</span><span class="p">]);</span>

<span class="c1">// you would replace this selector with something specific to your content's page structure</span>
<span class="kd">var</span> <span class="nx">anchors</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">querySelectorAll</span><span class="p">(</span><span class="dl">'</span><span class="s1">article a[href], .text a[href], .h-entry a[href]</span><span class="dl">'</span><span class="p">);</span>
<span class="nx">anchors</span><span class="p">.</span><span class="nf">forEach</span><span class="p">(</span><span class="kd">function</span><span class="p">(</span><span class="nx">anchor</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">var</span> <span class="nx">href</span> <span class="o">=</span> <span class="nx">anchor</span><span class="p">.</span><span class="nx">href</span><span class="p">;</span>
    <span class="kd">var</span> <span class="nx">rawHref</span> <span class="o">=</span> <span class="nx">anchor</span><span class="p">.</span><span class="nf">getAttribute</span><span class="p">(</span><span class="dl">'</span><span class="s1">href</span><span class="dl">'</span><span class="p">);</span>
    <span class="c1">// Skip hash links, javascript:, hidden elements, and duplicates</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">rawHref</span><span class="p">.</span><span class="nf">startsWith</span><span class="p">(</span><span class="dl">'</span><span class="s1">javascript:</span><span class="dl">'</span><span class="p">)</span> <span class="o">||</span> <span class="nx">rawHref</span><span class="p">.</span><span class="nf">startsWith</span><span class="p">(</span><span class="dl">'</span><span class="s1">#</span><span class="dl">'</span><span class="p">)</span> <span class="o">||</span> <span class="nx">anchor</span><span class="p">.</span><span class="nf">closest</span><span class="p">(</span><span class="dl">'</span><span class="s1">.print-hidden</span><span class="dl">'</span><span class="p">))</span> <span class="p">{</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">seenUrls</span><span class="p">.</span><span class="nf">has</span><span class="p">(</span><span class="nx">href</span><span class="p">))</span> <span class="p">{</span>
        <span class="nx">seenUrls</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="nx">href</span><span class="p">);</span>
        <span class="nx">links</span><span class="p">.</span><span class="nf">push</span><span class="p">({</span><span class="na">href</span><span class="p">:</span> <span class="nx">href</span><span class="p">,</span> <span class="na">anchor</span><span class="p">:</span> <span class="nx">anchor</span><span class="p">,</span> <span class="na">label</span><span class="p">:</span> <span class="kc">null</span><span class="p">});</span>
    <span class="p">}</span>
<span class="p">});</span>
</code></pre></div></div>

<p>The selector targets links within article content specifically - navigation, masthead, and footer links are already hidden in print via <code class="language-plaintext highlighter-rouge">.print-hidden</code>, so we skip those.</p>

<h3 id="generating-footnotes-and-qr-codes">Generating Footnotes and QR Codes</h3>

<p>For each link, we add a superscript to the anchor (if it exists) and create a footnote entry:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">links</span><span class="p">.</span><span class="nf">forEach</span><span class="p">(</span><span class="kd">function</span><span class="p">(</span><span class="nx">link</span><span class="p">,</span> <span class="nx">index</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">link</span><span class="p">.</span><span class="nx">anchor</span><span class="p">)</span> <span class="p">{</span>
        <span class="kd">var</span> <span class="nx">sup</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">createElement</span><span class="p">(</span><span class="dl">'</span><span class="s1">sup</span><span class="dl">'</span><span class="p">);</span>
        <span class="nx">sup</span><span class="p">.</span><span class="nx">className</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">print-footnote-ref print-only</span><span class="dl">'</span><span class="p">;</span>
        <span class="nx">sup</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">L</span><span class="dl">'</span> <span class="o">+</span> <span class="nx">index</span><span class="p">;</span>
        <span class="nx">link</span><span class="p">.</span><span class="nx">anchor</span><span class="p">.</span><span class="nf">appendChild</span><span class="p">(</span><span class="nx">sup</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="kd">var</span> <span class="nx">li</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">createElement</span><span class="p">(</span><span class="dl">'</span><span class="s1">li</span><span class="dl">'</span><span class="p">);</span>
    <span class="nx">li</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="nx">index</span><span class="p">;</span>

    <span class="c1">// QR code first for easy scanning alignment</span>
    <span class="kd">var</span> <span class="nx">qrContainer</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">createElement</span><span class="p">(</span><span class="dl">'</span><span class="s1">div</span><span class="dl">'</span><span class="p">);</span>
    <span class="nx">qrContainer</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">cssText</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">width:64px;height:64px;display:inline-block;vertical-align:middle;margin-right:8px</span><span class="dl">'</span><span class="p">;</span>
    <span class="nx">li</span><span class="p">.</span><span class="nf">appendChild</span><span class="p">(</span><span class="nx">qrContainer</span><span class="p">);</span>

    <span class="kd">var</span> <span class="nx">urlSpan</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">createElement</span><span class="p">(</span><span class="dl">'</span><span class="s1">span</span><span class="dl">'</span><span class="p">);</span>
    <span class="nx">urlSpan</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nx">link</span><span class="p">.</span><span class="nx">href</span><span class="p">;</span>
    <span class="nx">li</span><span class="p">.</span><span class="nf">appendChild</span><span class="p">(</span><span class="nx">urlSpan</span><span class="p">);</span>

    <span class="k">new</span> <span class="nc">QRCode</span><span class="p">(</span><span class="nx">qrContainer</span><span class="p">,</span> <span class="p">{</span>
        <span class="na">text</span><span class="p">:</span> <span class="nx">link</span><span class="p">.</span><span class="nx">href</span><span class="p">,</span>
        <span class="na">width</span><span class="p">:</span> <span class="mi">64</span><span class="p">,</span>
        <span class="na">height</span><span class="p">:</span> <span class="mi">64</span><span class="p">,</span>
        <span class="na">correctLevel</span><span class="p">:</span> <span class="nx">QRCode</span><span class="p">.</span><span class="nx">CorrectLevel</span><span class="p">.</span><span class="nx">L</span>
    <span class="p">});</span>
<span class="p">});</span>
</code></pre></div></div>

<h2 id="css-considerations">CSS Considerations</h2>

<p>A few CSS details make this work:</p>

<ol>
  <li>
    <p><strong>Print-only visibility</strong>: The <code class="language-plaintext highlighter-rouge">.print-only</code> class hides elements on screen but shows them in print. The superscripts and footnotes section both use this.</p>
  </li>
  <li>
    <p><strong>Inline superscripts</strong>: The <code class="language-plaintext highlighter-rouge">.print-only</code> class uses <code class="language-plaintext highlighter-rouge">display: block !important</code>, so the superscript refs need <code class="language-plaintext highlighter-rouge">display: inline !important</code> to stay inline with the link text.</p>
  </li>
  <li>
    <p><strong>List markers</strong>: Using <code class="language-plaintext highlighter-rouge">display: flex</code> on list items hides the default markers. Switching to <code class="language-plaintext highlighter-rouge">display: list-item</code> with explicit <code class="language-plaintext highlighter-rouge">list-style-type: decimal</code> brings them back.</p>
  </li>
</ol>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.print-only</span> <span class="p">{</span>
  <span class="nl">display</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">@media</span> <span class="n">print</span> <span class="p">{</span>
  <span class="nc">.print-only</span> <span class="p">{</span>
    <span class="nl">display</span><span class="p">:</span> <span class="nb">block</span> <span class="cp">!important</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="nf">#print-footnotes</span> <span class="nt">ol</span> <span class="p">{</span>
    <span class="nl">list-style-type</span><span class="p">:</span> <span class="nb">decimal</span> <span class="cp">!important</span><span class="p">;</span>
    <span class="nl">list-style-position</span><span class="p">:</span> <span class="nb">outside</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="nf">#print-footnotes</span> <span class="nt">li</span> <span class="p">{</span>
    <span class="nl">display</span><span class="p">:</span> <span class="nb">list-item</span> <span class="cp">!important</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="nc">.print-footnote-ref</span> <span class="p">{</span>
    <span class="nl">display</span><span class="p">:</span> <span class="nb">inline</span> <span class="cp">!important</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="gotchas">Gotchas</h2>

<p><strong><code class="language-plaintext highlighter-rouge">href</code> property vs attribute</strong>: The <code class="language-plaintext highlighter-rouge">anchor.href</code> property returns the fully resolved URL (e.g., <code class="language-plaintext highlighter-rouge">https://example.com/page#section</code>), while <code class="language-plaintext highlighter-rouge">getAttribute('href')</code> returns the raw attribute value (<code class="language-plaintext highlighter-rouge">#section</code>). Using the attribute makes it easy to filter out in-page anchor links.</p>

<p><strong>CSS display conflicts</strong>: The <code class="language-plaintext highlighter-rouge">.print-only</code> class uses <code class="language-plaintext highlighter-rouge">display: block !important</code> for print media. Superscript elements inside links need <code class="language-plaintext highlighter-rouge">display: inline !important</code> to stay inline with the link text rather than breaking to a new line.</p>

<h2 id="why-this-matters">Why This Matters</h2>

<p>This isn’t just for mailed print publications like Philip’s. I sometimes print web pages to archive on my physical bookshelf, or print recipes, instructions, and reference tables to use around the house. Others save web pages to PDF. In all these cases, the links in the original content become dead text unless you preserve them somehow.</p>

<p><img width="767" height="626" alt="Example print dialog of a page with these footnote links" src="/assets/images/47d1a581-cff7-4631-a885-e5f91f7ed135.png" /></p>

<p>Now when someone prints an article from my site, the links stick around. They can scan a QR code or type a URL. The reference is preserved.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="personal-blog" /><category term="code-snippets" /><category term="language-javascript" /><category term="publishing" /><category term="accessibility" /><summary type="html"><![CDATA[How I built automatic QR code footnotes so printed pages keep their links.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/d3b4eef5-6d42-4ae9-87e0-3fd0df13af74.jpeg" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/d3b4eef5-6d42-4ae9-87e0-3fd0df13af74.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Site Traffic in 2025</title><link href="https://www.joshbeckman.org/blog/practicing/site-traffic-in-2025" rel="alternate" type="text/html" title="Site Traffic in 2025" /><published>2026-01-03T17:03:30+00:00</published><updated>2026-01-03T17:03:30+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/site-traffic-in-2025</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/site-traffic-in-2025"><![CDATA[<p>2025 was the first full year I had basic analytics on this site (<a href="https://www.joshbeckman.org/blog/analytics-experiment-switching-from-tinylitics-to-goatcounter">via Goatcounter</a>), so I figured I would review the most popular posts/pages. I also wrote more in 2025 than in (any?) prior years.</p>

<blockquote class="markdown-alert markdown-alert-note">
  <p><strong class="markdown-alert-title"><svg width="16" height="16" class="octicon octicon-info mr-2" aria-hidden="true" viewBox="0 0 16 16" version="1.1"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</strong></p>

  <p>This is of course based on incomplete data, as many people (including myself) now browse the web with tracking blockers. But directionally this should be indicative of traffic.</p>
</blockquote>
<p><img width="1355" height="862" alt="The top pages on Goatcounter" src="/assets/images/3c613879-fed3-4a75-bfd0-a73ed18c6ba6.png" /></p>

<p>This data is <a href="https://joshbeckman.goatcounter.com/?hl-period=&amp;period-start=2025-01-01&amp;period-end=2026-01-01&amp;filter=&amp;group=day">publicly available here</a>.</p>

<h2 id="totals">Totals</h2>

<p>~30,140 visits in the year, with a peak of 7,613 on 2025-08-11.</p>

<p>I published <a href="https://www.joshbeckman.org/search/?q=*&amp;keys=&amp;type=post&amp;category=blog&amp;after=2024-12-31&amp;before=2026-01-01">215 blog posts</a>, <a href="https://www.joshbeckman.org/search/?q=*&amp;keys=&amp;type=post&amp;category=replies&amp;after=2024-12-31&amp;before=2026-01-01">23 replies</a>, <a href="https://www.joshbeckman.org/search/?q=*&amp;keys=&amp;type=post&amp;category=notes&amp;after=2024-12-31&amp;before=2026-01-01">192 notes</a>, <a href="https://www.joshbeckman.org/search/?q=*&amp;keys=&amp;type=post&amp;category=exercise&amp;after=2024-12-31&amp;before=2026-01-01">343 exercises</a>, and <a href="https://www.joshbeckman.org/search/?q=*&amp;keys=&amp;type=post&amp;category=proverbs&amp;after=2024-12-31&amp;before=2026-01-01">7 proverbs</a>. I hope to publish more than that in 2026.</p>

<h2 id="top-posts">Top Posts</h2>

<table>
  <thead>
    <tr>
      <th>Post</th>
      <th>Visits</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="https://www.joshbeckman.org/blog/practicing/ui-vs-api-vs-uai">UI vs. API. vs. UAI</a></td>
      <td>9,014</td>
    </tr>
    <tr>
      <td><a href="https://www.joshbeckman.org/blog/practicing/contributing-to-opensource-should-be-required-like-jury-duty">Contributing to Open-Source Should be Required, Like Jury Duty</a></td>
      <td>1,144</td>
    </tr>
    <tr>
      <td><a href="https://www.joshbeckman.org/blog/practicing/dont-forget-remote-mcp-servers-are-just-curl-calls">Don’t Forget: Remote MCP Servers are Just cURL Calls</a></td>
      <td>996</td>
    </tr>
    <tr>
      <td><a href="https://www.joshbeckman.org/blog/practicing/feedforward-tolerance-feedback-improving-interfaces-for-llm-agents">Feedforward, Tolerance, Feedback: Improving Interfaces for LLM Agents</a></td>
      <td>862</td>
    </tr>
    <tr>
      <td><a href="https://www.joshbeckman.org/blog/practicing/directed-notifications-for-claude-code-async-programming">Directed Notifications for Claude Code Async Programming</a></td>
      <td>233</td>
    </tr>
  </tbody>
</table>

<p>I’m actually kind of embarrassed that the Jury Duty post is so popular. In retrospect, I only half stand behind it; I think it’s too flippant.</p>

<p>I’m happy the UAI post did so well - it was a concept I developed and anchored on at work all year and I think it has been immensely useful for aiming my teams.</p>

<h2 id="top-referrers">Top Referrers</h2>

<table>
  <thead>
    <tr>
      <th>Referrer</th>
      <th>Visits</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>(unknown)</td>
      <td>31%</td>
    </tr>
    <tr>
      <td>Hacker News</td>
      <td>22%</td>
    </tr>
    <tr>
      <td>Google</td>
      <td>4%</td>
    </tr>
    <tr>
      <td>notes.joshbeckman.org</td>
      <td>2%</td>
    </tr>
    <tr>
      <td>tldrwebdev</td>
      <td>2%</td>
    </tr>
    <tr>
      <td>andjosh (<a href="https://www.joshbeckman.org/subscribe/#email-newsletter">my newsletter</a>)</td>
      <td>1%</td>
    </tr>
  </tbody>
</table>

<p>I <em>think</em> the high proportion of <code class="language-plaintext highlighter-rouge">(unknown)</code> referrals is coming from internally-shared links to my site. I love that! It tells me that people find my writing useful enough to share <em>themselves</em> rather than just consuming it from a feed or something. I want my work to be a helpful reference.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="data" /><category term="personal-blog" /><category term="year-in-review" /><summary type="html"><![CDATA[2025 was the first full year I had basic analytics on this site (via Goatcounter), so I figured I would review the most popular posts/pages. I also wrote more in 2025 than in (any?) prior years.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/3c613879-fed3-4a75-bfd0-a73ed18c6ba6.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/3c613879-fed3-4a75-bfd0-a73ed18c6ba6.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Bringing Back Webmentions</title><link href="https://www.joshbeckman.org/blog/practicing/bringing-back-webmentions" rel="alternate" type="text/html" title="Bringing Back Webmentions" /><published>2026-01-02T16:08:30+00:00</published><updated>2026-01-02T16:08:30+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/bringing-back-webmentions</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/bringing-back-webmentions"><![CDATA[<p>A year and a half ago I <a href="https://www.joshbeckman.org/blog/dropping-support-for-webmentions">dropped support for webmentions</a> on this blog, but I’ve brought them back over the last month.</p>

<p>At the time I removed them, I wasn’t <em>getting</em> any <a href="https://indieweb.org/Webmention">webmentions</a> sent to my site and I wasn’t <em>sending</em> any webmentions because the sites I was linking to didn’t support them. I thought it was a dead end and instead <a href="https://www.joshbeckman.org/blog/rules-for-syndication-on-my-site">built POSSE for this site</a> to the likes of Mastodon and Bluesky.</p>

<p>Recently, I have started <a href="https://webmention.io/api/mentions.html?token=4N8Bpl6KX64j8VB4k5A3ww">seeing webmentions</a> for some of my more popular posts, and I started displaying them on post pages and added a simple little form for people to manually send them when they write their own responses to my things. I’m receiving them through <a href="https://indieweb.org/webmention.io">webmention.io</a> and displaying them through their simple+fast API.</p>

<p><img width="719" height="332" alt="Webmention display on my posts" src="/assets/images/87ae2e1d-f1b8-4cdc-9aed-2b60e218a294.png" /></p>

<p>Because I have I’ve started seeing a slight uptick in people sending me webmentions and I started displaying them, I figured I should re-enable my automations to <em>send</em> them to others. Here’s how that works:</p>
<ul>
  <li>I have a GitHub workflow that runs twice daily</li>
  <li>A Ruby script pulls <a href="https://www.joshbeckman.org/subscribe/">the RSS feed for my site</a> and iterates through the recent posts</li>
  <li>The script uses <a href="https://github.com/indieweb/webmention-client-ruby">indieweb/webmention-client-ruby (A Ruby gem for sending and verifying Webmention notifications)</a> to test/find endpoints for any sites linked-to from the post and send a mention where possible</li>
</ul>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">require</span> <span class="s1">'net/http'</span>
<span class="nb">require</span> <span class="s1">'webmention'</span>
<span class="nb">require</span> <span class="s1">'uri'</span>
<span class="nb">require</span> <span class="s1">'nokogiri'</span>

<span class="k">class</span> <span class="nc">SendMentionsFromRss</span>
  <span class="no">Config</span> <span class="o">=</span> <span class="no">Struct</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span>
    <span class="s1">'MentionsFromRssConfig'</span><span class="p">,</span>
    <span class="ss">:feed_url</span><span class="p">,</span>
    <span class="ss">:item_selector</span><span class="p">,</span>
    <span class="ss">:item_href_proc</span><span class="p">,</span>
    <span class="ss">:excluded_url_matcher</span><span class="p">,</span>
    <span class="ss">:verbose</span><span class="p">,</span>
    <span class="ss">keyword_init: </span><span class="kp">true</span>
  <span class="p">)</span>

  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">execute</span><span class="p">(</span><span class="n">config</span><span class="p">)</span>
    <span class="k">raise</span> <span class="no">ArgumentError</span><span class="p">,</span> <span class="s1">'Must use a Config'</span> <span class="k">unless</span> <span class="n">config</span><span class="p">.</span><span class="nf">is_a?</span><span class="p">(</span><span class="no">Config</span><span class="p">)</span>

    <span class="nb">puts</span> <span class="s2">"Loading RSS feed from </span><span class="si">#{</span><span class="n">config</span><span class="p">.</span><span class="nf">feed_url</span><span class="si">}</span><span class="s2">"</span>
    <span class="n">export_uri</span> <span class="o">=</span> <span class="no">URI</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="n">config</span><span class="p">.</span><span class="nf">feed_url</span><span class="p">)</span>
    <span class="n">export_req</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="o">::</span><span class="no">Get</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">export_uri</span><span class="p">)</span>
    <span class="n">export_res</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="p">.</span><span class="nf">start</span><span class="p">(</span><span class="n">export_uri</span><span class="p">.</span><span class="nf">hostname</span><span class="p">,</span> <span class="n">export_uri</span><span class="p">.</span><span class="nf">port</span><span class="p">,</span> <span class="ss">use_ssl: </span><span class="kp">true</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">http</span><span class="o">|</span>
      <span class="n">http</span><span class="p">.</span><span class="nf">request</span><span class="p">(</span><span class="n">export_req</span><span class="p">)</span>
    <span class="k">end</span>
    <span class="k">raise</span> <span class="no">StandardError</span><span class="p">,</span> <span class="s1">'Feed request failed'</span> <span class="k">unless</span> <span class="n">export_res</span><span class="p">.</span><span class="nf">is_a?</span><span class="p">(</span><span class="no">Net</span><span class="o">::</span><span class="no">HTTPSuccess</span><span class="p">)</span>

    <span class="n">doc</span> <span class="o">=</span> <span class="no">Nokogiri</span><span class="o">::</span><span class="no">XML</span><span class="p">(</span><span class="n">export_res</span><span class="p">.</span><span class="nf">body</span><span class="p">)</span>
    <span class="n">doc</span><span class="p">.</span><span class="nf">css</span><span class="p">(</span><span class="n">config</span><span class="p">.</span><span class="nf">item_selector</span><span class="p">).</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">link</span><span class="o">|</span>
      <span class="n">href</span> <span class="o">=</span> <span class="n">config</span><span class="p">.</span><span class="nf">item_href_proc</span><span class="p">.</span><span class="nf">call</span><span class="p">(</span><span class="n">link</span><span class="p">)</span>
      <span class="no">Webmention</span><span class="p">.</span><span class="nf">mentioned_urls</span><span class="p">(</span><span class="n">href</span><span class="p">).</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">url</span><span class="o">|</span>
        <span class="k">if</span> <span class="n">url</span><span class="p">.</span><span class="nf">match?</span><span class="p">(</span><span class="n">config</span><span class="p">.</span><span class="nf">excluded_url_matcher</span><span class="p">)</span>
          <span class="nb">puts</span> <span class="s2">"skipping </span><span class="si">#{</span><span class="n">url</span><span class="si">}</span><span class="s2">"</span> <span class="k">if</span> <span class="n">config</span><span class="p">.</span><span class="nf">verbose</span>
          <span class="k">next</span>
        <span class="k">end</span>

        <span class="n">result</span> <span class="o">=</span> <span class="no">Webmention</span><span class="p">.</span><span class="nf">send_webmention</span><span class="p">(</span><span class="n">href</span><span class="p">,</span> <span class="n">url</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">result</span><span class="p">.</span><span class="nf">ok?</span>
          <span class="nb">puts</span> <span class="s2">"Sent mention of </span><span class="si">#{</span><span class="n">url</span><span class="si">}</span><span class="s2"> by </span><span class="si">#{</span><span class="n">href</span><span class="si">}</span><span class="s2">"</span>
        <span class="k">else</span>
          <span class="nb">puts</span> <span class="n">result</span><span class="p">.</span><span class="nf">message</span>
        <span class="k">end</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="n">site_config</span> <span class="o">=</span> <span class="no">SendMentionsFromRss</span><span class="o">::</span><span class="no">Config</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span>
  <span class="ss">feed_url: </span><span class="s1">'https://www.joshbeckman.org/feed.xml'</span><span class="p">,</span>
  <span class="ss">item_selector: </span><span class="s1">'entry link'</span><span class="p">,</span>
  <span class="ss">item_href_proc: </span><span class="o">-&gt;</span> <span class="p">(</span><span class="n">link</span><span class="p">)</span> <span class="p">{</span> <span class="n">link</span><span class="p">[</span><span class="s1">'href'</span><span class="p">]</span> <span class="p">},</span>
  <span class="ss">excluded_url_matcher: </span><span class="p">[</span>
    <span class="sr">/www.joshbeckman.org/</span><span class="p">,</span>
    <span class="sr">/notes.joshbeckman.org/</span><span class="p">,</span>
    <span class="sr">/readwise.io\/open\//</span><span class="p">,</span>
    <span class="sr">/readwise.io\/reader\//</span><span class="p">,</span>
    <span class="sr">/s2.googleusercontent.com/</span><span class="p">,</span>
    <span class="sr">/gravatar.com\/avatar/</span><span class="p">,</span>
    <span class="sr">/indieweb.org\/Webmention/</span>
  <span class="p">].</span><span class="nf">join</span><span class="p">(</span><span class="s1">'|'</span><span class="p">),</span>
  <span class="ss">verbose: </span><span class="kp">false</span>
<span class="p">)</span>
<span class="no">SendMentionsFromRss</span><span class="p">.</span><span class="nf">execute</span><span class="p">(</span><span class="n">site_config</span><span class="p">)</span>
</code></pre></div></div>

<p>I hope to see it discover endpoints on sites I’m linking to and sending webmentions out in the new year.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="personal-blog" /><category term="publishing" /><category term="blogs" /><category term="social-networks" /><summary type="html"><![CDATA[A year and a half ago I dropped support for webmentions on this blog, but I’ve brought them back over the last month.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/87ae2e1d-f1b8-4cdc-9aed-2b60e218a294.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/87ae2e1d-f1b8-4cdc-9aed-2b60e218a294.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">My GitHub Wrapped 2025</title><link href="https://www.joshbeckman.org/blog/practicing/my-github-wrapped-2025" rel="alternate" type="text/html" title="My GitHub Wrapped 2025" /><published>2025-12-21T15:37:22+00:00</published><updated>2025-12-21T15:37:22+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/my-github-wrapped-2025</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/my-github-wrapped-2025"><![CDATA[<p>First I used <a href="https://git-wrapped.com/">git-wrapped.com</a> again to pull my 2025 stats but then I built <a href="https://www.joshbeckman.org/blog/practicing/ghwrapped-is-your-github-wrapped-year-in-review-on-demand">gh-wrapped to give a better overview</a>. Here’s what it gave me for this far through 2025.</p>

<blockquote class="markdown-alert markdown-alert-note">
  <p><strong class="markdown-alert-title"><svg width="16" height="16" class="octicon octicon-info mr-2" aria-hidden="true" viewBox="0 0 16 16" version="1.1"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</strong></p>

  <p>See last year’s data at <a href="https://www.joshbeckman.org/blog/github-wrapped-2024">My GitHub Wrapped 2024</a> or (for fun) I generated <a href="https://gist.github.com/joshbeckman/f6bdac657901e1fe3e862c8e4c0a7d98">My GitHub Wrapped 2019</a> with my new <code class="language-plaintext highlighter-rouge">gh-wrapped</code> script.</p>
</blockquote>
<p><img src="/assets/images/194cb282-c047-4e64-9880-3d6747d755ab.jpeg" alt="The cold December snow and early dark nights make you reflect" /></p>

<h2 id="contribution-summary">Contribution Summary</h2>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Count</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Total contributions</strong></td>
      <td><strong>7057</strong></td>
    </tr>
    <tr>
      <td>↳ Public</td>
      <td>4673 (66.2%)</td>
    </tr>
    <tr>
      <td>↳ Private</td>
      <td>2384 (33.7%)</td>
    </tr>
    <tr>
      <td>Commits</td>
      <td>4513</td>
    </tr>
    <tr>
      <td>Pull requests</td>
      <td>8</td>
    </tr>
    <tr>
      <td>PR reviews</td>
      <td>6</td>
    </tr>
    <tr>
      <td>Issues opened</td>
      <td>139</td>
    </tr>
    <tr>
      <td>New repositories</td>
      <td>7</td>
    </tr>
  </tbody>
</table>

<h2 id="activity-patterns">Activity Patterns</h2>

<ul>
  <li><strong>Busiest day of week:</strong> Tuesday (1228 contributions)</li>
  <li><strong>Longest streak:</strong> 236 days (2025-04-28 to 2025-12-19)</li>
  <li><strong>Active days:</strong> 329 / 365 (90.1%)</li>
  <li><strong>Peak day:</strong> 2025-06-10 with 68 contributions</li>
</ul>

<h2 id="activity-by-day-of-week">Activity by Day of Week</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Sunday      763 ████████████████████████
Monday     1160 █████████████████████████████████████
Tuesday    1228 ████████████████████████████████████████
Wednesday  1191 ██████████████████████████████████████
Thursday    998 ████████████████████████████████
Friday     1046 ██████████████████████████████████
Saturday    671 █████████████████████
</code></pre></div></div>

<h2 id="monthly-breakdown">Monthly Breakdown</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Jan   263 ██████████
Feb   400 ███████████████
Mar   438 ████████████████
Apr   246 █████████
May   500 ███████████████████
Jun   637 ████████████████████████
Jul   451 █████████████████
Aug  1032 ████████████████████████████████████████
Sep   815 ███████████████████████████████
Oct   766 █████████████████████████████
Nov   929 ████████████████████████████████████
Dec   580 ██████████████████████
</code></pre></div></div>

<h2 id="top-languages-by-commits-to-public-repos">Top Languages (by commits to public repos)</h2>

<table>
  <thead>
    <tr>
      <th>Language</th>
      <th>Commits</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>CSS</td>
      <td>4442</td>
    </tr>
    <tr>
      <td>Ruby</td>
      <td>35</td>
    </tr>
    <tr>
      <td>HTML</td>
      <td>15</td>
    </tr>
    <tr>
      <td>Shell</td>
      <td>12</td>
    </tr>
    <tr>
      <td>JavaScript</td>
      <td>9</td>
    </tr>
  </tbody>
</table>

<h2 id="top-repositories-by-commits-to-public-repos">Top Repositories (by commits to public repos)</h2>

<table>
  <thead>
    <tr>
      <th>Repository</th>
      <th>Commits</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="https://github.com/joshbeckman/notes">joshbeckman/notes</a></td>
      <td>4442</td>
    </tr>
    <tr>
      <td><a href="https://github.com/joshbeckman/marybethkram">joshbeckman/marybethkram</a></td>
      <td>15</td>
    </tr>
    <tr>
      <td><a href="https://github.com/joshbeckman/readwise-ruby">joshbeckman/readwise-ruby</a></td>
      <td>13</td>
    </tr>
    <tr>
      <td><a href="https://github.com/joshbeckman/gh-view-md">joshbeckman/gh-view-md</a></td>
      <td>9</td>
    </tr>
    <tr>
      <td><a href="https://github.com/Shopify/flow-code-examples">Shopify/flow-code-examples</a></td>
      <td>8</td>
    </tr>
    <tr>
      <td><a href="https://github.com/joshbeckman/mcp_cli">joshbeckman/mcp_cli</a></td>
      <td>7</td>
    </tr>
    <tr>
      <td><a href="https://github.com/joshbeckman/gh-pr-staleness">joshbeckman/gh-pr-staleness</a></td>
      <td>6</td>
    </tr>
    <tr>
      <td><a href="https://github.com/joshbeckman/paperboy">joshbeckman/paperboy</a></td>
      <td>6</td>
    </tr>
    <tr>
      <td><a href="https://github.com/joshbeckman/gh-nvim-username-keywords">joshbeckman/gh-nvim-username-keywords</a></td>
      <td>3</td>
    </tr>
    <tr>
      <td><a href="https://github.com/joshbeckman/gh-wrapped">joshbeckman/gh-wrapped</a></td>
      <td>3</td>
    </tr>
  </tbody>
</table>

<h2 id="repositories-created-in-2025">Repositories Created in 2025</h2>

<table>
  <thead>
    <tr>
      <th>Repository</th>
      <th>Language</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="https://github.com/joshbeckman/gh-nvim-username-keywords">joshbeckman/gh-nvim-username-keywords</a></td>
      <td>Shell</td>
      <td>GitHub CLI extension for populating your Neovim ke</td>
    </tr>
    <tr>
      <td><a href="https://github.com/joshbeckman/gh-wrapped">joshbeckman/gh-wrapped</a></td>
      <td>Shell</td>
      <td>GitHub CLI extension for showing an annual review</td>
    </tr>
    <tr>
      <td><a href="https://github.com/joshbeckman/mcp_cli">joshbeckman/mcp_cli</a></td>
      <td>Ruby</td>
      <td>CLI for calling/interacting with MCP (Model Contex</td>
    </tr>
    <tr>
      <td><a href="https://github.com/joshbeckman/gh-pr-staleness">joshbeckman/gh-pr-staleness</a></td>
      <td>Shell</td>
      <td>GitHub CLI extension for showing the number of com</td>
    </tr>
    <tr>
      <td><a href="https://github.com/joshbeckman/strava-ruby-client">joshbeckman/strava-ruby-client</a></td>
      <td>Ruby</td>
      <td>A complete Ruby client for the Strava API v3.</td>
    </tr>
    <tr>
      <td><a href="https://github.com/joshbeckman/paperboy">joshbeckman/paperboy</a></td>
      <td>Ruby</td>
      <td>Generate a newspaper from my Readwise Reader inbox</td>
    </tr>
    <tr>
      <td><a href="https://github.com/joshbeckman/gh-view-md">joshbeckman/gh-view-md</a></td>
      <td>Ruby</td>
      <td>GitHub CLI extension for rendering issues and pull</td>
    </tr>
  </tbody>
</table>

<h2 id="gists-created-in-2025">Gists Created in 2025</h2>

<table>
  <thead>
    <tr>
      <th>Gist</th>
      <th>Visibility</th>
      <th>Files</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Private gist</td>
      <td>🔒</td>
      <td>md</td>
    </tr>
    <tr>
      <td>Private gist</td>
      <td>🔒</td>
      <td>md</td>
    </tr>
    <tr>
      <td>Private gist</td>
      <td>🔒</td>
      <td>md</td>
    </tr>
    <tr>
      <td>Private gist</td>
      <td>🔒</td>
      <td>md</td>
    </tr>
    <tr>
      <td>Private gist</td>
      <td>🔒</td>
      <td>md</td>
    </tr>
    <tr>
      <td>Private gist</td>
      <td>🔒</td>
      <td>md</td>
    </tr>
    <tr>
      <td>Private gist</td>
      <td>🔒</td>
      <td>md</td>
    </tr>
    <tr>
      <td>Private gist</td>
      <td>🔒</td>
      <td>js</td>
    </tr>
    <tr>
      <td>Private gist</td>
      <td>🔒</td>
      <td>sh</td>
    </tr>
    <tr>
      <td>Private gist</td>
      <td>🔒</td>
      <td>sh</td>
    </tr>
    <tr>
      <td>Private gist</td>
      <td>🔒</td>
      <td>rb</td>
    </tr>
  </tbody>
</table>

<p><strong>Total:</strong> 11 gists (0 public, 11 private)</p>

<h2 id="github-sponsors">GitHub Sponsors</h2>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Count</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Sponsoring</td>
      <td>3</td>
    </tr>
    <tr>
      <td>Sponsors</td>
      <td>0</td>
    </tr>
  </tbody>
</table>

<p><strong>Supporting:</strong> <a href="https://github.com/tpope">@tpope</a>, <a href="https://github.com/wez">@wez</a>, <a href="https://github.com/jaredhanson">@jaredhanson</a></p>

<h2 id="profile-stats">Profile Stats</h2>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Count</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Total stars (all repos)</td>
      <td>685</td>
    </tr>
    <tr>
      <td>Public repositories</td>
      <td>132</td>
    </tr>
    <tr>
      <td>Followers</td>
      <td>154</td>
    </tr>
    <tr>
      <td>Following</td>
      <td>77</td>
    </tr>
  </tbody>
</table>

<h2 id="2024-vs-2025-comparison">2024 vs 2025 Comparison</h2>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>2024</th>
      <th>2025</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Total contributions</strong></td>
      <td>2,954</td>
      <td>6,994</td>
      <td>+4,040 (+137%)</td>
    </tr>
    <tr>
      <td>Commits</td>
      <td>922</td>
      <td>4,482</td>
      <td>+3,560 (+386%)</td>
    </tr>
    <tr>
      <td>Public pull requests</td>
      <td>4</td>
      <td>8</td>
      <td>+4 (+100%)</td>
    </tr>
    <tr>
      <td>Public PR reviews</td>
      <td>7</td>
      <td>6</td>
      <td>-1 (-14%)</td>
    </tr>
    <tr>
      <td>Public issues opened</td>
      <td>112</td>
      <td>137</td>
      <td>+25 (+22%)</td>
    </tr>
    <tr>
      <td>New public repositories</td>
      <td>2</td>
      <td>5</td>
      <td>+3 (+150%)</td>
    </tr>
    <tr>
      <td>Private contributions</td>
      <td>+1,907</td>
      <td>+2,356</td>
      <td>+449 (+24%)</td>
    </tr>
  </tbody>
</table>

<h3 id="activity-patterns-1">Activity Patterns</h3>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>2024</th>
      <th>2025</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Busiest day of week</td>
      <td>Wednesday (617)</td>
      <td>Tuesday (1,229)</td>
    </tr>
    <tr>
      <td>Longest streak</td>
      <td>25 days</td>
      <td><strong>234 days</strong></td>
    </tr>
    <tr>
      <td>Active days</td>
      <td>279/366 (76.2%)</td>
      <td>327/365 (89.5%)</td>
    </tr>
    <tr>
      <td>Peak single day</td>
      <td>52 contributions</td>
      <td>68 contributions</td>
    </tr>
  </tbody>
</table>

<h3 id="monthly-highlights">Monthly Highlights</h3>
<ul>
  <li><strong>2024 peak month:</strong> December (448)</li>
  <li><strong>2025 peak month:</strong> August (1,032)</li>
</ul>

<h2 id="notes">Notes</h2>

<p>2025 has <em>way</em> way more commits than 2024 for a couple reasons:</p>
<ul>
  <li><a href="https://code.claude.com/docs/en/overview">Claude Code</a> was released and completely changed my workflow</li>
  <li>I set up more frequent syndication and automations on my personal site that committed changes under my signature</li>
</ul>

<p>Here’s how I was working with Claude Code for the latter 2/3 of 2025:</p>
<ul>
  <li>Have an idea for something to build or fix or explore in a code repository</li>
  <li>Open a new <a href="https://git-scm.com/docs/git-worktree">git-worktree</a> in the repository, in a tab in my <a href="https://wezterm.org/index.html">WezTerm</a> terminal</li>
  <li>Split the tab/window into two panes:
    <ul>
      <li>The left pane is for me to view files, edit in Neovim, run shell commands, etc.</li>
      <li>The right pane is for me to run a Claude Code session/chat</li>
    </ul>
  </li>
  <li>Write a pretty lengthy prompt explaining what I want to do (or use one of my dozens of commands for e.g. fixing a bug, writing a test, etc.) and send Claude Code on its merry way</li>
  <li>While it’s running, review changes, edit my own, etc. in parallel</li>
  <li>Rely on <a href="https://www.joshbeckman.org/blog/practicing/directed-notifications-for-claude-code-async-programming">Directed Notifications for Claude Code Async Programming</a> to tell me when I need to give permission or intervene</li>
</ul>

<p>This allowed me to be running a half-dozen parallel ideas/tasks on a project at once, with Claude Code committing as went went along.</p>

<p>I really leaned into GitHub’s CLI extension system in 2025 (<a href="https://www.joshbeckman.org/search/?q=%22gh%20CLI%20extension%22">examples</a>), which helped me package my ideas and share them with the wider organization at Shopify. I think 2026 should shift my focus to Claude Code extensions; I’ve been building and privately distributing a bunch of them all year, but I think I should shift to open-sourcing them and building a proper library out of them. I’ve hesitated in 2025 because I think the systems were too fresh and in flux, but I expect things to calm down and consolidate in 2026.</p>

<p>I didn’t gain any sponsors in 2025 but it’s because I’m only working on small (though many!) tools and projects. I think people only want to sponsor big ideas.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="github" /><category term="data" /><category term="open-source" /><category term="year-in-review" /><summary type="html"><![CDATA[First I used git-wrapped.com again to pull my 2025 stats but then I built gh-wrapped to give a better overview. Here’s what it gave me for this far through 2025.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/194cb282-c047-4e64-9880-3d6747d755ab.jpeg" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/194cb282-c047-4e64-9880-3d6747d755ab.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">gh-nvim-username-keywords: GitHub @-mention Autocomplete in Your Neovim Editor</title><link href="https://www.joshbeckman.org/blog/practicing/ghnvimusernamekeywords-github-mention-autocomplete-in-your-neovim-editor" rel="alternate" type="text/html" title="gh-nvim-username-keywords: GitHub @-mention Autocomplete in Your Neovim Editor" /><published>2025-12-19T13:38:58+00:00</published><updated>2025-12-19T13:38:58+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/ghnvimusernamekeywords-github-mention-autocomplete-in-your-neovim-editor</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/ghnvimusernamekeywords-github-mention-autocomplete-in-your-neovim-editor"><![CDATA[<p>I’ve been writing my GitHub issue and PR comments in <a href="https://neovim.io/">neovim</a> more and more. Sometimes through the <code class="language-plaintext highlighter-rouge">gh</code> CLI directly, sometimes through interactions with claude-code: both places where I can pop into an <code class="language-plaintext highlighter-rouge">nvim</code> session to edit lengthy text and then return to context. It’s faster to write, I get my keybindings, and I can think more clearly in my own editor.</p>

<p>But there’s one thing the GitHub web UI does really well: @-mention autocomplete. Start typing <code class="language-plaintext highlighter-rouge">@</code> and it suggests teammates, reviewers, anyone you might want to loop in. That was unavailable in neovim. Until now.</p>

<p>I built <a href="https://github.com/joshbeckman/gh-nvim-username-keywords">gh-nvim-username-keywords</a>.</p>

<blockquote class="markdown-alert markdown-alert-note">
  <p><strong class="markdown-alert-title"><svg width="16" height="16" class="octicon octicon-info mr-2" aria-hidden="true" viewBox="0 0 16 16" version="1.1"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</strong></p>

  <p><code class="language-plaintext highlighter-rouge">gh-nvim-username-keywords</code> is a <a href="https://docs.github.com/en/github-cli/github-cli/creating-github-cli-extensions">GitHub CLI extension</a> that adds GitHub usernames to your nvim keywords file for autocomplete. It discovers usernames from your GitHub teams and PR activity, making it easy to @-mention colleagues.</p>
</blockquote>
<p><img width="718" height="261" alt="Example dry run of gh nvim-username-keywords" src="/assets/images/b767b9ff-5e57-4be8-9b8d-666ad8cd7331.png" /></p>

<h2 id="installation">Installation</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gh extension <span class="nb">install </span>joshbeckman/gh-nvim-username-keywords
</code></pre></div></div>

<p>That’s it. Run the <code class="language-plaintext highlighter-rouge">gh nvim-username-keywords</code> command whenever you want to sync your recent username interactions to your local nvim dictionary keywords. Then they’re just a tab-complete away while you’re editing.</p>

<h2 id="how-it-works">How It Works</h2>

<p>The extension collects usernames from two sources:</p>

<ol>
  <li><strong>GitHub Teams</strong> — members of teams you’re on</li>
  <li><strong>PR Activity</strong> — authors, reviewers, and commenters from PRs you’ve authored or reviewed</li>
</ol>

<p>If you don’t specify teams or repos explicitly, it auto-discovers defaults from your most recent activity. It filters out known bot accounts, compares against your existing keywords file, and appends only new usernames (prefixed with <code class="language-plaintext highlighter-rouge">@</code>). The keywords file is append-only—existing entries are never removed.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Use auto-discovered defaults</span>
gh nvim-username-keywords

<span class="c"># Specify teams explicitly</span>
gh nvim-username-keywords <span class="nt">--team</span> myorg/engineering <span class="nt">--team</span> myorg/design

<span class="c"># Preview what would be added</span>
gh nvim-username-keywords <span class="nt">--dry-run</span> <span class="nt">--verbose</span>
</code></pre></div></div>

<h2 id="nvim-setup">Nvim Setup</h2>

<p>Your nvim needs a keywords file at <code class="language-plaintext highlighter-rouge">~/.config/nvim/keywords.txt</code> (or wherever <code class="language-plaintext highlighter-rouge">$XDG_CONFIG_HOME/nvim/</code> points). If you’re not already using a keywords file for autocomplete, add this to your nvim config:</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">vim</span><span class="p">.</span><span class="n">opt</span><span class="p">.</span><span class="n">dictionary</span><span class="p">:</span><span class="n">append</span><span class="p">(</span><span class="n">vim</span><span class="p">.</span><span class="n">fn</span><span class="p">.</span><span class="n">stdpath</span><span class="p">(</span><span class="s2">"config"</span><span class="p">)</span> <span class="o">..</span> <span class="s2">"/keywords.txt"</span><span class="p">)</span>
<span class="n">vim</span><span class="p">.</span><span class="n">opt</span><span class="p">.</span><span class="n">complete</span><span class="p">:</span><span class="n">append</span><span class="p">(</span><span class="s2">"k"</span><span class="p">)</span>
</code></pre></div></div>

<p>Now <code class="language-plaintext highlighter-rouge">&lt;C-n&gt;</code> or <code class="language-plaintext highlighter-rouge">&lt;C-p&gt;</code> in insert mode will suggest from your keywords file, including all those <code class="language-plaintext highlighter-rouge">@username</code> entries.</p>

<p>I also recommend configuring nvim to not break words on hypens. This is because many users have hyphens in their handles and in the default configuration, nvim will break their names and only tab-complete sections at a time. Here’s how you can change that:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>" include hyphens in keyword completion (for usernames like @Gasser-Aly)
set iskeyword+=-
</code></pre></div></div>

<blockquote class="markdown-alert markdown-alert-note">
  <p><strong class="markdown-alert-title"><svg width="16" height="16" class="octicon octicon-info mr-2" aria-hidden="true" viewBox="0 0 16 16" version="1.1"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</strong></p>

  <p>This is my fourth <code class="language-plaintext highlighter-rouge">gh</code> extension. Previously released: <a href="https://www.joshbeckman.org/blog/gh-wrapped-your-github-year-in-review-on-demand">gh-wrapped</a>, <a href="https://www.joshbeckman.org/blog/practicing/releasing-ghprstaleness-github-cli-extension-for-commits-behind-target">gh-pr-staleness</a>, and <a href="https://www.joshbeckman.org/blog/practicing/releasing-ghviewmd-a-github-cli-extension-for-llmoptimized-issue-and-pr-viewing">gh-view-md</a></p>
</blockquote>
<p>The <a href="https://github.com/joshbeckman/gh-nvim-username-keywords">source is on GitHub</a>. Contributions welcome!</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="tools" /><category term="github" /><category term="open-source" /><category term="vim" /><summary type="html"><![CDATA[I’ve been writing my GitHub issue and PR comments in neovim more and more. Sometimes through the gh CLI directly, sometimes through interactions with claude-code: both places where I can pop into an nvim session to edit lengthy text and then return to context. It’s faster to write, I get my keybindings, and I can think more clearly in my own editor.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/b767b9ff-5e57-4be8-9b8d-666ad8cd7331.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/b767b9ff-5e57-4be8-9b8d-666ad8cd7331.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">gh-wrapped is Your GitHub Wrapped Year in Review, On Demand</title><link href="https://www.joshbeckman.org/blog/practicing/ghwrapped-is-your-github-wrapped-year-in-review-on-demand" rel="alternate" type="text/html" title="gh-wrapped is Your GitHub Wrapped Year in Review, On Demand" /><published>2025-12-19T03:11:29+00:00</published><updated>2025-12-19T03:11:29+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/ghwrapped-is-your-github-wrapped-year-in-review-on-demand</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/ghwrapped-is-your-github-wrapped-year-in-review-on-demand"><![CDATA[<p>Recent Decembers, I watch people share their “Spotify/etc. Wrapped” results and wonder about building a proper GitHub version. Various “GitHub Wrapped” websites pop up each year, but they always fall short for me:</p>

<ol>
  <li>They only work for public contributions, and a lot of mine are now in private repos.</li>
  <li>They miss gists, repos created, and sponsorship data, but these are part of how I use GitHub.</li>
  <li>They’re only available during a narrow window in December or only consider the single year</li>
  <li>You can only check your own stats</li>
</ol>

<p>I got tired of wondering, so I built <a href="https://github.com/joshbeckman/gh-wrapped">gh-wrapped</a> to give you a complete <em>GitHub Wrapped</em> on demand.</p>

<blockquote class="markdown-alert markdown-alert-note">
  <p><strong class="markdown-alert-title"><svg width="16" height="16" class="octicon octicon-info mr-2" aria-hidden="true" viewBox="0 0 16 16" version="1.1"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</strong></p>

  <p><code class="language-plaintext highlighter-rouge">gh-wrapped</code> is a <a href="https://docs.github.com/en/github-cli/github-cli/creating-github-cli-extensions">GitHub CLI extension</a> that generates a year-in-review summary of your GitHub activity, inspired by Spotify Wrapped. Get detailed stats on your contributions, activity patterns, top languages, and more—all as formatted markdown.</p>
</blockquote>
<p><img width="662" height="289" alt="Example rendering of the beginning of gh-wrapped markdown" src="/assets/images/c00b8275-9b80-41c1-bbea-86bc33607789.png" /></p>

<h2 id="installation">Installation</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gh extension <span class="nb">install </span>joshbeckman/gh-wrapped
</code></pre></div></div>

<p>That’s it. You have the GitHub CLI installed and authenticated (and <code class="language-plaintext highlighter-rouge">jq</code> for JSON parsing), you’re ready to go.</p>

<h2 id="run-it-any-time-for-any-year-for-anyone">Run It Any Time, For Any Year, For Anyone</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Your stats for this year</span>
gh wrapped

<span class="c"># Your stats for 2023</span>
gh wrapped 2023

<span class="c"># Check someone else's public activity</span>
gh wrapped 2024 octocat

<span class="c"># Save it as a gist</span>
gh wrapped 2024 | gh gist create <span class="nt">-f</span> wrapped-2024.md
</code></pre></div></div>

<p>The third-party websites that appear each December are fine for a quick shareable image, but I wanted something I could run any time I’m curious. How did Q1 compare to Q4? What was my busiest month? Did I actually contribute more this year than last? These questions don’t care about December.</p>

<h2 id="what-you-get">What You Get</h2>

<p>The tool pulls from GitHub’s GraphQL <code class="language-plaintext highlighter-rouge">contributionsCollection</code> endpoint which is the same data source that powers your profile’s contribution graph. This means it includes <strong>both public and private contributions</strong> in the totals.</p>

<ul>
  <li><strong>Contribution Summary</strong> with public/private breakdown</li>
  <li><strong>Activity Patterns</strong>: busiest day of week, longest streak, peak activity day</li>
  <li><strong>Visual Charts</strong> for daily and monthly activity (ASCII bar charts in your terminal)</li>
  <li><strong>Top Languages</strong> by commits</li>
  <li><strong>Top Repositories</strong> by commits</li>
  <li><strong>Repositories Created</strong> that year</li>
  <li><strong>Gists Created</strong> (including private ones)</li>
  <li><strong>GitHub Sponsors</strong> activity (when checking your own stats)</li>
  <li><strong>Profile Stats</strong>: stars, followers, following</li>
</ul>

<blockquote class="markdown-alert markdown-alert-note">
  <p><strong class="markdown-alert-title"><svg width="16" height="16" class="octicon octicon-info mr-2" aria-hidden="true" viewBox="0 0 16 16" version="1.1"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</strong></p>

  <p>See an example at <a href="https://gist.github.com/joshbeckman/f6bdac657901e1fe3e862c8e4c0a7d98">this gist of my 20219 GitHub Wrapped</a> generated with this script.</p>
</blockquote>
<h2 id="private-contributions">Private Contributions</h2>

<p>This was the main reason I built this. GitHub’s API provides the total count of private contributions through <code class="language-plaintext highlighter-rouge">restrictedContributionsCount</code>, which those web-based tools often ignore or can’t access. The detailed per-repo and per-language breakdowns only cover public repos (GitHub’s API doesn’t expose private repo details), but at least your totals reflect reality.</p>

<blockquote class="markdown-alert markdown-alert-warning">
  <p><strong class="markdown-alert-title"><svg width="16" height="16" class="octicon octicon-alert mr-2" aria-hidden="true" viewBox="0 0 16 16" version="1.1"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>Warning</strong></p>

  <p>GitHub only provides the contribution count (not details on whether they are commits/PRs/etc) for private repos. I tried making it actually crawl through a year’s worth of private repo commits/PRs/etc. to calculate the count, but GitHub API rate limits inevitably make the whole thing come crashing down.</p>
</blockquote>
<p>The output clearly labels what’s public vs private so you know exactly what you’re looking at.</p>

<blockquote class="markdown-alert markdown-alert-note">
  <p><strong class="markdown-alert-title"><svg width="16" height="16" class="octicon octicon-info mr-2" aria-hidden="true" viewBox="0 0 16 16" version="1.1"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</strong></p>

  <p>This is my third <code class="language-plaintext highlighter-rouge">gh</code> extension. Previously released: <a href="https://www.joshbeckman.org/blog/practicing/releasing-ghprstaleness-github-cli-extension-for-commits-behind-target">gh-pr-staleness</a> and <a href="https://www.joshbeckman.org/blog/practicing/releasing-ghviewmd-a-github-cli-extension-for-llmoptimized-issue-and-pr-viewing">gh-view-md</a></p>
</blockquote>
<p>The <a href="https://github.com/joshbeckman/gh-wrapped">source is on GitHub</a> with examples and full documentation. Contributions are welcome!</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="tools" /><category term="github" /><category term="open-source" /><category term="CLI" /><category term="year-in-review" /><summary type="html"><![CDATA[Recent Decembers, I watch people share their “Spotify/etc. Wrapped” results and wonder about building a proper GitHub version. Various “GitHub Wrapped” websites pop up each year, but they always fall short for me:]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/c00b8275-9b80-41c1-bbea-86bc33607789.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/c00b8275-9b80-41c1-bbea-86bc33607789.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">My Raycast Wrapped 2025 (and Prior Years)</title><link href="https://www.joshbeckman.org/blog/practicing/my-raycast-wrapped-2025-and-prior-years" rel="alternate" type="text/html" title="My Raycast Wrapped 2025 (and Prior Years)" /><published>2025-12-16T17:46:33+00:00</published><updated>2025-12-16T17:46:33+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/my-raycast-wrapped-2025-and-prior-years</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/my-raycast-wrapped-2025-and-prior-years"><![CDATA[<p>I’ve been using <a href="https://www.raycast.com/">Raycast</a> on my work and personal computers for over 3 years now and heavily use it (<a href="https://www.joshbeckman.org/blog/raycast-snippets-for-conventional-comments-commits">example</a>) for text snippets and app/site navigation. It’s so much more than an app launcher and still way better than MacOS defaults and the other competition. I heartily recommend it if you’re using a Mac.</p>

<p>They’ve been providing their own version of “Raycast Wrapped” for years and I just got mine for 2025:</p>

<p><img width="2944" height="2064" alt="My Raycast Wrapped 2025" src="/assets/images/6d14ad30-7d58-48f0-8991-59e64f46378e.png" /></p>

<blockquote class="markdown-alert markdown-alert-note">
  <p><strong class="markdown-alert-title"><svg width="16" height="16" class="octicon octicon-info mr-2" aria-hidden="true" viewBox="0 0 16 16" version="1.1"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</strong></p>

  <p>These stats (and the ones below) are just from my work machine. It’s where I spend the majority of my computing time.</p>
</blockquote>
<p>I think a change for 2025 was that I leaned further into quicklinks. I still need to remember to use my bookmarks and history extension/integration for faster recall of pages (so I can close tabs more aggressively).</p>

<h2 id="2024">2024</h2>

<p><img width="2848" height="1840" alt="My Raycast Wrapped 2024" src="/assets/images/6f4df59e-256f-4996-825f-832a23a3da64.png" /></p>

<h2 id="2023">2023</h2>

<p><img width="2844" height="1964" alt="My Raycast Wrapped 2023" src="/assets/images/f5699c48-a56d-4b50-9570-9cd703b947de.png" /></p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="tools" /><category term="year-in-review" /><summary type="html"><![CDATA[I’ve been using Raycast on my work and personal computers for over 3 years now and heavily use it (example) for text snippets and app/site navigation. It’s so much more than an app launcher and still way better than MacOS defaults and the other competition. I heartily recommend it if you’re using a Mac.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/6d14ad30-7d58-48f0-8991-59e64f46378e.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/6d14ad30-7d58-48f0-8991-59e64f46378e.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Shopify Editions Winter ‘26</title><link href="https://www.joshbeckman.org/blog/practicing/shopify-editions-winter-26" rel="alternate" type="text/html" title="Shopify Editions Winter ‘26" /><published>2025-12-10T16:21:31+00:00</published><updated>2025-12-10T16:21:31+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/shopify-editions-winter-26</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/shopify-editions-winter-26"><![CDATA[<p>Jump to <a href="https://x.com/Shopify/status/1998754028314882421?s=20">15:10 of the X spaces livestream</a> to get a brief overview of a big project I championed for a bit this fall: <strong><a href="https://changelog.shopify.com/posts/create-flow-automations-with-sidekick">AI-generated workflows</a></strong> with Sidekick. (<a href="https://www.shopify.com/ca/editions/winter2026#sidekick">view in Editions</a>)</p>

<p><img width="1294" height="712" alt="Editions feature page of Sidekick + Flow" src="/assets/images/71f8da65-6b8b-413d-a8fa-384983c1522d.png" /></p>

<p>Elsewhere, Flow had several other big projects launch, like a whole “<a href="https://changelog.shopify.com/posts/flow-preview-workflows-safely-with-test-runs">test your workflows before you activate them</a>” system and a complete editor redesign: <a href="https://www.shopify.com/ca/editions/winter2026#operations">Shopify Editions Winter ‘26: The Renaissance Edition</a>.</p>

<p>It was difficult to get here, and I’m so proud of my team.</p>

<blockquote class="markdown-alert markdown-alert-note">
  <p><strong class="markdown-alert-title"><svg width="16" height="16" class="octicon octicon-info mr-2" aria-hidden="true" viewBox="0 0 16 16" version="1.1"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</strong></p>

  <p>Full Shopify blog post: <a href="https://www.shopify.com/blog/flow-automation-updates-2025">How Shopify Flow Automation Got Faster, Safer, and Smarter in 2025</a></p>
</blockquote>
<p>See also the <a href="https://youtu.be/rd0fiAutj5o?si=bPny0MDbUu1lrLg2&amp;t=638">~10:40 mark in <em>The Unofficial Shopify Podcast</em></a>.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="shopify" /><summary type="html"><![CDATA[Jump to 15:10 of the X spaces livestream to get a brief overview of a big project I championed for a bit this fall: AI-generated workflows with Sidekick. (view in Editions)]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/71f8da65-6b8b-413d-a8fa-384983c1522d.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/71f8da65-6b8b-413d-a8fa-384983c1522d.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">My Studio Stool</title><link href="https://www.joshbeckman.org/blog/practicing/my-studio-stool" rel="alternate" type="text/html" title="My Studio Stool" /><published>2025-11-30T16:00:26+00:00</published><updated>2025-11-30T16:00:26+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/my-studio-stool</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/my-studio-stool"><![CDATA[<p>I bought the instruction zine for <a href="https://www.manual.is/the-studio-stool">The Studio Stool from Manual</a> months ago and then cut up some 3/4” plywood sometime this summer and finally reorganized enough to finish it yesterday.</p>

<p><img src="/assets/images/fb89850e-8d79-4e0f-9e85-712ab5878942.jpeg" alt="The finished studio stool in situ" /></p>

<p>But I didn’t make mine exactly to pattern. I wanted to make mind a bit “my own” and so I simplified the dimensions and complicated the fastening to eliminate the offset.<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> To fasten without an offset, I found some 1/4” bamboo garden stakes and cut them into 2” dowel rods. I drilled holes and then glued the dowels in place to hold the whole assembly through and through.</p>

<p><img src="/assets/images/00fe32db-3f5a-466f-93a7-c32c1364c023.jpeg" alt="Adding my own math and calculations to the instruction sheet" /></p>

<p><img src="/assets/images/b59fcfbb-e321-4e94-af52-8c8a067d72d0.jpeg" alt="Detail of the plywood endgrain" /></p>

<p>I used <a href="https://www.youtube.com/watch?v=pVxldyIa0Bg">3/4” A/C birch plywood</a> for mine because I love the endgrain it brings to a sandwich alignment like this. I finished mine with boiled linseed oil (as I do with most wood these days) to really bring out the whorls and grain.</p>

<p><img src="/assets/images/cd70276d-4136-4e77-908c-81f89469e667.jpeg" alt="The deep amber of the birch after finishing with linseed oil is lovely" /></p>

<p>I don’t have a table saw - only my jigsaw - so the cuts aren’t straight on this, but the seat feels perfectly firm and stable.</p>

<h2 id="future">Future</h2>

<p>I’ve long wanted to build my own <a href="https://piperhaywood.com/rietveld-stool/">Rietveld-esque crate stool / table (written up by Piper Haywood)</a>, so maybe that will be my next build. The whole idea of <a href="https://piperhaywood.com/rietveld-brigham-didion/?__readwiseLocation=">crate furniture</a> obviously appeals to me: so functional and efficient and customizable to your own comfort.</p>

<p>Another suggestion from a colleague was <a href="https://www.youtube.com/watch?v=fKYRfSpSBEM">to make a traditional Japanese tool box</a>.</p>

<p>More crate furniture (or “box furniture”) reading/watching:</p>
<ul>
  <li><a href="https://brimstonesandtreacle.wordpress.com/2012/02/10/reclaiming-simplicity-thrift-and-utility/?__readwiseLocation=">Reclaiming simplicity, thrift, and utility</a></li>
  <li><a href="https://www.joshbeckman.org/notes/01k2g8ygfag276yj0tw5bb3b4z">Tom Sachs speaking at IKEA: My Personal Journey with Chairs</a></li>
  <li><a href="https://judd.furniture/">Donald Judd Furniture</a>
    <ul>
      <li>I’d love to make a <a href="https://judd.furniture/furniture/double-back-bench-20/">Double Back Bench 20</a></li>
    </ul>
  </li>
  <li><a href="https://www.core77.com/posts/42562/nomadic-furniture-diy-designs-from-the-1970s">“Nomadic Furniture:” DIY Designs from the 1970s</a></li>
  <li>Another I want to make many of: <a href="https://hartzivmoebel.blogspot.com/p/berliner-hocker.html">Berlin Hocker stool</a> (stackable and composable into so many things!)</li>
  <li>Another I want to make: <a href="https://rietveldoriginals.com/en/products/steltman-chair#product-details">Steltman chair</a></li>
</ul>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>I forgot to accommodate the extra 3/4” on the two cross beams, so I found a way to gracefully improvise. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="building" /><category term="folk-creations" /><summary type="html"><![CDATA[I bought the instruction zine for The Studio Stool from Manual months ago and then cut up some 3/4” plywood sometime this summer and finally reorganized enough to finish it yesterday.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/fb89850e-8d79-4e0f-9e85-712ab5878942.jpeg" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/fb89850e-8d79-4e0f-9e85-712ab5878942.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">A Digital Two-Sentence Journal</title><link href="https://www.joshbeckman.org/blog/practicing/a-digital-twosentence-journal" rel="alternate" type="text/html" title="A Digital Two-Sentence Journal" /><published>2025-11-19T14:06:34+00:00</published><updated>2025-11-19T14:06:34+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/a-digital-twosentence-journal</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/a-digital-twosentence-journal"><![CDATA[<p>When Marybeth and I got married, I bought us a <a href="https://www.leuchtturm1917.us/some-lines-a-day.html">Some Lines A Day notebook from LEUCHTTURM1917</a> and we started filling it with a daily note (one sentence from each of us) on our favorite part of our day. We had been asking each other this nightly for years, and it was a nice physical structure to keep and reflect on those moments. The structure of this notebook is such that each page is a day of the year, with sections for 5 years on that day. By writing any one day, you read through that day on prior years.</p>

<p><img src="/assets/images/7521025c-92a3-4a6d-8c06-9abcd751fdd9.jpeg" alt="The autumn leaves force contemplation on us all, I think." /></p>

<p>Recently I found <a href="https://alexanderbjoy.com/two-sentence-journal/">the two-sentence journal via De minimis non curat Lex</a>. In it, Alexander describes how he’s adapted a game mechanic from <em>Thousand Year Old Vampire</em> for his own journaling. In the game, your vampire has finite memory, composed of experiences that you have written down during the game (and as the centuries pass, you must choose which aspects of your vampire’s life to retain or forget).</p>

<blockquote>
  <p>An Experience should be a single evocative sentence. An Experience is the distillation of an event—a single sentence that combines what happened and why it matters to your vampire. A good format for an Experience is “[description of the event]; [how I feel or what I did about it].”</p>
</blockquote>

<blockquote>
  <p>As for how an experience might be written, the rulebook offers the following example:</p>
  <blockquote>
    <p>Stalking the deserts over lonely years, I watch generations of Christian knights waste themselves on the swords of the Saracen; it’s a certainty that Charles is among them—I dream of his touch as I sleep beneath the burning sand.</p>
  </blockquote>
</blockquote>

<p>Alexander took this into a journaling habit of his own:</p>

<blockquote>
  <p>I would aim to constrain each day’s entry to one or two key things, and limit their expression to one or two sentences. I allowed myself that latter tweak because the initial Thousand Year Old Vampire rule encourages improper semicolon usage and ugly run-on sentences, both of which strain language in hopes of cramming in as much as possible.</p>
</blockquote>

<p>I fully agree with Alexander on loosening the constraint to two sentences; it’s much more beautiful that way. Two sentences allow narrative arcs, short stories, and juxtaposition to form. But you still can’t ramble and writing a sentence or two is always easy.</p>

<h2 id="digital-form">Digital Form</h2>

<p>Inspired by this two-sentence journal and the physical structure of our <em>Some Lines A Day</em> notebook, I wanted to make a digital space for both.</p>

<p>So, I wrote a little command line program, <code class="language-plaintext highlighter-rouge">journal</code>, that will set up the structure of a daily, annualized journal in a markdown file, opening it to today. Then you write your one or two sentences and close it.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>

<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="nv">JOURNAL_FILE</span><span class="o">=</span><span class="s2">"JOURNAL.md"</span>
<span class="nv">CURRENT_DATE</span><span class="o">=</span><span class="si">$(</span><span class="nb">date</span> +<span class="s2">"%B-%d"</span><span class="si">)</span>
<span class="nv">CURRENT_YEAR</span><span class="o">=</span><span class="si">$(</span><span class="nb">date</span> +<span class="s2">"%Y"</span><span class="si">)</span>

<span class="k">if</span> <span class="o">[[</span> <span class="o">!</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$JOURNAL_FILE</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
    <span class="o">{</span>
        <span class="nb">echo</span> <span class="s2">"# Journal"</span>
        <span class="nb">echo</span> <span class="s2">""</span>
        <span class="k">for </span>month <span class="k">in</span> <span class="o">{</span>1..12<span class="o">}</span><span class="p">;</span> <span class="k">do
            </span><span class="nv">days_in_month</span><span class="o">=</span><span class="si">$(</span>cal <span class="nv">$month</span> <span class="nv">$CURRENT_YEAR</span> | <span class="nb">awk</span> <span class="s1">'NF {DAYS = $NF} END {print DAYS}'</span><span class="si">)</span>

            <span class="k">for </span>day <span class="k">in</span> <span class="si">$(</span><span class="nb">seq </span>1 <span class="nv">$days_in_month</span><span class="si">)</span><span class="p">;</span> <span class="k">do
                </span><span class="nv">month_padded</span><span class="o">=</span><span class="si">$(</span><span class="nb">printf</span> <span class="s2">"%02d"</span> <span class="nv">$month</span><span class="si">)</span>
                <span class="nv">day_padded</span><span class="o">=</span><span class="si">$(</span><span class="nb">printf</span> <span class="s2">"%02d"</span> <span class="nv">$day</span><span class="si">)</span>
                <span class="nv">day_date</span><span class="o">=</span><span class="si">$(</span><span class="nb">date</span> <span class="nt">-j</span> <span class="nt">-f</span> <span class="s2">"%Y-%m-%d"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">CURRENT_YEAR</span><span class="k">}</span><span class="s2">-</span><span class="k">${</span><span class="nv">month_padded</span><span class="k">}</span><span class="s2">-</span><span class="k">${</span><span class="nv">day_padded</span><span class="k">}</span><span class="s2">"</span> <span class="s2">"+%B-%d"</span> 2&gt;/dev/null<span class="si">)</span>

                <span class="nb">echo</span> <span class="s2">"## </span><span class="nv">$day_date</span><span class="s2">"</span>
                <span class="nb">echo</span> <span class="s2">""</span>
            <span class="k">done
        done</span>
    <span class="o">}</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$JOURNAL_FILE</span><span class="s2">"</span>
<span class="k">fi

if</span> <span class="o">!</span> <span class="nb">awk</span> <span class="nt">-v</span> <span class="nb">date</span><span class="o">=</span><span class="s2">"</span><span class="nv">$CURRENT_DATE</span><span class="s2">"</span> <span class="nt">-v</span> <span class="nv">year</span><span class="o">=</span><span class="s2">"</span><span class="nv">$CURRENT_YEAR</span><span class="s2">"</span> <span class="s1">'
    /^## / { in_day = 0 }
    $0 ~ "^## " date "$" { in_day = 1 }
    in_day &amp;&amp; /^### / &amp;&amp; $2 == year { found = 1; exit }
    END { exit !found }
'</span> <span class="s2">"</span><span class="nv">$JOURNAL_FILE</span><span class="s2">"</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">awk</span> <span class="nt">-v</span> <span class="nb">date</span><span class="o">=</span><span class="s2">"</span><span class="nv">$CURRENT_DATE</span><span class="s2">"</span> <span class="nt">-v</span> <span class="nv">year</span><span class="o">=</span><span class="s2">"</span><span class="nv">$CURRENT_YEAR</span><span class="s2">"</span> <span class="s1">'
        /^## / {
            if (in_day &amp;&amp; !year_added) {
                print ""
            }
            in_day = 0
            year_added = 0
        }
        $0 ~ "^## " date "$" {
            in_day = 1
            print
            print ""
            print "### " year
            year_added = 1
            next
        }
        { print }
    '</span> <span class="s2">"</span><span class="nv">$JOURNAL_FILE</span><span class="s2">"</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="k">${</span><span class="nv">JOURNAL_FILE</span><span class="k">}</span><span class="s2">.tmp"</span>
    <span class="nb">mv</span> <span class="s2">"</span><span class="k">${</span><span class="nv">JOURNAL_FILE</span><span class="k">}</span><span class="s2">.tmp"</span> <span class="s2">"</span><span class="nv">$JOURNAL_FILE</span><span class="s2">"</span>
<span class="k">fi

</span><span class="nv">LINE_NUM</span><span class="o">=</span><span class="si">$(</span><span class="nb">awk</span> <span class="nt">-v</span> <span class="nb">date</span><span class="o">=</span><span class="s2">"</span><span class="nv">$CURRENT_DATE</span><span class="s2">"</span> <span class="nt">-v</span> <span class="nv">year</span><span class="o">=</span><span class="s2">"</span><span class="nv">$CURRENT_YEAR</span><span class="s2">"</span> <span class="s1">'
    /^## / { in_day = 0 }
    $0 ~ "^## " date "$" { in_day = 1 }
    in_day &amp;&amp; /^### / &amp;&amp; $2 == year { print NR; exit }
'</span> <span class="s2">"</span><span class="nv">$JOURNAL_FILE</span><span class="s2">"</span><span class="si">)</span>

<span class="k">if</span> <span class="o">[[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$LINE_NUM</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
    </span><span class="nv">LINE_NUM</span><span class="o">=</span><span class="si">$(</span><span class="nb">grep</span> <span class="nt">-n</span> <span class="s2">"^## </span><span class="nv">$CURRENT_DATE</span><span class="s2">$"</span> <span class="s2">"</span><span class="nv">$JOURNAL_FILE</span><span class="s2">"</span> | <span class="nb">cut</span> <span class="nt">-d</span>: <span class="nt">-f1</span><span class="si">)</span>
<span class="k">fi

if</span> <span class="o">[[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$LINE_NUM</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
    </span>nvim <span class="s2">"+</span><span class="k">${</span><span class="nv">LINE_NUM</span><span class="k">}</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$JOURNAL_FILE</span><span class="s2">"</span>
<span class="k">else
    </span>nvim <span class="s2">"</span><span class="nv">$JOURNAL_FILE</span><span class="s2">"</span>
<span class="k">fi</span>
</code></pre></div></div>

<p>I won’t be switching all my journaling to this digital form (in fact, I just bought another couple <em>Some Lines A Day</em> journals for myself and others), but I want to start a daily journal like this for work. For work notes, I only use digital storage; I can share them with colleagues and basically everything I do for work happens digitally.</p>

<h2 id="memory">Memory</h2>

<p>In the game, your vampire holds five “Memories,” which each have room for three “Experiences.”</p>

<blockquote>
  <p>Memories and Experiences are important moments that have shaped your vampire, crystallized in writing. They make up the core of the vampire’s self—the things they know and care about. An Experience is a particular event; a Memory is an arc of Experiences that are tied together by subject or theme.</p>
</blockquote>

<p>I actually love this idea of constrained context. It feels like a great exercise to refocus on a structured narrative, to guide yourself into the shape you want to take. I haven’t figured out how to adopt this secondary structure into this program, yet.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="writing" /><category term="games" /><category term="note-taking" /><category term="code-snippets" /><summary type="html"><![CDATA[A vampire's journal and a physical notebook inspired me to write a program to store my experiences]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/7521025c-92a3-4a6d-8c06-9abcd751fdd9.jpeg" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/7521025c-92a3-4a6d-8c06-9abcd751fdd9.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Contributing to Open-Source Should be Required, Like Jury Duty</title><link href="https://www.joshbeckman.org/blog/practicing/contributing-to-opensource-should-be-required-like-jury-duty" rel="alternate" type="text/html" title="Contributing to Open-Source Should be Required, Like Jury Duty" /><published>2025-11-11T16:31:52+00:00</published><updated>2025-11-11T16:31:52+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/contributing-to-opensource-should-be-required-like-jury-duty</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/contributing-to-opensource-should-be-required-like-jury-duty"><![CDATA[<p>A note I found in my journal, from seven years ago, on the day I was summoned to participate in a jury of my peers:</p>

<blockquote>
  <p>I have to go to jury duty as a member of this democracy. what if I had a summons to contribute to open source software because I use FOOS?</p>
</blockquote>

<p>I was <em>so</em> happy to go spend a day exercising my membership in this hallmark of democracy: a participation requirement in return for the benefits I enjoy under due process of the law. Ultimately, I was dismissed because of my connection with the law (several friends and family members practice law).</p>

<p>But it got me dreaming of a world where software developers, having enjoyed the benefits of open-source software and libraries and tools, having built their fortunes on the shared work of the community, were then summoned regularly to use their knowledge and talents to maintain and improve that shared resource.</p>

<p>See, previously:</p>
<ul>
  <li><a href="https://www.joshbeckman.org/blog/contribute-to-open-source-as-a-code-test">Contribute to Open Source as a Code Test</a></li>
  <li><a href="https://www.joshbeckman.org/blog/good-first-issue-is-a-gift">Good First Issues Are Gifts</a></li>
  <li><a href="https://www.joshbeckman.org/blog/reading/after-reading-working-in-public">After Reading: Working in Public</a></li>
</ul>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="open-source" /><category term="government" /><category term="popular" /><summary type="html"><![CDATA[A note I found in my journal, from seven years ago, on the day I was summoned to participate in a jury of my peers:]]></summary></entry><entry><title type="html">Word Count Bookmarklet</title><link href="https://www.joshbeckman.org/blog/practicing/word-count-bookmarklet" rel="alternate" type="text/html" title="Word Count Bookmarklet" /><published>2025-11-11T15:25:24+00:00</published><updated>2025-11-11T15:25:24+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/word-count-bookmarklet</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/word-count-bookmarklet"><![CDATA[<p>Inspired by <a href="https://www.lesswrong.com/posts/8G24bWJ5nE2QjCGAb/see-your-word-count-while-you-write">See Your Word Count While You Write from dreeves</a>, I whipped up my own word count bookmarklet.</p>

<p>My version:</p>
<ul>
  <li>displays the word count on the <code class="language-plaintext highlighter-rouge">textarea</code> when you focus it</li>
  <li>updates the count as you type</li>
</ul>

<blockquote class="markdown-alert markdown-alert-tip">
  <p><strong class="markdown-alert-title"><svg width="16" height="16" class="octicon octicon-light-bulb mr-2" aria-hidden="true" viewBox="0 0 16 16" version="1.1"><path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>Tip</strong></p>

  <p>Drag this bookmarklet to your bookmarks bar:<br />
<a href="javascript:(function(){document.addEventListener('focusin',(e)=&gt;{if(e.target.tagName==='TEXTAREA'){const t=e.target;if(t.dataset.hasCounter)return;t.dataset.hasCounter='true';const o=t.style.position;if(!o||o==='static')t.style.position='relative';const c=document.createElement('div');c.style.cssText='position:absolute;bottom:4px;right:8px;font-size:11px;color:#666;pointer-events:none;background:rgba(255,255,255,0.9);padding:2px 6px;border-radius:3px;font-family:monospace;z-index:1';t.parentElement.insertBefore(c,t.nextSibling);let r=null;const u=()=&gt;{const txt=t.value.trim();const w=txt?txt.split(/\s+/).length:0;c.textContent=`${w} word${w!==1?'s':''}`;};t.addEventListener('input',()=&gt;{if(r)cancelAnimationFrame(r);r=requestAnimationFrame(u);});u();t.addEventListener('blur',()=&gt;{setTimeout(()=&gt;{if(document.activeElement!==t){c.remove();if(!o||o==='static')t.style.position='';delete t.dataset.hasCounter;}},100);});}});})();" class="bookmarklet">Word Counter</a></p>
</blockquote>
<p>How to use:</p>

<ol>
  <li>Drag the link above to your bookmarks bar</li>
  <li>Click it on any webpage</li>
  <li>Focus on any textarea to see a live word count</li>
</ol>

<p><img width="448" height="227" alt="Bookmarklet for word count, in action" src="/assets/images/8736b73f-6fd5-4e5d-a3d3-216654c9d160.png" /></p>

<p>Full code:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">document</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">focusin</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">tagName</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">TEXTAREA</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">textarea</span> <span class="o">=</span> <span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">;</span>
    
    <span class="k">if </span><span class="p">(</span><span class="nx">textarea</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">hasCounter</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
    <span class="nx">textarea</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">hasCounter</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">true</span><span class="dl">'</span><span class="p">;</span>
    
    <span class="kd">const</span> <span class="nx">originalPosition</span> <span class="o">=</span> <span class="nx">textarea</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">position</span><span class="p">;</span>
    
    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">originalPosition</span> <span class="o">||</span> <span class="nx">originalPosition</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">static</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">textarea</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">position</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">relative</span><span class="dl">'</span><span class="p">;</span>
    <span class="p">}</span>
    
    <span class="kd">const</span> <span class="nx">counter</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">createElement</span><span class="p">(</span><span class="dl">'</span><span class="s1">div</span><span class="dl">'</span><span class="p">);</span>
    <span class="nx">counter</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">cssText</span> <span class="o">=</span> <span class="s2">`
      position: absolute;
      bottom: 4px;
      right: 8px;
      font-size: 11px;
      color: #666;
      pointer-events: none;
      background: rgba(255, 255, 255, 0.9);
      padding: 2px 6px;
      border-radius: 3px;
      font-family: monospace;
      z-index: 1;
    `</span><span class="p">;</span>
    
    <span class="nx">textarea</span><span class="p">.</span><span class="nx">parentElement</span><span class="p">.</span><span class="nf">insertBefore</span><span class="p">(</span><span class="nx">counter</span><span class="p">,</span> <span class="nx">textarea</span><span class="p">.</span><span class="nx">nextSibling</span><span class="p">);</span>
    
    <span class="kd">let</span> <span class="nx">rafId</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>
    <span class="kd">const</span> <span class="nx">updateCount</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">text</span> <span class="o">=</span> <span class="nx">textarea</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nf">trim</span><span class="p">();</span>
      <span class="kd">const</span> <span class="nx">words</span> <span class="o">=</span> <span class="nx">text</span> <span class="p">?</span> <span class="nx">text</span><span class="p">.</span><span class="nf">split</span><span class="p">(</span><span class="sr">/</span><span class="se">\s</span><span class="sr">+/</span><span class="p">).</span><span class="nx">length</span> <span class="p">:</span> <span class="mi">0</span><span class="p">;</span>
      <span class="nx">counter</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">words</span><span class="p">}</span><span class="s2"> word</span><span class="p">${</span><span class="nx">words</span> <span class="o">!==</span> <span class="mi">1</span> <span class="p">?</span> <span class="dl">'</span><span class="s1">s</span><span class="dl">'</span> <span class="p">:</span> <span class="dl">''</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
    <span class="p">};</span>
    
    <span class="nx">textarea</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">input</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="k">if </span><span class="p">(</span><span class="nx">rafId</span><span class="p">)</span> <span class="nf">cancelAnimationFrame</span><span class="p">(</span><span class="nx">rafId</span><span class="p">);</span>
      <span class="nx">rafId</span> <span class="o">=</span> <span class="nf">requestAnimationFrame</span><span class="p">(</span><span class="nx">updateCount</span><span class="p">);</span>
    <span class="p">});</span>
    
    <span class="nf">updateCount</span><span class="p">();</span>
    
    <span class="nx">textarea</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">blur</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="nf">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="k">if </span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">activeElement</span> <span class="o">!==</span> <span class="nx">textarea</span><span class="p">)</span> <span class="p">{</span>
          <span class="nx">counter</span><span class="p">.</span><span class="nf">remove</span><span class="p">();</span>
          <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">originalPosition</span> <span class="o">||</span> <span class="nx">originalPosition</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">static</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
            <span class="nx">textarea</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">position</span> <span class="o">=</span> <span class="dl">''</span><span class="p">;</span>
          <span class="p">}</span>
          <span class="k">delete</span> <span class="nx">textarea</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">hasCounter</span><span class="p">;</span>
        <span class="p">}</span>
      <span class="p">},</span> <span class="mi">100</span><span class="p">);</span>
    <span class="p">});</span>
  <span class="p">}</span>
<span class="p">});</span>
</code></pre></div></div>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="language-javascript" /><category term="code-snippets" /><category term="writing" /><summary type="html"><![CDATA[Inspired by See Your Word Count While You Write from dreeves, I whipped up my own word count bookmarklet.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/8736b73f-6fd5-4e5d-a3d3-216654c9d160.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/8736b73f-6fd5-4e5d-a3d3-216654c9d160.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Making MCP Tool Calls Scriptable with mcp_cli</title><link href="https://www.joshbeckman.org/blog/practicing/making-mcp-tool-calls-scriptable-with-mcpcli" rel="alternate" type="text/html" title="Making MCP Tool Calls Scriptable with mcp_cli" /><published>2025-11-06T14:15:22+00:00</published><updated>2025-11-06T14:15:22+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/making-mcp-tool-calls-scriptable-with-mcpcli</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/making-mcp-tool-calls-scriptable-with-mcpcli"><![CDATA[<p><img src="/assets/images/f4041c35-1df5-42a4-ac64-2a2ac880ee48.jpeg" alt="The fall colors in my yard are dazzling me this week" /></p>

<p>I will often explore a solution or script with an LLM agent (e.g. Claude Code) for a while. I’ll iterate on ways to extract data from the current codebase and use that to call MCP server tools, eventually getting to something I’d like to run regularly.</p>

<p>For example, I might want to pull all the recent errors for the application (queried from logs exposed by MCP server tools) and correlate them to find problematic areas of the codebase. In exploring this, the LLM agent can get me to an answer, but the program of getting there is locked and woven away inside the conversation history with the agent.</p>

<p>Increasingly, I’ve been wanting to extract a final executable script from conversations like these, so I can run them again later without needing to re-engage the LLM agent. I’ve found that asking the agent to produce a final script at the end of the conversation is often unsatisfactory, as it may not capture all the nuances of the exploration and often key data or functionality is hidden away behind an MCP server tool call that the agent made during the conversation. As I’ve written about <a href="https://www.joshbeckman.org/blog/practicing/the-hidden-cost-of-humancentric-tools-in-llm-workflows">the hidden cost of human-centric tools in LLM workflows</a>, we’re constantly reconsidering the tools offered to LLM agents and finding ways to make them more efficient.</p>

<p>This is where tooling can bridge the gap.</p>

<blockquote class="markdown-alert markdown-alert-note">
  <p><strong class="markdown-alert-title"><svg width="16" height="16" class="octicon octicon-info mr-2" aria-hidden="true" viewBox="0 0 16 16" version="1.1"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</strong></p>

  <p><a href="https://modelcontextprotocol.io/">MCP (Model Context Protocol)</a> is a standard for connecting LLM agents to external data sources and tools. MCP servers expose “tools” that agents can call - like searching your notes, querying logs, sending emails, etc. When you use Claude Code or Cursor, they’re calling MCP server tools behind the scenes.</p>
</blockquote>
<h2 id="the-mcp_cli-tool">The <code class="language-plaintext highlighter-rouge">mcp_cli</code> Tool</h2>

<p>To better support scripting over MCP server tools like this, I’ve open-sourced a tool I’ve been using: <a href="https://github.com/joshbeckman/mcp_cli"><code class="language-plaintext highlighter-rouge">mcp_cli</code></a>. It’s simply a CLI for calling and interacting with MCP (Model Context Protocol) servers.</p>

<p>You can use <code class="language-plaintext highlighter-rouge">mcp_cli</code> to call MCP server tools directly from the command line, passing in inputs and receiving outputs in a structured way. This allows you to extract the core logic of your LLM agent explorations into standalone scripts that can be run independently.</p>

<p>For example, instead of asking an LLM agent “search my notes for posts about MCP”, you can now run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gem <span class="nb">exec </span>mcp_cli call josh-notes search_posts <span class="se">\</span>
  <span class="nt">--query</span> <span class="s2">"MCP"</span> <span class="se">\</span>
  <span class="nt">--limit</span> 5 | jq <span class="s1">'.[] | .text'</span>
</code></pre></div></div>

<p>This returns structured JSON that you can pipe to other tools like <code class="language-plaintext highlighter-rouge">jq</code> to extract just the text content, save to a file, or process in a script.</p>

<p>You can use <code class="language-plaintext highlighter-rouge">mcp_cli</code> to:</p>
<ul>
  <li>Call MCP server tools directly from the command line</li>
  <li>Explore and test MCP server tools and prompts without needing an LLM agent</li>
  <li>Extract and save the logic of your LLM agent explorations into reusable scripts</li>
  <li>Automate regular tasks by scripting MCP server tool calls</li>
  <li>Build <a href="https://www.joshbeckman.org/notes/487680878">your own jigs</a> and script for LLM agents to use instead of relying on the agent to figure out MCP server tool sequences</li>
</ul>

<blockquote class="markdown-alert markdown-alert-note">
  <p><strong class="markdown-alert-title"><svg width="16" height="16" class="octicon octicon-info mr-2" aria-hidden="true" viewBox="0 0 16 16" version="1.1"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</strong></p>

  <p><a href="https://github.com/joshbeckman/mcp_cli/blob/main/README.md#quick-start-no-installation">Read the <code class="language-plaintext highlighter-rouge">mcp_cli</code> README for installation and usage instructions</a> and usage examples. But the simplest way to get started is to use <code class="language-plaintext highlighter-rouge">gem exec</code> (no installation required): <code class="language-plaintext highlighter-rouge">gem exec mcp_cli --help</code>.</p>
</blockquote>
<p>This tool builds on two of my recent enthusiasms: <a href="https://www.joshbeckman.org/blog/practicing/dont-forget-remote-mcp-servers-are-just-curl-calls">MCP tool calls are just cURL calls</a>, and <a href="https://www.joshbeckman.org/blog/practicing/the-gem-exec-command-gives-me-hope-for-ruby-in-a-world-of-fast-software">the <code class="language-plaintext highlighter-rouge">gem exec</code> pattern for fast distribution of Ruby CLI tools</a>.</p>

<p>It’s trivial to expose MCP server tools to any program because most can be translated into a simple HTTP request or Bash command. That’s essentially what <code class="language-plaintext highlighter-rouge">mcp_cli</code> does, along with some niceties around detecting MCP configuration you’ve set up for major LLM agent/editor platforms (Claude, Cursor, etc). And it can do it with <em>zero dependencies</em> beyond Ruby itself.</p>

<p>You <em>could</em> just use <code class="language-plaintext highlighter-rouge">cURL</code> directly to call MCP server tools, but <code class="language-plaintext highlighter-rouge">mcp_cli</code> makes it quite a bit more user-friendly and easier to integrate into scripts. It also makes it trivial to explore MCP server tools interactively from the command line, which is great for prototyping and testing.</p>

<p>The <code class="language-plaintext highlighter-rouge">gem exec</code> pattern means you can run <code class="language-plaintext highlighter-rouge">mcp_cli</code> without installation - just like <code class="language-plaintext highlighter-rouge">npx</code> or <code class="language-plaintext highlighter-rouge">uvx</code>. In this new world of fast-software where LLM agents generate and run code on the fly, I want Ruby to compete with Python and JavaScript for instant execution. Ruby remains a lovely language for building succinct, readable software, and <code class="language-plaintext highlighter-rouge">gem exec</code> gives it the same distribution speed.</p>

<h2 id="the-dream">The Dream</h2>

<p>I keep daydreaming of a near future where I have a lengthy conversation with an LLM agent to explore a solution, and at the end of it, I can say “export this as a script” and get back a fully functioning script that uses <code class="language-plaintext highlighter-rouge">mcp_cli</code> calls to replicate the logic we explored together. This script could then be run independently, scheduled to run regularly, or even shared with others to use. It’s a jig that guarantees the same behavior as the LLM agent exploration, but without needing to re-engage the agent each time. It’s faster, more efficient, and more reliable.</p>

<p>This directly enables what Anthropic recently described in <a href="https://www.anthropic.com/engineering/code-execution-with-mcp">code execution with MCP</a>: combining multiple MCP tool calls into scripts to reduce context usage, latency, and cost. Where they show TypeScript examples requiring execution infrastructure, <code class="language-plaintext highlighter-rouge">mcp_cli</code> makes this immediately practical with bash pipes and standard CLI patterns. An agent can compose multiple MCP operations into a single script that processes data through pipes rather than through the model’s context window - achieving the same efficiency gains without additional infrastructure.</p>

<p>For example, Anthropic shows fetching a document from Google Drive and attaching it to a Salesforce record. With <code class="language-plaintext highlighter-rouge">mcp_cli</code>, that becomes:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Fetch transcript and pipe directly to Salesforce without going through model context</span>
gem <span class="nb">exec </span>mcp_cli call gdrive getDocument <span class="nt">--documentId</span> <span class="s2">"abc123"</span> <span class="se">\</span>
  | jq <span class="nt">-r</span> <span class="s1">'.content'</span> <span class="se">\</span>
  | gem <span class="nb">exec </span>mcp_cli call salesforce updateRecord <span class="se">\</span>
      <span class="nt">--objectType</span> <span class="s2">"SalesMeeting"</span> <span class="se">\</span>
      <span class="nt">--recordId</span> <span class="s2">"00Q5f000001abcXYZ"</span> <span class="se">\</span>
      <span class="nt">--data</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">cat</span> -<span class="si">)</span><span class="s2">"</span>
</code></pre></div></div>

<p>The full transcript flows through the pipe, never entering the model’s context window. For a 2-hour meeting transcript (potentially 50,000 tokens), this approach saves those tokens from being processed twice.</p>

<p>Here’s another contrived example:</p>

<p><strong>Before:</strong> A 20-message conversation with Claude Code:</p>
<blockquote>
  <p>“Can you find all my blog posts about MCP from the last month?”
<em>Claude searches notes, filters by date, formats results…</em>
“Now check which ones mention security concerns”
<em>Claude filters further…</em>
“Great, now send me a summary via email”
<em>Claude composes and sends…</em></p>
</blockquote>

<p><strong>After:</strong> A 5-line bash script I can run anytime:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
gem <span class="nb">exec </span>mcp_cli call josh-notes search_posts <span class="se">\</span>
  <span class="nt">--query</span> <span class="s2">"MCP"</span> <span class="nt">--category</span> <span class="s2">"blog"</span> <span class="nt">--startDate</span> <span class="s2">"2024-10-01"</span> <span class="se">\</span>
  | jq <span class="s1">'.[] | select(.text | contains("security")) | .text'</span> <span class="se">\</span>
  | gem <span class="nb">exec </span>mcp_cli call josh-beckman-status send_email_to_josh <span class="se">\</span>
      <span class="nt">--subject</span> <span class="s2">"MCP Security Posts"</span> <span class="nt">--body</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">cat</span> -<span class="si">)</span><span class="s2">"</span> <span class="nt">--from</span> <span class="s2">"mcp_script"</span>
</code></pre></div></div>

<p>That’s what I’m working on next.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="llm" /><category term="tools" /><category term="open-source" /><category term="automation" /><category term="language-ruby" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/f4041c35-1df5-42a4-ac64-2a2ac880ee48.jpeg" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/f4041c35-1df5-42a4-ac64-2a2ac880ee48.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">SYSTEMS AND METHODS FOR SELECTIVELY EXECUTING USER-GENERATED LOGIC</title><link href="https://www.joshbeckman.org/blog/practicing/systems-and-methods-for-selectively-executing-usergenerated-logic" rel="alternate" type="text/html" title="SYSTEMS AND METHODS FOR SELECTIVELY EXECUTING USER-GENERATED LOGIC" /><published>2025-10-30T14:32:39+00:00</published><updated>2025-10-30T14:32:39+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/systems-and-methods-for-selectively-executing-usergenerated-logic</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/systems-and-methods-for-selectively-executing-usergenerated-logic"><![CDATA[<p>Our (me and some of my team members at Shopify) U.S. patent application (number 10677, with the menacing all-caps title “SYSTEMS AND METHODS FOR SELECTIVELY EXECUTING USER-GENERATED LOGIC”) was published today. It may be several years before the application grants. This has taken a <em>long</em> time but it’s been interesting to see the machinery of patents at work in a large company.</p>

<p><img height="571" alt="Sketches in patents are fun to peruse. Sketches of software less so." src="/assets/images/a83005c6-32d9-4007-8f09-2e8d4cd0847a.png" /></p>

<p>Shopify’s (rough (this isn’t speaking for my employer)) stance on patents is that they are net-negative but it’s best to file patents for things that are valuable, while patent law is still enforced. This protects you from being sued by patent tolls; you don’t have to use them as weapons against others. I like that stance, for a big company like Shopify.</p>

<p>The patent application is available on <a href="https://ppubs.uspto.gov/pubwebapp/static/pages/ppubsbasic.html">the UPSTO public search</a> if you search for <code class="language-plaintext highlighter-rouge">20250335250</code>.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="shopify" /><category term="united-states" /><summary type="html"><![CDATA[Our (me and some of my team members at Shopify) U.S. patent application (number 10677, with the menacing all-caps title “SYSTEMS AND METHODS FOR SELECTIVELY EXECUTING USER-GENERATED LOGIC”) was published today. It may be several years before the application grants. This has taken a long time but it’s been interesting to see the machinery of patents at work in a large company.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/a83005c6-32d9-4007-8f09-2e8d4cd0847a.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/a83005c6-32d9-4007-8f09-2e8d4cd0847a.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Earning the Right to Be Illegible</title><link href="https://www.joshbeckman.org/blog/practicing/earning-the-right-to-be-illegible" rel="alternate" type="text/html" title="Earning the Right to Be Illegible" /><published>2025-10-12T14:41:06+00:00</published><updated>2025-10-12T14:41:06+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/earning-the-right-to-be-illegible</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/earning-the-right-to-be-illegible"><![CDATA[<p><a href="https://www.seangoedecke.com/seeing-like-a-software-company/">Seeing like a software company</a> is the best writing about large-company software engineering I’ve read in quite a while.</p>

<p>In it, Goedecke maps the concepts of <em><a href="https://en.wikipedia.org/wiki/Seeing_Like_a_State">Seeing Like A State</a></em> into a corporate atmosphere:</p>

<blockquote>
  <ol>
    <li>Modern organizations exert control by maximising “legibility”: by altering the system so that all parts of it can be measured, reported on, and so on.</li>
    <li>However, these organizations are dependent on a huge amount of “illegible” work: work that cannot be tracked or planned for, but is nonetheless essential.</li>
    <li>Increasing legibility thus often actually lowers efficiency - but the other benefits are high enough that organizations are typically willing to do so regardless.</li>
  </ol>

  <p>By “legible”, I mean work that is predictable, well-estimated, has a paper trail, and doesn’t depend on any contingent factors (like the availability of specific people). Quarterly planning, OKRs, and Jira all exist to make work legible. Illegible work is everything else […]</p>
</blockquote>

<p>And I tend to think the need for legibility is <em>essentially</em> a <a href="https://news.ycombinator.com/item?id=45510656">need for internal control</a>. It is a known fact that many people in the organization cannot be trusted - this is why the rules of legibility exist. The paradox is that the engineers who complain most loudly about Jira and planning ceremonies are often the ones with the <em>least</em> illegibility budget. They haven’t earned the right to skip the process by first proving they can execute within it flawlessly.</p>

<blockquote class="markdown-alert markdown-alert-tip">
  <p><strong class="markdown-alert-title"><svg width="16" height="16" class="octicon octicon-light-bulb mr-2" aria-hidden="true" viewBox="0 0 16 16" version="1.1"><path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>Tip</strong></p>

  <p>If you are interested in this type of organizational-/systems-thinking, I <em>highly</em> recommend <a href="https://www.joshbeckman.org/blog/reading/after-re-reading-the-systems-bible">reading Systemantics - The Systems Bible</a>.</p>
</blockquote>
<p>And Goedecke even has <a href="https://www.seangoedecke.com/breaking-rules/">some tips on how to break those legibility rules</a>. But I think it’s important to understand how you, as an engineer in that system, can <em>earn</em> illegibility and how you should think about using that to your advantage.</p>

<p>I think you should always know the degree of illegibility that you are <em>consciously allowed</em> by your lead/organization and I think you should approach it like tending a fire or refilling a battery. You should be earning and spending your illegibility budget strategically.</p>

<p>The more senior<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> you get, the more illegible work you’re allowed/encouraged to do.</p>

<p>That’s because you are trusted much more by the organization as you gain a larger scope / get more senior. That’s essentially what a more senior role entails: trusting you to have wider effects with less oversight.</p>

<p>You earn trust by being legible and executing on things well. <a href="https://fs.blog/knowledge-project-podcast/tobi-lutke/#:~:text=Trust%20battery%20fits,each%20other%20feedback.">Shopify’s trust battery metaphor</a> applies here. You earn the right to be illegible by proving that you can be highly legible when necessary.</p>

<p>You also gain that trust by demonstrating that you <em>can</em> make illegible things legible, on demand or at your own will. You can do this by:</p>
<ol>
  <li>taking an illegible problem (a vague product request, a very high-level problem) and</li>
  <li>doing the research and reasoning and legwork to actually define the problem and</li>
  <li>finding a solution and then</li>
  <li><em>communicating that widely and clearly</em> to the rest of the organization</li>
</ol>

<p>For example: taking “users are frustrated by the latency of the app” and transforming it into “reducing p95 latency of these key operations by 2s by connection pooling and an API change.”</p>

<p>Or this could happen when your VP asks why you’ve been heads-down for the last week and you are able to legibly explain it <em>and they agree</em> with what you’ve been doing. If you demonstrate repeatedly that your illegibility is not a risk, but a speedup and asset, they will give you illegibility budget.</p>

<p>You need to ebb and flow into and out of legibility to get things done in any given month. Being successful as a senior lead is often about having great taste for when you should dip into illegibility reserves to move quickly and experiment and when you should resurface to distribute ideas legibly (building trust with those above your) and when you should embed yourself into the general teams’ legible processes temporarily (building trust with those below you). Spending too much time in illegible work will cause managers above you to question your direction, and never dipping into the wider legibility processes will cause engineers below you to discount your direction because you’re not playing fair.</p>

<p>Leadership is partly about legibly conveying your illegible findings/hunches. This is where I find the most value in being a senior crafter engineer: developing your taste and leaning on it to move quickly - illegibly - and then packing up your work into highly legible projects or features or strategy for the rest of the organization. Just don’t forget to recharge that battery after you’ve spent it.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>I’m going to kinda use the terms “more senior” and “higher career level” and “higher scope” interchangeably here. I don’t <em>like</em> the term “senior” because of the tenure connotation, but it conveys the career ladder level concept succinctly. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="management" /><category term="software-engineering" /><category term="communication" /><category term="leadership" /><summary type="html"><![CDATA[Seeing like a software company is the best writing about large-company software engineering I’ve read in quite a while.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/d09a6887-eafc-4037-ab7f-0ee489b7cee4.jpeg" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/d09a6887-eafc-4037-ab7f-0ee489b7cee4.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Re-Investing in Local CI</title><link href="https://www.joshbeckman.org/blog/practicing/reinvesting-in-local-ci" rel="alternate" type="text/html" title="Re-Investing in Local CI" /><published>2025-09-27T16:27:34+00:00</published><updated>2025-09-27T16:27:34+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/reinvesting-in-local-ci</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/reinvesting-in-local-ci"><![CDATA[<p>I read <a href="https://brandur.org/nanoglyphs/043-rails-world-2025">this great post by Brandur</a> over the weekend and got inspired. Specifically, the sketch of a world where continuous integration (CI) for your software is local and takes less than 2min to run!</p>

<blockquote>
  <p>70 seconds test times for a large real world app. That’s better than Google, better than Apple, better than Dropbox, better than Netflix, and better than Stripe, likely by 10-100x. A test suite that fast keeps developers happy and productivity high. And all in Ruby! One of the world’s slowest programming languages.</p>
</blockquote>

<p>Inspired by this, I spent some time this week on my teams’ test suites and got them to be 70% faster in local execution, with a fraction of the noisy log output, with multiple shorthand commands for running “local CI” by the user themselves or by AI agents/editors. This means a much tighter and more accurate feedback loop for their local development (they are instructed to run local CI on all changes before proposing them, running it hundreds of times an hour).</p>

<p>Largely, I did this by:</p>
<ul>
  <li>parallelizing tests (we have MacBooks with 11 CPU cores!)</li>
  <li>intelligent test selection (instead of all-or-nothing approaches)</li>
  <li>log hygiene (instead of tolerating noise)</li>
  <li>making affordances (easier handholds and documented practices get used much much more)</li>
</ul>

<p><img src="/assets/images/289ad3e9-b5f4-4225-b130-12353c24b777.jpeg" alt="Caldwell Lily Pool in Chicago" /></p>

<p>Before my changes, developers would write some code, maybe run some listing or type-checking locally, then push their changes into a pull-request to let cloud CI tell them if it was fully correct. That’s nice and holistic, but makes the feedback loop take minutes (to hours, if the CI pipelines and worker fleets are backlogged). There’s always a tension here between speed and thoroughness. <a href="https://www.joshbeckman.org/notes/563106559">As in</a>, faster unit tests can suffer from Goodhart’s Law - optimizing for speed over actual correctness. But I believe we can have both: fast local feedback for the majority of cases, with comprehensive cloud CI as the final arbiter.</p>

<p>In the current world, I’m working on 5 branches (git worktrees) concurrently and 4 of them are running AI agents in a loop, feeding on their own feedback and pinging me for guidance. I/they need much faster and more accurate <a href="https://www.joshbeckman.org/blog/practicing/feedforward-tolerance-feedback-improving-interfaces-for-llm-agents">feedback</a> loops to test ideas and be more confident in making changes. I want to get to a place where local CI takes a minute or two and is 99% accurate to what cloud CI will tell you, with one bash command.</p>

<p>Brandur describes why it’s hard to maintain local development feedback loops in large codebases:</p>

<blockquote>
  <p>Broadly, there are three stages in the long arc of a company’s CI trajectory:</p>
  <ol>
    <li>Early on, the test suite is run only locally.</li>
    <li>CI is set up. Test suite is runnable both locally or in CI.</li>
    <li>Test suite gets too big, or too custom, or has too many dependencies. Test suite is runnable only in CI.</li>
  </ol>
</blockquote>

<blockquote>
  <p>After the transition to stage 3 there’s a brief moment where things are still theoretically recoverable, like if a small team of dedicated engineers worked day and night for a few weeks they could walk things back from the brink, maybe. But generally speaking, you’re caught in the gravity well. After crossing the event horizon, there’s no going back. The overwhelming default will be to descend further into the black hole. The build continues to get more custom and requires more configuration and leverages more cloud constructs. There’s ~0 performance feedback now, so engineers don’t even notice half the time when they write slow tests, further degrading the morass. […] A test suite that can still run on one machine can be shaped and sped up. Once you’re cloud only, all bets are off.</p>
</blockquote>

<p>I’m not as pessimistic as Brandur here; I think we can measure performance of testing even in a cloud CI environment, and deliver that feedback (with incentives) to the responsible teams to get them to improve tests. This is slowly happening on my teams. <em>But</em> I do agree that local CI has <em>all</em> the incentive alignment and feedback provided to the end user such that they are much more likely to fix things. Now, I’m running loops faster and <a href="https://www.joshbeckman.org/notes/535683127">cleaning the streets so that others feel encouraged to do so</a>.</p>

<p>Also, the day after I made the big testing speed-ups, there was a cloud CI outage that halted most teams’ feedback loops - but not mine.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="tests" /><category term="software-engineering" /><category term="shopify" /><summary type="html"><![CDATA[I read this great post by Brandur over the weekend and got inspired. Specifically, the sketch of a world where continuous integration (CI) for your software is local and takes less than 2min to run!]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/289ad3e9-b5f4-4225-b130-12353c24b777.jpeg" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/289ad3e9-b5f4-4225-b130-12353c24b777.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Box Model: A Framework for Role Clarity</title><link href="https://www.joshbeckman.org/blog/practicing/the-box-model-a-framework-for-role-clarity" rel="alternate" type="text/html" title="The Box Model: A Framework for Role Clarity" /><published>2025-09-25T12:53:04+00:00</published><updated>2025-09-25T12:53:04+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/the-box-model-a-framework-for-role-clarity</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/the-box-model-a-framework-for-role-clarity"><![CDATA[<p>I spent months as a Senior Staff Engineer before realizing nobody had clear and consistent expectations of what I should be doing. In leadership roles, everything becomes less defined. You’re responsible for shaping your own position and how you spend your time.</p>

<p>When I moved into a Senior Staff Engineer role at Shopify, I really felt this ambiguity. The official role description provided <em>general</em> guidelines, but they hardly mapped to what I spent my hours doing or what I got feedback on in Impact Reviews. I needed a way to define my own job - for my own clarity, and to agree on it with my new manager and peers.</p>

<p>Working with Shopify’s career coach (a <em>great</em> perk that more Shopifolk should take advantage of), I worked on defining my role more clearly.<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup></p>

<p>I interviewed several other engineers in my new role and in the level above me. I asked them how they defined their role, what they spent their time on, and how they knew if they were successful. I found a lot of variation but as I summarized my notes, I found I started grouping traits into four sides.</p>

<p>From those sides, I developed a simple mapping framework that’s helped me and others figure out the exact responsibilities of a role and how to evolve it. It takes five minutes as a first pass, and you can keep refining it as you grow.</p>

<h2 id="draw-your-box">Draw Your Box</h2>

<p>To understand any role (even my own), I started by asking: what would it take to replace this person? What <em>doesn’t</em> happen when they’re gone? As I described what they do and don’t do, a box emerged.</p>

<p><img src="/assets/images/75d5f1fe-d78d-41b8-b2bc-626a27c330eb.jpeg" alt="Box model sketch I made at the time" /></p>

<h3 id="left-side-priorities">Left Side: Priorities</h3>
<p>Write out your priorities and responsibilities. This is the input to your day: how you’re <em>directed</em> to fill your time. These come from company strategies, product initiatives, team goals. If you don’t know these like the back of your hand, your next meeting should focus on clarifying them. It’s your lead’s critical responsibility that you know these like your own name.</p>

<p>At levels of leadership, <em>you</em> have control over these priorities. You should be able to articulate them clearly and negotiate them as needed. If you can’t, you’re not in a leadership role yet. You can spend more or less time on this “Left Side” - thinking about and agreeing on priorities - depending on your appetite and the organization’s needs. But priorities don’t really affect change until acted on, so this side is only half the equation.</p>

<h3 id="right-side-output">Right Side: Output</h3>
<p>Write out what you’re actually spending your time doing. This is your output, your impact, the actual work. Look at your calendar, your commits, your reviews, your shipped changes, your mentoring, your 1:1s. Writing something hand-wavy will impede yourself because you won’t see the gap between priorities and activities.</p>

<p>On my first pass at the “Right Side,” I actually mapped out calendar time according to categories of work I was doing every day. I did this for about a month, then averaged it out. This gave me a clear picture of where my time was going, and I could see the gaps between what I thought I was doing and what I was actually doing. I was spending way more time in interviews than I thought, and way less time on strategic thinking.</p>

<h3 id="bottom-side-delegation">Bottom Side: Delegation</h3>
<p>Write out what you’re <em>not</em> doing, shouldn’t be doing, or have delegated. This is the distinction between your current role and what you <em>used</em> to do, or what team members below you handle. If you’re having trouble filling this side, look at what people one level below you spend their time on. There should be clear differences.</p>

<p>You can also clearly identify things you <em>don’t</em> want to delegate. For example, many Senior Staff Engineers are not in on-call rotations. I specifically agreed with my manager that I <em>remain</em> in rotation so that I stay connected to the product and team and pain points. This is a conscious trade-off of my time, at the expense of other things I could be doing.</p>

<h3 id="top-side-reaching">Top Side: Reaching</h3>
<p>Write out what you see others above you doing, or what you want to be doing but haven’t been given responsibility for yet. This is your growth edge. These are things you can practice and try; things that, once you do them well, will necessarily <em>pull</em> you up to the next level of scope.</p>

<p>For me, this includes things like cross-org prototyping, writing org strategy, and being vocal at the company level. I now allot time in my calendar to work on these things, or ask for stretch assignments that let me practice them. I also look for people above me doing these things and take notes on how they do them.</p>

<h2 id="using-your-box-as-a-map">Using Your Box as a Map</h2>

<p>I took this map of my role and wrote it up as a document. It is very specific about how I expect to spend my time, what I prioritize, what I delegate, and what I’m reaching for. I share it with my manager and peers to get their feedback and agreement. This way, we all have a shared understanding of my role and how they can complement it and where I might try to reach.</p>

<p>Once drawn, this box becomes a lens: a map of where you are and where to go. I update mine regularly, whenever org strategy shifts, or when I feel lost. Armed with my box, I have no ambiguity going into impact reviews or 1:1s. I can clearly articulate what I’m doing, why, and where I want to go.</p>

<p>If you draw your current box and don’t like its shape, you need to focus on moving/shaping the box by change one or more of those sides.</p>

<h3 id="moving-to-a-new-box">Moving to a New Box</h3>
<p>Want to move into a new role? You can use this framework two ways:</p>
<ul>
  <li><strong>Proactively</strong>: Map out the box you want to fill, then start doing that work</li>
  <li><strong>Retroactively</strong>: Map your current activities and discover you’ve already moved into a new box—best to recognize it and formalize it</li>
</ul>

<p>Either way, for you to take that position (move into a ‘<a href="https://www.danielscrivner.com/tobi-lutke-shopify-summit-speech-on-solving-new-problems/">new box</a>’), the organization must have a gap shaped like that box. You need to align with everyone that:</p>
<ol>
  <li>The gap exists</li>
  <li>You’re already doing work shaped like that box</li>
  <li>You’re the best person to fill it</li>
</ol>

<h3 id="reshaping-your-current-box">Reshaping Your Current Box</h3>
<p>Want to change your current role? Focus on moving one or more sides:</p>
<ul>
  <li>Push the top up by taking on stretch responsibilities</li>
  <li>Pull the bottom up by delegating more effectively</li>
  <li>Shift the left side by renegotiating priorities</li>
  <li>Align the right side by changing how you spend your time</li>
</ul>

<p>Remember: the box’s total area is generally constant. We only have so many hours in the day. Reaching upward (raising the top edge) requires delegating more (raising the bottom edge). The trade-off exists side-to-side too: spending more time defining priorities (moving left) means less time for productive output (pulling from the right).</p>

<h2 id="start-drawing">Start Drawing</h2>

<p>The box is zero-zum but your impact shouldn’t be. Good leaders need to be <a href="https://www.lennysnewsletter.com/p/tobi-lutkes-leadership-playbook">striving for positive-sum work</a> at all times. By default, time flows like a stream: constantly away from us. The leadership challenge is <a href="https://www.joshbeckman.org/notes/564019851">planting seeds</a> in that box instead of letting effort flow away.</p>

<p>Take five minutes and sketch your box. You might be surprised by the gaps between what you think the shape is and what you actually draw out. More importantly, they might explain why your last review felt off, why you’re exhausted, or where you need to focus next.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>I actually drafted this post in 2024, over a year ago, after giving this advice several times to peers inside Shopify. I actually took the role and started using this model in 2023. I’m publishing it now because I started giving the same advice again and I think it’s widely useful. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="software-engineering" /><category term="leadership" /><category term="career" /><summary type="html"><![CDATA[I spent months as a Senior Staff Engineer before realizing nobody had clear and consistent expectations of what I should be doing. In leadership roles, everything becomes less defined. You’re responsible for shaping your own position and how you spend your time.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/75d5f1fe-d78d-41b8-b2bc-626a27c330eb.jpeg" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/75d5f1fe-d78d-41b8-b2bc-626a27c330eb.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Apple Calendar’s Search Just Doesn’t</title><link href="https://www.joshbeckman.org/blog/practicing/apple-calendars-search-just-doesnt" rel="alternate" type="text/html" title="Apple Calendar’s Search Just Doesn’t" /><published>2025-09-21T21:24:18+00:00</published><updated>2025-09-21T21:24:18+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/apple-calendars-search-just-doesnt</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/apple-calendars-search-just-doesnt"><![CDATA[<p>I cannot, for the life of me, figure out what Apple Calendar’s search feature doesn’t return events/results with titles that exactly match my query.</p>

<video controls="" src="/assets/videos/70c6a341-1056-4e00-8237-4b6401e8e750.mov"></video>

<p>I’m on a recent version of the operating system. The calendar is the most basic version Apple Calendar provides me. I am typing the words exactly. Other calendar apps return results based on the same query. I made the event weeks ago. Other search terms return results.</p>

<p>Why do I have to rely on my own memory instead of Apple’s software? Now I question every use of this app, if this most basic functionality does not work.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="interfaces" /><summary type="html"><![CDATA[I cannot, for the life of me, figure out what Apple Calendar’s search feature doesn’t return events/results with titles that exactly match my query.]]></summary></entry><entry><title type="html">Bending the Fiddle Leaf Fig</title><link href="https://www.joshbeckman.org/blog/practicing/bending-the-fiddle-leaf-fig" rel="alternate" type="text/html" title="Bending the Fiddle Leaf Fig" /><published>2025-09-21T20:41:04+00:00</published><updated>2025-09-21T20:41:04+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/bending-the-fiddle-leaf-fig</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/bending-the-fiddle-leaf-fig"><![CDATA[<p>This fiddle leaf fig has been in two homes with us now for about 7 years, maybe 8. It first grew from about  a foot tall to our 8’ ceilings at our old home. Once we moved here, to the capacious 25’ main room, it struggled to find the light for a bit.</p>

<p>Soon it discovered the upper window and has been drunk on that unfiltered sunlight for so long that it had plastered itself all over the glass. Its leaves were burnt from the heat and it was stifling its own growth.</p>

<p><img src="/assets/images/e870664f-3072-44bb-9a46-1a80e47f116f.jpeg" alt="The full height of the fiddle leaf on a cloudy morning" /></p>

<p>So I took some time over the weekend to bend it away from the light, back into our lives. I hope it respects my help and lives with us for many more years.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="flora" /><summary type="html"><![CDATA[This fiddle leaf fig has been in two homes with us now for about 7 years, maybe 8. It first grew from about a foot tall to our 8’ ceilings at our old home. Once we moved here, to the capacious 25’ main room, it struggled to find the light for a bit.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/e870664f-3072-44bb-9a46-1a80e47f116f.jpeg" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/e870664f-3072-44bb-9a46-1a80e47f116f.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Directed Notifications for Claude Code Async Programming</title><link href="https://www.joshbeckman.org/blog/practicing/directed-notifications-for-claude-code-async-programming" rel="alternate" type="text/html" title="Directed Notifications for Claude Code Async Programming" /><published>2025-09-16T03:37:56+00:00</published><updated>2025-09-16T03:37:56+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/directed-notifications-for-claude-code-async-programming</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/directed-notifications-for-claude-code-async-programming"><![CDATA[<p>This afternoon I leveled up my <a href="https://www.joshbeckman.org/blog/practicing/claude-code-notifications-for-async-programming">previous Claude Code notifications</a>. Now I have Claude’s notifications:</p>
<ul>
  <li>grouped by project/directory</li>
  <li>take me directly to the relevant terminal pane if clicked</li>
  <li>persisted until I act on them</li>
</ul>

<blockquote>
  <p>Read that original post for details, but generally I have Claude send me notifications in two channels: general prompt and hooks.</p>
</blockquote>

<p>In that previous configuration, I was using Apple’s built-in notifications via AppleScript. Now, I’ve finally bitten the bullet and upgraded to <a href="https://github.com/julienXX/terminal-notifier">julienXX’s <code class="language-plaintext highlighter-rouge">terminal-notifier</code></a> package that lets you customize much more. Specifically, it lets you set:</p>
<ul>
  <li>an application to activate upon notification click</li>
  <li>a shell command to execute upon notification click</li>
  <li>a group identifier for grouping notifications</li>
  <li>and a bunch of <a href="https://github.com/julienXX/terminal-notifier?tab=readme-ov-file#options">other options</a></li>
</ul>

<p>So, where previously all the Claude notifications were bunched together and clicking any did nothing but bring up AppleScripts, now I can have each Claude instance send its own notifications to its own group and when I click one I’m brought directly to that Claude Code panel in my terminal.</p>

<h2 id="installation">Installation</h2>

<p>You’ll need to install:</p>
<ul>
  <li><a href="https://github.com/julienXX/terminal-notifier">terminal-notifier</a></li>
  <li>a terminal that allows for programmatic tab/panel activation
    <ul>
      <li>I use <a href="https://wezterm.org/">WezTerm</a></li>
      <li>(you could replicate this in <code class="language-plaintext highlighter-rouge">tmux</code> or <code class="language-plaintext highlighter-rouge">kitty</code>, for example)</li>
    </ul>
  </li>
</ul>

<p>Once you have those installed, you can install a <a href="https://docs.anthropic.com/en/docs/claude-code/hooks">Claude Code Hook</a> to get a desktop notification whenever it needs you to unblock its progress on a task. It will ping you when it needs permission or input on a task, with details about the permission.</p>

<p>Here’s what I have in my <code class="language-plaintext highlighter-rouge">~/.claude/settings.json</code> user settings:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"Notification"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="p">{</span><span class="w">
        </span><span class="nl">"matcher"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
        </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
          </span><span class="p">{</span><span class="w">
            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"command"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jq -r '.message' | xargs -I {} terminal-notifier -message </span><span class="se">\"</span><span class="s2">{}</span><span class="se">\"</span><span class="s2"> -title </span><span class="se">\"</span><span class="s2">Claude Hook</span><span class="se">\"</span><span class="s2"> -group </span><span class="se">\"</span><span class="s2">$(pwd):hook</span><span class="se">\"</span><span class="s2"> -execute </span><span class="se">\"</span><span class="s2">/opt/homebrew/bin/wezterm cli activate-pane --pane-id $WEZTERM_PANE</span><span class="se">\"</span><span class="s2"> -activate com.github.wez.wezterm"</span><span class="w">
          </span><span class="p">}</span><span class="w">
        </span><span class="p">]</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Breaking that down:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># parse out the message field from the hook payload sent by Claude Code</span>
jq <span class="nt">-r</span> <span class="s1">'.message'</span> | <span class="se">\</span>
<span class="c"># pipe that into terminal-notifier as the message</span>
xargs <span class="nt">-I</span> <span class="o">{}</span> terminal-notifier <span class="nt">-message</span> <span class="se">\"</span><span class="o">{}</span><span class="se">\"</span> <span class="se">\</span>
<span class="c"># title it Claude Hook so I can distinguish it from Claude itself</span>
<span class="nt">-title</span> <span class="se">\"</span>Claude Hook<span class="se">\"</span> <span class="se">\</span>
<span class="c"># group it by the directory Claude is operating in, suffixed with ':hook'</span>
<span class="nt">-group</span> <span class="se">\"</span><span class="si">$(</span><span class="nb">pwd</span><span class="si">)</span>:hook<span class="se">\"</span> <span class="se">\</span>
<span class="c"># focus this Claude instance's pane within the terminal multiplexer when the notification is clicked</span>
<span class="nt">-execute</span> <span class="se">\"</span>/opt/homebrew/bin/wezterm cli activate-pane <span class="nt">--pane-id</span> <span class="nv">$WEZTERM_PANE</span><span class="se">\"</span> <span class="se">\</span>
<span class="c"># activate WezTerm when the notification is clicked</span>
<span class="nt">-activate</span> com.github.wez.wezterm
</code></pre></div></div>

<p>This is using WezTerm’s <a href="https://wezterm.org/cli/cli/activate-pane.html">activate-pane CLI command</a> and <a href="https://wezterm.org/cli/cli/index.html#:~:text=If%20the%20%24WEZTERM_PANE%20environment%20variable%20is%20set%2C%20it%20will%20be%20used">its <code class="language-plaintext highlighter-rouge">$WEZTERM_PANE</code> environment variable</a> for each session.</p>

<p>So that takes care of notifying me when Claude needs permission for something. I also tell Claude Code to notify me when it accomplishes something (and it’s about to stop). Here’s the relevant section in my user settings prompt <code class="language-plaintext highlighter-rouge">~/.claude/CLAUDE.md</code>:</p>

<div class="language-md highlighter-rouge"><div class="highlight"><pre class="highlight"><code>After making a set of changes to files or satisfying a task, you MUST display a <span class="sb">`terminal-notifier`</span> notification to tell me what's been done. Use a title and a brief descriptive message. Here's an example:

<span class="p">```</span><span class="nl">bash
</span>terminal-notifier <span class="nt">-message</span> <span class="s2">"I've finished refactoring the FooBar class into smaller methods"</span> <span class="nt">-title</span> <span class="s2">"Claude Code"</span> <span class="nt">-group</span> <span class="nv">$PWD</span> <span class="nt">-execute</span> <span class="s2">"/opt/homebrew/bin/wezterm cli activate-pane --pane-id </span><span class="nv">$WEZTERM_PANE</span><span class="s2">"</span> <span class="nt">-activate</span> com.github.wez.wezterm
<span class="p">```</span>
</code></pre></div></div>

<p>This is very similar to the hook implementation but I just have it grouping by the present working directory. Since I make git worktrees for each of my different tasks these days, each task tends to have its own dedicated directory.</p>

<p>For this to reliably be available for Claude, I also add a permission to my user settings to allow terminal-notifier commands:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"permissions"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"allow"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="s2">"Bash(terminal-notifier:*)"</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><img width="445" height="109" alt="Example Claude notification via terminal-notifier" src="/assets/images/ab4a116a-65da-4bc0-84c1-1cbecefc941d.png" /></p>

<p>Another change I’ve made with this new configuration is that I have the notifications persist until I dismiss or click them. You can set this in System Preferences under <code class="language-plaintext highlighter-rouge">Notifications &gt; terminal-notifier</code>.</p>

<p><img width="497" height="250" alt="Allowing Alerts for terminal-notifier" src="/assets/images/b77aa74c-23bf-41af-9745-ba956aae23f9.png" /></p>

<p>I know others have been building more… complex interfaces for managing lots of Claude instances, but I like to keep as close to the terminal as possible (as close to the actual Claude and its code/changeset as possible). With this new setup, I’m even more confident running handfuls of concurrent Claude coders. Each one gets its own notification queue and I can be sure of finding them in just a single click.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="code-snippets" /><category term="ai" /><category term="tools" /><category term="popular" /><summary type="html"><![CDATA[This afternoon I leveled up my previous Claude Code notifications. Now I have Claude’s notifications: grouped by project/directory take me directly to the relevant terminal pane if clicked persisted until I act on them]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/ab4a116a-65da-4bc0-84c1-1cbecefc941d.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/ab4a116a-65da-4bc0-84c1-1cbecefc941d.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Releasing gh-pr-staleness: GitHub CLI Extension for Commits Behind Target</title><link href="https://www.joshbeckman.org/blog/practicing/releasing-ghprstaleness-github-cli-extension-for-commits-behind-target" rel="alternate" type="text/html" title="Releasing gh-pr-staleness: GitHub CLI Extension for Commits Behind Target" /><published>2025-09-14T16:41:11+00:00</published><updated>2025-09-14T16:41:11+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/releasing-ghprstaleness-github-cli-extension-for-commits-behind-target</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/releasing-ghprstaleness-github-cli-extension-for-commits-behind-target"><![CDATA[<p>Working in a monorepo and with a merge-queue (as we are now doing inside Shopify - I’m <a href="https://www.joshbeckman.org/notes/01jwtzrp79033w8x8dc8sje7d6">grappling</a> with it), it becomes imperative that pull-requests (proposed changes) are compared and tested against the most up-to-date version of the codebase. To ensure tests and reviews are accurate, they need to cross the smallest gap possible.</p>

<p>So, the operators of a merge-queue will often implement staleness checks/guarantees like:</p>
<ul>
  <li>PRs can only be merged if they are less than X commits behind the target branch</li>
  <li>Code will only be tested in CI if it is less than X commits behind <code class="language-plaintext highlighter-rouge">main</code>/trunk</li>
</ul>

<p>So, the engineers need to get into a habit of tracking staleness and proactively rebasing their changes on the active target/trunk. <em>I”m</em> having to do this quite a lot, so I built <a href="https://github.com/joshbeckman/gh-pr-staleness">gh-pr-staleness</a>.</p>

<blockquote>
  <p>gh-pr-staleness is a <a href="https://docs.github.com/en/github-cli/github-cli/creating-github-cli-extensions">GitHub CLI extension</a> that calculates the staleness (how many commits behind TARGET branch) of a pull request.</p>
</blockquote>

<p><img width="1200" height="600" alt="gh-pr-staleness repo" src="/assets/images/d8238fc8-3f69-4c51-9be0-94d238607ceb.png" /></p>

<p>This is very useful for determining which PRs are actionably mergeable, especially in highly active monorepo or merge-queue environments. It’s simple and fast and easy to use in scripts.</p>

<p>What I <em>also</em> want is for GitHub to display the PR staleness <em>in their actual pull request user interface</em>, but I think they are resistant because it’s kind of an expensive calculation.</p>

<blockquote>
  <p>This is my second <code class="language-plaintext highlighter-rouge">gh</code> extension. Previously released: <a href="https://www.joshbeckman.org/blog/practicing/releasing-ghviewmd-a-github-cli-extension-for-llmoptimized-issue-and-pr-viewing">gh-view-md - A GitHub CLI Extension for LLM-Optimized Issue and PR Viewing</a></p>
</blockquote>

<h2 id="installation">Installation</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gh extension <span class="nb">install </span>joshbeckman/gh-pr-staleness
</code></pre></div></div>

<p>That’s it. If you have the GitHub CLI installed and authenticated, you’re ready to go.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gh pr-staleness &lt;github_pr_url_or_number&gt;
<span class="c"># or, if you are on a branch that has a PR already, it will be inferred:</span>
gh pr-staleness
</code></pre></div></div>

<p>The <a href="https://github.com/joshbeckman/gh-pr-staleness">source is on GitHub</a> with examples and full documentation. Contributions are welcome!</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="tools" /><category term="llm" /><category term="language-ruby" /><category term="open-source" /><summary type="html"><![CDATA[Working in a monorepo and with a merge-queue (as we are now doing inside Shopify - I’m grappling with it), it becomes imperative that pull-requests (proposed changes) are compared and tested against the most up-to-date version of the codebase. To ensure tests and reviews are accurate, they need to cross the smallest gap possible.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/d8238fc8-3f69-4c51-9be0-94d238607ceb.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/d8238fc8-3f69-4c51-9be0-94d238607ceb.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Website Redesign</title><link href="https://www.joshbeckman.org/blog/practicing/website-redesign" rel="alternate" type="text/html" title="Website Redesign" /><published>2025-09-14T16:18:18+00:00</published><updated>2025-09-14T16:18:18+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/website-redesign</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/website-redesign"><![CDATA[<p>I’ve spent the last week or so redesigning my personal blog/site to make images/video/code/tables stand out more while remaining readably inline. I recoded a video walkthrough:</p>

<iframe width="100%" height="315" src="https://www.youtube-nocookie.com/embed/ssEja-yAUqI?si=hV7BAmRmUpcMl_Na" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen=""></iframe>

<p><img width="1617" height="1168" alt="Example of the new design with high contrast" src="/assets/images/03127241-d3f8-482d-9d88-79a64c3495cf.png" /></p>

<p>I got inspired by <a href="https://carlbarenbrug.com/">Carl Barenbrug</a>’s site layout that features an off-center, 3-column grid that balances text readability with large inline images. I’ve seen other sites that rely on centered text and centered images for this kind of thing, but this ratio feels much more elegant to me; it evokes <a href="https://en.wikipedia.org/wiki/Golden_ratio">the golden ratio</a>.</p>

<p><img width="1617" height="1168" alt="Carl Barenbrug site design" src="/assets/images/34b086b1-885a-43eb-b7eb-2ec07c0c751e.png" /></p>

<p>Carl’s site is <em>very</em> minimalist with low contrast elements. I retained my high-contrast, high-legibility aspects and mostly took layout styles.</p>

<p>The <a href="/blog/test">test page</a> has a full list of example elements/rendering, but here are some before+after examples:</p>

<p><img width="1617" height="1168" alt="Example blog post in old design" src="/assets/images/4b751d1f-74f1-40f1-896e-6b9cb7f4a934.png" /></p>

<p><img width="1617" height="1168" alt="Example blog post in new design" src="/assets/images/28d81710-e4c3-4f40-aecf-0d2fc97e475a.png" /></p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="personal-blog" /><category term="interfaces" /><summary type="html"><![CDATA[I’ve spent the last week or so redesigning my personal blog/site to make images/video/code/tables stand out more while remaining readably inline. I recoded a video walkthrough:]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/03127241-d3f8-482d-9d88-79a64c3495cf.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/03127241-d3f8-482d-9d88-79a64c3495cf.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">My Markdown Preview Utility</title><link href="https://www.joshbeckman.org/blog/practicing/my-markdown-preview-utility" rel="alternate" type="text/html" title="My Markdown Preview Utility" /><published>2025-08-12T17:18:45+00:00</published><updated>2025-08-12T17:18:45+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/my-markdown-preview-utility</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/my-markdown-preview-utility"><![CDATA[<p>I write a lot of <a href="https://www.markdownguide.org/">markdown</a> and I am delivered a lot of markdown, increasingly from LLM agents. And while markdown is easy to read inline, often I want a preview of how it will render or maybe I just need to take a nice screenshot for a presentation to the CTO later or maybe I just want to read the long LLM output in a proportional font.</p>

<p>So I’ve been increasingly using the command line script (stored in <code class="language-plaintext highlighter-rouge">bin/preview-md</code>) that I wrote a while ago:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>

<span class="k">if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"-h"</span> <span class="o">]]</span> <span class="o">||</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"--help"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"preview-md - Preview markdown as HTML in your browser"</span>
    <span class="nb">echo</span> <span class="s2">""</span>
    <span class="nb">echo</span> <span class="s2">"Usage: preview-md [FILE]"</span>
    <span class="nb">echo</span> <span class="s2">"       preview-md </span><span class="se">\"</span><span class="s2">STRING</span><span class="se">\"</span><span class="s2">"</span>
    <span class="nb">echo</span> <span class="s2">"       command | preview-md"</span>
    <span class="nb">echo</span> <span class="s2">"       preview-md &lt; file.md"</span>
    <span class="nb">echo</span> <span class="s2">""</span>
    <span class="nb">echo</span> <span class="s2">"Reads markdown from stdin, a file, or a string argument and opens it as HTML in your browser."</span>
    <span class="nb">echo</span> <span class="s2">"Uses pandoc to convert GitHub Flavored Markdown to HTML."</span>
    <span class="nb">exit </span>0
<span class="k">fi

if</span> <span class="o">!</span> <span class="nb">command</span> <span class="nt">-v</span> pandoc &amp;&gt; /dev/null<span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"Error: pandoc is not installed."</span> <span class="o">&gt;</span>&amp;2
    <span class="nb">echo</span> <span class="s2">"Please install pandoc to use this tool:"</span> <span class="o">&gt;</span>&amp;2
    <span class="nb">echo</span> <span class="s2">"  macOS: brew install pandoc"</span> <span class="o">&gt;</span>&amp;2
    <span class="nb">echo</span> <span class="s2">"  Ubuntu/Debian: sudo apt-get install pandoc"</span> <span class="o">&gt;</span>&amp;2
    <span class="nb">echo</span> <span class="s2">"  Other: https://pandoc.org/installing.html"</span> <span class="o">&gt;</span>&amp;2
    <span class="nb">exit </span>1
<span class="k">fi

</span><span class="nv">timestamp</span><span class="o">=</span><span class="si">$(</span><span class="nb">date</span> +%Y%m%d_%H%M%S<span class="si">)</span>
<span class="nv">tmpfile</span><span class="o">=</span><span class="s2">"/tmp/preview-md-</span><span class="k">${</span><span class="nv">timestamp</span><span class="k">}</span><span class="s2">-</span><span class="nv">$$</span><span class="s2">.html"</span>

<span class="c"># Add cleanup trap to remove tmpfile after browser opens</span>
<span class="nb">trap</span> <span class="s2">"sleep 5; rm -f '</span><span class="nv">$tmpfile</span><span class="s2">'"</span> EXIT

<span class="nb">echo</span> <span class="s1">'&lt;link href="https://www.joshbeckman.org/assets/css/site.css" rel="stylesheet"&gt;&lt;style&gt;body { max-width: 800px; margin: 1em auto; padding: 1em; font-family: "IBM Plex Sans", sans-serif; }&lt;/style&gt;'</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$tmpfile</span><span class="s2">"</span>

<span class="k">if</span> <span class="o">[[</span> <span class="nv">$# </span><span class="nt">-eq</span> 1 <span class="o">]]</span><span class="p">;</span> <span class="k">then
    if</span> <span class="o">[[</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
        </span>pandoc <span class="nt">-f</span> gfm <span class="nt">-t</span> html <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span> <span class="o">&gt;&gt;</span> <span class="s2">"</span><span class="nv">$tmpfile</span><span class="s2">"</span>
    <span class="k">else
        </span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span> | pandoc <span class="nt">-f</span> gfm <span class="nt">-t</span> html <span class="o">&gt;&gt;</span> <span class="s2">"</span><span class="nv">$tmpfile</span><span class="s2">"</span>
    <span class="k">fi
else
    </span>pandoc <span class="nt">-f</span> gfm <span class="nt">-t</span> html <span class="o">&gt;&gt;</span> <span class="s2">"</span><span class="nv">$tmpfile</span><span class="s2">"</span>
<span class="k">fi

</span>open <span class="s2">"</span><span class="nv">$tmpfile</span><span class="s2">"</span>
</code></pre></div></div>

<p>This little script allows you to pipe a file or string output or just give it a file path and it will use <a href="https://pandoc.org/">pandoc</a> to render an HTML page and open it in your browser for you. Beautifully flexible.</p>

<p>I have it rendering the HTML with my own site’s CSS because I like that style and specifically <a href="https://www.joshbeckman.org/blog/my-favorite-fonts">those fonts</a>, but you can remove that by simplifying this line:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="s1">'&lt;style&gt;body { max-width: 800px; margin: 1em auto; padding: 1em; font-family: sans-serif; }&lt;/style&gt;'</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$tmpfile</span><span class="s2">"</span>
</code></pre></div></div>

<h2 id="in-vim">In Vim</h2>

<p>Since this script is so flexible, I’ve hooked it up in a few key places. For one, I have a (Neo)vim command to open the current buffer as a markdown preview:</p>

<div class="language-vim highlighter-rouge"><div class="highlight"><pre class="highlight"><code>command<span class="p">!</span> PreviewMd <span class="k">call</span> PreviewMarkdown<span class="p">()</span>

<span class="k">function</span><span class="p">!</span> PreviewMarkdown<span class="p">()</span>
    <span class="k">let</span> filepath <span class="p">=</span> <span class="nb">expand</span><span class="p">(</span><span class="s2">"%:p"</span><span class="p">)</span>
    <span class="nb">execute</span> <span class="s2">"!preview-md "</span> <span class="p">.</span> <span class="nb">shellescape</span><span class="p">(</span>filepath<span class="p">)</span>
<span class="k">endfunction</span>
</code></pre></div></div>

<h2 id="in-claude">In Claude</h2>

<p>And I also have made a <a href="https://docs.anthropic.com/en/docs/claude-code/slash-commands">Claude slash command</a> to have the LLM agent render its last message to me as a markdown preview in the browser:</p>

<div class="language-md highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">description</span><span class="pi">:</span> <span class="s">Preview Last Response as Markdown</span>
<span class="na">allowed-tools</span><span class="pi">:</span> <span class="s">Bash(preview-md:*)</span>
<span class="nn">---</span>

Please take your last response and pass it directly to the <span class="sb">`preview-md`</span> command as a string argument for visual preview.

Do this by:
<span class="p">1.</span> Taking the full content of your previous response
<span class="p">2.</span> Running the command: <span class="sb">`preview-md "YOUR_LAST_RESPONSE_AS_MARKDOWN"`</span>

Make sure to:
<span class="p">-</span> Escape any double quotes in the content with <span class="se">\"</span>
<span class="p">-</span> Preserve all markdown formatting (headers, lists, code blocks, links, etc.)
<span class="p">-</span> Include any code blocks with proper language identifiers
<span class="p">-</span> Keep all line breaks and whitespace

Just run the command directly without explanation.
</code></pre></div></div>

<p>So I can now run <code class="language-plaintext highlighter-rouge">/preview-md</code> in any Claude conversation and view the LLM’s response nicely formatted.</p>

<p><img width="520" height="271" alt="Example rendered LLM response" src="/assets/images/83488d01-ae66-42c1-bcaf-2b2e01cff096.png" /></p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="blog" /><category term="practicing" /><category term="code-snippets" /><category term="vim" /><category term="tools" /><category term="llm-prompts" /><summary type="html"><![CDATA[I write a lot of markdown and I am delivered a lot of markdown, increasingly from LLM agents. And while markdown is easy to read inline, often I want a preview of how it will render or maybe I just need to take a nice screenshot for a presentation to the CTO later or maybe I just want to read the long LLM output in a proportional font.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/83488d01-ae66-42c1-bcaf-2b2e01cff096.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/83488d01-ae66-42c1-bcaf-2b2e01cff096.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>