<?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-06-07T23:54:16+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">Automating My Apple Music Library Export</title><link href="https://www.joshbeckman.org/blog/practicing/automating-my-apple-music-library-export" rel="alternate" type="text/html" title="Automating My Apple Music Library Export" /><published>2026-04-16T11:00:00+00:00</published><updated>2026-04-16T11:00:00+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/automating-my-apple-music-library-export</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/automating-my-apple-music-library-export"><![CDATA[<p>Two years ago I wrote <a href="/blog/pulling-fun-insights-out-of-my-apple-music-library">a parser that pulls fun insights out of my Apple Music library</a> and generates my <a href="/music">Music Listening page</a>. The pipeline worked well but it had an annoying manual step: I had to open Music.app, click File &gt; Library &gt; Export Library, navigate to the right directory, and save the XML file before running the script. It was enough friction that I’d forget to do it for months.</p>

<h2 id="the-automation">The automation</h2>

<p>Apple Music doesn’t expose a “library export” command in its AppleScript dictionary, so the only option is GUI scripting through System Events. The script:</p>

<ol>
  <li>Activates Music.app</li>
  <li>Navigates the menu: File &gt; Library &gt; Export Library…</li>
  <li>Uses Cmd+Shift+G in the save dialog to jump to the project directory</li>
  <li>Clicks Save</li>
  <li>Waits for the ~46MB XML file to finish writing (by polling file size until it stabilizes)</li>
  <li>Runs the existing <code class="language-plaintext highlighter-rouge">update_music</code> Ruby script to regenerate the page</li>
  <li>Commits any changes to <code class="language-plaintext highlighter-rouge">blog/</code> and <code class="language-plaintext highlighter-rouge">assets/</code> and pushes to deploy</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./utilities/export_and_update_music
</code></pre></div></div>

<p>The interesting part was discovering that Music.app opens a standalone <code class="language-plaintext highlighter-rouge">window "Save"</code> rather than the more common <code class="language-plaintext highlighter-rouge">sheet 1 of window 1</code> pattern that most macOS apps use for save dialogs. That small difference was the only real debugging needed.</p>

<h2 id="running-it-on-a-schedule">Running it on a schedule</h2>

<p>Since GUI scripting requires a logged-in session with screen access, a plain cron job won’t work. Instead, I’m using a <code class="language-plaintext highlighter-rouge">launchd</code> agent with <code class="language-plaintext highlighter-rouge">LimitLoadToSessionType: Aqua</code>, which ensures it only fires when I’m actually at my Mac.</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;key&gt;</span>LimitLoadToSessionType<span class="nt">&lt;/key&gt;</span>
<span class="nt">&lt;string&gt;</span>Aqua<span class="nt">&lt;/string&gt;</span>
</code></pre></div></div>

<p>It runs weekly on Sunday mornings. Unlike cron, <code class="language-plaintext highlighter-rouge">launchd</code> with <code class="language-plaintext highlighter-rouge">StartCalendarInterval</code> will fire a missed job on wake, so if my laptop was sleeping during the scheduled time, it catches up as soon as I open the lid.</p>

<p>The other <code class="language-plaintext highlighter-rouge">launchd</code> gotcha: the agent runs with a minimal environment, not your shell profile. I had to set <code class="language-plaintext highlighter-rouge">PATH</code> (to find Homebrew’s Ruby and <code class="language-plaintext highlighter-rouge">bundle</code>) and <code class="language-plaintext highlighter-rouge">LANG</code> (to <code class="language-plaintext highlighter-rouge">en_US.UTF-8</code>, since the plist parser chokes on non-ASCII track metadata without it) in the plist’s <code class="language-plaintext highlighter-rouge">EnvironmentVariables</code>.</p>

<h2 id="requirements">Requirements</h2>

<p>The prerequisite is granting Accessibility permissions (System Settings &gt; Privacy &amp; Security &gt; Accessibility). When running from a terminal, your terminal app needs the permission. When running via <code class="language-plaintext highlighter-rouge">launchd</code>, <code class="language-plaintext highlighter-rouge">/usr/bin/env</code> needs it instead. Since the script’s shebang uses <code class="language-plaintext highlighter-rouge">#!/usr/bin/env bash</code>, <code class="language-plaintext highlighter-rouge">env</code> is the parent process that macOS checks for Accessibility access.</p>

<p>The full script is in <a href="https://github.com/joshbeckman/notes/blob/master/utilities/export_and_update_music">the repo</a>.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="practicing" /><category term="code-snippets" /><category term="music" /><category term="automation" /><summary type="html"><![CDATA[Using macOS GUI scripting to fully automate my music stats pipeline]]></summary></entry><entry><title type="html">Moving the Critic Into My Editor</title><link href="https://www.joshbeckman.org/blog/practicing/moving-the-critic-into-my-editor" rel="alternate" type="text/html" title="Moving the Critic Into My Editor" /><published>2026-03-29T21:46:00+00:00</published><updated>2026-03-29T21:46:00+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/moving-the-critic-into-my-editor</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/moving-the-critic-into-my-editor"><![CDATA[<p>I was editing <a href="/blog/practicing/three-agents-for-a-knowledge-garden">the blog post about my AI writing critic</a> (based on the critique it had generated) when I realized I didn’t want to be incorporating this feedback <em>after</em> I had already published; I wanted the next critique now, against the draft in front of me.</p>

<p>The <a href="/blog/practicing/three-agents-for-a-knowledge-garden#the-critic-walks-beside">Critic agent</a> I built runs on a cron schedule. It monitors my RSS feed, reads new posts, searches my garden for related writing, and emails me a critique. That’s good for reflection. I read the critique over coffee the next morning, think about it, sometimes update the post. But it means publishing first and polishing later. For a post I cared about getting right before it went out, I wanted the feedback loop tighter.</p>

<h2 id="bring-the-feedback-to-the-source">Bring the feedback to the source</h2>

<p>Feedback is more useful the closer it is to the action. An email critique that arrives hours later is a thought-provoker, <a href="https://www.joshbeckman.org/notes/692534908">only affecting future behavior</a>. A critique that appears inline against your prose, while you’re still shaping it, is a collaborator. I wanted something <a href="https://www.joshbeckman.org/notes/888705369">like inline semantic linting</a>.</p>

<p>I already had the infrastructure: the Critic val on <a href="https://val.town">Val Town</a>, the Anthropic API, the garden search tools. I needed two things: a way to critique unpublished drafts, and a way to see the results in my editor.</p>

<h2 id="the-critique-command">The <code class="language-plaintext highlighter-rouge">:Critique</code> command</h2>

<p>I added a <code class="language-plaintext highlighter-rouge">POST /draft</code> endpoint to the <a href="https://www.val.town/x/joshbeckman/criticCron">Critic val</a> that accepts a title and content (password-protected), runs the same critique pipeline, and returns the result. Then I <a href="https://github.com/joshbeckman/dotfiles/blob/a1eeebd11c81c86abf167f8b82c863fd180fdb7e/.config/nvim/init.vim#L347">wired it into a neovim command</a>:</p>

<div class="language-vim highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">:</span>Critique
</code></pre></div></div>

<p>The command runs asynchronously via <code class="language-plaintext highlighter-rouge">jobstart()</code> (I get my editor back immediately). About a minute later, two things happen: the full critique opens in a background tab, and inline annotations appear in my buffer via <a href="https://github.com/dense-analysis/ale">ALE</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>If you’re not familiar with ALE and/or [neo]vim, think of these inline annotations as the squiggly spellchecker lines you see in your Microsoft Word and Google Docs.</p>
</blockquote>
<p><img src="/assets/images/critique-ale-display.png" alt="Here's how the critique linter displays in my (neo)vim editor" /></p>

<p>The annotations use ALE’s <code class="language-plaintext highlighter-rouge">other_source</code> API, which lets you push linter-style results from any external process. Each annotation has a line number, a severity (<code class="language-plaintext highlighter-rouge">I</code> for suggestions, <code class="language-plaintext highlighter-rouge">W</code> for clarity issues, <code class="language-plaintext highlighter-rouge">E</code> for errors), and a short message. When the feedback targets a specific phrase, ALE highlights just those words rather than the whole line.</p>

<p>With this, I can navigate around the file and read the feedback inline, or <code class="language-plaintext highlighter-rouge">:lopen</code> the location list window to view all of them at once. Or I can tab to the full-prose critique and see the full context from the critic. In practice, I do all these things and then revise, rinse, and repeat.</p>

<p>The annotation step is deliberately separate from the critique step. The Critic agent has a single job: read the post, research the garden, write a critique in prose. A second, cheaper call maps that prose onto line numbers and phrases. The separation matters for several reasons:</p>

<ul>
  <li>I have a hunch that the critique agent produces better output when it’s not distracted by linter formatting</li>
  <li>The critique stays useful as standalone prose; it’s not locked to a display format</li>
  <li>The annotation mapping is composable: I can map any critique-and-source pair, not just ones the Critic generated</li>
  <li>I can use different model tiers for each: Opus for the critique, Sonnet for the mapping</li>
</ul>

<h2 id="two-resolutions-of-the-same-feedback">Two resolutions of the same feedback</h2>

<p>Seeing the critique at two resolutions simultaneously changed how I process it.</p>

<p>The full critique in the background tab gives me the arc: what’s working, what’s missing, how the piece connects to my other writing. The inline annotations give me specific pressure against specific sentences. I read the tab first to understand the big picture, then work through the inline feedback phrase by phrase.</p>

<p>This also taught me to edit in phases. Saving the file clears the ALE annotations (the linter display is tied to the buffer state), so I make several edits before saving. That batch-editing rhythm turns out to be better anyway; I’m responding to a coherent set of feedback rather than fixing things one at a time.</p>

<p>The async email critic still runs for every published post. It’s more of a thought-provoker, and only sometimes pushes me to update something. The inline critic, because it’s right there next to my words, makes me edit more. Proximity matters.</p>

<h2 id="agents-write-to-me-i-edit-my-own-files">Agents write to me, I edit my own files</h2>

<p>A principle I’m finding: agents should communicate <em>to</em> me, not edit over my work. Code is now an agent’s artifact. The critique is the agent’s artifact. Whatever I’m writing is mine: my thinking, possibly refined by the agent’s interrogation. A linter never rewrites your code; it tells you what to reconsider. The Critic works the same way.</p>

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

<p>The Critic val now has three relevant endpoints:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">POST /draft</code> - accepts <code class="language-plaintext highlighter-rouge">{title, content}</code>, runs Sonnet for research (searching the garden, reading linked sources) and Opus for the final critique. Returns the critique as markdown and HTML.</li>
  <li><code class="language-plaintext highlighter-rouge">POST /annotate</code> - accepts <code class="language-plaintext highlighter-rouge">{content, critique}</code>, uses Sonnet to map each critique point to a line number, severity, and optional verbatim phrase from the source. Returns a JSON array of annotations.</li>
  <li><code class="language-plaintext highlighter-rouge">GET /cron</code> - the original: processes new RSS entries and emails critiques.</li>
</ul>

<p>The neovim <code class="language-plaintext highlighter-rouge">:Critique</code> command chains <code class="language-plaintext highlighter-rouge">/draft</code> and <code class="language-plaintext highlighter-rouge">/annotate</code> asynchronously. It writes the critique to <code class="language-plaintext highlighter-rouge">/tmp</code> (so it’s automatically cleaned up), opens it in a background tab, then pushes annotations to ALE. The phrase-to-column resolution happens in vimscript: if the annotation includes a phrase, <code class="language-plaintext highlighter-rouge">stridx()</code> finds it on the target line and sets <code class="language-plaintext highlighter-rouge">col</code>/<code class="language-plaintext highlighter-rouge">end_col</code> for precise highlighting.</p>

<p>The Sonnet-for-research, Opus-for-critique split was originally a performance optimization - the val was timing out on Val Town’s free tier. It turned out to be the right architecture regardless. No noticeable difference in critique quality, meaningfully faster, and cheaper.</p>

<h2 id="whats-next">What’s next</h2>

<p>The composable annotation endpoint opens other possibilities. Any document paired with any feedback (from an agent or a human) could be mapped to inline annotations: code review comments against a diff, editor notes against a manuscript, study questions against a reading. The pattern generalizes beyond my specific critic.</p>

<p>For now, though, the main thing is simpler: I write drafts, I <code class="language-plaintext highlighter-rouge">:Critique</code> them, and I make more edits because the feedback is right there. The agents write to me. I write my own prose.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="practicing" /><category term="ai" /><category term="writing" /><category term="tools" /><category term="vim" /><category term="feedback" /><summary type="html"><![CDATA[What if you could have an AI critic semantically linting your writing, inline?]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/critique-ale-display.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/critique-ale-display.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Three LLM Agents for My Knowledge Garden</title><link href="https://www.joshbeckman.org/blog/practicing/three-agents-for-a-knowledge-garden" rel="alternate" type="text/html" title="Three LLM Agents for My Knowledge Garden" /><published>2026-03-26T09:40:00+00:00</published><updated>2026-03-26T09:40:00+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/three-agents-for-a-knowledge-garden</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/three-agents-for-a-knowledge-garden"><![CDATA[<p>I was lifting weights when I realized my notes had gotten lazy. Long quotes, short thoughts, no connections drawn. I’d saved a quote about the laziness of forwarding raw AI output and left <a href="https://www.joshbeckman.org/notes/997876311">no thoughts of my own about it</a>. I was hoarding links instead of digesting them.</p>

<p>I’ve been building <a href="/blog/opening-up-my-highlights-notes">this knowledge garden</a> for years, and the collection has grown to thousands of notes, blog posts, highlights, and replies. I know the latent connections between them are there, but tracing the threads takes work. I usually write from personal experience and prior reading/writing, but want to include actual links to relevant work. Additionally, I don’t always remember prior work that would contradict or inform my current thinking. Having someone check for those things would help me improve.</p>

<p>So I’ve been building agents to push my critical thinking and tend the garden alongside me. Three of them now, each with a different role.</p>

<h2 id="the-pre-reader-walks-in-front">The Pre-Reader walks in front</h2>

<p>I built <a href="/pre-read">Pre-Read</a> to help me figure out whether I have an opinion on a piece. It can help before diving into a long article someone sent me or that I found in my feed. I give it a URL and it searches my garden for things I’ve already written or saved that might relate to the piece. It frames what I already know before I start reading.</p>

<p>This is useful when I haven’t quite solidified my opinion on something. The pre-reader jogs loose some thoughts I can then maybe turn into a note. It walks ahead of me on the trail, scouting what’s familiar.</p>

<p>It produces three perspectives on any given piece: a <em>Proponent</em>, an <em>Opponent</em>, and a <em>Questioner</em>. These mirror how I naturally read things. I usually give an author the benefit of the doubt first — I agree, I look for where it reinforces what I already believe. Then I force myself to take the opposing stance and poke holes in the argument. And when I’m really stuck, I try to think of questions for the author that would open up a better conversation around the topic. The three personas externalize that cycle so I don’t get stuck on step one, which is what happens when I’m just saving a quick note.</p>

<h2 id="the-suggester-walks-behind">The Suggester walks behind</h2>

<p>There are already hundreds of notes here that I’ve neglected to write about. I built an <a href="/uncommented/">Uncommented Notes</a> page that surfaces them - notes where I saved a quote or a link but never added my own thoughts. From there, the <a href="/suggest/">Suggest Comments</a> page takes any post and generates suggested comments I might add: connections to other posts, reactions I haven’t articulated, threads I haven’t pulled. Each suggestion comes as a short paragraph with markdown links to related garden posts.</p>

<p>This gives me a starting point to write my own expansion on the note. I don’t want to copy and paste this output mechanically: the point here is to kickstart my own thinking and put my own words into the system.</p>

<p>These are tools for downtime. On the train, waiting in line - I open the uncommented list, pick a note, and let the suggester stimulate a thought. It helps me fill in blanks productively, which is better than scrolling social media. It walks behind me, picking up what I dropped.</p>

<p>The suggester uses the same three personas (Proponent, Opponent, Questioner) applied to my own posts instead of someone else’s. The same reading cycle works in reverse: where do I agree with my past self, where would I push back now, and what questions does the note leave open?</p>

<h2 id="the-critic-walks-beside">The Critic walks beside</h2>

<p>The newest agent is the one I’m most excited about. The <a href="https://www.val.town/x/joshbeckman/criticCron">Critic</a> runs on a cron schedule every few hours. It monitors my site’s <a href="/subscribe">RSS feed</a>, and when I publish something new (a blog post, a note, an exercise log, anything) it reads the post, follows any linked sources (both internal garden links and external pages), searches the garden for related prior writing, and emails me a critique.</p>

<p>It’s a simplistic agent. It’s not constantly expanding its context scope, so it doesn’t <a href="https://www.joshbeckman.org/notes/916587771">fall prey to compounding error rates</a>. This simplicity is a deliberate design choice for this tool. LLMs perform well in this sweet spot where they’re given a tight context window, allowed to agentically operate in that window, and then state is output/saved for recursive processing.</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>The critic doesn’t retain memory of past critiques or track whether I acted on its feedback. That compounding memory might be a future improvement, to make it a <a href="https://www.joshbeckman.org/notes/922122090">full entity</a>.</p>
</blockquote>
<p>The critique covers argument strength, writing clarity, connections to other posts, how I engaged with sources, and growth edges. It’s constructive, specific, and it cites passages. It walks beside me: it reads what I’ve just written, and later that day or the next morning over coffee I read a critique as if from a colleague. I consider it and hopefully incorporate it into an edit or a follow-on post. I want it to feel collaborative: <a href="https://www.joshbeckman.org/blog/practicing/feedforward-tolerance-feedback-improving-interfaces-for-llm-agents">a feedforward mechanism</a> for my own writing process.</p>

<p><img src="/assets/images/critic-cron-email-screenshot.png" alt="Critic cron email for an exercise post" /></p>

<p>I chose a strong model for this (Anthropic’s Opus). The other agents use Sonnet, which is fast and good enough for search-and-suggest work. But I want a writing mentor to be as sharp as possible. Lower-quality models still sound like parrots, echoing your context window back at you and telling you what you want to hear. I want genuine pushback. The critic runs infrequently enough that the cost is not a concern.</p>

<h2 id="early-results">Early results</h2>

<p>I’ve been using the suggester for a few days now, and every day I’ve updated at least one note with new comments and links. I’m finding myself using the pre-reader on my backlog of reading - things I would have skimmed in the past, but now I can see them through the lens of what I’ve already written. I’m already getting ideas for how the pre-reader could evolve: giving it more agency to recommend whether I should read the full piece or not, pulling quotes into its summaries.</p>

<p>The critic is newest, but the first critiques have been pointed enough that I’m optimistic. I used it repeatedly on this very post and it pushed me to refine wording, expand things I only gestured toward, and found source links to support my words. It also challenged some of my earlier claims, so that I strengthened them or removed them. It has made me engage much more than I would have previously with this writing.</p>

<p><img src="/assets/images/critique-of-critique-post.png" alt="Here's the critic critiquing this post as I wrote it" /></p>

<p>Importantly, I’m <em>not</em> copying and pasting the output from these agents, or letting them edit my writing directly. I want <em>influence</em> and collaboration, not delegation (feedforward context that shapes environment, not writing output for me). Unlike how I’m using agents to write code on my behalf, I want to be writing this prose myself. I need to actually write things, <a href="https://www.joshbeckman.org/notes/724851287">as output, to have them fully change my thinking</a>. Code was always an intermediary between the system and behavior I was designing and the computer that would enact it, so with coding agents I operate at a system design level, now rarely editing lines of code directly. With prose, I communicate directly with the minds of the audience, and the writing is a <a href="https://www.joshbeckman.org/notes/429253538">tool for my own mind</a>; having an agent write it on my behalf cheats myself (see the SloppyPasta source at the top).</p>

<h2 id="a-modern-website">A modern website</h2>

<p>Any one of these agents is a neat trick, but together, they’re something more: a living feedback loop built into my reading and writing on this site. The pre-reader prepares me before I read. The suggester fills in gaps when I have spare attention. The critic holds me accountable after I publish. A modern blogging website in this age of LLM agents should have AI feedback loops like these built in. I think this is the prose writing equivalent of my claude code software writing feedback loops.</p>

<p>This has been an evolution based on experience, not theory. The three agents replace the <a href="https://www.joshbeckman.org/blog/upgraded-insight-widget-with-mcp-server">Insight widget</a> I built last year (and <a href="https://www.joshbeckman.org/blog/using-an-llmand-rag-to-wring-insights-from-my-posts">the prior RAG-based insights before that</a>), which tried to do everything in one pass.</p>

<p>Using the basic RAG, I eventually found it uncreative. Using the agent, I found two distinct needs: suggesting connections and critiquing writing. Splitting them into dedicated agents with different triggers and interaction patterns serves each need better. It’s also now trivially easy to manage many vals for many distinct purposes when LLM agents are doing the coding, testing, and development for me.</p>

<p>I don’t want to just collect and hoard links. I want to integrate, connect, and digest. I can do that in conversation with colleagues, but I can’t always find a sparring partner at the moment I need one. These agents are tireless, and they know my entire body of work. They fill the gaps between conversations, keeping the garden tended when I’m not paying attention.</p>

<h2 id="how-the-critic-works">How the Critic works</h2>

<p>The whole thing is built on <a href="https://val.town">Val Town</a>, which has become my go-to for this kind of lightweight agent infrastructure. It encourages small compositional units, exposes a nice CLI for managing things, supports email/HTTP/cron as agent triggers, has minimal-but-complete storage, etc. The stack:</p>

<ul>
  <li><strong>RSS feed parsing</strong> to detect new posts</li>
  <li><strong>Blob storage</strong> to track the last processed post and avoid duplicates</li>
  <li>An <strong>agentic tool-use loop</strong> where the LLM can search my garden via <a href="/blog/i-built-an-mcp-server-for-my-site">the MCP server I built for this site</a>, read specific posts, and fetch external pages via <a href="https://jina.ai/">jina.ai</a></li>
  <li><strong>HTML email</strong> with the critique, sent via Val Town’s built-in email service</li>
  <li><strong>HTTP routes</strong> for ad-hoc testing — a <code class="language-plaintext highlighter-rouge">/preview?url=</code> endpoint that renders the critique as a web page</li>
</ul>

<p>I built each interface by orchestrating <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a> agents. I <a href="https://github.com/joshbeckman/dotfiles/tree/master/.claude/skills/val-town-dev">designed a skill</a> for them to know how to create and manage vals on Val Town. They tested and iterated using the <a href="https://github.com/anthropics/anthropic-cookbook/tree/main/anthropic-mcp-client/chrome-devtools-mcp">Chrome DevTools MCP server</a> to render the pages in real time.</p>

<p>All of the source code is public. The <a href="https://www.val.town/x/joshbeckman/criticCron">Critic</a>, <a href="https://www.val.town/x/joshbeckman/preRead">Pre-Reader</a>, and <a href="https://www.val.town/x/joshbeckman/suggestComments">Suggester</a> vals are on Val Town. The <a href="https://github.com/joshbeckman/dotfiles/tree/master/.claude/skills/val-town-dev">Val Town dev skill</a> for Claude Code is on GitHub. If you have a site with an RSS feed and a search index, you could wire up something similar.</p>]]></content><author><name>Josh Beckman</name><email>josh@joshbeckman.org</email><uri>https://www.joshbeckman.org/about</uri></author><category term="practicing" /><category term="ai" /><category term="writing" /><category term="tools" /><category term="open-source" /><category term="personal-blog" /><summary type="html"><![CDATA[I built three AI agents that tend my knowledge garden in different ways: one walks in front, one walks behind, and one walks beside me.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/critic-cron-email-screenshot.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/critic-cron-email-screenshot.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">README, Don’t AGENTS.md Me</title><link href="https://www.joshbeckman.org/blog/practicing/readme-dont-agentsmd-me" rel="alternate" type="text/html" title="README, Don’t AGENTS.md Me" /><published>2026-03-13T13:04:19+00:00</published><updated>2026-03-13T13:04:19+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/readme-dont-agentsmd-me</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/readme-dont-agentsmd-me"><![CDATA[<p>This is the place where I rant that <a href="https://agents.md/">the <code class="language-plaintext highlighter-rouge">AGENTS.md</code> pattern</a> is a distraction and slows down your software development.</p>

<p>The software engineering industry has had a standard of adding a <code class="language-plaintext highlighter-rouge">README.md</code> file to the root of projects and also the root of any subdirectory of those projects where deemed necessary. These files are easily discoverable by operators of the codebase, and their contents - while not standard - have been embraced as a place to put instructions for patterns and how to operate in the codebase.</p>

<p>with the rise of coding agents over the last couple years, people found that they needed to give them special instructions because they were error prone in certain ways, and we hadn’t built out the harnesses and capabilities for them to replicate how human operators work in a code base. That is no longer true today.</p>

<p>We We have incredibly capable models and the harnesses and tooling that we give them (like shell access and MCPs for browser control, among hundreds of other tools), being that they can do <em>everything</em> that a human operator can do to operate on the code. Everything I would say to a human operator in the codebase, I would say to an LLM agent working in that same codebase.</p>

<p>So, I just revamped my project’s READMEs and symlinked them to the <code class="language-plaintext highlighter-rouge">AGENTS.md</code> location. It’s not useful to separate the instructions for humans from LLM agents. In fact, when we separate them, we <em>increase</em> the likelihood that they will operate in different ways and do things that the other does not expect or intend. This actively slows down development on both sides.</p>

<p>Solidifying a single place - the old <code class="language-plaintext highlighter-rouge">README.md</code> standard, that is present in all modern software - is the path forward. I’m symlinking my READMEs to conform to this standard for now, because it’s free and doesn’t clutter anything for me, but I hope it eventually falls away.</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="ai" /><category term="tools" /><category term="software-engineering" /><summary type="html"><![CDATA[This is the place where I rant that the AGENTS.md pattern is a distraction and slows down your software development.]]></summary></entry><entry><title type="html">Trust But Verify</title><link href="https://www.joshbeckman.org/blog/practicing/trust-but-verify" rel="alternate" type="text/html" title="Trust But Verify" /><published>2026-03-06T14:19:08+00:00</published><updated>2026-03-06T14:19:08+00:00</updated><id>https://www.joshbeckman.org/blog/practicing/trust-but-verify</id><content type="html" xml:base="https://www.joshbeckman.org/blog/practicing/trust-but-verify"><![CDATA[<p>I’ve been a huge fan of <a href="https://webcomicname.com/">webcomic name</a> for years. Every comic ends with “oh no”. Perfect.</p>

<p>This one popped into my head after seeing some coworkers blindly trusting an LLM/AI agent’s output, which was entirely incorrect.</p>

<p><img width="906" height="308" alt="Trust But Verify" src="/assets/images/955f9c15-077b-4b71-b2ca-0b77b0287e07.png" /></p>

<p>This is my little reminder to always check the output of today’s AI agents. Know the checks applied to their work before it reaches us. Understand the provenance of their output. Their incentives are not your own.</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="software-engineering" /><category term="llm" /><category term="ai" /><category term="trust" /><summary type="html"><![CDATA[I’ve been a huge fan of webcomic name for years. Every comic ends with “oh no”. Perfect.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.joshbeckman.org/assets/images/955f9c15-077b-4b71-b2ca-0b77b0287e07.png" /><media:content medium="image" url="https://www.joshbeckman.org/assets/images/955f9c15-077b-4b71-b2ca-0b77b0287e07.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">ComEd Hourly Pricing Calendar</title><link href="https://www.joshbeckman.org/blog/practicing/comed-hourly-pricing-as-calendar-events" rel="alternate" type="text/html" title="ComEd Hourly Pricing Calendar" /><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" /><category term="popular" /><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 FOSS?</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" /><category term="popular" /><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" /><category term="popular" /><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></feed>