michaelheap.comThoughts on leadership, code and how to fix odd edge cases in tools (not necessarily in that order)2026-02-13T03:52:05Zhttps://michaelheap.comMichael Heapm@michaelheap.comNever leave on a no2026-02-13T03:52:05Zhttps://michaelheap.com/never-leave-on-a-no/<p>One of the biggest mistakes I see new managers make is how they manage disagreement with their boss.</p>
<p>They do one of two things:</p>
<ul>
<li>They nod along, but disagree quietly and disengage</li>
<li>They keep arguing until they run out of time and the meeting ends awkwardly, with no real decision</li>
</ul>
<p>Both options leave their boss frustrated.</p>
<p>Something that's worked well for me is that I <strong>never leave a conversation on a no</strong>.</p>
<p>It doesn't mean we always agree. It means that we're aligned on what the next steps are.</p>
<h2 id="disagreeing-behind-closed-doors" tabindex="-1">Disagreeing behind closed doors</h2>
<p>I believe that it's everyone's responsibility to challenge bad ideas. You should challenge ideas when:</p>
<ul>
<li>You have information that the person you're talking to doesn't</li>
<li>You're accountable for the outcome</li>
<li>You believe a decision will materially harm your team, customers or business</li>
</ul>
<p>How you challenge is important. Shouting in a meeting room with 15 other people won't get you anywhere. Instead, argue with your boss <em>in private</em> where there is space to listen and to be heard.</p>
<h2 id="know-when-to-stop" tabindex="-1">Know when to stop</h2>
<p>There's a difference between advocacy and infinite debate. Knowing when to stop pushing and start aligning is a skill.</p>
<p>Early in a discussion you can push hard:</p>
<ul>
<li>Ask clarifying questions</li>
<li>Share concrete risks</li>
<li>Offer alternatives, not just objections</li>
</ul>
<p>But at some point, you'll feel a shift in the conversation. You start getting repeated answers. The trade-offs have been heard. The decision has been made.</p>
<p>The most senior person in the room decides when the argument is over. Your job is to notice.</p>
<p>Missing that moment once is forgivable. Missing it repeatedly gets you labeled “difficult,” regardless of the quality of your ideas.</p>
<p>Disagreement is a tool for improving decisions, and once the decision is made your job changes.</p>
<h2 id="what-%E2%80%9Cnever-leave-on-a-no%E2%80%9D-actually-means" tabindex="-1">What “never leave on a no” actually means</h2>
<p>Before the meeting ends, alignment has to be explicit.</p>
<p>You should be able to say one of these - and mean it:</p>
<ul>
<li>“I disagree, but I understand the decision and I’ll execute it.”</li>
<li>“I still have concerns, but given the trade-offs, this is the direction we’re taking.”</li>
<li>“Let’s try this for X weeks and revisit with data.”</li>
</ul>
<p>A clean disagreement that ends in commitment builds trust. A fuzzy disagreement that lingers destroys it.</p>
<p>From your boss’s perspective, there are few things more dangerous than a manager who:</p>
<ul>
<li>Appears aligned in the meeting</li>
<li>Signals doubt afterward</li>
<li>Executes half-heartedly</li>
</ul>
<p>If you can’t commit, say so <em>in the room</em>. Don’t carry a private veto.</p>
<h2 id="sharing-with-your-team" tabindex="-1">Sharing with your team</h2>
<p>It's easy to paint leadership as the bad guys.</p>
<ul>
<li>"This wasn't my call"</li>
<li>"I don't agree, but..."</li>
<li>"Leadership wants us to..."</li>
</ul>
<p>It feels like solidarity, but it teaches your team that alignment is optional.</p>
<p>Your team works for <em>you</em>, not leadership, and they need to know what they need to do be successful in your business.</p>
<ul>
<li>What was the decision?</li>
<li>Why did we decide this?</li>
<li>What does success look like?</li>
</ul>
<p>You don't have to say that you're fully behind the idea, but you <em>do</em> need to communicate that this is the direction.</p>
<blockquote>
<p>“There were other options, and we discussed them. This is the direction we’re taking, and here’s how we’ll make it successful.”</p>
</blockquote>
<p>If you can't stand behind the decision at all, you need to get back in a room with your boss and talk until you can.</p>
<h2 id="your-real-job" tabindex="-1">Your real job</h2>
<p>Your job isn’t to win arguments.<br />
It’s to reduce uncertainty.</p>
<p>Argue fiercely when it matters.<br />
Notice when the argument is over.<br />
And never walk out of the room without a clear answer to one question:</p>
<p><strong>“What are we doing next?”</strong></p>
Update the AUR from GitHub Actions2026-02-05T13:45:31Zhttps://michaelheap.com/actions-update-aur/<p>I maintain <a href="https://github.com/mheap/trello-cli">trello-cli</a>, which is also published to the Arch User Repository (AUR).</p>
<p>Each time the package is published, I needed to update the <code>PKGBUILD</code> with a new version and <code>sha512sum</code>. I'm no longer an Arch user (I miss it, but MacOS works so much better for my current role) and so I don't have <code>makepkg</code> readily available.</p>
<p>I had an <a href="https://michaelheap.com/ideas">idea</a> that updating the <code>PKGBUILD</code> could be done with GitHub Actions:</p>
<blockquote>
<p>Updating an AUR package for a node based tool could be automated using GitHub Actions.<br />
Each time a release is tagged, the <code>pkgver</code> and <code>sha512sums</code> fields in the <code>PKGBUILD</code> need updating, and the repo needs pushing to the <a href="https://aur.archlinux.org/cgit/aur.git/tree/PKGBUILD">https://aur.archlinux.org/cgit/aur.git/tree/PKGBUILD</a><br />
The <code>sha512sum</code> needs to be calculated from the published NPM package, so we'd need to wait until the version is available, download it from the registry and then calculate the checksum.</p>
</blockquote>
<p>Here's how I did it:</p>
<ol>
<li>
<p>Generate an SSH keypair:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">ssh-keygen -t ed25519 -f </span><span style="color: #81A1C1">~</span><span style="color: #D8DEE9FF">/.ssh/aur -C </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">GH AUR - your-email@example.com</span><span style="color: #ECEFF4">"</span></div></code></div></pre>
</li>
<li>
<p>Add the public key to your AUR account:</p>
<ul>
<li>Go to <a href="https://aur.archlinux.org/">https://aur.archlinux.org</a> → My Account → SSH Public Keys</li>
<li>Paste contents of <code>~/.ssh/aur.pub</code></li>
</ul>
</li>
<li>
<p>Add the private key as a GitHub secret:</p>
<ul>
<li>Go to your repo → Settings → Secrets and variables → Actions</li>
<li>Create secret named <code>AUR_SSH_PRIVATE_KEY</code></li>
<li>Paste contents of <code>~/.ssh/aur</code></li>
</ul>
</li>
</ol>
<p>Then create a GitHub Actions workflow that you can run. You can't add it to your <code>npm release</code> workflow as there is a delay between publishing and it appearing in the API.</p>
<p>There's some tricky <code>runuser</code> logic required when updating <code>.SRCINFO</code> as you can't run <code>makepkg</code> as <code>root</code>:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">yaml</div><div class="code-container"><code><div class="line"><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Update AUR Package</span></div><div class="line"></div><div class="line"><span style="color: #81A1C1">on</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">workflow_dispatch</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">schedule</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">cron</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">0 0 * * 0</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF"> </span><span style="color: #616E88"># Weekly on Sunday at midnight UTC</span></div><div class="line"></div><div class="line"><span style="color: #8FBCBB">jobs</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">update-aur</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">runs-on</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">ubuntu-latest</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">container</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">image</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">archlinux:base-devel</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">steps</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Install dependencies</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">run</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">pacman -Sy --noconfirm jq openssh git</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Get latest npm version</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">id</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">npm</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">run</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">|</span></div><div class="line"><span style="color: #A3BE8C"> VERSION=$(curl -s https://registry.npmjs.org/trello-cli/latest | jq -r '.version')</span></div><div class="line"><span style="color: #A3BE8C"> echo "version=$VERSION" >> $GITHUB_OUTPUT</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Download tarball and get sha512sum</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">id</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">checksum</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">run</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">|</span></div><div class="line"><span style="color: #A3BE8C"> curl -sLO "https://registry.npmjs.org/trello-cli/-/trello-cli-$.tgz"</span></div><div class="line"><span style="color: #A3BE8C"> SHA512=$(sha512sum trello-cli-$.tgz | cut -d' ' -f1)</span></div><div class="line"><span style="color: #A3BE8C"> echo "sha512=$SHA512" >> $GITHUB_OUTPUT</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Setup SSH for AUR</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">run</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">|</span></div><div class="line"><span style="color: #A3BE8C"> mkdir -p ~/.ssh</span></div><div class="line"><span style="color: #A3BE8C"> echo "$" > ~/.ssh/aur</span></div><div class="line"><span style="color: #A3BE8C"> chmod 600 ~/.ssh/aur</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Clone AUR repo</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">env</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">GIT_SSH_COMMAND</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">ssh -i ~/.ssh/aur -o IdentitiesOnly=yes -o StrictHostKeyChecking=no</span><span style="color: #ECEFF4">"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">run</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">git clone ssh://aur@aur.archlinux.org/trello-cli.git aur-repo</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Check if update needed</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">id</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">check</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">working-directory</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">aur-repo</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">run</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">|</span></div><div class="line"><span style="color: #A3BE8C"> CURRENT=$(grep '^pkgver=' PKGBUILD | cut -d= -f2)</span></div><div class="line"><span style="color: #A3BE8C"> if [ "$CURRENT" = "$" ]; then</span></div><div class="line"><span style="color: #A3BE8C"> echo "Already up-to-date (version $CURRENT)"</span></div><div class="line"><span style="color: #A3BE8C"> echo "skip=true" >> $GITHUB_OUTPUT</span></div><div class="line"><span style="color: #A3BE8C"> else</span></div><div class="line"><span style="color: #A3BE8C"> echo "Update available: $CURRENT -> $"</span></div><div class="line"><span style="color: #A3BE8C"> echo "skip=false" >> $GITHUB_OUTPUT</span></div><div class="line"><span style="color: #A3BE8C"> fi</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Update PKGBUILD</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">if</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">steps.check.outputs.skip != 'true'</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">working-directory</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">aur-repo</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">run</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">|</span></div><div class="line"><span style="color: #A3BE8C"> sed -i "s/^pkgver=.*/pkgver=${{ steps.npm.outputs.version }}/" PKGBUILD</span></div><div class="line"><span style="color: #A3BE8C"> sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD</span></div><div class="line"><span style="color: #A3BE8C"> sed -i "s/^sha512sums=.*/sha512sums=('${{ steps.checksum.outputs.sha512 }}')/" PKGBUILD</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Update .SRCINFO</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">if</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">steps.check.outputs.skip != 'true'</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">working-directory</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">aur-repo</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">run</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">|</span></div><div class="line"><span style="color: #A3BE8C"> useradd -m builder</span></div><div class="line"><span style="color: #A3BE8C"> mkdir -p /home/builder/build</span></div><div class="line"><span style="color: #A3BE8C"> cp -a . /home/builder/build</span></div><div class="line"><span style="color: #A3BE8C"> chown -R builder:builder /home/builder/build</span></div><div class="line"><span style="color: #A3BE8C"> runuser -u builder -- bash -lc 'cd /home/builder/build && makepkg --printsrcinfo > .SRCINFO'</span></div><div class="line"><span style="color: #A3BE8C"> cp /home/builder/build/.SRCINFO ./.SRCINFO</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Commit and push</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">if</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">steps.check.outputs.skip != 'true'</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">working-directory</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">aur-repo</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">env</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">GIT_SSH_COMMAND</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">ssh -i ~/.ssh/aur -o IdentitiesOnly=yes -o StrictHostKeyChecking=no</span><span style="color: #ECEFF4">"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">run</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">|</span></div><div class="line"><span style="color: #A3BE8C"> git config user.name "Michael Heap"</span></div><div class="line"><span style="color: #A3BE8C"> git config user.email "m@michaelheap.com"</span></div><div class="line"><span style="color: #A3BE8C"> git add PKGBUILD .SRCINFO</span></div><div class="line"><span style="color: #A3BE8C"> git commit -m "Update to version $"</span></div><div class="line"><span style="color: #A3BE8C"> git push</span></div></code></div></pre>
<p>Now you have a workflow that will update your <code>PKGBUILD</code> automatically once per week, or whenever you trigger the action manually.</p>
Yes, if…2026-02-04T20:25:58Zhttps://michaelheap.com/yes-if/<p>People love hearing “yes.”</p>
<p>In a meeting, it sounds like momentum. In Slack, it reads as alignment. On a roadmap, it feels like progress.</p>
<p>It's also the first step towards disappointment. "Yes" is a contract - a commitment to deliver something. If you say "yes" to everything, you start paying for it with late nights, dropped balls and a creeping resentment that's hard to admit because you <em>chose</em> to say yes.</p>
<p>Every “yes” goes on an invisible ledger. Time, attention, credibility. All quietly debited, rarely reconciled. Eventually you <em>will</em> go bankrupt and disappoint someone. The only question is whether you disappoint them when they ask, or later when your lack of delivery has a much higher cost.</p>
<h2 id="the-two-types-of-%22yes%22" tabindex="-1">The two types of "yes"</h2>
<p>We say “yes” for many reasons. In personal relationships, it can strengthen bonds and make others feel valued. In professional settings, saying “yes” can signal teamwork, ambition, and a commitment to shared goals. However, saying “yes” can also be driven by <em>fear</em>. A fear of disappointing others, a fear of missing out, or a fear of conflict.</p>
<p>No matter <em>why</em> we say yes, the expectation to be agreeable leads to us committing to way more than we can ever hope to achieve. You say yes to avoid disappointing someone, only to disappoint them at the worst possible time: after you've agreed, they've told other people about their idea and half of the org is excited.</p>
<p>So, how do you keep the momentum that "yes" brings without overcommitting yourself? By using "Yes, if."</p>
<h2 id="%22yes%2C-if%22-is-a-trade" tabindex="-1">"Yes, If" is a trade</h2>
<p>“Yes” pretends there is no conversation. It hides the cost and defers the pain.</p>
<p>“No” without context shuts the conversation down before anything useful is learned.</p>
<p>“Yes, if” <em>is</em> the conversation.</p>
<p>“Yes, if” makes the cost visible. It takes the invisible tax you were about to pay and puts it on the table where everyone can see it. Not as a complaint. Not as drama. As accounting.</p>
<ul>
<li>“Yes, if you can wait until Thursday after the report goes out.”</li>
<li>“Yes, if we agree this replaces the other thing I’m doing.”</li>
<li>“Yes, if you do the first draft and I’ll review.”</li>
<li>“Yes, if we can fund the work and assign an owner.”</li>
</ul>
<p>Most frustration at work doesn’t come from disagreement. It comes from ambiguity.</p>
<h2 id="applying-this-in-real-life" tabindex="-1">Applying this in real life</h2>
<p>A good “Yes, if” is just good bookkeeping. It names which account you’re drawing from.</p>
<ul>
<li>Time: “Yes, if it can wait until Thursday.”</li>
<li>Priority: “Yes, if Bob agrees this outranks the report.”</li>
<li>Ownership: “Yes, if you draft it and I’ll review.”</li>
<li>Scope: “Yes, if we do a 1-page version, not a full report.”</li>
<li>Resourcing: “Yes, if we fund it / staff it.”</li>
</ul>
<p>Most org pain comes from pretending those currencies are infinite. By explicitly naming them and negotiating with the requester you can be collaborative without overcommitting.</p>
<h2 id="price-the-work" tabindex="-1">Price the work</h2>
<p>“Yes, if” is about maintaining balance. It’s about making sure the work you agree to aligns with your time, energy, and priorities.</p>
<p>Next time someone asks you for something, name the trade-offs. Make the constraint explicit. Price the work. See if they still want it.</p>
<p>If that trade kills the request, it wasn’t a priority. It was just an idea looking for someone else to carry the cost.</p>
<p>“Yes, if” is about maintaining balance. It's about making sure that projects align with your time, energy, and priorities. It fosters collaboration without compromising your well-being. Next time you’re faced with a request, remember: you don’t have to say “yes” to everything, but with the right conditions in place, you might just find a way to make it work for everyone.</p>
Delivery and Dopamine2026-01-28T20:08:57Zhttps://michaelheap.com/delivery-and-dopamine/<p>I've built my career around being the person that delivers. Throw me into a messy problem and I'll figure it out and make progress.</p>
<p>My performance reviews praised output. Peers trusted me as the person who “got things done”. The system rewarded shipping, and for a long time I used that as my definition of “doing my job well”. I confused visibility with value, and if I’m being totally honest, I was addicted to being the person that everyone pinged.</p>
<p>Then one day, my career stalled. I just couldn't justify another promotion on output alone. Shipping things feels good in a way that designing systems doesn't. Shipping provided that immediate dopamine hit, but system design doesn't work like that. Feedback is delayed and ambiguous, and honestly, things usually get worse before they get better.</p>
<p>You can justify being a player-coach as a manager, senior manager and in some places, even a director. But at some point you need to accept that the best use of your time is to act as a multiplier for the team that you're supporting.</p>
<p>That's where I failed.</p>
<h2 id="dopamine-vs.-leverage" tabindex="-1">Dopamine vs. Leverage</h2>
<p>Execution gives you fast feedback. System design demands patience.</p>
<p>When I say “designing systems,” I don’t mean architecture diagrams. I mean the interfaces that shape how the business operates: who makes decisions, how work gets planned and what happens when things break.</p>
<p>Discipline means letting the system converge on an idea and make decisions without you "helping". It means allowing systems to fail without heroics, enabling the organization to understand the flaws in the system. It means trading that dopamine hit for organizational leverage.</p>
<p>It also means being comfortable with feeling like a failure. Letting projects fail felt irresponsible. Not being hands on felt lazy. And if I'm honest, not getting that dopamine hit felt like withdrawal.</p>
<h2 id="the-relapse" tabindex="-1">The Relapse</h2>
<p>When leaders step back, things slow down. Metrics wobble. People complain.</p>
<p>That's the system revealing itself. Heroics are praised, and organizations choose short-term relief over long-term leverage. So when things got messy, I didn’t <em>choose</em> execution, I relapsed into it. It was the easy choice. The safe choice. It was also the wrong choice.</p>
<p>I fell back into being an IC multiple times. Each time the dopamine hit was even bigger. "Things were really going downhill and you turned it around!" Each time, it was even harder to crawl back out of the routine of shipping and start thinking about the overall system.</p>
<p>In one example, there was a last minute tooling update required for a key customer. So I dove in, made the changes, got the relevant approval and shipped a fix within 24 hours. The customer was happy, the account team was happy, the engineering team appreciated not being interrupted... then less than a week later the <em>exact</em> same thing happened again. Everyone's first instinct was to message me. My save the previous week didn’t fix the problem; it taught the organization the workaround.</p>
<h2 id="the-%22helping%22-trap" tabindex="-1">The "helping" trap</h2>
<p>I told myself I was "just helping".</p>
<p>But “helping” was a trap. It kept me focused on solving the immediate problem instead of asking why this problem kept showing up. It made me the bottleneck instead of fixing the decision boundary. It let me feel needed, and in the process <strong>I stole learning from the organization</strong>. The team didn’t get stronger; the system didn’t improve; I just became the workaround.</p>
<p>I'm not <em>entirely</em> at fault, though. While I was chasing the next hit I was also operating in an environment that was easy to understand. Do good work, get rewarded. Designing systems is filled with ambiguity and risk. System design is political, cross-functional and uncertain. It's also invisible to your stakeholders (until it fails). Execution is visible, measurable and safe.</p>
<h2 id="build-a-system-that-ships-without-you" tabindex="-1">Build a System That Ships Without You</h2>
<p>Building a sustainable system is thankless work. The feedback loop is slow, and the wins are quiet. You don’t measure it by what <em>you</em> shipped - you measure it by what happens when you’re not there.</p>
<p>A system is getting healthier when:</p>
<ul>
<li><strong>Recurring fires disappear</strong> because you removed root causes instead of treating symptoms.</li>
<li><strong>Decisions happen without you</strong> (and they’re good enough. Not <em>perfect</em>. Good enough).</li>
<li><strong>Approval paths are explicit</strong>: who decides, what “good” looks like, and how long each step should take.</li>
<li><strong>Leading indicators show up early</strong>, so problems surface while they’re still cheap to fix.</li>
</ul>
<p>If you're looking for a concrete example, we moved approvals from an ad-hoc Slack-based approach with emoji reactions to a weekly forum with named decision owners and SLAs. It <em>felt</em> like more process, but the overall time investment was much lower.</p>
<p>To understand if the system is working, ask yourself "if I take a month off, does the team still make good decisions, ship on time and recover from setbacks?".</p>
<p>(Fun fact! This is one of the reasons I quit my last job. I took 3 months off when my baby was born and when I got back to work it was as though I'd been there the whole time. They no longer needed me, and it was time to go and build systems somewhere else.)</p>
<h2 id="the-organization-trained-me" tabindex="-1">The organization trained me</h2>
<p>The org rewarded heroics, and I cashed the checks. Repeatedly.</p>
<p>In many cases a leader is doing what the organization expects of them. It's not that they can't let go of execution, it's that no-one tells them that they should. So we keep delivering, getting that dopamine hit and feeling effective.</p>
<p>Until organizations realize that leadership is about designing systems that enable the work, rather than doing it, they'll keep celebrating productivity and wondering why nothing scales.</p>
<p>We have to fix the incentives. Evaluate leaders based on team throughput and decision quality, not individual output. Celebrate recurring fires that no longer happen. Put “eliminated dependencies” and “decisions made without me” into performance reviews. Heroic saves should result in a postmortem and a system fix, not a medal.</p>
<p>If heroics are the path to praise, leaders will keep relapsing (even when they know better). If you keep rewarding people for <em>being the system</em> rather than <em>building the system</em> you won’t get leaders.</p>
<p>You’ll get bottlenecks with better titles.</p>
Fix the inputs, not the outputs2026-01-21T09:58:37Zhttps://michaelheap.com/fix-the-inputs/<p>A friend of mine is a design leader at a company with a couple of thousand people. We were recently chatting about the amount of time she was spending reviewing her team's work. It wasn't because the work was bad, but because she had no idea if the design met the needs of the project. She was spending hours trying to reverse-engineer the requirements from the final design.</p>
<p>The reviews didn't feel good for the individual designers either. Their manager was coming in at the end of a project and scrutinizing every little decision without the full context of what the requirements were. As it turned out, the designers <em>also</em> didn't have the full context - they knew how it started, but not how the requirements evolved over time. That information lived somewhere between the manager, the designers, and the project lead, with no clear source of truth.</p>
<p>My friend works in a design team, but I've seen this play out in a variety of settings. Engineers without clear product requirements. DevRel teams without clear metrics. Salespeople without a solid ideal customer profile. Anytime someone's required to fill in the blanks themselves, there's a high chance that it will lead to frustration for everyone involved.</p>
<h2 id="garbage-in%2C-garbage-out" tabindex="-1">Garbage In, Garbage Out</h2>
<p>Every output is an echo of its inputs. If the team doesn't know what "good" looks like from the start, they have to make decisions that they're not informed enough to make. Sometimes it works out, but more often than not it results in conflict.</p>
<p>The person reviewing the output feels that something is "off", while the team is blindsided by feedback that could have been provided as a requirement early, before the work was done.</p>
<p>Good requirements aren't about more documentation - they're about building a shared understanding of what you're trying to achieve.</p>
<ul>
<li>What problem are we solving?</li>
<li>What does success look like?</li>
<li>What constraints do we have?</li>
</ul>
<p>By aligning on the answer to these questions early, you make it easy to evaluate the work objectively. Did it solve the problem within the constraints expressed? Yes? Excellent work! Let's ship it.</p>
<h2 id="a-stitch-in-time" tabindex="-1">A Stitch in Time</h2>
<p>"But we don't have time to do such rigorous alignment work! They need this delivered yesterday!" I hear you, I understand, and respectfully, I disagree.</p>
<p>You're already spending this time, and more, at the end of the project "catching problems" with the implementation. It feels productive to catch the issues, but doing it after the work is complete is both expensive and demoralizing for the team.</p>
<p>Aligning on the inputs takes time, but it takes less time than trying to reconstruct requirements from an output. It takes less time than redoing the work because someone made a decision they didn't have enough context to make. Investing the time up front pays off in the long run. <em>A stitch in time saves nine.</em></p>
<blockquote>
<p>In order to improve for good, you need to solve problems at the systems level. Fix the inputs and the outputs will fix themselves. - <strong><em>James Clear, Atomic Habits</em></strong></p>
</blockquote>
<h2 id="how-to-fix-the-inputs" tabindex="-1">How to Fix the Inputs</h2>
<p>Fixing the inputs isn't hard, but it does take some concerted effort. At your next project kickoff, try asking the initial framing questions:</p>
<ul>
<li>What problem are we solving?</li>
<li>What does success look like?</li>
<li>What constraints do we have?</li>
</ul>
<p>Take the answers provided and write them down in a location that can be linked to from the work you do. This provides the base for your requirements doc.</p>
<p>Over time, you'll build up a list of additional questions that are specific to your org, for example:</p>
<ul>
<li>Is this intended for new users or for experienced users?</li>
<li>Are they likely to interact on a small screen, or will they always be on a laptop-sized screen?</li>
<li>There's a lot of data being processed here. Are users expecting a result within a second, a minute or an hour?</li>
</ul>
<p>Of course, not every requirement can be known in advance. Discovery happens through iteration and exploration. You learn what users really need, what's technically feasible, and what trade-offs actually matter by trying things out.</p>
<p>That's normal. The problem isn't that requirements evolve, it's that the written requirements are rarely updated as the real world requirements change. The initial assumptions are treated as the "requirements" even as reality shifts. This is even worse than having no requirements at all, as the final output is then evaluated against incorrect assumptions.</p>
<p>When requirements change, don't delete the old requirements. Instead, append to the document and make a note of the date. Then add a "current requirements" section to the top that is a synthesis of everything you know from the initial definition and the updated requirements. This is your evaluation criteria once the project is ready for review.</p>
<h2 id="the-real-definition-of-%22done%22" tabindex="-1">The Real Definition of "Done"</h2>
<p>Everyone has a different definition of "done" depending on their context and point of view. When teams say a project is "done", they mean that the solution is ready for review. In reality, the work is only done when everyone is aligned that this is the best possible outcome given the constraints.</p>
<p>Before calling something complete, review everyone's understanding of the problem and the proposed solution. Ask "Do we still agree on what good looks like? Are the inputs still valid?"</p>
<p>When inputs are solid, reviews become easier. Feedback stops becoming a surprise attack and becomes a shared evaluation against a known goal. Teams spend less time justifying their decisions and more time improving the product. "Done" stops meaning "I hope this is what you wanted" and starts meaning "we all understand why this is the best solution given the trade-offs".</p>
<p>So the next time you're tempted to try and reverse engineer what was required from the final output (and inevitably provide misguided feedback), pause and think about what led you to this point.</p>
<p>Don't fix the output.</p>
<p>Fix the input.</p>
When Asking is Easy (and Answering Isn't)2026-01-14T08:34:05Zhttps://michaelheap.com/asymmetric-questions/<p>It's 4pm on a Thursday, and I'm patting myself on the back that I've managed to align multiple teams on a tricky project. The requirements document has been reviewed and approved by everyone involved, and I'm excited to see what's been scoped come to life.</p>
<p>I'm about to sign off for the day when someone from our SaaS team pinged me: "Hey, quick question. What would it take to make this multi-tenant?". It's as though the walls come crashing down around me. It's not a quick question at all - it's one that takes deep technical, historical, and organizational knowledge to unpack.</p>
<p>I sigh as I make another cup of tea and settle in to write an essay of a response, trying to balance the need for details without it appearing as though I'm trying to overwhelm them with complex explanations.</p>
<p>Questions like this aren't malicious. They're often great questions, asked in good faith. But they reveal something important about effort - it's much easier to ask questions than to answer them.</p>
<h2 id="asymmetric-questions" tabindex="-1">Asymmetric Questions</h2>
<blockquote>
<p>An asymmetric question is one where the effort to ask is trivial compared to the effort to answer.</p>
</blockquote>
<p>This asymmetry isn't inherently bad. I'd choose to work with a curious team over a disinterested team every time given a choice. But when left unchecked, these questions can lead to a high emotional load for those receiving them.</p>
<p>I've been the person receiving the questions, and my responses have ranged from "Why are they asking questions that they definitely won't understand the answer to?" (which was an unkind categorization on my behalf) to "This sucks. Weeks of alignment undone by a single question. Maybe I can pretend I didn't see it?" to "This is a good question. I should have covered this in the first place. It's going to take time, but it's worthwhile".</p>
<p>Lack of knowledge is often the driver of asymmetric questions. The person asking is often missing the context required to realize how much effort it will take to answer what appears to be a simple question.</p>
<p>In general, the more senior or cross-functional your audience, the higher the likelihood is of one of them asking an asymmetric question. This isn't malicious - their curiosity spans domains that they're not totally familiar with.</p>
<p>Asymmetric questions can be valuable, but they can also be distracting. If the system that the asker is operating in rewards visibility, you're going to get spontaneous questions that don't add much. They don't care as much about the answer as they do about being seen to contribute. Over time, this erodes focus and encourages reactive rather than strategic work.</p>
<h2 id="managing-the-imbalance" tabindex="-1">Managing the Imbalance</h2>
<p>Asymmetric questions aren't malicious. They're born from curiosity, but left unchecked they can command a lot of time. It takes 30 seconds to ask a question, and 30 minutes to answer it.</p>
<p>My rule of thumb for asymmetric questions is to spend twice as long answering a question as it took to ask it. If they spent a minute asking a question, I'll give it two minutes of thought then respond (usually to ask clarifying questions of my own). The asker's response might take 5 minutes to write, and so I'll take 10 minutes to respond.</p>
<p>If a question takes one minute to write and will take twenty minutes to respond to, schedule a call. That's a good sign that there's something missing from either the asker's knowledge, or from your proposal itself.</p>
<p>The goal isn't to stop people asking questions. It's to right-size your response.</p>
<h2 id="navigating-asymmetric-questions" tabindex="-1">Navigating Asymmetric Questions</h2>
<p>The first step to navigating asymmetric questions successfully is to get past the resentment stage as quickly as possible. Even being aware of the knowledge asymmetry, I still have an initial burst of resentment every time someone asks "why don't we do X?".</p>
<p>The impact that their question has on the project, and on me as an individual, is huge. But I need to separate intent from impact. People don't generally ask asymmetric questions without a reason.</p>
<p>Once you're past the initial resentment, you can engage with the asker in good faith. Many people see a question and feel like they have to answer it immediately, and in depth. This usually isn't the case.</p>
<h3 id="exploring-or-deciding" tabindex="-1">Exploring or Deciding</h3>
<p>Questions on proposals are either <em>exploring</em> or <em>deciding</em>.</p>
<p>If someone is <em>exploring</em> an idea e.g. "Did we consider internationalizing the docs?" then you can respond with a similar level of depth. You could say "We did, but the return on investment for both time and cost wasn't there. I'm happy to dig in more if you like."</p>
<p>If the same question is phrased slightly differently, they're looking for a <em>decision</em>: "Why aren't we internationalizing the docs?". My personal instinct here is to write a 10 paragraph response in the comments section of a Google doc, but that's the wrong approach. Instead, propose a follow-up with the correct audience: "That's a good question. A full answer will take some time to put together. Can we chat next week with a few others that were involved in the decision?"</p>
<h3 id="clarify-and-redirect" tabindex="-1">Clarify and Redirect</h3>
<p>If the question is open-ended, don't try to answer every potential question. Here's one question that I had recently:</p>
<blockquote>
<p>Why don't we provide a hosted version of {Dependency X}?</p>
</blockquote>
<p>Instead of diving in, I asked a clarifying question to narrow the scope: "There a couple of reasons. Are you asking about technical, cost or market reasons?". The author came back with "market" and I explained that a hosted version would be well received by the market, but the technical implications (single tenant dependency) meant that the cost of providing a service (COGS) was too high for it to be a viable option for us.</p>
<p>Asking questions turns what sounds like a demand into a conversation. Either the asker wants to really dig in and you can have a fruitful conversation, or (as I've experienced many times) they just don't respond at all and you can forget about the question entirely.</p>
<h2 id="answer-with-intention" tabindex="-1">Answer with Intention</h2>
<p>Asymmetric questions are inevitable in collaborative work, especially as scope and seniority increases. By clarifying the questions and matching your effort to their intent you can keep projects moving without letting "one quick question" derail weeks of progress.</p>
Visibility is Velocity2026-01-07T11:29:57Zhttps://michaelheap.com/visibility-is-velocity/<p>Once upon a time, I worked with a Product Manager, Bob. Bob's team worked with incoming data pipelines, and transformed and augmented a variety of disparate data sources in to a consistent data model. It was a tough job, as you didn't actually know what data you were going to receive from our providers until it arrived.</p>
<p>Bob’s team also had a reputation for being <em>slow</em>. On average, onboarding a new data source took about three months. When the work shipped, though, it was excellent: reliable, well-instrumented, and easy to maintain.</p>
<p>But when they shipped, they didn't get any accolades. The team heard "Finally! Why did it take so long?". Worse, they heard "I wish you'd have warned us - we haven't implemented the new data source in the billing system". So not only did the project <em>feel</em> late, the business couldn’t even use what had been delivered.</p>
<p>A few months later, Bob was no longer with the company. The feedback was that he “wasn’t compatible with how the business needed to operate.” We hired Alice as his replacement.</p>
<p>The perceived change was immediate. Alice shipped something in her first two weeks. People were impressed. It didn’t even seem plausible that Bob’s team could ship <em>anything</em> in two weeks, let alone within two weeks of a new PM starting.</p>
<p>But Alice didn’t <em>actually</em> make the team faster. New data sources still took three months to fully integrate. The work was the same. The complexity was the same. What changed was that progress became visible.</p>
<p>And suddenly, everyone believed the team had become fast.</p>
<h2 id="the-dangers-of-silence" tabindex="-1">The Dangers of Silence</h2>
<p>Humans hate ambiguity. When there’s no signal, people invent their own story.</p>
<p>Bob's lack of updates created its own narrative: the work had stalled, or it wasn’t a priority. The project lost momentum. Other teams postponed their related work. Leaders delayed decisions tied to the project. Everyone just kind of "forgot" about the ongoing work.</p>
<p>The lack of communication usually comes from a good place:</p>
<blockquote>
<p>“I didn’t want to distract people until I had something solid.”</p>
</blockquote>
<p>But updates aren't a distraction. Surprises are. And surprises are expensive for everyone.</p>
<p>Each and every time I've found myself surprising an executive, I've ended up on calls where they're trying to figure out what's going on. Those calls take a <em>lot</em> of time.</p>
<p>We need to re-establish context for those that aren't deep in the project, then explain what's happening and why. The conversation inevitably turns to re-litigation of a decision that was already made.</p>
<p>Before you know it, the engineering team is frustrated that we're revisiting old decisions and the execs are frustrated that things are taking even longer. Surprises don't serve anyone.</p>
<h2 id="the-power-of-updates" tabindex="-1">The Power of Updates</h2>
<p>Sending regular updates doesn't make you go any faster - in fact, it slows you down slightly as writing the updates takes time. You don't want them to be a torrent of useless information ("We added 14 new tests and added support for the FlubJam"). Instead, focus on what's useful ("We added tests to ensure that URL mapping works consistently, and integrated FlubJam so that the billing team can get started with their work").</p>
<p>These small, regular updates build trust and predictability for your audience. They know what's happening, and that the project is still on track. And if there <em>is</em> something that isn't quite right, these regular updates allows people to raise concerns, build alignment and course correct while the cost of doing so is cheap.</p>
<h2 id="how-to-ship-incrementally" tabindex="-1">How to Ship Incrementally</h2>
<p>Let's go back to Bob and Alice. Alice didn’t deliver results any faster. She just made progress visible.</p>
<p>Instead of waiting for everything to be solid, she shared updates like these:</p>
<ul>
<li>Week 2: Great news! We've connected to the third party system. Here's a video of us ingesting a piece of data (no mapping, no augmentation)</li>
<li>Week 4: We've figured out what data is available. Here's a link to our proposed data mapping, and a demo video with the mapping added</li>
<li>Week 6: The pipeline is running well, and we've managed to add some augmentations. Here's a video of URL extraction from text working. Look how it augments the data with the page title from the URL</li>
<li>Week 8: Slow progress this week. We realised that the data isn't guaranteed to arrive in order, so it turns out that we were missing some data. We're reworking our pipeline to use the deduplication service so that we don't have to rely on timestamps. I'm still optimistic that we'll be done next month</li>
<li>Week 10: All solved! I've shared the data size/volume information with the billing team so that they can load it into the rate card system. We're working on additional test cases to give us confidence that we haven't missed anything.</li>
<li>Week 12: Tada! The project is done. Here's a list of sample configurations and test cases that we run. Thank you for all the feedback along the way.</li>
</ul>
<p>We still couldn't ship the new data source to customers until week 12, but all of the stakeholders were clear what was happening every step of the way.</p>
<p>Compare that to Bob's updates:</p>
<ul>
<li>Week 12: The integration is complete. Here’s the final implementation - let me know if you have feedback.</li>
</ul>
<p>The engineering team did the same work on the same timeline, but the leadership team had a <em>very different perception</em>.</p>
<p>Alice wasn’t seen as faster because she did more. She was seen as faster because progress showed up continuously. Risks were surfaced early, course corrections happened when needed, and no one was surprised at the end.</p>
<p>Your updates don’t need to be long. Fifteen minutes to produce. Two minutes to consume. That's all it takes.</p>
Bulk unsubscribe in Gmail2026-01-06T10:23:42Zhttps://michaelheap.com/gmail-unsubscribe/<p>Back in 2021 I added an idea to <a href="https://michaelheap.com/ideas">/ideas</a> that would scan my email inbox and provide a single page containing unsubscribe links that have been scraped from emails. Here was the proposal:</p>
<blockquote>
<p>A UI that reads all emails in a folder and shows all emails with an <code>unsubscribe</code> link.<br />
Provide a way to group by sender / subject / unsubscribe URL<br />
I'd like to be able to choose which folder this runs in (minimum Inbox + Trash, but ideally any folder/label)</p>
</blockquote>
<p>Today I learned that Google have built this themselves. In your inbox, click on <code>More</code> in the sidebar and then choose <code>Manage subscriptions</code>. You'll see a screen that shows you email volume from each sender and allows you to unsubscribe with a single click.</p>
<div class="image-wrapper "><picture> <source class="" type="image/webp" srcset="https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-320.webp 320w, https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-640.webp 640w, https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-960.webp 960w, https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-1200.webp 1200w" sizes="(max-width: 320px) 320px, (max-width: 640px) 640px, (max-width: 960px) 960px, (max-width: 1200px) 1200px, 100vw" /> <img class="" alt="Gmail Manage Subscriptions page screenshot" src="https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-1200.jpeg" sizes="(max-width: 320px) 320px, (max-width: 640px) 640px, (max-width: 960px) 960px, (max-width: 1200px) 1200px, 100vw" srcset="https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-320.jpeg 320w, https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-640.jpeg 640w, https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-960.jpeg 960w, https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-1200.jpeg 1200w" width="1200" height="592" /> </picture></div>
<p>Now, I need to go and unsubscribe from all of these Kickstarter platform emails that I always delete without reading...</p>
Building `listentotaxman-cli` with OpenCode + Claude Sonnet 4.52026-01-05T16:01:31Zhttps://michaelheap.com/listentotaxman-cli/<p>If you've ever needed to calculate tax in the UK, you've probably landed on <a href="https://listentotaxman.com/">https://listentotaxman.com/</a>. My main use case is comparing two sets of numbers - sometimes it's base salary, sometimes it's to see what happens if I change a pension contribution %.</p>
<p>The website is ok, but I wanted something I could script. So this morning, I <a href="https://github.com/mheap/listentotaxman-cli?tab=readme-ov-file#usage">wrapped their API in a CLI</a>. Or rather, I asked Copilot / Claude Sonnet 4.5 to wrap their API while I did some other work.</p>
<p>This idea has been on my <a href="https://michaelheap.com/ideas">/ideas</a> page since September 2021, and I've never taken the time to build it out. Occasionally I'll use the website and remember that I wanted to build a CLI but then I solve my immediate problem and move on.</p>
<p>Today, I decided that this was a nice, bounded problem that I could use to test out <code>OpenCode</code>, which was recommended to me over the weekend as a vendor neutral Claude Code replacement.</p>
<h2 id="building-with-opencode-%2B-copilot" tabindex="-1">Building with OpenCode + Copilot</h2>
<p>Here's the transcript of the conversation I had with OpenCode. I've skipped over some of the clarifying questions that were inconsequential (e.g. when it asked if responses should be cached, which ASCII table library I prefer etc). I've added a <code>clarification</code> section when it asked good questions, and <code>insight</code> for when I was pleasantly surprised when the LLM reminded me about actions that I needed to take.</p>
<p>As with when I was building <a href="https://michaelheap.com/gh-saved-issues/">gh-saved-issues</a> I did some initial exploration so that I could provide <code>curl</code> commands to get the project started:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>You are building a Cobra based Golang CLI for <a href="https://listentotaxman.com/">https://listentotaxman.com/</a>. A sample curl request for calling the API is:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">curl </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">https://listentotaxman.com/ws/tax/index.js.php</span><span style="color: #ECEFF4">'</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> --data-raw </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">{"response":"json","year":"2025","taxregion":"uk","age":"0","pension":"3%","time":"1","grosswage":100000}</span><span style="color: #ECEFF4">'</span></div></code></div></pre>
<p>Build a CLI that can be invoked as:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">listentotaxman check --year 2025 --region uk --age 0 --pension 3% --grosswage 100000 --student-loan postgraduate --extra 999 --tax-code K12</span></div></code></div></pre>
<p>The --tax-code flag should be passed as "taxcode" in the API request<br />
The --student-loan flag should be passed as "plan" in the API request<br />
The --region flag should be passed as "taxregion" in the API request</p>
<p>Year should default to the current year (e.g. 2026) if today's date is after the fifth of april. Otherwise it should default to the previous year.</p>
<p>--tax-region should default to "uk"</p>
</div>
</div>
<p>The LLM one-shot this pretty well. I could run <code>listentotaxman check</code> and see the output:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="code-container"><code><div class="line"><span style="color: undefined">╔══════════════════════════════════════════════╗</span></div><div class="line"><span style="color: undefined">║ Tax Calculation for 2025 (UK) - Yearly ║</span></div><div class="line"><span style="color: undefined">╠══════════════════════════════════════════════╣</span></div><div class="line"><span style="color: undefined">║ Gross Salary £100,000.00 ║</span></div><div class="line"><span style="color: undefined">║ Taxable Pay £97,129.00 ║</span></div><div class="line"><span style="color: undefined">║ Tax Paid £31,311.60 ║</span></div><div class="line"><span style="color: undefined">║ National Insurance £4,010.60 ║</span></div><div class="line"><span style="color: undefined">║ Student Loan £4,740.00 ║</span></div><div class="line"><span style="color: undefined">║ Pension (You) £3,000.00 ║</span></div><div class="line"><span style="color: undefined">║ Net Pay £56,937.80 ║</span></div><div class="line"><span style="color: undefined">╠══════════════════════════════════════════════╣</span></div><div class="line"><span style="color: undefined">║ Employer's NI £14,250.00 ║</span></div><div class="line"><span style="color: undefined">║ Pension (HMRC) £2,000.00 ║</span></div><div class="line"><span style="color: undefined">║ Total Cost £116,250.00 ║</span></div><div class="line"><span style="color: undefined">╚══════════════════════════════════════════════╝</span></div></code></div></pre>
<p>One of the things I liked about the website is the ability to see these figures for a specific time period:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Add a --period flag that defaults to "yearly". Other possible options are "monthly" "weekly" "daily" "hourly". Take the "yearly" values and divide by the 12 (monthly), 52 (weekly) or 365 (daily) or 2920 (hourly)</p>
</div>
</div>
<p>Did you spot my mistake? The LLM did:</p>
<div class="ai-input">
<div class="ai-header">
❓ Clarification
</div>
<div class="ai-body">
<p>Question: Is 2920 correct for hourly? This assumes 8-hour workdays. Some alternatives:</p>
<p>2080 hours (40 hours/week × 52 weeks) - standard US calculation<br />
1820 hours (typical UK full-time: 37.5 hours/week × 52 weeks - 5 weeks holiday)<br />
2920 hours (365 × 8 hours) - your specified value</p>
</div>
</div>
<p>I opted to use 2080 hours, assuming that I get paid for my lunchtime too.</p>
<p>At this point the CLI was working well, but there were some formatting issues due to the recent changes:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Alignment is off in the header:</p>
<p>══════════════════════════════════════════════╗<br />
║ Tax Calculation for 2025 (UK) - Monthly║<br />
╠══════════════════════════════════════════════╣</p>
</div>
</div>
<p>With the basic usage working, it was time to add the <code>compare</code> command that the UI doesn't provide. The trickiest thing to figure out here was how to accept groups of information.</p>
<p>I ended up accepting the pattern <code>--option <NAME> --flags go --here</code>, where any flags provided after one <code>--option</code> value, but before another are treated as one set of configuration:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Now add a new <code>listentotaxman compare</code> command. Accept all flags from <code>listentotaxman check</code></p>
<p>Each flag can be provided multiple times. Each group of inputs should be preceeded by an <code>--option</code> flag e.g.:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">listentotaxman compare --option job1 --pension 3% --grosswage 10 --option job2 --grosswage 1000 --pension 10%</span></div></code></div></pre>
</div>
</div>
<p>Once the command was built, I ran <code>listentotaxman compare --help</code> to see how to use it, but the help screen was useless:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Improve the --help output. The following is not useful:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">Error: no options specified </span><span style="color: #ECEFF4">(</span><span style="color: #D8DEE9FF">use --option to define each scenario</span><span style="color: #ECEFF4">)</span></div><div class="line"><span style="color: #D8DEE9FF">Usage:</span></div><div class="line"><span style="color: #D8DEE9FF">listentotaxman compare</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF">Flags:</span></div><div class="line"><span style="color: #D8DEE9FF">-h, --help </span><span style="color: #88C0D0">help</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">for</span><span style="color: #D8DEE9FF"> compare</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF">Error: no options specified </span><span style="color: #ECEFF4">(</span><span style="color: #D8DEE9FF">use --option to define each scenario</span><span style="color: #ECEFF4">)</span></div></code></div></pre>
</div>
</div>
<p>The LLM updated the <code>--help</code> command with <a href="https://github.com/mheap/listentotaxman-cli/blob/32e8fd38ec1e16689537196b405e6ff08c38e007/cmd/compare.go#L48-L72">many useful examples</a>:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">listentotaxman compare \</span></div><div class="line"><span style="color: #D8DEE9FF"> --option </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">Current Job</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF"> --income 100000 --pension 3% \</span></div><div class="line"><span style="color: #D8DEE9FF"> --option </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">New Offer</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF"> --income 120000 --pension 5%</span></div></code></div></pre>
<p>The CLI did everything I needed at this point. Everything that I did next was just polishing the experience:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Format numbers using the correct locale e.g. 100,000 rather than 100000</p>
</div>
</div>
<p>I realsied that I only provided sample API calls, and we should support everything the UI does. I've removed the HTML from the following prompt, but assume that I pasted in the whole HTML form from the UI:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Here is the HTML definition for the inputs available on the website. Which CLI flags are we missing?</p>
<p>...snip...</p>
</div>
</div>
<p>It was interesting for me to see the LLM match the UI properties to the form:</p>
<div class="ai-input">
<div class="ai-header">
👀 Insight
</div>
<div class="ai-body">
<p>Implemented ✅<br />
--year - Tax year<br />
--region - Tax region (uk, scotland, wales, ni)<br />
--grosswage - Salary amount<br />
--pension - Pension contribution (£ or %)<br />
--student-loan (maps to plan) - Student loan plan<br />
--age - Age selection<br />
--tax-code (maps to code) - Tax code<br />
--extra (maps to add) - Additional allowances/deductions<br />
Missing Flags 🔴<br />
Based on the HTML form, we're missing these inputs:</p>
<p>--married (checkbox: married)</p>
<p>Boolean flag for married status<br />
Affects marriage allowance calculations<br />
--blind (checkbox: blind)</p>
<p>Boolean flag for blind person's allowance<br />
Additional tax-free allowance for registered blind persons<br />
--no-ni or --exempt-ni** (checkbox: exNI)</p>
<p>Boolean flag for "I pay no NI"<br />
For people exempt from National Insurance<br />
--partner-wage (input: partnerGrossWage)</p>
<p>Partner's gross wage for marriage allowance calculations<br />
Only relevant when married flag is set<br />
Affects transferable marriage allowance<br />
--time - Input frequency selector</p>
<p>Currently we only accept yearly amounts<br />
Website supports: year, month, 4 weeks, 2 weeks, week, day, hour<br />
Note: This is an INPUT multiplier, different from our --period OUTPUT divisor<br />
Additional Observations<br />
Student Loan Plans<br />
We support:</p>
<p>✅ plan1<br />
✅ plan2<br />
✅ postgraduate<br />
We're missing:</p>
<p>🔴 plan4 - Plan 4 (for students from Scotland)<br />
🔴 scottish - Scottish student loan<br />
Age Values<br />
Current implementation uses string values like "0", "1", "2", "3"</p>
<p>Website uses:</p>
<p>0 - under 65 ✅<br />
3 - female 60-65 (historical, likely for older tax years) 🔴<br />
1 - 65-74 ✅<br />
2 - over 75 ✅<br />
We're mostly correct but missing the "female 60-65" option.</p>
<p>Region Values<br />
Our values match except:</p>
<p>We use: uk, scotland, wales, ni ✅<br />
Website uses: uk, scotland, eng, wales, ni<br />
Minor difference: We don't have eng (England) as distinct from uk 🟡</p>
</div>
</div>
<p>I had the LLM implement all of the above flags and options (and add them to <code>--help</code>), then continued to polish the experience:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Rename --grosswage to --income</p>
</div>
</div>
<p>Finally, it was time to release. I knew I wanted to use GitHub Actions and <code>goreleaser</code>:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>I want to build and release this tool with github actions and goreleaser. Build a plan to add this CI/CD setup</p>
</div>
</div>
<p>The LLM wanted to do so much more! It suggested enabling CodeQL, dependabot, Homebrew support and Docker builds.</p>
<div class="ai-input">
<div class="ai-header">
❓ Clarification
</div>
<div class="ai-body">
<p>Please answer the questions above, and I'll create:</p>
<p>Minimal setup (if you want quick start):</p>
<ul>
<li>.goreleaser.yml with basic config</li>
<li>.github/workflows/release.yml for releases</li>
<li>.github/workflows/ci.yml for testing</li>
</ul>
<p>Full-featured setup (if you want everything):</p>
<ul>
<li>All workflows (release, CI, CodeQL, dependabot)</li>
<li>Homebrew tap support</li>
<li>Docker image builds</li>
<li>Comprehensive documentation</li>
<li>Security scanning</li>
</ul>
<p>Which approach do you prefer? And please let me know your answers to the questions above!</p>
</div>
</div>
<p>I chose an option that it didn't give me: the minimal setup, but with Homebrew/Docker support too.</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Option 3: Minimal setup, but with Homebrew / Docker support</p>
</div>
</div>
<p>This is when the LLM surprised me. In addition to generating all of the workflows, it succinctly explained which secrets I needed to add to my repository, and how to find those values:</p>
<div class="ai-input">
<div class="ai-header">
👀 Insight
</div>
<div class="ai-body">
<p>🔧 Manual Setup Steps Checklist<br />
Before you can release, complete these manual steps:</p>
<p>✅ Step 1: Create Docker Hub Access Token<br />
Go to <a href="https://hub.docker.com/settings/security">https://hub.docker.com/settings/security</a><br />
Click "New Access Token"<br />
Description: "GoReleaser CI/CD"<br />
Access permissions: "Read, Write, Delete"<br />
Generate and COPY the token (shown only once)</p>
<p>✅ Step 2: Create GitHub Personal Access Token<br />
Go to <a href="https://github.com/settings/tokens">https://github.com/settings/tokens</a><br />
Click "Generate new token (classic)"<br />
Name: "GoReleaser Homebrew Tap"<br />
Expiration: No expiration (or as preferred)<br />
Scopes: Check repo (all sub-scopes)<br />
Generate and COPY the token</p>
<p>✅ Step 3: Add Secrets to GitHub Repository<br />
Navigate to: <a href="https://github.com/mheap/listentotaxman-cli/settings/secrets/actions">https://github.com/mheap/listentotaxman-cli/settings/secrets/actions</a></p>
<p>Click "New repository secret" for each:</p>
<p>Secret 1: GH_PAT</p>
<p>Name: GH_PAT<br />
Value: (GitHub PAT from Step 2)<br />
Secret 2: DOCKERHUB_USERNAME</p>
<p>Name: DOCKERHUB_USERNAME<br />
Value: mheap<br />
Secret 3: DOCKERHUB_TOKEN</p>
<p>Name: DOCKERHUB_TOKEN<br />
Value: (Docker Hub token from Step 1)</p>
<p>✅ Step 4: Configure GitHub Actions Permissions<br />
Go to: <a href="https://github.com/mheap/listentotaxman-cli/settings/actions">https://github.com/mheap/listentotaxman-cli/settings/actions</a><br />
Under "Workflow permissions":<br />
Select "Read and write permissions"<br />
Check "Allow GitHub Actions to create and approve pull requests"<br />
Click "Save"</p>
<p>✅ Step 5: Verify Homebrew Tap Repository<br />
Confirm <a href="https://github.com/mheap/homebrew-tap">https://github.com/mheap/homebrew-tap</a> exists and is:</p>
<p>Public<br />
Empty (or has minimal content - GoReleaser will manage it)</p>
</div>
</div>
<p>I didn't like that I had to provide permissions via settings. I want them declared in the workflow instead:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Update the GitHub Actions workflows to set permissions explicitly</p>
</div>
</div>
<p>And finally, it wouldn't be a GitHub Actions implementation unless the first build failed. The LLM somehow generated a version 2 configuration file for <code>goreleaser</code> while using version 1 in CI. I pasted the error in and it fixed it with no intervention from me:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>only configurations files on version: 1 are supported, yours is version: 2, please update your configuration<br />
Error: The process '/opt/hostedtoolcache/goreleaser-action/1.26.2/x64/goreleaser' failed with exit code 1</p>
</div>
</div>
<p>I pushed these changes to the repo, tagged a new release and watched as the <code>listentotaxman</code> CLI came alive! I ran <code>brew install mheap/tap/listentotaxman</code> on my local machine and now I can compare tax implications as much as I want 😁.</p>
Electron Node module version2025-12-27T17:11:26Zhttps://michaelheap.com/electron-node-module-version/<p>I've been building an Electron app for the first time. It was all going well until I tried to use a dependency that involved native components. When I tried to run the app, I got an error that <code>[...] was compiled against a different Node.js version</code>:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">Error occurred </span><span style="color: #81A1C1">in</span><span style="color: #D8DEE9FF"> handler </span><span style="color: #81A1C1">for</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">tasks:fetch</span><span style="color: #ECEFF4">'</span><span style="color: #D8DEE9FF">: Error: The module </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">/private/tmp/electron-demo/node_modules/better-sqlite3/build/Release/better_sqlite3.node</span><span style="color: #ECEFF4">'</span></div><div class="line"><span style="color: #D8DEE9FF">was compiled against a different Node.js version using</span></div><div class="line"><span style="color: #D8DEE9FF">NODE_MODULE_VERSION 137. This version of Node.js requires</span></div><div class="line"><span style="color: #D8DEE9FF">NODE_MODULE_VERSION 140. Please try re-compiling or re-installing</span></div><div class="line"><span style="color: #D8DEE9FF">the module </span><span style="color: #ECEFF4">(</span><span style="color: #D8DEE9FF">for instance, using </span><span style="color: #ECEFF4">`</span><span style="color: #A3BE8C">npm rebuild</span><span style="color: #ECEFF4">`</span><span style="color: #D8DEE9FF"> or </span><span style="color: #ECEFF4">`</span><span style="color: #A3BE8C">npm install</span><span style="color: #ECEFF4">`</span><span style="color: #ECEFF4">)</span><span style="color: #D8DEE9FF">.</span></div></code></div></pre>
<p>It turns out that the node.js and Electron ABIs are not compatible (see the <a href="https://github.com/nodejs/node/blob/main/doc/abi_version_registry.json">compatibility matrix</a>), and any native code needs to be compiled specifically for Electron.</p>
<p>To solve the issue above, you have to rebuild native modules for Electron using <code>@electron/rebuild</code>:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">npm install --save-dev @electron/rebuild</span></div><div class="line"><span style="color: #D8DEE9FF">./node_modules/.bin/electron-rebuild</span></div></code></div></pre>
<p>Then <code>npm start</code> and everything works.</p>
<blockquote>
<p>⚠️ You'll need to re-run <code>electron-rebuild</code> after every <code>npm install</code>.</p>
</blockquote>
<h2 id="reproduction-case" tabindex="-1">Reproduction case</h2>
<p>If you're looking for a simple reproduction case:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">git clone https://github.com/mheap/electron-build-sample</span></div><div class="line"><span style="color: #88C0D0">cd</span><span style="color: #D8DEE9FF"> electron-build-sample</span></div><div class="line"><span style="color: #D8DEE9FF">npm install</span></div><div class="line"><span style="color: #D8DEE9FF">npm start </span><span style="color: #616E88"># You'll get an error here</span></div></code></div></pre>
<p>Rebuild the native modules and try again:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">./node_modules/.bin/electron-rebuild</span></div><div class="line"><span style="color: #D8DEE9FF">npm start</span></div></code></div></pre>
<p>You'll see a task that says "Do the thing".</p>